botmux 3.10.0 → 3.11.0

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 (539) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/herdr-backend.d.ts +4 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +11 -8
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts +1 -1
  7. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/pty-backend.js +4 -1
  9. package/dist/adapters/backend/pty-backend.js.map +1 -1
  10. package/dist/adapters/backend/riff-backend.d.ts +29 -5
  11. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/riff-backend.js +302 -31
  13. package/dist/adapters/backend/riff-backend.js.map +1 -1
  14. package/dist/adapters/backend/sandbox.d.ts +6 -0
  15. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  16. package/dist/adapters/backend/sandbox.js +7 -0
  17. package/dist/adapters/backend/sandbox.js.map +1 -1
  18. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  19. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/tmux-backend.js +4 -1
  21. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  22. package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
  23. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  26. package/dist/adapters/backend/types.d.ts +37 -3
  27. package/dist/adapters/backend/types.d.ts.map +1 -1
  28. package/dist/adapters/backend/types.js.map +1 -1
  29. package/dist/adapters/backend/zellij-backend.d.ts +3 -3
  30. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  31. package/dist/adapters/backend/zellij-backend.js +9 -3
  32. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  33. package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
  34. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-observe-backend.js +12 -7
  36. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  37. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  38. package/dist/adapters/cli/codex-app.js +11 -2
  39. package/dist/adapters/cli/codex-app.js.map +1 -1
  40. package/dist/adapters/cli/read-isolation.d.ts +35 -2
  41. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  42. package/dist/adapters/cli/read-isolation.js +88 -7
  43. package/dist/adapters/cli/read-isolation.js.map +1 -1
  44. package/dist/adapters/cli/runner-input.d.ts +15 -14
  45. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  46. package/dist/adapters/cli/runner-input.js +64 -26
  47. package/dist/adapters/cli/runner-input.js.map +1 -1
  48. package/dist/adapters/cli/types.d.ts +20 -5
  49. package/dist/adapters/cli/types.d.ts.map +1 -1
  50. package/dist/bot-registry.d.ts +24 -0
  51. package/dist/bot-registry.d.ts.map +1 -1
  52. package/dist/bot-registry.js +110 -5
  53. package/dist/bot-registry.js.map +1 -1
  54. package/dist/cli/fleet-shutdown.d.ts +47 -0
  55. package/dist/cli/fleet-shutdown.d.ts.map +1 -0
  56. package/dist/cli/fleet-shutdown.js +387 -0
  57. package/dist/cli/fleet-shutdown.js.map +1 -0
  58. package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
  59. package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
  60. package/dist/cli/pm2-descriptor-guard.js +122 -0
  61. package/dist/cli/pm2-descriptor-guard.js.map +1 -0
  62. package/dist/cli/pm2-exact-start.d.ts +16 -0
  63. package/dist/cli/pm2-exact-start.d.ts.map +1 -0
  64. package/dist/cli/pm2-exact-start.js +63 -0
  65. package/dist/cli/pm2-exact-start.js.map +1 -0
  66. package/dist/cli/pm2-god-admission.d.ts +8 -0
  67. package/dist/cli/pm2-god-admission.d.ts.map +1 -0
  68. package/dist/cli/pm2-god-admission.js +25 -0
  69. package/dist/cli/pm2-god-admission.js.map +1 -0
  70. package/dist/cli/pm2-jlist.d.ts +13 -0
  71. package/dist/cli/pm2-jlist.d.ts.map +1 -0
  72. package/dist/cli/pm2-jlist.js +108 -0
  73. package/dist/cli/pm2-jlist.js.map +1 -0
  74. package/dist/cli/pm2-preflight.d.ts +10 -0
  75. package/dist/cli/pm2-preflight.d.ts.map +1 -0
  76. package/dist/cli/pm2-preflight.js +23 -0
  77. package/dist/cli/pm2-preflight.js.map +1 -0
  78. package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
  79. package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
  80. package/dist/cli/pm2-shutdown-capability.js +161 -0
  81. package/dist/cli/pm2-shutdown-capability.js.map +1 -0
  82. package/dist/cli/pm2-start-transaction.d.ts +55 -0
  83. package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
  84. package/dist/cli/pm2-start-transaction.js +220 -0
  85. package/dist/cli/pm2-start-transaction.js.map +1 -0
  86. package/dist/cli/send-dispatch.d.ts +12 -0
  87. package/dist/cli/send-dispatch.d.ts.map +1 -1
  88. package/dist/cli/send-dispatch.js +31 -4
  89. package/dist/cli/send-dispatch.js.map +1 -1
  90. package/dist/cli/session-list-liveness.d.ts +1 -0
  91. package/dist/cli/session-list-liveness.d.ts.map +1 -1
  92. package/dist/cli/session-list-liveness.js +6 -0
  93. package/dist/cli/session-list-liveness.js.map +1 -1
  94. package/dist/cli/session-picker-layout.d.ts +26 -0
  95. package/dist/cli/session-picker-layout.d.ts.map +1 -0
  96. package/dist/cli/session-picker-layout.js +123 -0
  97. package/dist/cli/session-picker-layout.js.map +1 -0
  98. package/dist/cli/session-picker-viewport.d.ts +49 -0
  99. package/dist/cli/session-picker-viewport.d.ts.map +1 -0
  100. package/dist/cli/session-picker-viewport.js +46 -0
  101. package/dist/cli/session-picker-viewport.js.map +1 -0
  102. package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
  103. package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
  104. package/dist/cli/supervisor-shutdown-client.js +146 -0
  105. package/dist/cli/supervisor-shutdown-client.js.map +1 -0
  106. package/dist/cli/terminal-width.d.ts +48 -0
  107. package/dist/cli/terminal-width.d.ts.map +1 -0
  108. package/dist/cli/terminal-width.js +119 -0
  109. package/dist/cli/terminal-width.js.map +1 -0
  110. package/dist/cli/vc-agent.d.ts.map +1 -1
  111. package/dist/cli/vc-agent.js +1 -1
  112. package/dist/cli/vc-agent.js.map +1 -1
  113. package/dist/cli.d.ts.map +1 -1
  114. package/dist/cli.js +2269 -716
  115. package/dist/cli.js.map +1 -1
  116. package/dist/codex-app-runner.js +1630 -125
  117. package/dist/codex-app-runner.js.map +1 -1
  118. package/dist/core/ask-api.d.ts +8 -1
  119. package/dist/core/ask-api.d.ts.map +1 -1
  120. package/dist/core/ask-api.js +18 -0
  121. package/dist/core/ask-api.js.map +1 -1
  122. package/dist/core/ask-broker.d.ts +20 -0
  123. package/dist/core/ask-broker.d.ts.map +1 -1
  124. package/dist/core/ask-broker.js +467 -47
  125. package/dist/core/ask-broker.js.map +1 -1
  126. package/dist/core/ask-persist-store.d.ts +126 -0
  127. package/dist/core/ask-persist-store.d.ts.map +1 -0
  128. package/dist/core/ask-persist-store.js +188 -0
  129. package/dist/core/ask-persist-store.js.map +1 -0
  130. package/dist/core/ask-types.d.ts +80 -0
  131. package/dist/core/ask-types.d.ts.map +1 -1
  132. package/dist/core/ask-types.js +62 -0
  133. package/dist/core/ask-types.js.map +1 -1
  134. package/dist/core/cli-session-id-publisher.d.ts +20 -0
  135. package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
  136. package/dist/core/cli-session-id-publisher.js +21 -0
  137. package/dist/core/cli-session-id-publisher.js.map +1 -0
  138. package/dist/core/command-handler.d.ts.map +1 -1
  139. package/dist/core/command-handler.js +427 -255
  140. package/dist/core/command-handler.js.map +1 -1
  141. package/dist/core/cost-calculator.d.ts +13 -0
  142. package/dist/core/cost-calculator.d.ts.map +1 -1
  143. package/dist/core/cost-calculator.js +385 -26
  144. package/dist/core/cost-calculator.js.map +1 -1
  145. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  146. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  147. package/dist/core/dashboard-ipc-server.js +842 -288
  148. package/dist/core/dashboard-ipc-server.js.map +1 -1
  149. package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
  150. package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
  151. package/dist/core/explicit-session-backing-cleanup.js +76 -0
  152. package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
  153. package/dist/core/idle-worker-sweeper.d.ts +20 -0
  154. package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
  155. package/dist/core/idle-worker-sweeper.js +18 -0
  156. package/dist/core/idle-worker-sweeper.js.map +1 -1
  157. package/dist/core/inflight-input-tracker.d.ts +10 -0
  158. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  159. package/dist/core/inflight-input-tracker.js +7 -0
  160. package/dist/core/inflight-input-tracker.js.map +1 -1
  161. package/dist/core/managed-origin-attestation.d.ts +51 -0
  162. package/dist/core/managed-origin-attestation.d.ts.map +1 -0
  163. package/dist/core/managed-origin-attestation.js +208 -0
  164. package/dist/core/managed-origin-attestation.js.map +1 -0
  165. package/dist/core/managed-origin-capability.d.ts +62 -3
  166. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  167. package/dist/core/managed-origin-capability.js +419 -9
  168. package/dist/core/managed-origin-capability.js.map +1 -1
  169. package/dist/core/pending-repo-journal.d.ts +6 -0
  170. package/dist/core/pending-repo-journal.d.ts.map +1 -0
  171. package/dist/core/pending-repo-journal.js +85 -0
  172. package/dist/core/pending-repo-journal.js.map +1 -0
  173. package/dist/core/persistent-backend.d.ts +6 -1
  174. package/dist/core/persistent-backend.d.ts.map +1 -1
  175. package/dist/core/persistent-backend.js +12 -6
  176. package/dist/core/persistent-backend.js.map +1 -1
  177. package/dist/core/process-start-identity.d.ts +3 -0
  178. package/dist/core/process-start-identity.d.ts.map +1 -0
  179. package/dist/core/process-start-identity.js +62 -0
  180. package/dist/core/process-start-identity.js.map +1 -0
  181. package/dist/core/raw-command-writer.d.ts +34 -0
  182. package/dist/core/raw-command-writer.d.ts.map +1 -0
  183. package/dist/core/raw-command-writer.js +48 -0
  184. package/dist/core/raw-command-writer.js.map +1 -0
  185. package/dist/core/reply-target.d.ts +85 -22
  186. package/dist/core/reply-target.d.ts.map +1 -1
  187. package/dist/core/reply-target.js +267 -32
  188. package/dist/core/reply-target.js.map +1 -1
  189. package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
  190. package/dist/core/resource-monitor/runtime.js +4 -1
  191. package/dist/core/resource-monitor/runtime.js.map +1 -1
  192. package/dist/core/restart-report.d.ts +6 -1
  193. package/dist/core/restart-report.d.ts.map +1 -1
  194. package/dist/core/restart-report.js +22 -4
  195. package/dist/core/restart-report.js.map +1 -1
  196. package/dist/core/riff-shutdown-detach.d.ts +164 -0
  197. package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
  198. package/dist/core/riff-shutdown-detach.js +787 -0
  199. package/dist/core/riff-shutdown-detach.js.map +1 -0
  200. package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
  201. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
  202. package/dist/core/riff-worker-shutdown-readiness.js +21 -0
  203. package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
  204. package/dist/core/session-manager.d.ts +6 -6
  205. package/dist/core/session-manager.d.ts.map +1 -1
  206. package/dist/core/session-manager.js +1227 -594
  207. package/dist/core/session-manager.js.map +1 -1
  208. package/dist/core/session-marker.d.ts +1 -1
  209. package/dist/core/session-marker.d.ts.map +1 -1
  210. package/dist/core/session-marker.js +2 -2
  211. package/dist/core/session-marker.js.map +1 -1
  212. package/dist/core/session-mutation-guard.d.ts +17 -0
  213. package/dist/core/session-mutation-guard.d.ts.map +1 -0
  214. package/dist/core/session-mutation-guard.js +33 -0
  215. package/dist/core/session-mutation-guard.js.map +1 -0
  216. package/dist/core/shutdown-budgets.d.ts +27 -0
  217. package/dist/core/shutdown-budgets.d.ts.map +1 -0
  218. package/dist/core/shutdown-budgets.js +47 -0
  219. package/dist/core/shutdown-budgets.js.map +1 -0
  220. package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
  221. package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
  222. package/dist/core/supervisor-shutdown-ipc.js +10 -0
  223. package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
  224. package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
  225. package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
  226. package/dist/core/supervisor-shutdown-protocol.js +16 -0
  227. package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
  228. package/dist/core/trigger-final-suppression.d.ts +4 -0
  229. package/dist/core/trigger-final-suppression.d.ts.map +1 -1
  230. package/dist/core/trigger-final-suppression.js +6 -11
  231. package/dist/core/trigger-final-suppression.js.map +1 -1
  232. package/dist/core/trigger-session.d.ts +70 -0
  233. package/dist/core/trigger-session.d.ts.map +1 -1
  234. package/dist/core/trigger-session.js +923 -145
  235. package/dist/core/trigger-session.js.map +1 -1
  236. package/dist/core/types.d.ts +94 -1
  237. package/dist/core/types.d.ts.map +1 -1
  238. package/dist/core/types.js +9 -0
  239. package/dist/core/types.js.map +1 -1
  240. package/dist/core/worker-pool.d.ts +194 -13
  241. package/dist/core/worker-pool.d.ts.map +1 -1
  242. package/dist/core/worker-pool.js +2597 -547
  243. package/dist/core/worker-pool.js.map +1 -1
  244. package/dist/daemon.d.ts +76 -2
  245. package/dist/daemon.d.ts.map +1 -1
  246. package/dist/daemon.js +2255 -773
  247. package/dist/daemon.js.map +1 -1
  248. package/dist/dashboard/bot-payload.d.ts +20 -0
  249. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  250. package/dist/dashboard/bot-payload.js +24 -0
  251. package/dist/dashboard/bot-payload.js.map +1 -1
  252. package/dist/dashboard/feed-groups.d.ts +17 -0
  253. package/dist/dashboard/feed-groups.d.ts.map +1 -0
  254. package/dist/dashboard/feed-groups.js +75 -0
  255. package/dist/dashboard/feed-groups.js.map +1 -0
  256. package/dist/dashboard/session-card-model.d.ts +1 -1
  257. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  258. package/dist/dashboard/session-card-model.js +8 -5
  259. package/dist/dashboard/session-card-model.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  261. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults-page.js +139 -46
  263. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  264. package/dist/dashboard/web/bot-defaults.d.ts +4 -0
  265. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  266. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  267. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  268. package/dist/dashboard/web/connectors-page.js +40 -3
  269. package/dist/dashboard/web/connectors-page.js.map +1 -1
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +95 -2
  272. package/dist/dashboard/web/groups-page.js.map +1 -1
  273. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  274. package/dist/dashboard/web/i18n.js +86 -40
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/kanban-model.js +1 -1
  277. package/dist/dashboard/web/kanban-model.js.map +1 -1
  278. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  279. package/dist/dashboard/web/overview-page.js +3 -2
  280. package/dist/dashboard/web/overview-page.js.map +1 -1
  281. package/dist/dashboard/web/overview.d.ts +3 -0
  282. package/dist/dashboard/web/overview.d.ts.map +1 -1
  283. package/dist/dashboard/web/overview.js +2 -0
  284. package/dist/dashboard/web/overview.js.map +1 -1
  285. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  286. package/dist/dashboard/web/sessions-kanban.js +2 -0
  287. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  288. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  289. package/dist/dashboard/web/sessions-page.js +103 -2
  290. package/dist/dashboard/web/sessions-page.js.map +1 -1
  291. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  292. package/dist/dashboard/web/sessions.js +3 -2
  293. package/dist/dashboard/web/sessions.js.map +1 -1
  294. package/dist/dashboard/web/ui.d.ts +8 -0
  295. package/dist/dashboard/web/ui.d.ts.map +1 -1
  296. package/dist/dashboard/web/ui.js +15 -0
  297. package/dist/dashboard/web/ui.js.map +1 -1
  298. package/dist/dashboard-web/app.js +1 -1
  299. package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
  300. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
  301. package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
  302. package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
  303. package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
  304. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
  305. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
  306. package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
  307. package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
  308. package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
  309. package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
  310. package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
  311. package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
  312. package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
  313. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
  314. package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
  315. package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
  316. package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
  317. package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
  318. package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
  319. package/dist/dashboard-web/style.css +255 -0
  320. package/dist/dashboard.js +172 -7
  321. package/dist/dashboard.js.map +1 -1
  322. package/dist/desktop/renderer/app.js +21 -0
  323. package/dist/desktop/renderer/app.js.map +1 -1
  324. package/dist/i18n/en.d.ts.map +1 -1
  325. package/dist/i18n/en.js +35 -9
  326. package/dist/i18n/en.js.map +1 -1
  327. package/dist/i18n/zh.d.ts.map +1 -1
  328. package/dist/i18n/zh.js +35 -9
  329. package/dist/i18n/zh.js.map +1 -1
  330. package/dist/im/lark/ask-card.d.ts +23 -0
  331. package/dist/im/lark/ask-card.d.ts.map +1 -1
  332. package/dist/im/lark/ask-card.js +94 -4
  333. package/dist/im/lark/ask-card.js.map +1 -1
  334. package/dist/im/lark/card-builder.d.ts +6 -1
  335. package/dist/im/lark/card-builder.d.ts.map +1 -1
  336. package/dist/im/lark/card-builder.js +112 -15
  337. package/dist/im/lark/card-builder.js.map +1 -1
  338. package/dist/im/lark/card-handler.d.ts +1 -1
  339. package/dist/im/lark/card-handler.d.ts.map +1 -1
  340. package/dist/im/lark/card-handler.js +496 -310
  341. package/dist/im/lark/card-handler.js.map +1 -1
  342. package/dist/im/lark/client.d.ts +7 -0
  343. package/dist/im/lark/client.d.ts.map +1 -1
  344. package/dist/im/lark/client.js +39 -26
  345. package/dist/im/lark/client.js.map +1 -1
  346. package/dist/im/lark/doc-comment.d.ts +7 -4
  347. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  348. package/dist/im/lark/doc-comment.js +19 -6
  349. package/dist/im/lark/doc-comment.js.map +1 -1
  350. package/dist/im/lark/event-dispatcher.d.ts +16 -0
  351. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  352. package/dist/im/lark/event-dispatcher.js +88 -23
  353. package/dist/im/lark/event-dispatcher.js.map +1 -1
  354. package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
  355. package/dist/im/lark/forward-followup-content.js +9 -1
  356. package/dist/im/lark/forward-followup-content.js.map +1 -1
  357. package/dist/im/lark/grant-command.d.ts.map +1 -1
  358. package/dist/im/lark/grant-command.js +7 -4
  359. package/dist/im/lark/grant-command.js.map +1 -1
  360. package/dist/im/lark/md-card.d.ts +11 -0
  361. package/dist/im/lark/md-card.d.ts.map +1 -1
  362. package/dist/im/lark/md-card.js +46 -0
  363. package/dist/im/lark/md-card.js.map +1 -1
  364. package/dist/im/lark/message-parser.d.ts +23 -2
  365. package/dist/im/lark/message-parser.d.ts.map +1 -1
  366. package/dist/im/lark/message-parser.js +58 -3
  367. package/dist/im/lark/message-parser.js.map +1 -1
  368. package/dist/im/lark/overview-card.d.ts.map +1 -1
  369. package/dist/im/lark/overview-card.js +2 -1
  370. package/dist/im/lark/overview-card.js.map +1 -1
  371. package/dist/services/async-trigger-state.d.ts +6 -1
  372. package/dist/services/async-trigger-state.d.ts.map +1 -1
  373. package/dist/services/async-trigger-state.js +21 -0
  374. package/dist/services/async-trigger-state.js.map +1 -1
  375. package/dist/services/async-trigger-store.d.ts +23 -1
  376. package/dist/services/async-trigger-store.d.ts.map +1 -1
  377. package/dist/services/async-trigger-store.js +96 -13
  378. package/dist/services/async-trigger-store.js.map +1 -1
  379. package/dist/services/bridge-fallback-gate.d.ts +40 -0
  380. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  381. package/dist/services/bridge-fallback-gate.js +124 -24
  382. package/dist/services/bridge-fallback-gate.js.map +1 -1
  383. package/dist/services/codex-app-runner-protocol.d.ts +8 -0
  384. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  385. package/dist/services/codex-app-runner-protocol.js +9 -2
  386. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  387. package/dist/services/codex-bridge-queue.d.ts +1 -0
  388. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  389. package/dist/services/codex-bridge-queue.js +1 -0
  390. package/dist/services/codex-bridge-queue.js.map +1 -1
  391. package/dist/services/codex-service-tier.d.ts +2 -0
  392. package/dist/services/codex-service-tier.d.ts.map +1 -1
  393. package/dist/services/codex-service-tier.js +1 -0
  394. package/dist/services/codex-service-tier.js.map +1 -1
  395. package/dist/services/codex-transcript.d.ts +27 -0
  396. package/dist/services/codex-transcript.d.ts.map +1 -1
  397. package/dist/services/codex-transcript.js +340 -2
  398. package/dist/services/codex-transcript.js.map +1 -1
  399. package/dist/services/grant-policy.d.ts +3 -0
  400. package/dist/services/grant-policy.d.ts.map +1 -1
  401. package/dist/services/grant-policy.js +5 -0
  402. package/dist/services/grant-policy.js.map +1 -1
  403. package/dist/services/grant-prefs-store.d.ts +6 -3
  404. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  405. package/dist/services/grant-prefs-store.js +42 -12
  406. package/dist/services/grant-prefs-store.js.map +1 -1
  407. package/dist/services/hook-runner.d.ts +20 -1
  408. package/dist/services/hook-runner.d.ts.map +1 -1
  409. package/dist/services/hook-runner.js +17 -13
  410. package/dist/services/hook-runner.js.map +1 -1
  411. package/dist/services/idempotency-store.d.ts +134 -0
  412. package/dist/services/idempotency-store.d.ts.map +1 -0
  413. package/dist/services/idempotency-store.js +299 -0
  414. package/dist/services/idempotency-store.js.map +1 -0
  415. package/dist/services/jsonl-cursor.d.ts +7 -3
  416. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  417. package/dist/services/jsonl-cursor.js +55 -31
  418. package/dist/services/jsonl-cursor.js.map +1 -1
  419. package/dist/services/open-platform-rename.d.ts.map +1 -1
  420. package/dist/services/open-platform-rename.js +1 -108
  421. package/dist/services/open-platform-rename.js.map +1 -1
  422. package/dist/services/restart-intent-store.d.ts +22 -2
  423. package/dist/services/restart-intent-store.d.ts.map +1 -1
  424. package/dist/services/restart-intent-store.js +160 -18
  425. package/dist/services/restart-intent-store.js.map +1 -1
  426. package/dist/services/sandbox-store.d.ts +22 -0
  427. package/dist/services/sandbox-store.d.ts.map +1 -1
  428. package/dist/services/sandbox-store.js +37 -8
  429. package/dist/services/sandbox-store.js.map +1 -1
  430. package/dist/services/send-policy.d.ts +41 -33
  431. package/dist/services/send-policy.d.ts.map +1 -1
  432. package/dist/services/send-policy.js +39 -40
  433. package/dist/services/send-policy.js.map +1 -1
  434. package/dist/services/session-store.d.ts +66 -0
  435. package/dist/services/session-store.d.ts.map +1 -1
  436. package/dist/services/session-store.js +403 -67
  437. package/dist/services/session-store.js.map +1 -1
  438. package/dist/services/submit-notification.d.ts +3 -0
  439. package/dist/services/submit-notification.d.ts.map +1 -0
  440. package/dist/services/submit-notification.js +15 -0
  441. package/dist/services/submit-notification.js.map +1 -0
  442. package/dist/services/traex-transcript.d.ts +25 -2
  443. package/dist/services/traex-transcript.d.ts.map +1 -1
  444. package/dist/services/traex-transcript.js +133 -1
  445. package/dist/services/traex-transcript.js.map +1 -1
  446. package/dist/services/trigger-types.d.ts +13 -1
  447. package/dist/services/trigger-types.d.ts.map +1 -1
  448. package/dist/services/trigger-types.js +39 -0
  449. package/dist/services/trigger-types.js.map +1 -1
  450. package/dist/services/voice/index.d.ts +3 -2
  451. package/dist/services/voice/index.d.ts.map +1 -1
  452. package/dist/services/voice/index.js +5 -5
  453. package/dist/services/voice/index.js.map +1 -1
  454. package/dist/services/voice/openai.d.ts +2 -1
  455. package/dist/services/voice/openai.d.ts.map +1 -1
  456. package/dist/services/voice/openai.js +2 -1
  457. package/dist/services/voice/openai.js.map +1 -1
  458. package/dist/services/voice/sami.d.ts +5 -2
  459. package/dist/services/voice/sami.d.ts.map +1 -1
  460. package/dist/services/voice/sami.js +19 -4
  461. package/dist/services/voice/sami.js.map +1 -1
  462. package/dist/setup/bots-store.d.ts.map +1 -1
  463. package/dist/setup/bots-store.js +10 -4
  464. package/dist/setup/bots-store.js.map +1 -1
  465. package/dist/setup/lark-scopes.json +2 -0
  466. package/dist/setup/open-platform-automation.d.ts +9 -1
  467. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  468. package/dist/setup/open-platform-automation.js +44 -15
  469. package/dist/setup/open-platform-automation.js.map +1 -1
  470. package/dist/setup/open-platform-visibility.d.ts +42 -0
  471. package/dist/setup/open-platform-visibility.d.ts.map +1 -0
  472. package/dist/setup/open-platform-visibility.js +133 -0
  473. package/dist/setup/open-platform-visibility.js.map +1 -0
  474. package/dist/setup/verify-permissions.d.ts.map +1 -1
  475. package/dist/setup/verify-permissions.js +6 -0
  476. package/dist/setup/verify-permissions.js.map +1 -1
  477. package/dist/skills/definitions.js +2 -2
  478. package/dist/types.d.ts +290 -13
  479. package/dist/types.d.ts.map +1 -1
  480. package/dist/utils/bot-routing.d.ts +1 -0
  481. package/dist/utils/bot-routing.d.ts.map +1 -1
  482. package/dist/utils/bot-routing.js +1 -1
  483. package/dist/utils/bot-routing.js.map +1 -1
  484. package/dist/utils/child-env.d.ts +1 -1
  485. package/dist/utils/child-env.d.ts.map +1 -1
  486. package/dist/utils/child-env.js +9 -0
  487. package/dist/utils/child-env.js.map +1 -1
  488. package/dist/utils/codex-app-control.d.ts +438 -0
  489. package/dist/utils/codex-app-control.d.ts.map +1 -0
  490. package/dist/utils/codex-app-control.js +2336 -0
  491. package/dist/utils/codex-app-control.js.map +1 -0
  492. package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
  493. package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
  494. package/dist/utils/codex-app-dispatch-ledger.js +149 -0
  495. package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
  496. package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
  497. package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
  498. package/dist/utils/codex-app-turn-dispatch.js +141 -0
  499. package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
  500. package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
  501. package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
  502. package/dist/utils/codex-app-turn-liveness.js +301 -0
  503. package/dist/utils/codex-app-turn-liveness.js.map +1 -0
  504. package/dist/utils/idle-detector.d.ts +12 -0
  505. package/dist/utils/idle-detector.d.ts.map +1 -1
  506. package/dist/utils/idle-detector.js +19 -0
  507. package/dist/utils/idle-detector.js.map +1 -1
  508. package/dist/utils/input-gate.d.ts +87 -0
  509. package/dist/utils/input-gate.d.ts.map +1 -1
  510. package/dist/utils/input-gate.js +86 -0
  511. package/dist/utils/input-gate.js.map +1 -1
  512. package/dist/utils/lark-upload.d.ts.map +1 -1
  513. package/dist/utils/lark-upload.js +52 -1
  514. package/dist/utils/lark-upload.js.map +1 -1
  515. package/dist/utils/pending-input-queue.d.ts +15 -0
  516. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  517. package/dist/utils/pending-input-queue.js +5 -1
  518. package/dist/utils/pending-input-queue.js.map +1 -1
  519. package/dist/utils/user-token.d.ts.map +1 -1
  520. package/dist/utils/user-token.js +53 -2
  521. package/dist/utils/user-token.js.map +1 -1
  522. package/dist/worker-ipc-preload.d.ts +12 -0
  523. package/dist/worker-ipc-preload.d.ts.map +1 -0
  524. package/dist/worker-ipc-preload.js +44 -0
  525. package/dist/worker-ipc-preload.js.map +1 -0
  526. package/dist/worker.js +2604 -325
  527. package/dist/worker.js.map +1 -1
  528. package/dist/workflows/v3/session-relay-client.d.ts +1 -0
  529. package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
  530. package/dist/workflows/v3/session-relay-client.js +12 -3
  531. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  532. package/package.json +1 -1
  533. package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
  534. package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
  535. package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
  536. package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
  537. package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
  538. package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
  539. package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
@@ -12,7 +12,7 @@ import * as sessionStore from '../services/session-store.js';
12
12
  import * as messageQueue from '../services/message-queue.js';
13
13
  import { downloadMessageResource, listChatBotMembers, UserTokenMissingError } from '../im/lark/client.js';
14
14
  import { logger } from '../utils/logger.js';
15
- import { forkWorker, sendWorkerInput, forkAdoptWorker, adoptSandboxBlocked, killStalePids, sweepDeadPidMarkers, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionIfActive, setActiveSessionSafe, isDisposableCommandScratch, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker, isSessionTransferring, deferUntilSessionTransferSettled } from './worker-pool.js';
15
+ import { forkWorker, sendWorkerInput, promoteQueuedActivationTail, forkAdoptWorker, adoptSandboxBlocked, killStalePids, sweepDeadPidMarkers, getCurrentCliVersion, restoreUsageLimitRuntimeState, setActiveSessionSafe, setActiveSessionIfActive, isDisposableCommandScratch, isRelayableRealSession, closeSession, getActiveSessionsRegistry, suspendWorker, withActiveSessionKeyLock, isSessionTransferring, deferUntilSessionTransferSettled } from './worker-pool.js';
16
16
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
17
17
  import { buildBotmuxShellHints } from '../adapters/cli/shared-hints.js';
18
18
  import { resolveSkillInjectionModeForApp, builtinSkillEntries, buildBuiltinSkillCatalogBlock, builtinSkillHelpPointer, } from '../skills/injection-mode.js';
@@ -26,7 +26,10 @@ import { composeSpawnCodexAppContext, composeSpawnUserContent, deriveSessionTitl
26
26
  import { validateZellijAdoptTarget } from './zellij-adopt-discovery.js';
27
27
  import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js';
28
28
  import { addCodexAppContext } from '../utils/codex-app-context.js';
29
- import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId } from './types.js';
29
+ import { hasUnsettledCodexAppDispatch } from '../utils/codex-app-dispatch-ledger.js';
30
+ import { hasProtectedSessionMutationOwnership } from './session-mutation-guard.js';
31
+ import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, } from './types.js';
32
+ import { stagePendingRepoSetup, persistPendingRepoCardMessageId, restorePendingRepoRuntime } from './pending-repo-journal.js';
30
33
  import { announceSessionRow, markSessionActivity, announcePendingRepoSession } from './session-activity.js';
31
34
  import { scanMultipleProjects } from '../services/project-scanner.js';
32
35
  import { buildRepoSelectCard } from '../im/lark/card-builder.js';
@@ -50,6 +53,88 @@ function sessionCreatedAtMs(session) {
50
53
  function sessionLastMessageAtMs(session) {
51
54
  return session.lastMessageAt ? (Date.parse(session.lastMessageAt) || sessionCreatedAtMs(session)) : sessionCreatedAtMs(session);
52
55
  }
56
+ async function resumeRestoredPendingRepoSetup(ds, activeSessions) {
57
+ const setup = ds.session.pendingRepoSetup;
58
+ if (!setup || ds.session.queuedActivationPending || !ds.pendingRepo)
59
+ return;
60
+ const anchor = sessionAnchorId(ds);
61
+ const notify = async (content) => {
62
+ const { sendMessage, replyMessage } = await import('../im/lark/client.js');
63
+ return ds.scope === 'thread'
64
+ ? replyMessage(ds.larkAppId, anchor, content, 'text', true)
65
+ : sendMessage(ds.larkAppId, ds.chatId, content, 'text');
66
+ };
67
+ if (setup.mode === 'auto_worktree' && setup.baseDir) {
68
+ const { runAutoWorktreeCommit } = await import('../im/lark/card-handler.js');
69
+ void runAutoWorktreeCommit({
70
+ ds,
71
+ anchor,
72
+ larkAppId: ds.larkAppId,
73
+ baseDir: setup.baseDir,
74
+ title: ds.session.title,
75
+ prompt: setup.prompt,
76
+ operatorOpenId: ds.session.ownerOpenId,
77
+ activeSessions,
78
+ notify,
79
+ }).catch((err) => {
80
+ // Git/worktree recovery is deliberately detached. A failed publish or
81
+ // build may not reject daemon startup or erase this durable setup owner.
82
+ restorePendingRepoRuntime(ds);
83
+ logger.error(`[${ds.session.sessionId.substring(0, 8)}] Pending auto-worktree recovery failed; `
84
+ + `durable setup retained for retry: ${err instanceof Error ? err.message : String(err)}`);
85
+ });
86
+ logger.info(`[${ds.session.sessionId.substring(0, 8)}] Restarted durable pending auto-worktree setup`);
87
+ return;
88
+ }
89
+ // Always publish a fresh picker after restart: a persisted message id proves
90
+ // identity, not that the Lark message still exists. Persist the replacement
91
+ // before best-effort withdrawal so exactly one card remains authoritative.
92
+ const oldCardMessageId = setup.repoCardMessageId;
93
+ const scanDirs = getProjectScanDirs(ds).filter(dir => existsSync(dir));
94
+ const projects = scanDirs.length > 0
95
+ ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions())
96
+ : [];
97
+ if (projects.length > 0) {
98
+ const bot = getBot(ds.larkAppId);
99
+ const card = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(ds.larkAppId), bot.config.worktreeMultiPicker);
100
+ const { sendMessage, replyMessage, deleteMessage } = await import('../im/lark/client.js');
101
+ const messageId = ds.scope === 'thread'
102
+ ? await replyMessage(ds.larkAppId, anchor, card, 'interactive', true)
103
+ : await sendMessage(ds.larkAppId, ds.chatId, card, 'interactive');
104
+ ds.repoCardMessageId = messageId;
105
+ persistPendingRepoCardMessageId(ds, messageId);
106
+ if (oldCardMessageId && oldCardMessageId !== messageId) {
107
+ void deleteMessage(ds.larkAppId, oldCardMessageId).catch(() => { });
108
+ }
109
+ announcePendingRepoSession(ds);
110
+ logger.info(`[${ds.session.sessionId.substring(0, 8)}] Re-posted durable pending repo picker`);
111
+ return;
112
+ }
113
+ // The original pre-card scan may have crashed before learning there was no
114
+ // selectable repo. Resume the same no-project fallback without replacing N.
115
+ ds.pendingRepo = false;
116
+ ensureSessionWhiteboard(ds);
117
+ if (setup.rawInput) {
118
+ rememberLastCliInput(ds, setup.rawInput, setup.rawInput);
119
+ forkWorker(ds, '', { resume: false, turnId: setup.turnId });
120
+ }
121
+ else {
122
+ const bot = getBot(ds.larkAppId);
123
+ const availableBots = await getAvailableBots(ds.larkAppId, ds.chatId);
124
+ const input = buildNewTopicCliInput(setup.prompt, ds.session.sessionId, ds.session.cliId ?? bot.config.cliId, ds.session.cliPathOverride ?? bot.config.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(ds.larkAppId), ds.pendingSender, {
125
+ larkAppId: ds.larkAppId,
126
+ chatId: ds.chatId,
127
+ whiteboardId: ds.session.whiteboardId,
128
+ substituteTrigger: ds.pendingSubstituteTrigger,
129
+ codexAppText: ds.pendingCodexAppText,
130
+ codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
131
+ codexAppMessageContext: ds.pendingCodexAppMessageContext,
132
+ });
133
+ rememberLastCliInput(ds, setup.prompt, input);
134
+ forkWorker(ds, input, { resume: false, turnId: setup.turnId });
135
+ }
136
+ logger.info(`[${ds.session.sessionId.substring(0, 8)}] Resumed durable pending repo opening without selectable projects`);
137
+ }
53
138
  function quarantineUnregisteredRestoreSession(session, reason) {
54
139
  session.restoreQuarantinedAt ??= new Date().toISOString();
55
140
  try {
@@ -150,6 +235,23 @@ async function closeActiveSessionIfCliMismatch(ds) {
150
235
  const mismatch = sessionBotCliMismatch(ds);
151
236
  if (!mismatch)
152
237
  return 'not_mismatched';
238
+ // A config mismatch is not an explicit user close. In particular, bots.json
239
+ // may be edited while the daemon is down, leaving a persisted backend-neutral
240
+ // activation owner (or Codex App final) that only the frozen generation can
241
+ // reconcile on restart. Preserve it until its durable ownership settles.
242
+ // Report 'not_mismatched' so every caller leaves the row alone (no close, no
243
+ // teardown-failure quarantine, no transfer resweep) exactly as PR #597's
244
+ // boolean `return false` did before master's enum refactor.
245
+ if (hasProtectedSessionMutationOwnership(ds)) {
246
+ logger.warn(`[${ds.session.sessionId.substring(0, 8)}] CLI mismatch retained until `
247
+ + 'durable activation ownership is reconciled');
248
+ return 'not_mismatched';
249
+ }
250
+ // queuedActivationPending is the backend-neutral write-ahead owner for an
251
+ // opening/successor that has not crossed its adapter submission boundary.
252
+ // Its durable tail is ordered behind that owner. A bot config edit is not an
253
+ // explicit abandon operation, so it must not close backend work or a
254
+ // tail-only restore before recovery promotes its first entry.
153
255
  const tag = ds.session.sessionId.substring(0, 8);
154
256
  if (isSessionTransferring(ds)) {
155
257
  logger.warn(`[${tag}] CLI mismatch close deferred until routing transfer settles`);
@@ -206,6 +308,11 @@ export async function closeCliMismatchedSessionsForBot(larkAppId) {
206
308
  continue;
207
309
  if (ds.adoptedFrom || ds.session.adoptedFrom || ds.session.title?.startsWith('Adopt:'))
208
310
  continue;
311
+ // Defense in depth: the dashboard toggle preflights the whole bot before
312
+ // changing config. Never let a refused suspend fall through into
313
+ // closeSession: close is explicit abandon, and a settings toggle is not.
314
+ if (hasProtectedSessionMutationOwnership(ds))
315
+ continue;
209
316
  if (isSessionTransferring(ds)) {
210
317
  armCliMismatchResweep(ds);
211
318
  continue;
@@ -246,6 +353,11 @@ export async function suspendActiveSessionsForBot(larkAppId) {
246
353
  continue;
247
354
  if (ds.adoptedFrom || ds.session.adoptedFrom || ds.session.title?.startsWith('Adopt:'))
248
355
  continue;
356
+ // A settings helper is never an explicit abandon boundary. In particular,
357
+ // suspendWorker intentionally refuses pending Codex App ownership; do not
358
+ // reinterpret that refusal as permission to close and erase the FIFO.
359
+ if (hasProtectedSessionMutationOwnership(ds))
360
+ continue;
249
361
  if (isSessionTransferring(ds)) {
250
362
  logger.warn(`[${ds.session.sessionId.substring(0, 8)}] Read-isolation cold restart deferred during routing transfer`);
251
363
  continue;
@@ -1192,17 +1304,29 @@ const RECOVERY_FORK_DELAY_MS = config.daemon.recoveryForkDelayMs ?? 250;
1192
1304
  * staggered to avoid a thundering-herd CPU/IO spike when many sessions survive a
1193
1305
  * restart: spawn `batchSize` workers, wait `delayMs`, repeat.
1194
1306
  *
1195
- * Sessions whose worker is already live are skipped a real message can arrive
1196
- * (the Lark dispatcher is up before restore finishes) and lazily fork the worker
1197
- * during one of our `delayMs` pauses; re-forking it here would kill that live
1198
- * worker mid-turn via `forkWorker`'s double-fork guard.
1307
+ * Sessions whose worker is already live are skipped. Startup admissions are
1308
+ * held behind restore, but lifecycle callbacks or a future recovery path can
1309
+ * still close/replace/wake an entry during one of the batch delays; re-forking
1310
+ * that stale object would kill the current worker via the double-fork guard.
1199
1311
  */
1200
- export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY_FORK_BATCH_SIZE, delayMs = RECOVERY_FORK_DELAY_MS) {
1312
+ export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY_FORK_BATCH_SIZE, delayMs = RECOVERY_FORK_DELAY_MS, stillOwned = ds => ds.session.status === 'active') {
1201
1313
  let spawnedInBatch = 0;
1202
1314
  for (const ds of sessions) {
1203
- if (ds.worker)
1204
- continue; // already woken by a real message don't clobber it
1205
- fork(ds);
1315
+ // The batch delay is a lifecycle boundary: close/replace can remove this
1316
+ // exact object while we sleep. Never resurrect a closed or orphaned ds.
1317
+ if (ds.worker || ds.session.status !== 'active' || !stillOwned(ds))
1318
+ continue;
1319
+ try {
1320
+ fork(ds);
1321
+ }
1322
+ catch (err) {
1323
+ // One malformed/stale pane or synchronous init-IPC failure must not
1324
+ // abort recovery for every later durable owner. forkWorker compensates
1325
+ // its own pre-init journal mutations; isolate this row and continue.
1326
+ logger.error(`[${ds.session.sessionId.substring(0, 8)}] Recovery fork failed; retained for later retry: `
1327
+ + `${err instanceof Error ? err.message : String(err)}`);
1328
+ continue;
1329
+ }
1206
1330
  if (++spawnedInBatch >= batchSize) {
1207
1331
  spawnedInBatch = 0;
1208
1332
  if (delayMs > 0)
@@ -1210,7 +1334,7 @@ export async function staggeredRecoveryFork(sessions, fork, batchSize = RECOVERY
1210
1334
  }
1211
1335
  }
1212
1336
  }
1213
- export async function restoreActiveSessions(activeSessions) {
1337
+ export async function restoreActiveSessions(activeSessions, quarantinedSessionIds = new Set()) {
1214
1338
  const sessions = sessionStore.listSessions();
1215
1339
  const restorePriority = (session) => {
1216
1340
  if (session.adoptedFrom || session.cliId || session.lastCliInput || session.backendType)
@@ -1225,6 +1349,13 @@ export async function restoreActiveSessions(activeSessions) {
1225
1349
  // every startup candidate regardless of this disk ordering.
1226
1350
  const active = sessions
1227
1351
  .filter(s => s.status === 'active')
1352
+ // Idempotency quarantine (at-most-once): a session the boot reconcile just
1353
+ // terminalized as `dispatch_unknown` (or dropped as a pre-dispatch reserved
1354
+ // orphan) must NOT be re-attached — the poller now sees it `failed`, so
1355
+ // reviving its old pane would diverge state from execution. The reconcile
1356
+ // best-effort closed it; this is the belt-and-suspenders exclusion in case
1357
+ // that close failed.
1358
+ .filter(s => !quarantinedSessionIds.has(s.sessionId))
1228
1359
  .sort((a, b) => restorePriority(b) - restorePriority(a));
1229
1360
  // Sweep dead CLI-pid markers regardless of whether we have sessions to restore:
1230
1361
  // the landmine files (recycled-PID misroute source) accumulate across every
@@ -1252,66 +1383,196 @@ export async function restoreActiveSessions(activeSessions) {
1252
1383
  const stillOwnsRestoreRegistration = (ds) => ds.session.status === 'active'
1253
1384
  && activeSessions.get(activeSessionKey(ds)) === ds;
1254
1385
  for (const session of active) {
1255
- // Dispatcher/IPC may create and register this exact persisted row while
1256
- // startup restore is running. The runtime object is authoritative; never
1257
- // rebuild or later close it as a collision loser.
1258
- if (runtimeWinnerFor(session.sessionId)) {
1259
- logger.debug(`[${session.sessionId.substring(0, 8)}] Already registered by live runtime during restore; skipping snapshot row`);
1260
- continue;
1261
- }
1262
- // Restored sessions persisted before the scope field was added default to
1263
- // 'thread' that matches the legacy thread-only behaviour.
1264
- const scope = session.scope === 'chat' ? 'chat' : 'thread';
1265
- // Persisted metadata is the authoritative adopt marker. The title is
1266
- // user-editable via /rename and must not change restore semantics.
1267
- if (session.adoptedFrom) {
1268
- const adopted = session.adoptedFrom;
1269
- // Fail-closed BEFORE building an adopt DaemonSession: a sandbox-enabled
1270
- // session (frozen `session.sandbox`, or a bot that now requires the
1271
- // sandbox) can't attach to an already-running host CLI (confinement is
1272
- // spawn-time only). Convert it to a plain cold-start IN PLACE — clear the
1273
- // adopt metadata, normalize the "Adopt: …" title (else the next restart's
1274
- // legacy title-only branch below would permanently close it), persist —
1275
- // then fall through to the normal restore path so it registers/announces
1276
- // as an ordinary session, NOT an adopt row. Doing the conversion here (not
1277
- // via a worker-pool side-effect after announceSessionRow) keeps daemon
1278
- // orchestration state consistent.
1279
- let adoptBotCfg = {};
1280
- try {
1281
- adoptBotCfg = getBot(session.larkAppId ?? '').config;
1386
+ try {
1387
+ // True when this row's durable activation-tail promotion failed transiently
1388
+ // and it was registered as a quarantined owner (see the promotion block).
1389
+ let quarantinedActivationTailPromotion = false;
1390
+ // Dispatcher/IPC may create and register this exact persisted row while
1391
+ // startup restore is running. The runtime object is authoritative; never
1392
+ // rebuild or later close it as a collision loser.
1393
+ if (runtimeWinnerFor(session.sessionId)) {
1394
+ logger.debug(`[${session.sessionId.substring(0, 8)}] Already registered by live runtime during restore; skipping snapshot row`);
1395
+ continue;
1282
1396
  }
1283
- catch { /* unknown bot only the frozen decision matters */ }
1284
- if (adoptSandboxBlocked(adoptBotCfg, session)) {
1285
- logger.warn(`[${session.sessionId.substring(0, 8)}] isolated/no-transport session persisted as adopt converting to cold-start (a sandbox / apiOnly bot can't wrap a live external CLI)`);
1286
- session.adoptedFrom = undefined;
1287
- if (session.title?.startsWith('Adopt:')) {
1288
- const project = session.title.slice('Adopt:'.length).trim();
1289
- session.title = project || 'Session';
1290
- }
1397
+ // Restored sessions persisted before the scope field was added default to
1398
+ // 'thread' that matches the legacy thread-only behaviour.
1399
+ const scope = session.scope === 'chat' ? 'chat' : 'thread';
1400
+ // Persisted metadata is the authoritative adopt marker. The title is
1401
+ // user-editable via /rename and must not change restore semantics.
1402
+ if (session.adoptedFrom) {
1403
+ const adopted = session.adoptedFrom;
1404
+ // Fail-closed BEFORE building an adopt DaemonSession: a sandbox-enabled
1405
+ // session (frozen `session.sandbox`, or a bot that now requires the
1406
+ // sandbox) can't attach to an already-running host CLI (confinement is
1407
+ // spawn-time only). Convert it to a plain cold-start IN PLACE — clear the
1408
+ // adopt metadata, normalize the "Adopt: …" title (else the next restart's
1409
+ // legacy title-only branch below would permanently close it), persist —
1410
+ // then fall through to the normal restore path so it registers/announces
1411
+ // as an ordinary session, NOT an adopt row. Doing the conversion here (not
1412
+ // via a worker-pool side-effect after announceSessionRow) keeps daemon
1413
+ // orchestration state consistent.
1414
+ let adoptBotCfg = {};
1291
1415
  try {
1292
- sessionStore.updateSession(session);
1416
+ adoptBotCfg = getBot(session.larkAppId ?? '').config;
1293
1417
  }
1294
- catch { /* best-effort */ }
1295
- // fall through: session.adoptedFrom is now unset → normal restore below
1296
- }
1297
- else {
1298
- const frozenRuntimeExecutable = session.cliRuntime?.source === 'configured'
1299
- ? session.cliRuntime.executable
1300
- : undefined;
1301
- const validation = adopted.zellijPaneId
1302
- ? (typeof adopted.originalCliPid === 'number' && validateZellijAdoptTarget(adopted.zellijSession ?? '', adopted.zellijPaneId, adopted.originalCliPid, adopted.cliId, frozenRuntimeExecutable) ? 'alive' : 'missing')
1303
- : validateAdoptTargetState(adopted, frozenRuntimeExecutable);
1304
- if (validation === 'missing') {
1305
- logger.info(`Closing adopt session ${session.sessionId} (adopted target exited: ${adoptTargetLabel(adopted)})`);
1306
- sessionStore.closeSession(session.sessionId);
1307
- continue;
1418
+ catch { /* unknown bot → only the frozen decision matters */ }
1419
+ if (adoptSandboxBlocked(adoptBotCfg, session)) {
1420
+ logger.warn(`[${session.sessionId.substring(0, 8)}] isolated/no-transport session persisted as adopt — converting to cold-start (a sandbox / apiOnly bot can't wrap a live external CLI)`);
1421
+ session.adoptedFrom = undefined;
1422
+ if (session.title?.startsWith('Adopt:')) {
1423
+ const project = session.title.slice('Adopt:'.length).trim();
1424
+ session.title = project || 'Session';
1425
+ }
1426
+ try {
1427
+ sessionStore.updateSession(session);
1428
+ }
1429
+ catch { /* best-effort */ }
1430
+ // fall through: session.adoptedFrom is now unset → normal restore below
1308
1431
  }
1309
- if (validation === 'unknown') {
1310
- logger.warn(`Keeping adopt session ${session.sessionId} active but quarantined until the target can be verified (target validation failed: ${adoptTargetLabel(adopted)})`);
1311
- quarantineUnregisteredRestoreSession(session, 'adopt_target_validation_unknown');
1432
+ else {
1433
+ const frozenRuntimeExecutable = session.cliRuntime?.source === 'configured'
1434
+ ? session.cliRuntime.executable
1435
+ : undefined;
1436
+ const validation = adopted.zellijPaneId
1437
+ ? (typeof adopted.originalCliPid === 'number' && validateZellijAdoptTarget(adopted.zellijSession ?? '', adopted.zellijPaneId, adopted.originalCliPid, adopted.cliId, frozenRuntimeExecutable) ? 'alive' : 'missing')
1438
+ : validateAdoptTargetState(adopted, frozenRuntimeExecutable);
1439
+ if (validation === 'missing') {
1440
+ logger.info(`Closing adopt session ${session.sessionId} (adopted target exited: ${adoptTargetLabel(adopted)})`);
1441
+ sessionStore.closeSession(session.sessionId);
1442
+ continue;
1443
+ }
1444
+ if (validation === 'unknown') {
1445
+ logger.warn(`Keeping adopt session ${session.sessionId} active but quarantined until the target can be verified (target validation failed: ${adoptTargetLabel(adopted)})`);
1446
+ quarantineUnregisteredRestoreSession(session, 'adopt_target_validation_unknown');
1447
+ continue;
1448
+ }
1449
+ // Original CLI still alive — re-register and fork adopt worker
1450
+ const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1451
+ const ds = {
1452
+ session,
1453
+ worker: null,
1454
+ workerPort: null,
1455
+ workerToken: null,
1456
+ larkAppId,
1457
+ chatId: session.chatId,
1458
+ chatType: session.chatType ?? 'group',
1459
+ scope,
1460
+ spawnedAt: sessionCreatedAtMs(session),
1461
+ cliVersion: getCurrentCliVersion(),
1462
+ lastMessageAt: sessionLastMessageAtMs(session),
1463
+ hasHistory: false,
1464
+ workingDir: adopted.cwd,
1465
+ ownerOpenId: session.ownerOpenId,
1466
+ adoptedFrom: adopted,
1467
+ streamCardId: session.streamCardId,
1468
+ streamCardNonce: session.streamCardNonce,
1469
+ displayMode: session.displayMode === 'screenshot' || session.displayMode === 'hidden'
1470
+ ? session.displayMode
1471
+ : (session.streamExpanded ? 'screenshot' : 'hidden'),
1472
+ currentImageKey: session.currentImageKey,
1473
+ currentTurnTitle: session.currentTurnTitle,
1474
+ usageLimit: session.usageLimit,
1475
+ lastUserPrompt: session.lastUserPrompt,
1476
+ lastCliInput: session.lastCliInput,
1477
+ lastCodexAppInput: session.lastCodexAppInput,
1478
+ replyThreadAliases: session.replyThreadAliases,
1479
+ currentReplyTarget: session.currentReplyTarget,
1480
+ // Restart stays silent for adopt sessions too: forkAdoptWorker shares
1481
+ // setupWorkerHandlers, so the recovery ready/screen_update would post a
1482
+ // card without this. Cleared on the first real CLI input.
1483
+ suppressRecoveryCard: true,
1484
+ };
1485
+ const anchor = sessionAnchorId(ds);
1486
+ messageQueue.ensureQueue(anchor);
1487
+ if (ds.usageLimit)
1488
+ restoreUsageLimitRuntimeState(ds);
1489
+ // Same-key collision guard: if a prior iteration already set an entry
1490
+ // at this key (legitimately possible if disk holds two active sessions
1491
+ // resolving to the same chat-scope key — e.g. a leaked scratch +
1492
+ // relayed real session from a prior buggy run), reject and close the
1493
+ // incoming loser rather than silently overwriting the runtime winner.
1494
+ // #597: setActiveSessionSafe returns a structured result so a
1495
+ // both_pending / cleanup_failed collision keeps the durable row + pane
1496
+ // (quarantined) instead of aborting daemon startup.
1497
+ const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
1498
+ // A live runtime object (dispatcher/IPC woke this exact session during
1499
+ // restore) is authoritative; skip the snapshot row without treating the
1500
+ // benign race as a collision error.
1501
+ if (runtimeWinnerFor(session.sessionId, ds)) {
1502
+ logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won adopt restore registration`);
1503
+ continue;
1504
+ }
1505
+ if (!registration.accepted) {
1506
+ if (registration.reason === 'both_pending' || registration.reason === 'cleanup_failed') {
1507
+ logger.error(`[${session.sessionId.substring(0, 8)}] Isolated adopt restore collision; `
1508
+ + `durable row/pane retained without aborting daemon startup: ${registration.reason === 'both_pending'
1509
+ ? `two protected owners at ${activeSessionKey(ds)}`
1510
+ : `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
1511
+ continue;
1512
+ }
1513
+ logger.warn(`[${session.sessionId.substring(0, 8)}] restore collision lost to unsettled session ${registration.keptSessionId.substring(0, 8)}`);
1514
+ continue;
1515
+ }
1516
+ restoredByThisInvocation.push(ds);
1517
+ announceSessionRow(ds);
1518
+ forkAdoptWorker(ds, { restoredFromMetadata: true });
1519
+ logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`);
1312
1520
  continue;
1313
1521
  }
1314
- // Original CLI still alive — re-register and fork adopt worker
1522
+ }
1523
+ // Title-only adopt sessions have no target metadata and can only come from
1524
+ // legacy records. They cannot be validated or safely restored.
1525
+ if (session.title?.startsWith('Adopt:')) {
1526
+ logger.debug(`Closing adopt session ${session.sessionId} (no persisted metadata)`);
1527
+ sessionStore.closeSession(session.sessionId);
1528
+ continue;
1529
+ }
1530
+ if (session.queuedActivationPending) {
1531
+ const unsettledCodex = hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger);
1532
+ if (session.cliId === 'codex-app') {
1533
+ if (!unsettledCodex && !session.pendingRepoSetup?.rawInput) {
1534
+ // An activation FIFO can become empty only after its exact prepared
1535
+ // head settled. A lost submission-ACK persistence must not re-accept
1536
+ // the old prompt after that terminal proof.
1537
+ const priorTerminalActivation = {
1538
+ queuedActivationPending: session.queuedActivationPending,
1539
+ queuedActivationToken: session.queuedActivationToken,
1540
+ queuedActivationInput: session.queuedActivationInput,
1541
+ queuedActivationTurnId: session.queuedActivationTurnId,
1542
+ queuedActivationDispatchAttempt: session.queuedActivationDispatchAttempt,
1543
+ queuedActivationResume: session.queuedActivationResume,
1544
+ queuedPrompt: session.queuedPrompt,
1545
+ queuedCodexAppText: session.queuedCodexAppText,
1546
+ queuedCodexAppMessageContext: session.queuedCodexAppMessageContext,
1547
+ pendingRepoSetup: session.pendingRepoSetup,
1548
+ };
1549
+ session.queuedActivationPending = undefined;
1550
+ session.queuedActivationToken = undefined;
1551
+ session.queuedActivationInput = undefined;
1552
+ session.queuedActivationTurnId = undefined;
1553
+ session.queuedActivationDispatchAttempt = undefined;
1554
+ session.queuedActivationResume = undefined;
1555
+ session.queuedPrompt = undefined;
1556
+ session.queuedCodexAppText = undefined;
1557
+ session.queuedCodexAppMessageContext = undefined;
1558
+ session.pendingRepoSetup = undefined;
1559
+ try {
1560
+ sessionStore.updateSession(session);
1561
+ }
1562
+ catch (err) {
1563
+ Object.assign(session, priorTerminalActivation);
1564
+ throw err;
1565
+ }
1566
+ }
1567
+ }
1568
+ }
1569
+ // Queued(待办池)会话:CLI 从没起过,restore 必须保持 parked(hasHistory:false +
1570
+ // queued),绝不能走下面 hasHistory:true 的通用分支——否则下一条消息会 --resume 一个
1571
+ // 不存在的 CLI 会话。pendingPrompt 从持久化的 queuedPrompt 恢复,供激活时发首轮。
1572
+ if (session.queued) {
1573
+ if (hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger)) {
1574
+ throw new Error(`queued session ${session.sessionId} carries an unsettled Codex App dispatch`);
1575
+ }
1315
1576
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1316
1577
  const ds = {
1317
1578
  session,
@@ -1326,66 +1587,54 @@ export async function restoreActiveSessions(activeSessions) {
1326
1587
  cliVersion: getCurrentCliVersion(),
1327
1588
  lastMessageAt: sessionLastMessageAtMs(session),
1328
1589
  hasHistory: false,
1329
- workingDir: adopted.cwd,
1590
+ workingDir: session.workingDir,
1330
1591
  ownerOpenId: session.ownerOpenId,
1331
- adoptedFrom: adopted,
1332
- streamCardId: session.streamCardId,
1333
- streamCardNonce: session.streamCardNonce,
1334
- displayMode: session.displayMode === 'screenshot' || session.displayMode === 'hidden'
1335
- ? session.displayMode
1336
- : (session.streamExpanded ? 'screenshot' : 'hidden'),
1337
- currentImageKey: session.currentImageKey,
1338
- currentTurnTitle: session.currentTurnTitle,
1339
- usageLimit: session.usageLimit,
1340
- lastUserPrompt: session.lastUserPrompt,
1341
- lastCliInput: session.lastCliInput,
1342
- lastCodexAppInput: session.lastCodexAppInput,
1343
- replyThreadAliases: session.replyThreadAliases,
1344
- currentReplyTarget: session.currentReplyTarget,
1345
- // Restart stays silent for adopt sessions too: forkAdoptWorker shares
1346
- // setupWorkerHandlers, so the recovery ready/screen_update would post a
1347
- // card without this. Cleared on the first real CLI input.
1348
- suppressRecoveryCard: true,
1592
+ pendingPrompt: session.queuedPrompt,
1593
+ pendingCodexAppText: session.queuedCodexAppText,
1594
+ pendingCodexAppMessageContext: session.queuedCodexAppMessageContext,
1595
+ pendingAttachments: session.queuedAttachments,
1596
+ currentTurnTitle: session.currentTurnTitle ?? session.title,
1349
1597
  };
1598
+ const restoredPendingRepo = restorePendingRepoRuntime(ds);
1350
1599
  const anchor = sessionAnchorId(ds);
1351
1600
  messageQueue.ensureQueue(anchor);
1352
- if (ds.usageLimit)
1353
- restoreUsageLimitRuntimeState(ds);
1354
- // Same-key collision guard: if a prior iteration already set an entry
1355
- // at this key (legitimately possible if disk holds two active sessions
1356
- // resolving to the same chat-scope key — e.g. a leaked scratch +
1357
- // relayed real session from a prior buggy run), reject and close the
1358
- // incoming loser rather than silently overwriting the runtime winner.
1359
- const adoptKey = activeSessionKey(ds);
1360
- const adoptRegistered = await setActiveSessionSafe(activeSessions, adoptKey, ds);
1361
- const adoptCurrent = activeSessions.get(adoptKey);
1601
+ const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
1362
1602
  if (runtimeWinnerFor(session.sessionId, ds)) {
1363
- logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won adopt restore registration`);
1603
+ logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won queued restore registration`);
1364
1604
  continue;
1365
1605
  }
1366
- if (!adoptRegistered || session.status !== 'active' || adoptCurrent !== ds) {
1367
- logger.warn(`[${session.sessionId.substring(0, 8)}] Adopt restore was cancelled while resolving a routing collision`);
1368
- await closeSession(session.sessionId);
1606
+ if (!registration.accepted) {
1607
+ if (registration.reason === 'both_pending' || registration.reason === 'cleanup_failed') {
1608
+ logger.error(`[${session.sessionId.substring(0, 8)}] Isolated queued restore collision; `
1609
+ + `exact setup journal retained without aborting daemon startup: ${registration.reason === 'both_pending'
1610
+ ? `two protected owners at ${activeSessionKey(ds)}`
1611
+ : `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
1612
+ continue;
1613
+ }
1614
+ logger.warn(`[${session.sessionId.substring(0, 8)}] queued restore collision lost to unsettled session ${registration.keptSessionId.substring(0, 8)}`);
1369
1615
  continue;
1370
1616
  }
1371
1617
  restoredByThisInvocation.push(ds);
1618
+ // 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复,
1619
+ // 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。
1372
1620
  announceSessionRow(ds);
1373
- forkAdoptWorker(ds, { restoredFromMetadata: true });
1374
- logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`);
1621
+ if (restoredPendingRepo) {
1622
+ try {
1623
+ await resumeRestoredPendingRepoSetup(ds, activeSessions);
1624
+ }
1625
+ catch (err) {
1626
+ // One unavailable scan/Lark send/worktree import must not abort the
1627
+ // entire daemon restore. Rebuild volatile buffers from the retained
1628
+ // setup journal so a later retry/card action remains lossless.
1629
+ restorePendingRepoRuntime(ds);
1630
+ logger.error(`[${session.sessionId.substring(0, 8)}] Pending-repo restore failed; `
1631
+ + `durable setup retained for retry: ${err instanceof Error ? err.message : String(err)}`);
1632
+ }
1633
+ }
1634
+ logger.info(`[${session.sessionId.substring(0, 8)}] Restored queued `
1635
+ + `${restoredPendingRepo ? 'pending-repo' : '(待办池)'} session (scope: ${scope})`);
1375
1636
  continue;
1376
1637
  }
1377
- }
1378
- // Title-only adopt sessions have no target metadata and can only come from
1379
- // legacy records. They cannot be validated or safely restored.
1380
- if (session.title?.startsWith('Adopt:')) {
1381
- logger.debug(`Closing adopt session ${session.sessionId} (no persisted metadata)`);
1382
- sessionStore.closeSession(session.sessionId);
1383
- continue;
1384
- }
1385
- // Queued(待办池)会话:CLI 从没起过,restore 必须保持 parked(hasHistory:false +
1386
- // queued),绝不能走下面 hasHistory:true 的通用分支——否则下一条消息会 --resume 一个
1387
- // 不存在的 CLI 会话。pendingPrompt 从持久化的 queuedPrompt 恢复,供激活时发首轮。
1388
- if (session.queued) {
1389
1638
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1390
1639
  const ds = {
1391
1640
  session,
@@ -1399,164 +1648,203 @@ export async function restoreActiveSessions(activeSessions) {
1399
1648
  spawnedAt: sessionCreatedAtMs(session),
1400
1649
  cliVersion: getCurrentCliVersion(),
1401
1650
  lastMessageAt: sessionLastMessageAtMs(session),
1402
- hasHistory: false,
1651
+ hasHistory: session.queuedActivationPending
1652
+ ? (session.queuedActivationResume ?? false)
1653
+ : true, // restored ordinary sessions have prior CLI history
1654
+ initialStartPending: session.queuedActivationPending === true
1655
+ || (session.queuedActivationTail?.length ?? 0) > 0,
1403
1656
  workingDir: session.workingDir,
1404
1657
  ownerOpenId: session.ownerOpenId,
1405
- pendingPrompt: session.queuedPrompt,
1406
- pendingCodexAppText: session.queuedCodexAppText,
1407
- pendingCodexAppMessageContext: session.queuedCodexAppMessageContext,
1408
- pendingAttachments: session.queuedAttachments,
1409
- currentTurnTitle: session.currentTurnTitle ?? session.title,
1658
+ // Restore persisted streaming-card state — next screen_update will PATCH
1659
+ // the existing card instead of POSTing a fresh one. If the card was
1660
+ // withdrawn while we were down, the PATCH fails with MessageWithdrawnError
1661
+ // and the existing handler (worker-pool flushCardPatch) clears streamCardId,
1662
+ // letting the next update create a new card.
1663
+ streamCardId: session.streamCardId,
1664
+ streamCardNonce: session.streamCardNonce,
1665
+ displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
1666
+ currentImageKey: session.currentImageKey,
1667
+ currentTurnTitle: session.currentTurnTitle,
1668
+ usageLimit: session.usageLimit,
1669
+ lastUserPrompt: session.lastUserPrompt,
1670
+ lastCliInput: session.lastCliInput,
1671
+ lastCodexAppInput: session.lastCodexAppInput,
1672
+ replyThreadAliases: session.replyThreadAliases,
1673
+ currentReplyTarget: session.currentReplyTarget,
1674
+ // Restart stays silent in the group: the recovery re-fork won't post or
1675
+ // patch a streaming card. Cleared on the first real CLI input.
1676
+ suppressRecoveryCard: true,
1410
1677
  };
1678
+ if (session.deferredScheduleRun) {
1679
+ const binding = readDeferredTopicBinding(config.session.dataDir, session.sessionId);
1680
+ if (!binding) {
1681
+ // A daemon restart makes an unmaterialized hidden run ambiguous: there
1682
+ // is no visible conversation to resume and no safe user-facing place
1683
+ // to report recovery. Fence its backing process and close the audit row
1684
+ // instead of resurrecting an invisible worker.
1685
+ const backendType = getSessionPersistentBackendType(ds);
1686
+ if (backendType) {
1687
+ try {
1688
+ killPersistentBackendTarget(persistentBackendTargetForSession(ds), session.sessionId);
1689
+ }
1690
+ catch (err) {
1691
+ // Keep the row active when destruction cannot be proved. Closing it
1692
+ // would strand a possibly-live hidden run with no ownership record;
1693
+ // skipping registration also prevents this restore pass from
1694
+ // accidentally reattaching it. Continue with the remaining rows.
1695
+ logger.error(`[${session.sessionId.substring(0, 8)}] Could not tear down unmaterialized deferred run; keeping active row: `
1696
+ + `${err instanceof Error ? err.message : String(err)}`);
1697
+ quarantineUnregisteredRestoreSession(session, 'deferred_run_teardown_unverified');
1698
+ continue;
1699
+ }
1700
+ }
1701
+ sessionStore.closeSession(session.sessionId);
1702
+ removeDeferredTopicBinding(config.session.dataDir, session.sessionId);
1703
+ logger.info(`[${session.sessionId.substring(0, 8)}] Closed unmaterialized deferred schedule run during restore`);
1704
+ continue;
1705
+ }
1706
+ const nowIso = new Date().toISOString();
1707
+ const aliases = { ...(session.replyThreadAliases ?? {}) };
1708
+ aliases[binding.rootMessageId] = {
1709
+ createdAt: aliases[binding.rootMessageId]?.createdAt ?? binding.createdAt,
1710
+ lastUsedAt: nowIso,
1711
+ };
1712
+ session.replyThreadAliases = aliases;
1713
+ session.rootMessageId = binding.rootMessageId;
1714
+ ds.replyThreadAliases = aliases;
1715
+ sessionStore.updateSession(session);
1716
+ }
1717
+ // Literal pending-repo passthroughs have an empty init prompt and therefore
1718
+ // no Codex ledger entry. Their setup journal is the exact replay owner; the
1719
+ // recovered worker sends it at prompt_ready with the retained token.
1720
+ if (session.queuedActivationPending && session.pendingRepoSetup?.rawInput) {
1721
+ ds.pendingRawInput = session.pendingRepoSetup.rawInput;
1722
+ }
1723
+ try {
1724
+ const mismatchClose = await closeActiveSessionIfCliMismatch(ds);
1725
+ if (mismatchClose !== 'not_mismatched') {
1726
+ if (mismatchClose === 'teardown_failed') {
1727
+ quarantineUnregisteredRestoreSession(session, 'cli_mismatch_teardown_unverified');
1728
+ }
1729
+ continue;
1730
+ }
1731
+ }
1732
+ catch (err) {
1733
+ logger.error(`[${session.sessionId.substring(0, 8)}] CLI mismatch close failed during restore; keeping active row: `
1734
+ + `${err instanceof Error ? err.message : String(err)}`);
1735
+ quarantineUnregisteredRestoreSession(session, 'cli_mismatch_close_failed');
1736
+ continue;
1737
+ }
1738
+ if (!ds.session.queuedActivationPending
1739
+ && (ds.session.queuedActivationTail?.length ?? 0) > 0
1740
+ && !promoteQueuedActivationTail(ds, { send: false })) {
1741
+ // Promotion here only fails on a transient durable-write error (send:false
1742
+ // skips the worker-liveness gate, and the caller already checked a non-empty
1743
+ // tail). Throwing would drop to the isolation catch below WITHOUT registering
1744
+ // the row — leaving it active-on-disk but invisible to activeSessions, so IM
1745
+ // `/close` cannot reach it and a later inbound to the same anchor mints a
1746
+ // second active row while this one's tail dangles. Instead register it as a
1747
+ // visible quarantined owner: the unpromoted tail keeps protected ownership
1748
+ // (occupies the anchor, blocks a duplicate, stays closeable).
1749
+ //
1750
+ // The gate (`initialStartPending`, computed true below from the non-empty
1751
+ // tail) MUST stay up: no activation is actually in flight (promotion
1752
+ // FAILED), and the invariant is that the old tail head must not be overtaken
1753
+ // by a later turn. Self-healing happens at the next FORK BOUNDARY via the
1754
+ // central quarantine guard inside forkWorker (see resolveQuarantinedForkPlan):
1755
+ // it retries `promoteQueuedActivationTail` and, on success, cold-forks the
1756
+ // promoted head; on failure it refuses the fork and keeps this worker:null
1757
+ // owner. The `quarantinedActivationTailPromotion` flag set below is what the
1758
+ // central guard keys on.
1759
+ quarantinedActivationTailPromotion = true;
1760
+ logger.warn(`[${session.sessionId.substring(0, 8)}] Deferred durable activation-tail promotion `
1761
+ + `(transient persistence failure); registering as a quarantined owner so `
1762
+ + `it stays visible/closeable and retries promotion at the next fork boundary`);
1763
+ }
1411
1764
  const anchor = sessionAnchorId(ds);
1765
+ // A tail-only quarantine (promotion failed above) must NOT clear its gate:
1766
+ // the invariant is "retry the old head's promotion at the next fork boundary;
1767
+ // on failure keep owning the gate; never let a later turn overtake". Every
1768
+ // fork boundary (eager `toReattach` blank fork, daemon inbound refork, and
1769
+ // web-terminal lazy wake) routes through forkWorker, whose central guard
1770
+ // (resolveQuarantinedForkPlan) retries promotion first and refuses the fork
1771
+ // if it still fails — so a blank fork can never leave a live worker beside an
1772
+ // unpromoted tail and permanently wedge the gate. Mark the runtime flag the
1773
+ // guard keys on.
1774
+ if (quarantinedActivationTailPromotion)
1775
+ ds.quarantinedActivationTailPromotion = true;
1412
1776
  messageQueue.ensureQueue(anchor);
1413
- const queuedKey = activeSessionKey(ds);
1414
- const queuedRegistered = await setActiveSessionSafe(activeSessions, queuedKey, ds);
1415
- const queuedCurrent = activeSessions.get(queuedKey);
1777
+ if (ds.usageLimit)
1778
+ restoreUsageLimitRuntimeState(ds);
1779
+ // Same-key collision guard — see adopt-branch comment above.
1780
+ const registration = await setActiveSessionSafe(activeSessions, activeSessionKey(ds), ds);
1416
1781
  if (runtimeWinnerFor(session.sessionId, ds)) {
1417
- logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won queued restore registration`);
1782
+ logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won restore registration`);
1418
1783
  continue;
1419
1784
  }
1420
- if (!queuedRegistered || session.status !== 'active' || queuedCurrent !== ds) {
1421
- logger.warn(`[${session.sessionId.substring(0, 8)}] Queued restore was cancelled while resolving a routing collision`);
1422
- await closeSession(session.sessionId);
1785
+ // Normalize the registrar result. The authoritative registrar returns the
1786
+ // rich SetActiveSessionResult object; a legacy/boolean CAS (some test
1787
+ // doubles, or a future first-wins primitive) may return a bare false at
1788
+ // runtime, so read defensively through `any`.
1789
+ const registrationRaw = registration;
1790
+ const registrationAccepted = registrationRaw !== false
1791
+ && registrationRaw.accepted !== false;
1792
+ const registrationReason = typeof registrationRaw === 'object' && registrationRaw !== null
1793
+ ? registrationRaw.reason
1794
+ : undefined;
1795
+ const registrationKeptId = typeof registrationRaw === 'object' && registrationRaw !== null
1796
+ ? registrationRaw.keptSessionId
1797
+ : undefined;
1798
+ if (!registrationAccepted) {
1799
+ if (registrationReason === 'both_pending' || registrationReason === 'cleanup_failed') {
1800
+ logger.error(`[${session.sessionId.substring(0, 8)}] Isolated active restore collision; `
1801
+ + `durable row/pane retained without aborting daemon startup: ${registrationReason === 'both_pending'
1802
+ ? `two protected owners at ${activeSessionKey(ds)}`
1803
+ : `cleanup failed for ${registration.cleanupSessionId}: ${registration.error}`}`);
1804
+ // Master's reboot-resume visibility fix: this row stays active-on-disk
1805
+ // but was never registered into activeSessions, so upsert a dormant
1806
+ // persisted projection lest an already-open dashboard drop it.
1807
+ quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
1808
+ continue;
1809
+ }
1810
+ // Ordinary collision: a higher-priority row (real/adopt over queued over
1811
+ // scratch, per restorePriority) or a fresh runtime occupant already owns
1812
+ // this anchor. THIS row is the loser — close it so its store row doesn't
1813
+ // linger as a ghost-active duplicate. A rejected teardown keeps it active
1814
+ // (quarantined) for retry rather than crash-looping daemon boot.
1815
+ logger.warn(`[${session.sessionId.substring(0, 8)}] restore collision lost to session `
1816
+ + `${registrationKeptId ? registrationKeptId.substring(0, 8) : 'existing owner'}; closing the duplicate loser`);
1817
+ try {
1818
+ await closeSession(session.sessionId);
1819
+ }
1820
+ catch (err) {
1821
+ logger.error(`[${session.sessionId.substring(0, 8)}] Could not close restore collision loser; keeping active row: `
1822
+ + `${err instanceof Error ? err.message : String(err)}`);
1823
+ quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
1824
+ }
1423
1825
  continue;
1424
1826
  }
1425
1827
  restoredByThisInvocation.push(ds);
1426
- // 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复,
1427
- // 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。
1428
1828
  announceSessionRow(ds);
1429
- logger.info(`[${session.sessionId.substring(0, 8)}] Restored queued (待办池) session (scope: ${scope})`);
1430
- continue;
1431
- }
1432
- const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1433
- const ds = {
1434
- session,
1435
- worker: null,
1436
- workerPort: null,
1437
- workerToken: null,
1438
- larkAppId,
1439
- chatId: session.chatId,
1440
- chatType: session.chatType ?? 'group',
1441
- scope,
1442
- spawnedAt: sessionCreatedAtMs(session),
1443
- cliVersion: getCurrentCliVersion(),
1444
- lastMessageAt: sessionLastMessageAtMs(session),
1445
- hasHistory: true, // restored sessions have prior CLI history
1446
- workingDir: session.workingDir,
1447
- ownerOpenId: session.ownerOpenId,
1448
- // Restore persisted streaming-card state — next screen_update will PATCH
1449
- // the existing card instead of POSTing a fresh one. If the card was
1450
- // withdrawn while we were down, the PATCH fails with MessageWithdrawnError
1451
- // and the existing handler (worker-pool flushCardPatch) clears streamCardId,
1452
- // letting the next update create a new card.
1453
- streamCardId: session.streamCardId,
1454
- streamCardNonce: session.streamCardNonce,
1455
- displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
1456
- currentImageKey: session.currentImageKey,
1457
- currentTurnTitle: session.currentTurnTitle,
1458
- usageLimit: session.usageLimit,
1459
- lastUserPrompt: session.lastUserPrompt,
1460
- lastCliInput: session.lastCliInput,
1461
- lastCodexAppInput: session.lastCodexAppInput,
1462
- replyThreadAliases: session.replyThreadAliases,
1463
- currentReplyTarget: session.currentReplyTarget,
1464
- // Restart stays silent in the group: the recovery re-fork won't post or
1465
- // patch a streaming card. Cleared on the first real CLI input.
1466
- suppressRecoveryCard: true,
1467
- };
1468
- if (session.deferredScheduleRun) {
1469
- const binding = readDeferredTopicBinding(config.session.dataDir, session.sessionId);
1470
- if (!binding) {
1471
- // A daemon restart makes an unmaterialized hidden run ambiguous: there
1472
- // is no visible conversation to resume and no safe user-facing place
1473
- // to report recovery. Fence its backing process and close the audit row
1474
- // instead of resurrecting an invisible worker.
1475
- const backendType = getSessionPersistentBackendType(ds);
1476
- if (backendType) {
1477
- try {
1478
- killPersistentBackendTarget(persistentBackendTargetForSession(ds), session.sessionId);
1479
- }
1480
- catch (err) {
1481
- // Keep the row active when destruction cannot be proved. Closing it
1482
- // would strand a possibly-live hidden run with no ownership record;
1483
- // skipping registration also prevents this restore pass from
1484
- // accidentally reattaching it. Continue with the remaining rows.
1485
- logger.error(`[${session.sessionId.substring(0, 8)}] Could not tear down unmaterialized deferred run; keeping active row: `
1486
- + `${err instanceof Error ? err.message : String(err)}`);
1487
- quarantineUnregisteredRestoreSession(session, 'deferred_run_teardown_unverified');
1488
- continue;
1489
- }
1490
- }
1491
- sessionStore.closeSession(session.sessionId);
1492
- removeDeferredTopicBinding(config.session.dataDir, session.sessionId);
1493
- logger.info(`[${session.sessionId.substring(0, 8)}] Closed unmaterialized deferred schedule run during restore`);
1494
- continue;
1495
- }
1496
- const nowIso = new Date().toISOString();
1497
- const aliases = { ...(session.replyThreadAliases ?? {}) };
1498
- aliases[binding.rootMessageId] = {
1499
- createdAt: aliases[binding.rootMessageId]?.createdAt ?? binding.createdAt,
1500
- lastUsedAt: nowIso,
1501
- };
1502
- session.replyThreadAliases = aliases;
1503
- session.rootMessageId = binding.rootMessageId;
1504
- ds.replyThreadAliases = aliases;
1505
- sessionStore.updateSession(session);
1506
- }
1507
- try {
1508
- const mismatchClose = await closeActiveSessionIfCliMismatch(ds);
1509
- if (mismatchClose !== 'not_mismatched') {
1510
- if (mismatchClose === 'teardown_failed') {
1511
- quarantineUnregisteredRestoreSession(session, 'cli_mismatch_teardown_unverified');
1512
- }
1513
- continue;
1829
+ if (session.initialUserTurnPending) {
1830
+ // `hasHistory: true` above means "there may be a CLI process/transcript to
1831
+ // reattach or resume", NOT "a user has ever spoken to it". This session's
1832
+ // CLI was booted idle by the repo flow and never received a real turn, so
1833
+ // the next business message still routes through the new-topic opening
1834
+ // builder and cold-spawns instead of `--resume`. See core/initial-user-turn.ts.
1835
+ logger.info(`[${session.sessionId.substring(0, 8)}] Restored empty-started session — its first real user turn still opens as a new topic`);
1514
1836
  }
1837
+ logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
1515
1838
  }
1516
1839
  catch (err) {
1517
- logger.error(`[${session.sessionId.substring(0, 8)}] CLI mismatch close failed during restore; keeping active row: `
1840
+ // Restore is a per-row reconciliation job. A malformed legacy hybrid or
1841
+ // one transient persistence failure must retain that row for inspection
1842
+ // and retry without preventing every later healthy session from loading.
1843
+ logger.error(`[${session.sessionId.substring(0, 8)}] Isolated session restore failure; `
1844
+ + `durable row retained and daemon startup continuing: `
1518
1845
  + `${err instanceof Error ? err.message : String(err)}`);
1519
- quarantineUnregisteredRestoreSession(session, 'cli_mismatch_close_failed');
1520
1846
  continue;
1521
1847
  }
1522
- const anchor = sessionAnchorId(ds);
1523
- messageQueue.ensureQueue(anchor);
1524
- if (ds.usageLimit)
1525
- restoreUsageLimitRuntimeState(ds);
1526
- // Same-key collision guard — see adopt-branch comment above.
1527
- const restoreKey = activeSessionKey(ds);
1528
- const restored = await setActiveSessionSafe(activeSessions, restoreKey, ds);
1529
- const restoreCurrent = activeSessions.get(restoreKey);
1530
- if (runtimeWinnerFor(session.sessionId, ds)) {
1531
- logger.debug(`[${session.sessionId.substring(0, 8)}] Live runtime won restore registration`);
1532
- continue;
1533
- }
1534
- if (!restored || session.status !== 'active' || restoreCurrent !== ds) {
1535
- logger.warn(`[${session.sessionId.substring(0, 8)}] Restore was cancelled while resolving a routing collision`);
1536
- try {
1537
- await closeSession(session.sessionId);
1538
- }
1539
- catch (err) {
1540
- // A rejected ZMX ownership/generation probe means teardown was not
1541
- // proven. Keep the persisted row active for a later retry and continue
1542
- // restoring unrelated sessions instead of crash-looping daemon boot.
1543
- logger.error(`[${session.sessionId.substring(0, 8)}] Could not close restore collision loser; keeping active row: `
1544
- + `${err instanceof Error ? err.message : String(err)}`);
1545
- quarantineUnregisteredRestoreSession(session, 'restore_collision_close_failed');
1546
- }
1547
- continue;
1548
- }
1549
- restoredByThisInvocation.push(ds);
1550
- announceSessionRow(ds);
1551
- if (session.initialUserTurnPending) {
1552
- // `hasHistory: true` above means "there may be a CLI process/transcript to
1553
- // reattach or resume", NOT "a user has ever spoken to it". This session's
1554
- // CLI was booted idle by the repo flow and never received a real turn, so
1555
- // the next business message still routes through the new-topic opening
1556
- // builder and cold-spawns instead of `--resume`. See core/initial-user-turn.ts.
1557
- logger.info(`[${session.sessionId.substring(0, 8)}] Restored empty-started session — its first real user turn still opens as a new topic`);
1558
- }
1559
- logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
1560
1848
  }
1561
1849
  // Persistent backends: auto-fork workers for sessions whose backing session
1562
1850
  // survived daemon restart. Probe + zombie-close runs synchronously here; the
@@ -1584,7 +1872,14 @@ export async function restoreActiveSessions(activeSessions) {
1584
1872
  if (ds.session.queued)
1585
1873
  continue;
1586
1874
  const backendType = getSessionPersistentBackendType(ds);
1587
- if (!backendType || !shouldAutoForkOnRestore(backendType))
1875
+ if (!backendType) {
1876
+ if (hasProtectedSessionMutationOwnership(ds)) {
1877
+ logger.warn(`[${ds.session.sessionId.substring(0, 8)}] non-persistent backend has durable activation ownership — scheduling eager recovery`);
1878
+ toReattach.push(ds);
1879
+ }
1880
+ continue;
1881
+ }
1882
+ if (!shouldAutoForkOnRestore(backendType))
1588
1883
  continue;
1589
1884
  // Honour the worker-selected target (Herdr may own an agent inside a shared
1590
1885
  // host session) rather than assuming the deterministic whole-session name.
@@ -1622,6 +1917,19 @@ export async function restoreActiveSessions(activeSessions) {
1622
1917
  : probeSnapshots.get(backendType)?.get(backendTarget.sessionName) ?? 'unknown';
1623
1918
  if (probe === 'missing') {
1624
1919
  const tag = ds.session.sessionId.substring(0, 8);
1920
+ if (ds.session.queuedActivationPending) {
1921
+ logger.warn(`[${tag}] ${backendType} backing is missing with a tokened activation — scheduling fresh exact recovery`);
1922
+ toReattach.push(ds);
1923
+ continue;
1924
+ }
1925
+ // A missing pane is not disposable session metadata when a backend-neutral
1926
+ // durable activation owner is present. Start a fresh resume worker so its
1927
+ // exact head/tail can reconcile rather than strand.
1928
+ if (hasProtectedSessionMutationOwnership(ds)) {
1929
+ logger.warn(`[${tag}] ${backendType} backing is missing with durable activation ownership — scheduling fresh resume recovery`);
1930
+ toReattach.push(ds);
1931
+ continue;
1932
+ }
1625
1933
  // A missing backing pane is NEVER an auto-close trigger for a managed
1626
1934
  // session. Whatever wiped the pane — a host reboot (whole multiplexer
1627
1935
  // server gone, every pane at once), the idle-worker sweeper reclaiming
@@ -1648,7 +1956,8 @@ export async function restoreActiveSessions(activeSessions) {
1648
1956
  // tidier dashboard while risking irreversible session loss, so the whole
1649
1957
  // close-on-missing path was removed. A TTL sweep (evict long-idle active
1650
1958
  // rows) is the right tool for dashboard tidiness and is intentionally out
1651
- // of scope here.
1959
+ // of scope here. (A cap-suspended `suspendedColdResume` row is naturally
1960
+ // covered by this same keep-for-lazy-cold-resume path.)
1652
1961
  logger.info(`[${tag}] ${backendType} backing session "${backendName}" is missing — keeping active for lazy cold-resume (transcript is still resumable)`);
1653
1962
  continue;
1654
1963
  }
@@ -1660,6 +1969,11 @@ export async function restoreActiveSessions(activeSessions) {
1660
1969
  // store closed → no lazy recovery). Keep the worker-less active record and
1661
1970
  // let it re-attach on the next message, exactly like the old behaviour.
1662
1971
  const tag = ds.session.sessionId.substring(0, 8);
1972
+ if (hasProtectedSessionMutationOwnership(ds)) {
1973
+ logger.warn(`[${tag}] ${backendType} probe inconclusive with unsettled Codex App ownership — scheduling bounded eager recovery`);
1974
+ toReattach.push(ds);
1975
+ continue;
1976
+ }
1663
1977
  logger.warn(`[${tag}] ${backendType} backing session "${backendName}" probe inconclusive — keeping active session for lazy recovery`);
1664
1978
  continue;
1665
1979
  }
@@ -1684,7 +1998,27 @@ export async function restoreActiveSessions(activeSessions) {
1684
1998
  }
1685
1999
  // Staggered re-fork (see staggeredRecoveryFork): empty prompt = re-attach
1686
2000
  // only, no new turn — same as the old per-session eager fork.
1687
- await staggeredRecoveryFork(toReattach, (ds) => forkWorker(ds, '', true));
2001
+ await staggeredRecoveryFork(toReattach, (ds) => {
2002
+ // A quarantined tail-only owner (restore promotion failed transiently) is
2003
+ // handled by the CENTRAL guard inside forkWorker: this blank fork retries
2004
+ // the old head's promotion first and, if it still fails, refuses to fork
2005
+ // (returns false) — keeping the worker:null owner so a blank fork never
2006
+ // leaves a live worker beside an unpromoted tail. `recoverExactNonCodex`
2007
+ // below is the DIFFERENT, already-tokened recovery case (queuedActivation
2008
+ // is pending), which a quarantined owner is not (its promotion failed, so
2009
+ // queuedActivationPending stayed false) — the guard rewrites the fork args
2010
+ // in that case instead.
2011
+ const recoverExactNonCodex = ds.session.queuedActivationPending
2012
+ && ds.session.cliId !== 'codex-app'
2013
+ && ds.session.queuedActivationInput;
2014
+ forkWorker(ds, recoverExactNonCodex || '', recoverExactNonCodex
2015
+ ? {
2016
+ resume: ds.session.queuedActivationResume ?? ds.hasHistory,
2017
+ turnId: ds.session.queuedActivationTurnId,
2018
+ dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
2019
+ }
2020
+ : true);
2021
+ }, RECOVERY_FORK_BATCH_SIZE, RECOVERY_FORK_DELAY_MS, ds => activeSessions.get(activeSessionKey(ds)) === ds);
1688
2022
  const hasPersistentBackend = [...activeSessions.values()].some(ds => !!getSessionPersistentBackendType(ds));
1689
2023
  logger.info(`Restored ${active.length} session(s)${hasPersistentBackend ? '' : ', waiting for messages to resume'}`);
1690
2024
  }
@@ -1730,7 +2064,15 @@ export async function ensureTerminalWorkerPort(ds) {
1730
2064
  }
1731
2065
  if (!ds.worker) {
1732
2066
  logger.info(`[${ds.session.sessionId.substring(0, 8)}] terminal accessed with no live worker — waking to re-attach`);
1733
- forkWorker(ds, '', true);
2067
+ // Lazy-wake is a fork boundary too. The central guard inside forkWorker
2068
+ // refuses (returns false) for a quarantined tail-only owner whose promotion
2069
+ // still fails — waking a blank worker beside an unpromoted tail would wedge
2070
+ // the FIFO gate. Report unavailable (the terminal retries / 502s) instead of
2071
+ // blocking 10s for a port that will never arrive.
2072
+ if (!forkWorker(ds, '', true)) {
2073
+ logger.warn(`[${ds.session.sessionId.substring(0, 8)}] terminal wake refused (quarantined tail-only owner); serving unavailable`);
2074
+ return undefined;
2075
+ }
1734
2076
  }
1735
2077
  // Wait (bounded) for the re-forked worker to report its HTTP port via `ready`.
1736
2078
  // Re-attach is fast (~1-2s in practice); 10s covers a slow CLI restart.
@@ -1767,7 +2109,7 @@ export async function ensureTerminalWorkerPort(ds) {
1767
2109
  * - 'resume_cancelled' — a concurrent close won while resume was committing
1768
2110
  */
1769
2111
  export async function resumeSession(sessionId, activeSessions) {
1770
- const session = sessionStore.getSession(sessionId);
2112
+ let session = sessionStore.getSession(sessionId);
1771
2113
  if (!session)
1772
2114
  return { ok: false, error: 'not_found' };
1773
2115
  if (session.status !== 'closed')
@@ -1801,125 +2143,177 @@ export async function resumeSession(sessionId, activeSessions) {
1801
2143
  const larkAppId = session.larkAppId ?? getAllBots()[0]?.config.larkAppId ?? '';
1802
2144
  const anchor = storedSessionAnchorId(session);
1803
2145
  const key = sessionKey(anchor, larkAppId);
1804
- // In-memory occupant check. A daemon-command scratch (e.g. an unconfirmed
1805
- // `/relay` picker, a bare `/help`) parks a worker:null placeholder at this
1806
- // anchor; daemon.ts creates one for ANY DAEMON_COMMAND in a session-less
1807
- // chat. It's not a real conversation, so it must NOT block resume — but it
1808
- // also can't just be ignored: leaving it in the Map while we re-register
1809
- // the resumed session at the same key would orphan its still-active store
1810
- // row (the exact ghost-active bug we fixed elsewhere). So: close it (evicts
1811
- // Map + marks store closed + dashboard event), then fall through to resume.
1812
- //
1813
- // We keep blocking on a real session (isRelayableRealSession) AND on a
1814
- // pendingRepo session — the latter is a worker:null placeholder too, but it
1815
- // represents deliberate in-progress setup (user is picking a repo), not a
1816
- // throwaway command container, so clobbering it would lose real intent.
1817
- const existing = activeSessions.get(key);
1818
- if (existing) {
1819
- if (!isDisposableCommandScratch(existing)) {
1820
- return { ok: false, error: 'anchor_occupied', activeSessionId: existing.session.sessionId };
1821
- }
1822
- await closeSession(existing.session.sessionId);
1823
- const replacement = activeSessions.get(key);
1824
- if (replacement) {
1825
- return { ok: false, error: 'anchor_occupied', activeSessionId: replacement.session.sessionId };
1826
- }
1827
- }
1828
- // Belt-and-suspenders: also scan persisted sessions for any *other* active
1829
- // session pinned to the same (larkAppId, scope, anchor). The in-memory Map
1830
- // is the authoritative routing source for a running daemon, but it's only
1831
- // hydrated for sessions that survived restoreActiveSessions. Cross-process
1832
- // and partial-load situations (e.g. another bot's daemon writes a session
1833
- // file but our Map hasn't caught up, or a closed session was orphaned by a
1834
- // crash that left a sibling session active in the same anchor) can leave a
1835
- // store-level conflict invisible to the Map check above.
1836
- //
1837
- // Same scratch carve-out applies on disk: a persisted scratch has neither
1838
- // `cliId` nor `lastCliInput` (those are only written once a real CLI ran).
1839
- // A real conflict (either marker present) still blocks; scratch-only
1840
- // conflicts get closed so they stop occupying the anchor on disk.
1841
- //
1842
- // CAVEAT — this path canNOT honor the pendingRepo carve-out the in-memory
1843
- // branch above applies: `pendingRepo` is a runtime DaemonSession flag that
1844
- // is never persisted to the store, so a pendingRepo session that's only
1845
- // visible here as a disk row (not in our Map) looks identical to a scratch
1846
- // and would be closed. Safe under the production topology (one daemon per
1847
- // bot): this scan is larkAppId-scoped to OUR bot, and our bot's live
1848
- // pendingRepo sessions are always in our Map (handled by the in-memory
1849
- // branch first). A disk-only active row with no CLI markers for our own
1850
- // bot is therefore a genuine scratch or a crash leftover — closing it is
1851
- // correct either way. The two branches are intentionally NOT identical;
1852
- // don't "unify" them by reading pendingRepo here (it isn't there to read).
1853
- const conflicts = sessionStore.listSessions().filter(s => s.sessionId !== sessionId
1854
- && s.status === 'active'
1855
- && (s.larkAppId ?? '') === larkAppId
1856
- && (s.scope === 'chat' ? 'chat' : 'thread') === scope
1857
- && storedSessionAnchorId(s) === anchor);
1858
- const realConflict = conflicts.find(s => !!s.cliId || !!s.lastCliInput || !!s.backendType || !!s.adoptedFrom || s.queued === true);
1859
- if (realConflict) {
1860
- return { ok: false, error: 'anchor_occupied', activeSessionId: realConflict.sessionId };
1861
- }
1862
- for (const scratch of conflicts) {
1863
- await closeSession(scratch.sessionId);
1864
- }
1865
- const replacementAfterDiskCleanup = activeSessions.get(key);
1866
- if (replacementAfterDiskCleanup) {
1867
- return {
1868
- ok: false,
1869
- error: 'anchor_occupied',
1870
- activeSessionId: replacementAfterDiskCleanup.session.sessionId,
2146
+ return withActiveSessionKeyLock(activeSessions, key, async () => {
2147
+ // The resume request may have waited behind a fresh creator. Re-read the
2148
+ // durable row and keep the already-active creator as first owner.
2149
+ const latest = sessionStore.getSession(sessionId);
2150
+ if (!latest)
2151
+ return { ok: false, error: 'not_found' };
2152
+ if (latest.status !== 'closed')
2153
+ return { ok: false, error: 'not_closed' };
2154
+ if (latest.vcMeetingReceiver)
2155
+ return { ok: false, error: 'vc_receiver_managed' };
2156
+ if (latest.deferredScheduleRun
2157
+ && !readDeferredTopicBinding(config.session.dataDir, latest.sessionId)) {
2158
+ return { ok: false, error: 'deferred_unmaterialized' };
2159
+ }
2160
+ if (latest.title?.startsWith('Adopt:') || latest.adoptedFrom) {
2161
+ return { ok: false, error: 'adopt_unsupported' };
2162
+ }
2163
+ session = latest;
2164
+ // In-memory occupant check. A daemon-command scratch (e.g. an unconfirmed
2165
+ // `/relay` picker, a bare `/help`) parks a worker:null placeholder at this
2166
+ // anchor; daemon.ts creates one for ANY DAEMON_COMMAND in a session-less
2167
+ // chat. It's not a real conversation, so it must NOT block resume — but it
2168
+ // also can't just be ignored: leaving it in the Map while we re-register
2169
+ // the resumed session at the same key would orphan its still-active store
2170
+ // row (the exact ghost-active bug we fixed elsewhere). So: close it (evicts
2171
+ // Map + marks store closed + dashboard event), then fall through to resume.
2172
+ //
2173
+ // We keep blocking on a real session (isRelayableRealSession) AND on a
2174
+ // pendingRepo session the latter is a worker:null placeholder too, but it
2175
+ // represents deliberate in-progress setup (user is picking a repo), not a
2176
+ // throwaway command container, so clobbering it would lose real intent.
2177
+ const existing = activeSessions.get(key);
2178
+ if (existing) {
2179
+ // Master's isDisposableCommandScratch subsumes isRelayableRealSession /
2180
+ // pendingRepo / queued / adopt / pending-prompt. PR #597 additionally
2181
+ // refuses to clobber a durable activation owner or an opening that has not
2182
+ // crossed its submission boundary (initialStartPending), which the scratch
2183
+ // predicate does not cover — keep both so neither intent is lost.
2184
+ if (hasProtectedSessionMutationOwnership(existing)
2185
+ || existing.initialStartPending
2186
+ || !isDisposableCommandScratch(existing)) {
2187
+ return { ok: false, error: 'anchor_occupied', activeSessionId: existing.session.sessionId };
2188
+ }
2189
+ await closeSession(existing.session.sessionId);
2190
+ const replacement = activeSessions.get(key);
2191
+ if (replacement) {
2192
+ return { ok: false, error: 'anchor_occupied', activeSessionId: replacement.session.sessionId };
2193
+ }
2194
+ }
2195
+ // Belt-and-suspenders: also scan persisted sessions for any *other* active
2196
+ // session pinned to the same (larkAppId, scope, anchor). The in-memory Map
2197
+ // is the authoritative routing source for a running daemon, but it's only
2198
+ // hydrated for sessions that survived restoreActiveSessions. Cross-process
2199
+ // and partial-load situations (e.g. another bot's daemon writes a session
2200
+ // file but our Map hasn't caught up, or a closed session was orphaned by a
2201
+ // crash that left a sibling session active in the same anchor) can leave a
2202
+ // store-level conflict invisible to the Map check above.
2203
+ //
2204
+ // Same scratch carve-out applies on disk: a persisted scratch has neither
2205
+ // `cliId` / `lastCliInput` (those are only written once a real backend ran).
2206
+ // A real conflict (any marker present) still
2207
+ // blocks; scratch-only conflicts get closed so they stop occupying the
2208
+ // anchor on disk.
2209
+ //
2210
+ // CAVEAT — this path canNOT honor the pendingRepo carve-out the in-memory
2211
+ // branch above applies: `pendingRepo` is a runtime DaemonSession flag that
2212
+ // is never persisted to the store, so a pendingRepo session that's only
2213
+ // visible here as a disk row (not in our Map) looks identical to a scratch
2214
+ // and would be closed. Safe under the production topology (one daemon per
2215
+ // bot): this scan is larkAppId-scoped to OUR bot, and our bot's live
2216
+ // pendingRepo sessions are always in our Map (handled by the in-memory
2217
+ // branch first). A disk-only active row with no CLI markers for our own
2218
+ // bot is therefore a genuine scratch or a crash leftover — closing it is
2219
+ // correct either way. The two branches are intentionally NOT identical;
2220
+ // don't "unify" them by reading pendingRepo here (it isn't there to read).
2221
+ const conflicts = sessionStore.listSessions().filter(s => s.sessionId !== sessionId
2222
+ && s.status === 'active'
2223
+ // Legacy active rows predate per-bot stamping. Conservatively attribute an
2224
+ // unscoped row to this daemon, matching transfer/restore collision rules;
2225
+ // otherwise a disk-only legacy anchor can be ghosted by the resumed row.
2226
+ && (!s.larkAppId || s.larkAppId === larkAppId)
2227
+ && (s.scope === 'chat' ? 'chat' : 'thread') === scope
2228
+ && storedSessionAnchorId(s) === anchor);
2229
+ const realConflict = conflicts.find(s => hasProtectedSessionMutationOwnership(s)
2230
+ || !!s.cliId
2231
+ || !!s.lastCliInput
2232
+ || !!s.adoptedFrom
2233
+ || s.queued === true);
2234
+ if (realConflict) {
2235
+ return { ok: false, error: 'anchor_occupied', activeSessionId: realConflict.sessionId };
2236
+ }
2237
+ for (const scratch of conflicts) {
2238
+ await closeSession(scratch.sessionId);
2239
+ }
2240
+ const replacementAfterDiskCleanup = activeSessions.get(key);
2241
+ if (replacementAfterDiskCleanup) {
2242
+ return {
2243
+ ok: false,
2244
+ error: 'anchor_occupied',
2245
+ activeSessionId: replacementAfterDiskCleanup.session.sessionId,
2246
+ };
2247
+ }
2248
+ // Scratch cleanup and disk scans can await. A creator that does not yet use
2249
+ // the shared key lock still wins if it published meanwhile; never reactivate
2250
+ // the closed row and then replace that fresh owner.
2251
+ const lateExisting = activeSessions.get(key);
2252
+ if (lateExisting) {
2253
+ return {
2254
+ ok: false,
2255
+ error: 'anchor_occupied',
2256
+ activeSessionId: lateExisting.session.sessionId,
2257
+ };
2258
+ }
2259
+ // Reactivate and abandon any queued/setup ownership in one durable replace.
2260
+ // Closed rows created by older releases may still contain a prepared head,
2261
+ // tail, or repo picker; generic resume starts a new lifecycle and must never
2262
+ // replay those abandoned inputs.
2263
+ const reactivated = sessionStore.reactivateClosedSession(sessionId);
2264
+ if (!reactivated.ok)
2265
+ return reactivated;
2266
+ session = reactivated.session;
2267
+ const now = Date.now();
2268
+ const ds = {
2269
+ session,
2270
+ worker: null,
2271
+ workerPort: null,
2272
+ workerToken: null,
2273
+ larkAppId,
2274
+ chatId: session.chatId,
2275
+ chatType: session.chatType ?? 'group',
2276
+ scope,
2277
+ spawnedAt: sessionCreatedAtMs(session),
2278
+ cliVersion: getCurrentCliVersion(),
2279
+ lastMessageAt: now,
2280
+ hasHistory: true, // resumed sessions carry CLI history (--resume on next fork)
2281
+ workingDir: session.workingDir,
2282
+ ownerOpenId: session.ownerOpenId,
2283
+ streamCardId: session.streamCardId,
2284
+ streamCardNonce: session.streamCardNonce,
2285
+ displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
2286
+ currentImageKey: session.currentImageKey,
2287
+ currentTurnTitle: session.currentTurnTitle,
2288
+ usageLimit: session.usageLimit,
2289
+ lastUserPrompt: session.lastUserPrompt,
2290
+ lastCliInput: session.lastCliInput,
2291
+ lastCodexAppInput: session.lastCodexAppInput,
2292
+ replyThreadAliases: session.replyThreadAliases,
2293
+ currentReplyTarget: session.currentReplyTarget,
1871
2294
  };
1872
- }
1873
- // Reactivate in store clear closedAt so dashboard rows don't keep showing
1874
- // the stale close timestamp on the now-active session.
1875
- session.status = 'active';
1876
- session.closedAt = undefined;
1877
- session.lastMessageAt = new Date().toISOString();
1878
- sessionStore.updateSession(session);
1879
- const now = Date.now();
1880
- const ds = {
1881
- session,
1882
- worker: null,
1883
- workerPort: null,
1884
- workerToken: null,
1885
- larkAppId,
1886
- chatId: session.chatId,
1887
- chatType: session.chatType ?? 'group',
1888
- scope,
1889
- spawnedAt: sessionCreatedAtMs(session),
1890
- cliVersion: getCurrentCliVersion(),
1891
- lastMessageAt: now,
1892
- hasHistory: true, // resumed sessions carry CLI history (--resume on next fork)
1893
- workingDir: session.workingDir,
1894
- ownerOpenId: session.ownerOpenId,
1895
- streamCardId: session.streamCardId,
1896
- streamCardNonce: session.streamCardNonce,
1897
- displayMode: session.displayMode ?? (session.streamExpanded ? 'screenshot' : 'hidden'),
1898
- currentImageKey: session.currentImageKey,
1899
- currentTurnTitle: session.currentTurnTitle,
1900
- usageLimit: session.usageLimit,
1901
- lastUserPrompt: session.lastUserPrompt,
1902
- lastCliInput: session.lastCliInput,
1903
- lastCodexAppInput: session.lastCodexAppInput,
1904
- replyThreadAliases: session.replyThreadAliases,
1905
- currentReplyTarget: session.currentReplyTarget,
1906
- };
1907
- messageQueue.ensureQueue(anchor);
1908
- // setActiveSessionSafe over a bare Map.set: the scratch-eviction above
1909
- // should already have freed `key`, but its compare-and-set gate lets any
1910
- // concurrent runtime occupant win rather than silently orphaning it.
1911
- const registered = await setActiveSessionSafe(activeSessions, key, ds);
1912
- if (!registered || session.status !== 'active' || activeSessions.get(key) !== ds) {
1913
- const occupant = activeSessions.get(key);
1914
- if (session.status === 'active')
1915
- await closeSession(sessionId);
1916
- if (occupant && occupant !== ds) {
1917
- return { ok: false, error: 'anchor_occupied', activeSessionId: occupant.session.sessionId };
1918
- }
1919
- return { ok: false, error: 'resume_cancelled' };
1920
- }
1921
- logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`);
1922
- return { ok: true, ds };
2295
+ messageQueue.ensureQueue(anchor);
2296
+ // Already inside withActiveSessionKeyLock(activeSessions, key) above, so use
2297
+ // the SYNCHRONOUS non-locking compare-and-set here setActiveSessionSafe
2298
+ // re-acquires the same non-reentrant key lock and would self-deadlock. The
2299
+ // scratch-eviction above should already have freed `key`, but this gate lets
2300
+ // any concurrent runtime occupant (or a protected Codex App / activation
2301
+ // owner) win rather than silently orphaning it; on loss close THIS resumed
2302
+ // row never the surviving occupant — matching PR #597's "a user resume must
2303
+ // not clobber a newly-created worker" rule.
2304
+ const registered = setActiveSessionIfActive(activeSessions, key, ds);
2305
+ if (!registered || session.status !== 'active' || activeSessions.get(key) !== ds) {
2306
+ const occupant = activeSessions.get(key);
2307
+ if (session.status === 'active')
2308
+ await closeSession(sessionId);
2309
+ if (occupant && occupant !== ds) {
2310
+ return { ok: false, error: 'anchor_occupied', activeSessionId: occupant.session.sessionId };
2311
+ }
2312
+ return { ok: false, error: 'resume_cancelled' };
2313
+ }
2314
+ logger.info(`Resumed session ${sessionId.substring(0, 8)} (scope: ${scope}, anchor: ${anchor.substring(0, 12)})`);
2315
+ return { ok: true, ds };
2316
+ });
1923
2317
  }
1924
2318
  // ─── Scheduled task execution ────────────────────────────────────────────────
1925
2319
  /**
@@ -2137,143 +2531,181 @@ export async function executeScheduledTask(task, activeSessions, refreshCliVersi
2137
2531
  }
2138
2532
  }
2139
2533
  refreshCliVersion(bot.config);
2140
- // Silent fires flip the model's default from "post progress via botmux send"
2141
- // to "say nothing unless the alert condition is met".
2142
2534
  const firePrompt = silent
2143
2535
  ? `${buildSilentScheduleHint(task.name, localeForBot(larkAppId))}\n\n${task.prompt}`
2144
2536
  : task.prompt;
2145
- // Continue through the session already registered at this anchor. A
2146
- // deliberately suspended session keeps its active registration with
2147
- // worker=null; scheduled turns must cold-resume that same session instead of
2148
- // creating a competing row that the registration CAS will reject.
2149
- const activeKey = sessionKey(anchor, larkAppId);
2150
- const existing = activeSessions.get(activeKey);
2151
- const blockedDeferredSetup = existing && (existing.session.queued
2152
- || existing.pendingRepo
2153
- || existing.pendingRepoCommitInFlight
2154
- || existing.worktreeCreating);
2155
- if (isContinuation && blockedDeferredSetup) {
2156
- // Dashboard backlog and repo/worktree setup rows deliberately own the route
2157
- // while keeping their worker null. Cold-resuming one with the scheduled
2158
- // prompt either consumes queuedPrompt or races the later deferred fork.
2159
- // Reject observably while preserving the user's pending setup intact.
2160
- const setupKind = existing.session.queued ? 'queued' : 'pending setup';
2161
- throw new Error(`scheduled continuation ${task.id} is blocked by ${setupKind} session ${existing.session.sessionId}; ` +
2162
- 'the deferred task was preserved');
2163
- }
2164
- if (isContinuation && existing) {
2165
- markSessionActivity(existing);
2166
- ensureSessionWhiteboard(existing);
2167
- if (sharedTopicRootId) {
2168
- beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId);
2169
- sessionStore.updateSession(existing.session);
2170
- }
2171
- const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, {
2172
- isAdoptMode: false,
2173
- cliId: existing.session.cliId ?? bot.config.cliId,
2174
- cliPathOverride: existing.session.cliPathOverride ?? bot.config.cliPathOverride,
2175
- locale: localeForBot(larkAppId),
2176
- larkAppId,
2177
- chatId: task.chatId,
2178
- whiteboardId: existing.session.whiteboardId,
2179
- });
2180
- rememberLastCliInput(existing, task.prompt, input);
2181
- if (silent)
2182
- armSilentScheduledTurn(existing, scheduledTurnId);
2183
- if (existing.worker && !existing.worker.killed) {
2184
- try {
2185
- if (sendWorkerInput(existing, input, scheduledTurnId)) {
2186
- logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
2187
- return;
2537
+ const key = sessionKey(anchor, larkAppId);
2538
+ return withActiveSessionKeyLock(activeSessions, key, async () => {
2539
+ // Reuse the canonical owner only when it is an actual conversation. A
2540
+ // worker:null entry is also used for deliberately non-runnable states:
2541
+ // first-turn setup, repo selection (including an in-flight repo commit),
2542
+ // worktree creation, and dashboard backlog. Waking any of those here would
2543
+ // let the scheduled prompt overtake (or replace) the opening prompt that
2544
+ // owns the reservation.
2545
+ const existing = activeSessions.get(key);
2546
+ if (existing) {
2547
+ const reservedState = existing.pendingRepo
2548
+ ? 'pending_repo'
2549
+ : existing.pendingRepoCommitInFlight
2550
+ ? 'pending_repo_commit'
2551
+ : existing.initialStartPending
2552
+ ? 'initial_start_pending'
2553
+ : existing.worktreeCreating
2554
+ ? 'worktree_creating'
2555
+ : existing.session.queued
2556
+ ? 'queued_backlog'
2557
+ : undefined;
2558
+ if (reservedState) {
2559
+ throw new Error(`Scheduled task ${task.id} found owner ${existing.session.sessionId} `
2560
+ + `in ${reservedState}; preserving its opening prompt`);
2561
+ }
2562
+ if (hasProtectedSessionMutationOwnership(existing)) {
2563
+ throw new Error(`Scheduled task ${task.id} found durable activation owner `
2564
+ + `${existing.session.sessionId}; preserving it for recovery`);
2565
+ }
2566
+ // Cold-resume the exact registered owner (master's resilience model). A
2567
+ // deliberately suspended session keeps its active registration with
2568
+ // worker=null; scheduled continuations must wake THAT same row instead of
2569
+ // spawning a competing session the registration CAS would reject. Live
2570
+ // inject when the worker is up; on refusal or throw, fall back to a
2571
+ // cold-resume fork rather than hard-failing the scheduled turn. A proven
2572
+ // disposable command scratch (no worker, no CLI markers, no pending
2573
+ // intent) is NOT a resumable conversation — retire it below and spawn a
2574
+ // fresh scheduled session instead.
2575
+ const resumableOwner = isRelayableRealSession(existing)
2576
+ || !!existing.session.suspendedColdResume;
2577
+ if (isContinuation && resumableOwner) {
2578
+ markSessionActivity(existing);
2579
+ ensureSessionWhiteboard(existing);
2580
+ if (sharedTopicRootId) {
2581
+ beginReplyTargetTurn(existing, sharedTopicRootId, scheduledTurnId);
2582
+ sessionStore.updateSession(existing.session);
2188
2583
  }
2584
+ const input = buildFollowUpCliInput(firePrompt, existing.session.sessionId, {
2585
+ isAdoptMode: false,
2586
+ cliId: existing.session.cliId ?? bot.config.cliId,
2587
+ cliPathOverride: existing.session.cliPathOverride ?? bot.config.cliPathOverride,
2588
+ locale: localeForBot(larkAppId),
2589
+ larkAppId,
2590
+ chatId: task.chatId,
2591
+ whiteboardId: existing.session.whiteboardId,
2592
+ });
2593
+ rememberLastCliInput(existing, task.prompt, input);
2594
+ if (silent)
2595
+ armSilentScheduledTurn(existing, scheduledTurnId);
2596
+ if (existing.worker && !existing.worker.killed) {
2597
+ try {
2598
+ if (sendWorkerInput(existing, input, scheduledTurnId)) {
2599
+ logger.info(`[scheduler] Task "${task.name}" injected into live session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
2600
+ return;
2601
+ }
2602
+ }
2603
+ catch (err) {
2604
+ logger.warn(`[scheduler] Live injection threw (${err.message}); cold-resuming registered session`);
2605
+ }
2606
+ }
2607
+ if (activeSessions.get(key) !== existing || existing.session.status !== 'active') {
2608
+ if (silent)
2609
+ disarmSilentScheduledTurn(existing, scheduledTurnId);
2610
+ throw new Error(`scheduled continuation lost active session ${existing.session.sessionId}`);
2611
+ }
2612
+ try {
2613
+ forkWorker(existing, input, { resume: existing.hasHistory, turnId: scheduledTurnId });
2614
+ }
2615
+ catch (err) {
2616
+ if (silent)
2617
+ disarmSilentScheduledTurn(existing, scheduledTurnId);
2618
+ throw err;
2619
+ }
2620
+ logger.info(`[scheduler] Task "${task.name}" cold-resumed session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
2621
+ return;
2189
2622
  }
2190
- catch (err) {
2191
- logger.warn(`[scheduler] Live injection threw (${err.message}); cold-resuming registered session`);
2623
+ // Not a resumable conversation. A daemon-command scratch has no CLI
2624
+ // history and no pending user intent — retire the exact scratch before
2625
+ // creating the scheduled conversation so its store row doesn't linger as
2626
+ // a ghost-active; never fork it as if it were resumable. Only do this on a
2627
+ // continuation (the task's own retained anchor); a non-continuation
2628
+ // fresh-topic collision preserves the occupant and lets the registration
2629
+ // CAS below report the loss observably.
2630
+ if (isContinuation && isDisposableCommandScratch(existing)) {
2631
+ await closeSession(existing.session.sessionId);
2632
+ if (activeSessions.get(key) === existing)
2633
+ activeSessions.delete(key);
2192
2634
  }
2193
2635
  }
2194
- if (activeSessions.get(activeKey) !== existing || existing.session.status !== 'active') {
2195
- if (silent)
2196
- disarmSilentScheduledTurn(existing, scheduledTurnId);
2197
- throw new Error(`scheduled continuation lost active session ${existing.session.sessionId}`);
2636
+ // Spawn a fresh session bound to the chosen anchor.
2637
+ // Thread-scope: rootMessageId = anchor. Chat-scope: rootMessageId stores the
2638
+ // chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
2639
+ // formerly chat-scope task was redirected into a converted topic chat, promote
2640
+ // the runtime session to thread-scope so follow-up replies stay in-thread.
2641
+ const deferredFreshTopic = executionPosition === 'new-topic' && silent;
2642
+ const runtimeScope = deferredFreshTopic
2643
+ ? 'chat'
2644
+ : scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
2645
+ const session = sessionStore.createSession(task.chatId, anchor, `${t('schedule.title_prefix', undefined, localeForBot(larkAppId))} ${task.name}`, task.chatType === 'p2p' ? 'p2p' : 'group');
2646
+ const now = Date.now();
2647
+ session.larkAppId = larkAppId;
2648
+ session.scope = runtimeScope;
2649
+ if (deferredFreshTopic) {
2650
+ session.deferredScheduleRun = {
2651
+ taskId: task.id,
2652
+ turnId: scheduledTurnId,
2653
+ routingAnchor: anchor,
2654
+ ...(task.topicTitle?.trim() ? { topicTitle: task.topicTitle.trim() } : {}),
2655
+ createdAt: new Date(now).toISOString(),
2656
+ };
2657
+ }
2658
+ session.lastMessageAt = new Date(now).toISOString();
2659
+ sessionStore.updateSession(session);
2660
+ messageQueue.ensureQueue(anchor);
2661
+ const ds = {
2662
+ session,
2663
+ worker: null,
2664
+ workerPort: null,
2665
+ workerToken: null,
2666
+ larkAppId,
2667
+ chatId: task.chatId,
2668
+ chatType: task.chatType === 'p2p' ? 'p2p' : 'group',
2669
+ scope: runtimeScope,
2670
+ spawnedAt: sessionCreatedAtMs(session),
2671
+ cliVersion: getCurrentCliVersion(),
2672
+ lastMessageAt: now,
2673
+ hasHistory: isContinuation,
2674
+ workingDir: task.workingDir,
2675
+ initialStartPending: true,
2676
+ pendingPrompt: firePrompt,
2677
+ };
2678
+ if (sharedTopicRootId) {
2679
+ beginReplyTargetTurn(ds, sharedTopicRootId, scheduledTurnId);
2680
+ sessionStore.updateSession(ds.session);
2198
2681
  }
2682
+ ensureSessionWhiteboard(ds);
2683
+ const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId });
2684
+ // Compare-and-set registration (master): a concurrent creator/restore may
2685
+ // have claimed this anchor between the scratch cleanup above and here.
2686
+ // Refuse to overwrite the live occupant, retire THIS rejected candidate's
2687
+ // durable row, and fail observably rather than reporting a false success.
2688
+ if (!setActiveSessionIfActive(activeSessions, key, ds)) {
2689
+ const winner = activeSessions.get(key);
2690
+ await closeSession(session.sessionId);
2691
+ throw new Error(`scheduled task ${task.id} lost active-session registration for ${anchor}` +
2692
+ (winner ? ` to ${winner.session.sessionId}` : ''));
2693
+ }
2694
+ rememberLastCliInput(ds, task.prompt, prompt);
2695
+ if (silent)
2696
+ armSilentScheduledTurn(ds, scheduledTurnId);
2199
2697
  try {
2200
- forkWorker(existing, input, {
2201
- resume: existing.hasHistory,
2202
- turnId: scheduledTurnId,
2203
- });
2698
+ forkWorker(ds, prompt, scheduledTurnId);
2204
2699
  }
2205
2700
  catch (err) {
2206
2701
  if (silent)
2207
- disarmSilentScheduledTurn(existing, scheduledTurnId);
2702
+ disarmSilentScheduledTurn(ds, scheduledTurnId);
2208
2703
  throw err;
2209
2704
  }
2210
- logger.info(`[scheduler] Task "${task.name}" cold-resumed session ${existing.session.sessionId}${silent ? ' (silent)' : ''}`);
2211
- return;
2212
- }
2213
- // Spawn a fresh session bound to the chosen anchor.
2214
- // Thread-scope: rootMessageId = anchor. Chat-scope: rootMessageId stores the
2215
- // chatId-as-seed for audit (sessionAnchorId() returns chatId via scope). If a
2216
- // formerly chat-scope task was redirected into a converted topic chat, promote
2217
- // the runtime session to thread-scope so follow-up replies stay in-thread.
2218
- const deferredFreshTopic = executionPosition === 'new-topic' && silent;
2219
- const runtimeScope = deferredFreshTopic
2220
- ? 'chat'
2221
- : scope === 'chat' && anchor !== task.chatId ? 'thread' : scope;
2222
- const session = sessionStore.createSession(task.chatId, anchor, `${t('schedule.title_prefix', undefined, localeForBot(larkAppId))} ${task.name}`, task.chatType === 'p2p' ? 'p2p' : 'group');
2223
- const now = Date.now();
2224
- session.larkAppId = larkAppId;
2225
- session.scope = runtimeScope;
2226
- if (deferredFreshTopic) {
2227
- session.deferredScheduleRun = {
2228
- taskId: task.id,
2229
- turnId: scheduledTurnId,
2230
- routingAnchor: anchor,
2231
- ...(task.topicTitle?.trim() ? { topicTitle: task.topicTitle.trim() } : {}),
2232
- createdAt: new Date(now).toISOString(),
2233
- };
2234
- }
2235
- session.lastMessageAt = new Date(now).toISOString();
2236
- sessionStore.updateSession(session);
2237
- messageQueue.ensureQueue(anchor);
2238
- const ds = {
2239
- session,
2240
- worker: null,
2241
- workerPort: null,
2242
- workerToken: null,
2243
- larkAppId,
2244
- chatId: task.chatId,
2245
- chatType: task.chatType === 'p2p' ? 'p2p' : 'group',
2246
- scope: runtimeScope,
2247
- spawnedAt: sessionCreatedAtMs(session),
2248
- cliVersion: getCurrentCliVersion(),
2249
- lastMessageAt: now,
2250
- hasHistory: isContinuation,
2251
- workingDir: task.workingDir,
2252
- };
2253
- if (sharedTopicRootId) {
2254
- beginReplyTargetTurn(ds, sharedTopicRootId, scheduledTurnId);
2255
- sessionStore.updateSession(ds.session);
2256
- }
2257
- ensureSessionWhiteboard(ds);
2258
- const prompt = buildNewTopicCliInput(firePrompt, session.sessionId, bot.config.cliId, bot.config.cliPathOverride, undefined, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId: task.chatId, whiteboardId: ds.session.whiteboardId });
2259
- if (!setActiveSessionIfActive(activeSessions, activeKey, ds)) {
2260
- const winner = activeSessions.get(activeKey);
2261
- await closeSession(session.sessionId);
2262
- throw new Error(`scheduled task ${task.id} lost active-session registration for ${anchor}` +
2263
- (winner ? ` to ${winner.session.sessionId}` : ''));
2264
- }
2265
- rememberLastCliInput(ds, task.prompt, prompt);
2266
- if (silent)
2267
- armSilentScheduledTurn(ds, scheduledTurnId);
2268
- try {
2269
- forkWorker(ds, prompt, scheduledTurnId);
2270
- }
2271
- catch (err) {
2272
- if (silent)
2273
- disarmSilentScheduledTurn(ds, scheduledTurnId);
2274
- throw err;
2275
- }
2276
- logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${runtimeScope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
2705
+ ds.initialStartPending = false;
2706
+ ds.pendingPrompt = undefined;
2707
+ logger.info(`[scheduler] Task "${task.name}" spawned (session: ${session.sessionId}, scope: ${runtimeScope}, anchor: ${anchor}, continuation: ${isContinuation}${silent ? ', silent' : ''})`);
2708
+ });
2277
2709
  }
2278
2710
  // ─── Dashboard「创建会话」spawn / activate ───────────────────────────────────
2279
2711
  /** 解析 dashboard 创建会话的 pinned workingDir:本群 oncall 绑定优先(弹框填了工作目录会
@@ -2319,6 +2751,14 @@ async function forkOrShowRepoCard(ds, userContent) {
2319
2751
  const baseDir = ds.workingDir;
2320
2752
  ds.pendingRepo = true; // router buffers concurrent msgs; commit clears it
2321
2753
  ds.pendingPrompt = userContent; // folded into the first turn by commitRepoSelection
2754
+ stagePendingRepoSetup(ds, {
2755
+ mode: 'auto_worktree',
2756
+ baseDir,
2757
+ turnId: ds.currentReplyTarget?.turnId ?? ds.session.rootMessageId,
2758
+ });
2759
+ // Route visibility moves atomically from initial-start reservation to
2760
+ // pendingRepo before the dynamic import can yield.
2761
+ ds.initialStartPending = false;
2322
2762
  // (The pending dashboard row is announced inside runAutoWorktreeCommit so all
2323
2763
  // three spawn callers get it from one place — no publish needed here.)
2324
2764
  const { runAutoWorktreeCommit } = await import('../im/lark/card-handler.js');
@@ -2330,41 +2770,75 @@ async function forkOrShowRepoCard(ds, userContent) {
2330
2770
  notify: (m) => sendMessage(larkAppId, ds.chatId, m),
2331
2771
  });
2332
2772
  logger.info(`[createSession] session ${ds.session.sessionId.substring(0, 8)} → pending, building worktree off ${baseDir}`);
2333
- return;
2773
+ return 'pending_repo';
2334
2774
  }
2335
2775
  }
2336
- const buildPrompt = () => buildNewTopicCliInput(userContent, ds.session.sessionId, bot.config.cliId, bot.config.cliPathOverride, ds.pendingAttachments, undefined, undefined, undefined, { name: bot.botName, openId: bot.botOpenId }, locale, undefined, {
2776
+ const buildPrompt = () => buildNewTopicCliInput(userContent, ds.session.sessionId, bot.config.cliId, bot.config.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, undefined, ds.pendingFollowUps, { name: bot.botName, openId: bot.botOpenId }, locale, ds.pendingSender, {
2337
2777
  larkAppId,
2338
2778
  chatId: ds.chatId,
2339
2779
  whiteboardId: ds.session.whiteboardId,
2340
2780
  codexAppText: ds.pendingCodexAppText,
2341
2781
  codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
2342
2782
  codexAppMessageContext: ds.pendingCodexAppMessageContext,
2783
+ codexAppFollowUps: ds.pendingCodexAppFollowUps,
2784
+ codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
2343
2785
  });
2344
2786
  if (!ds.workingDir) {
2345
2787
  // 没钉目录 → 复用 /repo 选择卡片让用户在群里选仓库。
2346
2788
  const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
2347
2789
  const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
2348
2790
  if (projects.length > 0) {
2791
+ const card = buildRepoSelectCard(projects, getSessionWorkingDir(ds), ds.chatId, locale, bot.config.worktreeMultiPicker);
2792
+ const { sendMessage } = await import('../im/lark/client.js');
2793
+ ds.pendingRepo = true;
2794
+ ds.pendingPrompt = userContent;
2349
2795
  try {
2350
- const card = buildRepoSelectCard(projects, getSessionWorkingDir(ds), ds.chatId, locale, bot.config.worktreeMultiPicker);
2351
- const { sendMessage } = await import('../im/lark/client.js');
2352
- ds.pendingRepo = true;
2353
- ds.pendingPrompt = userContent;
2354
- ds.repoCardMessageId = await sendMessage(larkAppId, ds.chatId, card, 'interactive');
2355
- announcePendingRepoSession(ds);
2356
- // 弹卡片这条路不经 forkWorker,session.spawned 不会自动发——手动 upsert 一条,
2357
- // 让 dashboard 显示这条「待选仓库」会话(in_progress 首次 spawn 走这里才会出现)。
2358
- dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
2359
- logger.info(`[createSession] repo select card posted for session ${ds.session.sessionId.substring(0, 8)} (${projects.length} projects)`);
2360
- return;
2796
+ stagePendingRepoSetup(ds, {
2797
+ mode: 'picker',
2798
+ turnId: ds.currentReplyTarget?.turnId ?? ds.session.rootMessageId,
2799
+ });
2361
2800
  }
2362
2801
  catch (err) {
2363
- // 发卡失败:退回直接起,别把会话卡死在 pendingRepo。
2802
+ // Nothing external was published. The journal helper rolled its own
2803
+ // mutation back, so direct fork remains a safe fallback.
2364
2804
  ds.pendingRepo = false;
2365
2805
  ds.pendingPrompt = undefined;
2366
2806
  ds.repoCardMessageId = undefined;
2367
- logger.warn(`[createSession] repo card failed (${err?.message ?? err}); forking with default cwd`);
2807
+ logger.warn(`[createSession] repo setup stage failed (${err?.message ?? err}); forking with default cwd`);
2808
+ }
2809
+ if (ds.pendingRepo) {
2810
+ let publishedCardId;
2811
+ try {
2812
+ publishedCardId = await sendMessage(larkAppId, ds.chatId, card, 'interactive');
2813
+ }
2814
+ catch (err) {
2815
+ // No picker exists, so the already-durable opening may safely move
2816
+ // into forkWorker's tokened activation journal.
2817
+ ds.pendingRepo = false;
2818
+ ds.repoCardMessageId = undefined;
2819
+ logger.warn(`[createSession] repo card publish failed (${err?.message ?? err}); forking with default cwd`);
2820
+ }
2821
+ if (publishedCardId) {
2822
+ ds.repoCardMessageId = publishedCardId;
2823
+ try {
2824
+ persistPendingRepoCardMessageId(ds, publishedCardId);
2825
+ }
2826
+ catch (err) {
2827
+ // The card is already visible. Keep its runtime identity and the
2828
+ // durable setup owner fail-closed; restart will publish and persist
2829
+ // a fresh picker instead of starting the CLI behind this card.
2830
+ logger.error(`[createSession] repo card id persistence failed for ${ds.session.sessionId.substring(0, 8)}; `
2831
+ + `retaining pending picker ${publishedCardId}: `
2832
+ + `${err instanceof Error ? err.message : String(err)}`);
2833
+ }
2834
+ ds.initialStartPending = false;
2835
+ announcePendingRepoSession(ds);
2836
+ // 弹卡片这条路不经 forkWorker,session.spawned 不会自动发——手动 upsert 一条,
2837
+ // 让 dashboard 显示这条「待选仓库」会话(in_progress 首次 spawn 走这里才会出现)。
2838
+ dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
2839
+ logger.info(`[createSession] repo select card posted for session ${ds.session.sessionId.substring(0, 8)} (${projects.length} projects)`);
2840
+ return 'pending_repo';
2841
+ }
2368
2842
  }
2369
2843
  }
2370
2844
  }
@@ -2372,11 +2846,25 @@ async function forkOrShowRepoCard(ds, userContent) {
2372
2846
  const prompt = buildPrompt();
2373
2847
  rememberLastCliInput(ds, userContent, prompt);
2374
2848
  forkWorker(ds, prompt);
2375
- ds.pendingCodexAppText = undefined;
2376
- ds.pendingCodexAppApplicationContext = undefined;
2377
- ds.pendingCodexAppMessageContext = undefined;
2378
- ds.pendingChatContext = undefined;
2379
- ds.pendingAttachments = undefined;
2849
+ // forkWorker pre-accept is synchronous. Keep the reservation and all input
2850
+ // buffers intact if it throws; only expose the normal worker state after it
2851
+ // has accepted the first turn.
2852
+ if (!ds.session.queuedActivationPending) {
2853
+ ds.initialStartPending = false;
2854
+ ds.pendingPrompt = undefined;
2855
+ ds.pendingCodexAppText = undefined;
2856
+ ds.pendingCodexAppApplicationContext = undefined;
2857
+ ds.pendingCodexAppMessageContext = undefined;
2858
+ ds.pendingChatContext = undefined;
2859
+ ds.pendingAttachments = undefined;
2860
+ ds.pendingMentions = undefined;
2861
+ ds.pendingSender = undefined;
2862
+ ds.pendingFollowUps = undefined;
2863
+ ds.pendingFollowUpTurnIds = undefined;
2864
+ ds.pendingCodexAppFollowUps = undefined;
2865
+ ds.pendingCodexAppFollowUpContexts = undefined;
2866
+ }
2867
+ return 'forked';
2380
2868
  }
2381
2869
  /** 在新建的飞书群里为某个 bot 拉起一条 chat-scope 会话(dashboard「创建会话」用)。
2382
2870
  * column='in_progress' → 立即 forkWorker 把内容当首轮发给 CLI;
@@ -2396,8 +2884,11 @@ export async function spawnDashboardSession(activeSessions, refreshCliVersion, a
2396
2884
  // chat-scope:锚点就是 chatId。先挡掉「同群同 bot 已有真会话」的撞键(会被
2397
2885
  // Map.set 覆盖而泄漏 worker)。queued 占位 / 纯 scratch 不算冲突。
2398
2886
  const anchor = chatId;
2399
- const existing = activeSessions.get(sessionKey(anchor, larkAppId));
2400
- if (existing && (existing.worker || existing.session.queued || isRelayableRealSession(existing))) {
2887
+ const key = sessionKey(anchor, larkAppId);
2888
+ const existing = activeSessions.get(key);
2889
+ if (existing && (existing.worker || existing.session.queued || existing.pendingRepo
2890
+ || existing.initialStartPending || existing.worktreeCreating || isRelayableRealSession(existing)
2891
+ || hasProtectedSessionMutationOwnership(existing))) {
2401
2892
  return { ok: false, error: 'session_exists' };
2402
2893
  }
2403
2894
  const quarantinedPersisted = sessionStore.listSessions().find(session => session.status === 'active'
@@ -2439,67 +2930,140 @@ export async function spawnDashboardSession(activeSessions, refreshCliVersion, a
2439
2930
  // 也带编排上下文(coworkers 只有此刻可靠,激活时已无从重算)。
2440
2931
  const userContent = composeSpawnUserContent({ content, role, coworkers: args.coworkers, locale });
2441
2932
  const codexAppMessageContext = composeSpawnCodexAppContext({ role, coworkers: args.coworkers, locale });
2442
- const resolvedTitle = args.title || deriveSessionTitleFromContent(content);
2443
- const session = sessionStore.createSession(chatId, bannerMessageId ?? chatId, resolvedTitle, 'group');
2444
- const now = Date.now();
2445
- session.larkAppId = larkAppId;
2446
- session.scope = 'chat';
2447
- session.ownerOpenId = args.ownerOpenId ?? getOwnerOpenId(larkAppId);
2448
- session.creatorOpenId = session.ownerOpenId;
2449
- if (args.ownerUnionId)
2450
- session.ownerUnionId = args.ownerUnionId;
2451
- session.lastMessageAt = new Date(now).toISOString();
2452
- if (args.attachments?.length)
2453
- session.dashboardAttachments = args.attachments;
2454
- if (column === 'backlog') {
2455
- session.queued = true;
2456
- session.queuedPrompt = userContent;
2457
- session.queuedCodexAppText = content;
2458
- session.queuedCodexAppMessageContext = codexAppMessageContext;
2459
- session.queuedAttachments = args.attachments;
2460
- session.kanbanColumn = 'backlog';
2461
- }
2462
- // workingDir:oncall 绑定(弹框填了目录)/ bot 默认。都没有 → undefined,激活/开跑时
2463
- // 会弹 /repo 卡片让用户在群里选仓库(复用普通新话题逻辑)。
2464
- const workingDir = resolveDashboardSpawnWorkingDir(larkAppId, chatId);
2465
- if (workingDir)
2466
- session.workingDir = workingDir;
2467
- sessionStore.updateSession(session);
2468
- messageQueue.ensureQueue(anchor);
2469
- const ds = {
2470
- session,
2471
- worker: null,
2472
- workerPort: null,
2473
- workerToken: null,
2474
- larkAppId,
2475
- chatId,
2476
- chatType: 'group',
2477
- scope: 'chat',
2478
- spawnedAt: sessionCreatedAtMs(session),
2479
- cliVersion: getCurrentCliVersion(),
2480
- lastMessageAt: now,
2481
- hasHistory: false,
2482
- workingDir,
2483
- ownerOpenId: session.ownerOpenId,
2484
- currentTurnTitle: resolvedTitle,
2485
- pendingCodexAppText: content,
2486
- pendingCodexAppMessageContext: codexAppMessageContext,
2487
- pendingAttachments: args.attachments,
2488
- };
2489
- if (!setActiveSessionIfActive(activeSessions, sessionKey(anchor, larkAppId), ds)) {
2490
- await closeSession(session.sessionId);
2933
+ const registered = await withActiveSessionKeyLock(activeSessions, key, async () => {
2934
+ const current = activeSessions.get(key);
2935
+ if (current) {
2936
+ const protectedOwner = current.worker || current.session.queued || current.pendingRepo
2937
+ || current.initialStartPending || current.worktreeCreating
2938
+ || isRelayableRealSession(current)
2939
+ || hasProtectedSessionMutationOwnership(current);
2940
+ if (protectedOwner)
2941
+ return undefined;
2942
+ await closeSession(current.session.sessionId);
2943
+ }
2944
+ const resolvedTitle = args.title || deriveSessionTitleFromContent(content);
2945
+ const session = sessionStore.createSession(chatId, bannerMessageId ?? chatId, resolvedTitle, 'group');
2946
+ const now = Date.now();
2947
+ session.larkAppId = larkAppId;
2948
+ session.scope = 'chat';
2949
+ session.ownerOpenId = args.ownerOpenId ?? getOwnerOpenId(larkAppId);
2950
+ session.creatorOpenId = session.ownerOpenId;
2951
+ if (args.ownerUnionId)
2952
+ session.ownerUnionId = args.ownerUnionId;
2953
+ session.lastMessageAt = new Date(now).toISOString();
2954
+ if (args.attachments?.length)
2955
+ session.dashboardAttachments = args.attachments;
2956
+ if (column === 'backlog') {
2957
+ session.queued = true;
2958
+ session.queuedPrompt = userContent;
2959
+ session.queuedCodexAppText = content;
2960
+ session.queuedCodexAppMessageContext = codexAppMessageContext;
2961
+ if (args.attachments?.length)
2962
+ session.queuedAttachments = args.attachments;
2963
+ session.kanbanColumn = 'backlog';
2964
+ }
2965
+ const workingDir = resolveDashboardSpawnWorkingDir(larkAppId, chatId);
2966
+ if (workingDir)
2967
+ session.workingDir = workingDir;
2968
+ sessionStore.updateSession(session);
2969
+ messageQueue.ensureQueue(anchor);
2970
+ const ds = {
2971
+ session,
2972
+ worker: null,
2973
+ workerPort: null,
2974
+ workerToken: null,
2975
+ larkAppId,
2976
+ chatId,
2977
+ chatType: 'group',
2978
+ scope: 'chat',
2979
+ spawnedAt: sessionCreatedAtMs(session),
2980
+ cliVersion: getCurrentCliVersion(),
2981
+ lastMessageAt: now,
2982
+ hasHistory: false,
2983
+ workingDir,
2984
+ ownerOpenId: session.ownerOpenId,
2985
+ currentTurnTitle: resolvedTitle,
2986
+ pendingCodexAppText: content,
2987
+ pendingCodexAppMessageContext: codexAppMessageContext,
2988
+ pendingAttachments: args.attachments,
2989
+ };
2990
+ if (column !== 'backlog') {
2991
+ ds.initialStartPending = true;
2992
+ ds.pendingPrompt = userContent;
2993
+ }
2994
+ activeSessions.set(key, ds);
2995
+ if (column === 'backlog') {
2996
+ ds.pendingPrompt = userContent;
2997
+ dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
2998
+ }
2999
+ else {
3000
+ // Keep the key reservation through initial worker/repo-picker
3001
+ // installation. Otherwise a concurrent creator can classify this brief
3002
+ // worker:null interval as disposable scratch, replace it, and let this
3003
+ // caller resume by forking an orphan.
3004
+ try {
3005
+ await forkOrShowRepoCard(ds, userContent);
3006
+ }
3007
+ catch (err) {
3008
+ const durableOwner = hasProtectedSessionMutationOwnership(ds.session);
3009
+ const liveWorkerOwner = !!ds.worker && !ds.worker.killed;
3010
+ if (durableOwner || liveWorkerOwner) {
3011
+ // `forkOrShowRepoCard` can fail after the repo/setup journal was
3012
+ // durably staged (for example picker publish fallback followed by a
3013
+ // fork rejection). That journal is now the exact opening owner; never
3014
+ // erase it in the generic spawn rollback. Rebuild volatile picker
3015
+ // buffers where possible and leave this exact map row active/retryable.
3016
+ if (!ds.session.queuedActivationPending && ds.session.pendingRepoSetup) {
3017
+ restorePendingRepoRuntime(ds);
3018
+ }
3019
+ ds.initialStartPending = ds.session.queuedActivationPending === true
3020
+ || (ds.session.queuedActivationTail?.length ?? 0) > 0;
3021
+ // The create IPC reports failure, but this durable row is still the
3022
+ // authoritative retry owner. Upsert it so dashboard clients do not
3023
+ // see an invisible occupied chat until the next full hydrate.
3024
+ announceSessionRow(ds);
3025
+ logger.error(`[createSession] opening failed after durable ownership transfer for ${session.sessionId}; `
3026
+ + `retaining exact active owner: ${err instanceof Error ? err.message : String(err)}`);
3027
+ return { error: err instanceof Error ? err.message : String(err) };
3028
+ }
3029
+ // No durable or live owner accepted the opening. Roll back only our
3030
+ // exact runtime claim and close only our row; a concurrently published
3031
+ // successor must survive.
3032
+ if (activeSessions.get(key) === ds)
3033
+ activeSessions.delete(key);
3034
+ try {
3035
+ sessionStore.closeSession(session.sessionId);
3036
+ }
3037
+ catch (closeErr) {
3038
+ logger.error(`[createSession] failed to close unaccepted session ${session.sessionId}: `
3039
+ + `${closeErr instanceof Error ? closeErr.message : String(closeErr)}`);
3040
+ }
3041
+ return {
3042
+ error: err instanceof Error ? err.message : String(err),
3043
+ };
3044
+ }
3045
+ }
3046
+ return { ds, session };
3047
+ });
3048
+ if (!registered)
2491
3049
  return { ok: false, error: 'session_exists' };
2492
- }
3050
+ if ('error' in registered && typeof registered.error === 'string') {
3051
+ return { ok: false, error: registered.error };
3052
+ }
3053
+ // PR #597 builds `ds`, registers it, and runs forkOrShowRepoCard INSIDE the
3054
+ // key lock above (returning { ds, session }); master's post-lock ds
3055
+ // construction + registration CAS is the pre-lock equivalent and would
3056
+ // double-register here, so it is intentionally dropped in favor of the locked
3057
+ // path. Master's quarantinedPersisted anchor guard is preserved before the
3058
+ // lock, and the backlog publish / forkOrShowRepoCard both happen in-callback.
3059
+ const { ds, session } = registered;
2493
3060
  if (column === 'backlog') {
2494
3061
  // Parked:不起 CLI。手动广播 session.spawned,让 dashboard 立刻显示待办池卡片
2495
3062
  // (forkWorker 才会自动发这个事件,parked 路径要自己发)。
2496
- ds.pendingPrompt = userContent;
2497
- dashboardEventBus.publish({ type: 'session.spawned', body: { session: composeRowFromActive(ds) } });
2498
3063
  logger.info(`[createSession] queued session ${session.sessionId.substring(0, 8)} (bot=${larkAppId}, chat=${chatId}, role=${role})`);
2499
3064
  return { ok: true, sessionId: session.sessionId };
2500
3065
  }
2501
3066
  // in_progress:立即开跑或弹 /repo 卡片(没钉目录时)。userContent 已按角色包装好。
2502
- await forkOrShowRepoCard(ds, userContent);
2503
3067
  logger.info(`[createSession] spawned session ${session.sessionId.substring(0, 8)} (bot=${larkAppId}, chat=${chatId}, role=${role}, pendingRepo=${!!ds.pendingRepo})`);
2504
3068
  return { ok: true, sessionId: session.sessionId };
2505
3069
  }
@@ -2511,35 +3075,104 @@ export async function activateQueuedSession(ds) {
2511
3075
  return (ds.worker && !ds.worker.killed) ? { ok: true } : { ok: false, error: 'not_queued' };
2512
3076
  }
2513
3077
  if (ds.worker && !ds.worker.killed) {
3078
+ if (ds.session.queuedActivationPending
3079
+ || (ds.session.queuedActivationTail?.length ?? 0) > 0
3080
+ || ds.session.pendingRepoSetup) {
3081
+ // A contradictory live+queued snapshot can occur around recovery or a
3082
+ // stale dashboard action. Never reinterpret the live child as proof that
3083
+ // its durable activation/setup owner crossed the adapter ACK boundary.
3084
+ logger.warn(`[${ds.session.sessionId.substring(0, 8)}] Ignored queued activation cleanup while durable ownership remains`);
3085
+ return { ok: true };
3086
+ }
2514
3087
  // 不该发生(queued 一定 worker:null),但保险:清标记即可。
2515
3088
  ds.session.queued = false;
2516
3089
  ds.session.queuedPrompt = undefined;
2517
3090
  ds.session.queuedCodexAppText = undefined;
2518
3091
  ds.session.queuedCodexAppMessageContext = undefined;
2519
3092
  ds.session.queuedAttachments = undefined;
3093
+ ds.session.queuedActivationPending = undefined;
3094
+ ds.session.queuedActivationToken = undefined;
3095
+ ds.session.queuedActivationInput = undefined;
3096
+ ds.session.queuedActivationTurnId = undefined;
3097
+ ds.session.queuedActivationDispatchAttempt = undefined;
3098
+ ds.session.queuedActivationResume = undefined;
3099
+ ds.session.pendingRepoSetup = undefined;
2520
3100
  sessionStore.updateSession(ds.session);
2521
3101
  return { ok: true };
2522
3102
  }
2523
- const content = ds.session.queuedPrompt ?? ds.pendingPrompt ?? '';
2524
- // A parked dashboard task may have crossed a daemon restart. Restore the
2525
- // persisted clean-input sidecar before clearing the durable backlog fields;
2526
- // forkOrShowRepoCard will carry it through either immediate fork or /repo.
2527
- ds.pendingCodexAppText ??= ds.session.queuedCodexAppText;
2528
- ds.pendingCodexAppMessageContext ??= ds.session.queuedCodexAppMessageContext;
2529
- ds.pendingAttachments ??= ds.session.queuedAttachments;
2530
- ds.session.queued = false;
2531
- ds.session.queuedPrompt = undefined;
2532
- ds.session.queuedCodexAppText = undefined;
2533
- ds.session.queuedCodexAppMessageContext = undefined;
2534
- ds.session.queuedAttachments = undefined;
2535
- ds.pendingPrompt = undefined;
2536
- // 激活即视为开始:从待办池挪到进行中,让卡片归位。
2537
- if (ds.session.kanbanColumn === 'backlog')
2538
- ds.session.kanbanColumn = 'in_progress';
2539
- sessionStore.updateSession(ds.session);
2540
- // 起会话或弹 /repo 卡片(没钉目录时)。content 已是包装好的首轮内容。
2541
- await forkOrShowRepoCard(ds, content);
2542
- logger.info(`[createSession] activated queued session ${ds.session.sessionId.substring(0, 8)} (bot=${ds.larkAppId}, pendingRepo=${!!ds.pendingRepo})`);
2543
- return { ok: true };
3103
+ // Repo selection / auto-worktree already owns this activation attempt. Keep
3104
+ // the durable queued payload as its crash-recovery journal and make repeated
3105
+ // dashboard starts idempotent instead of posting a second picker/build.
3106
+ if (ds.pendingRepo)
3107
+ return { ok: true };
3108
+ const activate = async () => {
3109
+ if (!ds.session.queued) {
3110
+ return (ds.worker && !ds.worker.killed) ? { ok: true } : { ok: false, error: 'not_queued' };
3111
+ }
3112
+ const content = ds.session.queuedPrompt ?? ds.pendingPrompt ?? '';
3113
+ // Preserve the durable queued payload until fork or pendingRepo setup has
3114
+ // succeeded. Ordinary inbound routing does not take the key lock, so the
3115
+ // runtime reservation must also be visible throughout every await.
3116
+ ds.initialStartPending = true;
3117
+ ds.pendingPrompt = content;
3118
+ ds.pendingCodexAppText ??= ds.session.queuedCodexAppText;
3119
+ ds.pendingCodexAppMessageContext ??= ds.session.queuedCodexAppMessageContext;
3120
+ ds.pendingAttachments ??= ds.session.queuedAttachments;
3121
+ let outcome;
3122
+ try {
3123
+ const exactRetry = ds.session.queuedActivationInput;
3124
+ if (exactRetry) {
3125
+ forkWorker(ds, exactRetry, {
3126
+ resume: ds.session.queuedActivationResume ?? ds.hasHistory,
3127
+ turnId: ds.session.queuedActivationTurnId,
3128
+ dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
3129
+ });
3130
+ outcome = 'forked';
3131
+ }
3132
+ else {
3133
+ outcome = await forkOrShowRepoCard(ds, content);
3134
+ }
3135
+ }
3136
+ catch (err) {
3137
+ // queued remains authoritative and retryable. Undo only transient route
3138
+ // state installed by this failed attempt; never discard the opening
3139
+ // prompt/sidecar before a worker or repo picker accepted it.
3140
+ ds.initialStartPending = false;
3141
+ ds.pendingRepo = false;
3142
+ ds.repoCardMessageId = undefined;
3143
+ ds.pendingPrompt = content;
3144
+ return { ok: false, error: err?.message ?? 'start_failed' };
3145
+ }
3146
+ // Ownership has transferred to a worker or pending-repo flow. Nothing
3147
+ // below this point may advertise a retryable activation failure.
3148
+ if (outcome === 'forked') {
3149
+ ds.session.queued = false;
3150
+ ds.session.queuedAttachments = undefined;
3151
+ }
3152
+ // pending_repo deliberately retains queued + queuedPrompt: pendingPrompt
3153
+ // is runtime-only, so clearing the durable copy here would lose the first
3154
+ // turn if the daemon dies before repo selection/worktree commit forks.
3155
+ if (ds.session.kanbanColumn === 'backlog')
3156
+ ds.session.kanbanColumn = 'in_progress';
3157
+ try {
3158
+ sessionStore.updateSession(ds.session);
3159
+ }
3160
+ catch (err) {
3161
+ logger.error(`[createSession] queued activation metadata persistence failed after ownership transfer: `
3162
+ + `${err instanceof Error ? err.message : String(err)}`);
3163
+ }
3164
+ logger.info(`[createSession] activated queued session ${ds.session.sessionId.substring(0, 8)} (bot=${ds.larkAppId}, pendingRepo=${!!ds.pendingRepo})`);
3165
+ return { ok: true };
3166
+ };
3167
+ const registry = getActiveSessionsRegistry();
3168
+ if (!registry)
3169
+ return activate();
3170
+ const key = activeSessionKey(ds);
3171
+ return withActiveSessionKeyLock(registry, key, async () => {
3172
+ if (registry.get(key) !== ds || ds.session.status !== 'active') {
3173
+ return { ok: false, error: 'session_not_active' };
3174
+ }
3175
+ return activate();
3176
+ });
2544
3177
  }
2545
3178
  //# sourceMappingURL=session-manager.js.map