agent-tempo 1.0.1

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 (484) hide show
  1. package/CLAUDE.md +213 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/assets/icon-32.png +0 -0
  5. package/assets/icon-512.png +0 -0
  6. package/assets/icon-64.png +0 -0
  7. package/assets/icon-dark-32.png +0 -0
  8. package/assets/icon-dark-64.png +0 -0
  9. package/assets/icon-dark.svg +9 -0
  10. package/assets/icon.svg +9 -0
  11. package/assets/logo-dark.svg +11 -0
  12. package/assets/logo-light.svg +11 -0
  13. package/dashboard/README.md +91 -0
  14. package/dashboard/dist/assets/index-CB78ToNE.css +2 -0
  15. package/dashboard/dist/assets/index-_5jV0Znu.js +62 -0
  16. package/dashboard/dist/assets/index-_5jV0Znu.js.map +1 -0
  17. package/dashboard/dist/index.html +21 -0
  18. package/dashboard/package.json +47 -0
  19. package/dist/activities/hard-terminate.d.ts +32 -0
  20. package/dist/activities/hard-terminate.js +460 -0
  21. package/dist/activities/maestro.d.ts +72 -0
  22. package/dist/activities/maestro.js +254 -0
  23. package/dist/activities/outbox.d.ts +188 -0
  24. package/dist/activities/outbox.js +849 -0
  25. package/dist/activities/resolve.d.ts +64 -0
  26. package/dist/activities/resolve.js +129 -0
  27. package/dist/activities/schedule-fire.d.ts +36 -0
  28. package/dist/activities/schedule-fire.js +147 -0
  29. package/dist/adapters/base.d.ts +426 -0
  30. package/dist/adapters/base.js +1270 -0
  31. package/dist/adapters/claude-api/adapter.d.ts +168 -0
  32. package/dist/adapters/claude-api/adapter.js +797 -0
  33. package/dist/adapters/claude-api/api-error.d.ts +96 -0
  34. package/dist/adapters/claude-api/api-error.js +191 -0
  35. package/dist/adapters/claude-api/index.d.ts +16 -0
  36. package/dist/adapters/claude-api/index.js +21 -0
  37. package/dist/adapters/claude-api/mcp-bridge.d.ts +50 -0
  38. package/dist/adapters/claude-api/mcp-bridge.js +157 -0
  39. package/dist/adapters/claude-code/adapter.d.ts +133 -0
  40. package/dist/adapters/claude-code/adapter.js +274 -0
  41. package/dist/adapters/claude-code/index.d.ts +15 -0
  42. package/dist/adapters/claude-code/index.js +20 -0
  43. package/dist/adapters/claude-code-headless/adapter.d.ts +131 -0
  44. package/dist/adapters/claude-code-headless/adapter.js +710 -0
  45. package/dist/adapters/claude-code-headless/error-mapper.d.ts +107 -0
  46. package/dist/adapters/claude-code-headless/error-mapper.js +281 -0
  47. package/dist/adapters/claude-code-headless/index.d.ts +17 -0
  48. package/dist/adapters/claude-code-headless/index.js +26 -0
  49. package/dist/adapters/claude-code-headless/pre-flight.d.ts +51 -0
  50. package/dist/adapters/claude-code-headless/pre-flight.js +207 -0
  51. package/dist/adapters/claude-code-headless/prompt.d.ts +93 -0
  52. package/dist/adapters/claude-code-headless/prompt.js +79 -0
  53. package/dist/adapters/claude-code-headless/stream-json.d.ts +242 -0
  54. package/dist/adapters/claude-code-headless/stream-json.js +208 -0
  55. package/dist/adapters/claude-code-headless/types.d.ts +28 -0
  56. package/dist/adapters/claude-code-headless/types.js +36 -0
  57. package/dist/adapters/copilot/adapter.d.ts +100 -0
  58. package/dist/adapters/copilot/adapter.js +730 -0
  59. package/dist/adapters/copilot/index.d.ts +15 -0
  60. package/dist/adapters/copilot/index.js +20 -0
  61. package/dist/adapters/index.d.ts +42 -0
  62. package/dist/adapters/index.js +115 -0
  63. package/dist/adapters/opencode/adapter.d.ts +82 -0
  64. package/dist/adapters/opencode/adapter.js +710 -0
  65. package/dist/adapters/opencode/config.d.ts +90 -0
  66. package/dist/adapters/opencode/config.js +137 -0
  67. package/dist/adapters/opencode/helpers.d.ts +40 -0
  68. package/dist/adapters/opencode/helpers.js +144 -0
  69. package/dist/adapters/opencode/index.d.ts +12 -0
  70. package/dist/adapters/opencode/index.js +17 -0
  71. package/dist/adapters/opencode/server-bridge.d.ts +124 -0
  72. package/dist/adapters/opencode/server-bridge.js +216 -0
  73. package/dist/adapters/sdk/base.d.ts +95 -0
  74. package/dist/adapters/sdk/base.js +134 -0
  75. package/dist/adapters/sdk/system-prompt.d.ts +64 -0
  76. package/dist/adapters/sdk/system-prompt.js +78 -0
  77. package/dist/adapters/terminal-error.d.ts +27 -0
  78. package/dist/adapters/terminal-error.js +39 -0
  79. package/dist/channel.d.ts +3 -0
  80. package/dist/channel.js +48 -0
  81. package/dist/cli/commands.d.ts +245 -0
  82. package/dist/cli/commands.js +2438 -0
  83. package/dist/cli/config-command.d.ts +8 -0
  84. package/dist/cli/config-command.js +254 -0
  85. package/dist/cli/daemon-command.d.ts +57 -0
  86. package/dist/cli/daemon-command.js +493 -0
  87. package/dist/cli/daemon.d.ts +217 -0
  88. package/dist/cli/daemon.js +632 -0
  89. package/dist/cli/dashboard-command.d.ts +20 -0
  90. package/dist/cli/dashboard-command.js +241 -0
  91. package/dist/cli/dev-banner.d.ts +107 -0
  92. package/dist/cli/dev-banner.js +190 -0
  93. package/dist/cli/dev-mode-bootstrap.d.ts +29 -0
  94. package/dist/cli/dev-mode-bootstrap.js +36 -0
  95. package/dist/cli/dev-verbs.d.ts +43 -0
  96. package/dist/cli/dev-verbs.js +254 -0
  97. package/dist/cli/help-text.d.ts +1 -0
  98. package/dist/cli/help-text.js +158 -0
  99. package/dist/cli/legacy-migration.d.ts +35 -0
  100. package/dist/cli/legacy-migration.js +335 -0
  101. package/dist/cli/mcp.d.ts +8 -0
  102. package/dist/cli/mcp.js +63 -0
  103. package/dist/cli/output.d.ts +12 -0
  104. package/dist/cli/output.js +37 -0
  105. package/dist/cli/preflight.d.ts +9 -0
  106. package/dist/cli/preflight.js +96 -0
  107. package/dist/cli/removed-verbs.d.ts +9 -0
  108. package/dist/cli/removed-verbs.js +78 -0
  109. package/dist/cli/sa-preflight.d.ts +99 -0
  110. package/dist/cli/sa-preflight.js +183 -0
  111. package/dist/cli/scenarios-command.d.ts +6 -0
  112. package/dist/cli/scenarios-command.js +167 -0
  113. package/dist/cli/startup.d.ts +112 -0
  114. package/dist/cli/startup.js +641 -0
  115. package/dist/cli/upgrade-command.d.ts +5 -0
  116. package/dist/cli/upgrade-command.js +240 -0
  117. package/dist/cli.d.ts +2 -0
  118. package/dist/cli.js +680 -0
  119. package/dist/client/core.d.ts +33 -0
  120. package/dist/client/core.js +1260 -0
  121. package/dist/client/ensure-conductor-spawned.d.ts +35 -0
  122. package/dist/client/ensure-conductor-spawned.js +48 -0
  123. package/dist/client/index.d.ts +32 -0
  124. package/dist/client/index.js +22 -0
  125. package/dist/client/interface.d.ts +461 -0
  126. package/dist/client/interface.js +2 -0
  127. package/dist/client/subscribe.d.ts +108 -0
  128. package/dist/client/subscribe.js +598 -0
  129. package/dist/client/with-spawn.d.ts +27 -0
  130. package/dist/client/with-spawn.js +87 -0
  131. package/dist/config.d.ts +323 -0
  132. package/dist/config.js +593 -0
  133. package/dist/connection.d.ts +7 -0
  134. package/dist/connection.js +46 -0
  135. package/dist/constants.d.ts +50 -0
  136. package/dist/constants.js +74 -0
  137. package/dist/copilot-bridge.d.ts +22 -0
  138. package/dist/copilot-bridge.js +565 -0
  139. package/dist/daemon-adapter-versions.d.ts +52 -0
  140. package/dist/daemon-adapter-versions.js +170 -0
  141. package/dist/daemon.d.ts +275 -0
  142. package/dist/daemon.js +989 -0
  143. package/dist/ensemble/agent-types.d.ts +23 -0
  144. package/dist/ensemble/agent-types.js +132 -0
  145. package/dist/ensemble/loader.d.ts +14 -0
  146. package/dist/ensemble/loader.js +140 -0
  147. package/dist/ensemble/saver.d.ts +49 -0
  148. package/dist/ensemble/saver.js +201 -0
  149. package/dist/ensemble/schema.d.ts +71 -0
  150. package/dist/ensemble/schema.js +3 -0
  151. package/dist/git-info.d.ts +4 -0
  152. package/dist/git-info.js +29 -0
  153. package/dist/http/aggregate.d.ts +319 -0
  154. package/dist/http/aggregate.js +684 -0
  155. package/dist/http/auth.d.ts +67 -0
  156. package/dist/http/auth.js +177 -0
  157. package/dist/http/body.d.ts +71 -0
  158. package/dist/http/body.js +121 -0
  159. package/dist/http/catalog.d.ts +67 -0
  160. package/dist/http/catalog.js +209 -0
  161. package/dist/http/cors.d.ts +42 -0
  162. package/dist/http/cors.js +111 -0
  163. package/dist/http/dashboard-pair.d.ts +94 -0
  164. package/dist/http/dashboard-pair.js +148 -0
  165. package/dist/http/dashboard.d.ts +20 -0
  166. package/dist/http/dashboard.js +160 -0
  167. package/dist/http/event-bus.d.ts +217 -0
  168. package/dist/http/event-bus.js +365 -0
  169. package/dist/http/event-id.d.ts +77 -0
  170. package/dist/http/event-id.js +117 -0
  171. package/dist/http/event-types.d.ts +348 -0
  172. package/dist/http/event-types.js +36 -0
  173. package/dist/http/fixtures/chat-stress.d.ts +8 -0
  174. package/dist/http/fixtures/chat-stress.js +63 -0
  175. package/dist/http/fixtures/conductor-leaving.d.ts +8 -0
  176. package/dist/http/fixtures/conductor-leaving.js +80 -0
  177. package/dist/http/fixtures/constants.d.ts +10 -0
  178. package/dist/http/fixtures/constants.js +13 -0
  179. package/dist/http/fixtures/eight-player-broadcast.d.ts +10 -0
  180. package/dist/http/fixtures/eight-player-broadcast.js +81 -0
  181. package/dist/http/fixtures/empty-ensemble.d.ts +6 -0
  182. package/dist/http/fixtures/empty-ensemble.js +26 -0
  183. package/dist/http/fixtures/index.d.ts +55 -0
  184. package/dist/http/fixtures/index.js +110 -0
  185. package/dist/http/fixtures/single-conductor.d.ts +7 -0
  186. package/dist/http/fixtures/single-conductor.js +46 -0
  187. package/dist/http/fixtures/sse-reconnect.d.ts +8 -0
  188. package/dist/http/fixtures/sse-reconnect.js +77 -0
  189. package/dist/http/index.d.ts +21 -0
  190. package/dist/http/index.js +61 -0
  191. package/dist/http/port-file.d.ts +22 -0
  192. package/dist/http/port-file.js +132 -0
  193. package/dist/http/responses.d.ts +27 -0
  194. package/dist/http/responses.js +40 -0
  195. package/dist/http/ring-buffer.d.ts +41 -0
  196. package/dist/http/ring-buffer.js +80 -0
  197. package/dist/http/server.d.ts +122 -0
  198. package/dist/http/server.js +459 -0
  199. package/dist/http/snapshot.d.ts +85 -0
  200. package/dist/http/snapshot.js +180 -0
  201. package/dist/http/sse-handler.d.ts +87 -0
  202. package/dist/http/sse-handler.js +294 -0
  203. package/dist/http/writes.d.ts +55 -0
  204. package/dist/http/writes.js +240 -0
  205. package/dist/palette/index.d.ts +138 -0
  206. package/dist/palette/index.js +221 -0
  207. package/dist/reconcile/orphans.d.ts +255 -0
  208. package/dist/reconcile/orphans.js +340 -0
  209. package/dist/scripts/258-spotcheck.js +303 -0
  210. package/dist/scripts/check-components-css-sync.js +199 -0
  211. package/dist/scripts/run-shard.js +121 -0
  212. package/dist/scripts/verify-daemon-isolation-guard.js +128 -0
  213. package/dist/server-tools.d.ts +87 -0
  214. package/dist/server-tools.js +146 -0
  215. package/dist/server.d.ts +2 -0
  216. package/dist/server.js +366 -0
  217. package/dist/spawn.d.ts +296 -0
  218. package/dist/spawn.js +747 -0
  219. package/dist/tools/agent-types.d.ts +2 -0
  220. package/dist/tools/agent-types.js +21 -0
  221. package/dist/tools/attachment-info.d.ts +4 -0
  222. package/dist/tools/attachment-info.js +48 -0
  223. package/dist/tools/broadcast.d.ts +4 -0
  224. package/dist/tools/broadcast.js +76 -0
  225. package/dist/tools/cancel-stage.d.ts +3 -0
  226. package/dist/tools/cancel-stage.js +20 -0
  227. package/dist/tools/clear-state.d.ts +3 -0
  228. package/dist/tools/clear-state.js +37 -0
  229. package/dist/tools/coat-check-evict.d.ts +4 -0
  230. package/dist/tools/coat-check-evict.js +43 -0
  231. package/dist/tools/coat-check-get.d.ts +4 -0
  232. package/dist/tools/coat-check-get.js +56 -0
  233. package/dist/tools/coat-check-list.d.ts +4 -0
  234. package/dist/tools/coat-check-list.js +60 -0
  235. package/dist/tools/coat-check-put.d.ts +4 -0
  236. package/dist/tools/coat-check-put.js +53 -0
  237. package/dist/tools/cue.d.ts +44 -0
  238. package/dist/tools/cue.js +201 -0
  239. package/dist/tools/destroy.d.ts +4 -0
  240. package/dist/tools/destroy.js +188 -0
  241. package/dist/tools/detach.d.ts +4 -0
  242. package/dist/tools/detach.js +45 -0
  243. package/dist/tools/encore.d.ts +4 -0
  244. package/dist/tools/encore.js +31 -0
  245. package/dist/tools/ensemble.d.ts +32 -0
  246. package/dist/tools/ensemble.js +198 -0
  247. package/dist/tools/evaluate-gate.d.ts +3 -0
  248. package/dist/tools/evaluate-gate.js +32 -0
  249. package/dist/tools/fetch-state.d.ts +13 -0
  250. package/dist/tools/fetch-state.js +78 -0
  251. package/dist/tools/gates.d.ts +3 -0
  252. package/dist/tools/gates.js +41 -0
  253. package/dist/tools/helpers.d.ts +21 -0
  254. package/dist/tools/helpers.js +25 -0
  255. package/dist/tools/hosts.d.ts +4 -0
  256. package/dist/tools/hosts.js +40 -0
  257. package/dist/tools/listen.d.ts +3 -0
  258. package/dist/tools/listen.js +22 -0
  259. package/dist/tools/load-lineup.d.ts +5 -0
  260. package/dist/tools/load-lineup.js +381 -0
  261. package/dist/tools/migrate.d.ts +4 -0
  262. package/dist/tools/migrate.js +60 -0
  263. package/dist/tools/pause-ensemble.d.ts +4 -0
  264. package/dist/tools/pause-ensemble.js +58 -0
  265. package/dist/tools/pause.d.ts +4 -0
  266. package/dist/tools/pause.js +36 -0
  267. package/dist/tools/play.d.ts +4 -0
  268. package/dist/tools/play.js +57 -0
  269. package/dist/tools/quality-gate.d.ts +3 -0
  270. package/dist/tools/quality-gate.js +26 -0
  271. package/dist/tools/recall.d.ts +3 -0
  272. package/dist/tools/recall.js +32 -0
  273. package/dist/tools/recruit.d.ts +38 -0
  274. package/dist/tools/recruit.js +447 -0
  275. package/dist/tools/release.d.ts +4 -0
  276. package/dist/tools/release.js +98 -0
  277. package/dist/tools/report.d.ts +3 -0
  278. package/dist/tools/report.js +29 -0
  279. package/dist/tools/resolve.d.ts +1 -0
  280. package/dist/tools/resolve.js +7 -0
  281. package/dist/tools/restart.d.ts +35 -0
  282. package/dist/tools/restart.js +131 -0
  283. package/dist/tools/restore.d.ts +4 -0
  284. package/dist/tools/restore.js +107 -0
  285. package/dist/tools/resume-ensemble.d.ts +4 -0
  286. package/dist/tools/resume-ensemble.js +79 -0
  287. package/dist/tools/save-lineup.d.ts +4 -0
  288. package/dist/tools/save-lineup.js +36 -0
  289. package/dist/tools/save-state.d.ts +3 -0
  290. package/dist/tools/save-state.js +57 -0
  291. package/dist/tools/schedule.d.ts +4 -0
  292. package/dist/tools/schedule.js +152 -0
  293. package/dist/tools/schedules.d.ts +4 -0
  294. package/dist/tools/schedules.js +54 -0
  295. package/dist/tools/set-ensemble-description.d.ts +4 -0
  296. package/dist/tools/set-ensemble-description.js +37 -0
  297. package/dist/tools/set-name.d.ts +4 -0
  298. package/dist/tools/set-name.js +45 -0
  299. package/dist/tools/set-part.d.ts +3 -0
  300. package/dist/tools/set-part.js +20 -0
  301. package/dist/tools/shutdown.d.ts +4 -0
  302. package/dist/tools/shutdown.js +54 -0
  303. package/dist/tools/stage.d.ts +3 -0
  304. package/dist/tools/stage.js +28 -0
  305. package/dist/tools/stages.d.ts +3 -0
  306. package/dist/tools/stages.js +35 -0
  307. package/dist/tools/stop.d.ts +4 -0
  308. package/dist/tools/stop.js +29 -0
  309. package/dist/tools/unschedule.d.ts +4 -0
  310. package/dist/tools/unschedule.js +35 -0
  311. package/dist/tools/who-am-i.d.ts +3 -0
  312. package/dist/tools/who-am-i.js +34 -0
  313. package/dist/tools/worktree.d.ts +4 -0
  314. package/dist/tools/worktree.js +181 -0
  315. package/dist/tui/App.d.ts +85 -0
  316. package/dist/tui/App.js +1791 -0
  317. package/dist/tui/bootstrap-types.d.ts +46 -0
  318. package/dist/tui/bootstrap-types.js +7 -0
  319. package/dist/tui/client.d.ts +6 -0
  320. package/dist/tui/client.js +9 -0
  321. package/dist/tui/commands.d.ts +71 -0
  322. package/dist/tui/commands.js +1375 -0
  323. package/dist/tui/components/ActivityLog.d.ts +16 -0
  324. package/dist/tui/components/ActivityLog.js +36 -0
  325. package/dist/tui/components/ChatView.d.ts +35 -0
  326. package/dist/tui/components/ChatView.js +54 -0
  327. package/dist/tui/components/CommandOverlay.d.ts +15 -0
  328. package/dist/tui/components/CommandOverlay.js +34 -0
  329. package/dist/tui/components/CommandPalette.d.ts +21 -0
  330. package/dist/tui/components/CommandPalette.js +67 -0
  331. package/dist/tui/components/ConductorChat.d.ts +16 -0
  332. package/dist/tui/components/ConductorChat.js +32 -0
  333. package/dist/tui/components/ConversationStream.d.ts +114 -0
  334. package/dist/tui/components/ConversationStream.js +307 -0
  335. package/dist/tui/components/CreateEnsembleWizard.d.ts +19 -0
  336. package/dist/tui/components/CreateEnsembleWizard.js +223 -0
  337. package/dist/tui/components/DestroyConfirmModal.d.ts +17 -0
  338. package/dist/tui/components/DestroyConfirmModal.js +62 -0
  339. package/dist/tui/components/EnsembleListView.d.ts +14 -0
  340. package/dist/tui/components/EnsembleListView.js +32 -0
  341. package/dist/tui/components/EnsemblePanel.d.ts +12 -0
  342. package/dist/tui/components/EnsemblePanel.js +40 -0
  343. package/dist/tui/components/ErrorView.d.ts +31 -0
  344. package/dist/tui/components/ErrorView.js +129 -0
  345. package/dist/tui/components/HomeView.d.ts +54 -0
  346. package/dist/tui/components/HomeView.js +306 -0
  347. package/dist/tui/components/InputBar.d.ts +13 -0
  348. package/dist/tui/components/InputBar.js +58 -0
  349. package/dist/tui/components/LoadLineupModal.d.ts +18 -0
  350. package/dist/tui/components/LoadLineupModal.js +79 -0
  351. package/dist/tui/components/MainView.d.ts +21 -0
  352. package/dist/tui/components/MainView.js +107 -0
  353. package/dist/tui/components/NewEnsembleModal.d.ts +9 -0
  354. package/dist/tui/components/NewEnsembleModal.js +73 -0
  355. package/dist/tui/components/Picker.d.ts +23 -0
  356. package/dist/tui/components/Picker.js +70 -0
  357. package/dist/tui/components/PlayerDetailView.d.ts +26 -0
  358. package/dist/tui/components/PlayerDetailView.js +118 -0
  359. package/dist/tui/components/PromptArea.d.ts +50 -0
  360. package/dist/tui/components/PromptArea.js +303 -0
  361. package/dist/tui/components/RecruitWizard.d.ts +17 -0
  362. package/dist/tui/components/RecruitWizard.js +221 -0
  363. package/dist/tui/components/RestoreConfirmModal.d.ts +18 -0
  364. package/dist/tui/components/RestoreConfirmModal.js +71 -0
  365. package/dist/tui/components/ScheduleOverlay.d.ts +13 -0
  366. package/dist/tui/components/ScheduleOverlay.js +113 -0
  367. package/dist/tui/components/ScheduleWizard.d.ts +19 -0
  368. package/dist/tui/components/ScheduleWizard.js +259 -0
  369. package/dist/tui/components/Splash.d.ts +23 -0
  370. package/dist/tui/components/Splash.js +221 -0
  371. package/dist/tui/components/StatusBar.d.ts +48 -0
  372. package/dist/tui/components/StatusBar.js +128 -0
  373. package/dist/tui/components/StatusOverlay.d.ts +15 -0
  374. package/dist/tui/components/StatusOverlay.js +76 -0
  375. package/dist/tui/components/TitleBar.d.ts +10 -0
  376. package/dist/tui/components/TitleBar.js +21 -0
  377. package/dist/tui/components/TopBar.d.ts +12 -0
  378. package/dist/tui/components/TopBar.js +15 -0
  379. package/dist/tui/core-api.d.ts +26 -0
  380. package/dist/tui/core-api.js +67 -0
  381. package/dist/tui/hooks/useEnsembleDiscovery.d.ts +3 -0
  382. package/dist/tui/hooks/useEnsembleDiscovery.js +30 -0
  383. package/dist/tui/hooks/useMaestroPoller.d.ts +3 -0
  384. package/dist/tui/hooks/useMaestroPoller.js +36 -0
  385. package/dist/tui/hooks/useSendCommand.d.ts +7 -0
  386. package/dist/tui/hooks/useSendCommand.js +29 -0
  387. package/dist/tui/index.d.ts +15 -0
  388. package/dist/tui/index.js +156 -0
  389. package/dist/tui/ink-context.d.ts +18 -0
  390. package/dist/tui/ink-context.js +59 -0
  391. package/dist/tui/ink-loader.d.ts +26 -0
  392. package/dist/tui/ink-loader.js +42 -0
  393. package/dist/tui/removed-commands.d.ts +9 -0
  394. package/dist/tui/removed-commands.js +22 -0
  395. package/dist/tui/sse-handler.d.ts +52 -0
  396. package/dist/tui/sse-handler.js +157 -0
  397. package/dist/tui/store.d.ts +598 -0
  398. package/dist/tui/store.js +753 -0
  399. package/dist/tui/utils/format.d.ts +56 -0
  400. package/dist/tui/utils/format.js +155 -0
  401. package/dist/tui/utils/fullscreen.d.ts +23 -0
  402. package/dist/tui/utils/fullscreen.js +71 -0
  403. package/dist/tui/utils/history.d.ts +10 -0
  404. package/dist/tui/utils/history.js +85 -0
  405. package/dist/tui/utils/platform.d.ts +45 -0
  406. package/dist/tui/utils/platform.js +258 -0
  407. package/dist/tui/utils/theme.d.ts +21 -0
  408. package/dist/tui/utils/theme.js +24 -0
  409. package/dist/types.d.ts +1020 -0
  410. package/dist/types.js +39 -0
  411. package/dist/utils/attachment-format.d.ts +22 -0
  412. package/dist/utils/attachment-format.js +32 -0
  413. package/dist/utils/default-part.d.ts +43 -0
  414. package/dist/utils/default-part.js +104 -0
  415. package/dist/utils/duration.d.ts +30 -0
  416. package/dist/utils/duration.js +69 -0
  417. package/dist/utils/ensemble-ops.d.ts +61 -0
  418. package/dist/utils/ensemble-ops.js +77 -0
  419. package/dist/utils/format-hosts.d.ts +21 -0
  420. package/dist/utils/format-hosts.js +73 -0
  421. package/dist/utils/hosts.d.ts +113 -0
  422. package/dist/utils/hosts.js +265 -0
  423. package/dist/utils/parent-death-watchdog.d.ts +1 -0
  424. package/dist/utils/parent-death-watchdog.js +47 -0
  425. package/dist/utils/query-timeout.d.ts +103 -0
  426. package/dist/utils/query-timeout.js +113 -0
  427. package/dist/utils/recall-format.d.ts +78 -0
  428. package/dist/utils/recall-format.js +105 -0
  429. package/dist/utils/restore-format.d.ts +49 -0
  430. package/dist/utils/restore-format.js +91 -0
  431. package/dist/utils/safe-path.d.ts +10 -0
  432. package/dist/utils/safe-path.js +43 -0
  433. package/dist/utils/sdk-probe.d.ts +9 -0
  434. package/dist/utils/sdk-probe.js +45 -0
  435. package/dist/utils/search-attributes.d.ts +76 -0
  436. package/dist/utils/search-attributes.js +86 -0
  437. package/dist/utils/validation.d.ts +113 -0
  438. package/dist/utils/validation.js +163 -0
  439. package/dist/utils/visibility-deadline.d.ts +186 -0
  440. package/dist/utils/visibility-deadline.js +158 -0
  441. package/dist/utils/worktree.d.ts +103 -0
  442. package/dist/utils/worktree.js +327 -0
  443. package/dist/worker.d.ts +14 -0
  444. package/dist/worker.js +146 -0
  445. package/dist/workflows/attachment-math.d.ts +56 -0
  446. package/dist/workflows/attachment-math.js +47 -0
  447. package/dist/workflows/index.d.ts +3 -0
  448. package/dist/workflows/index.js +11 -0
  449. package/dist/workflows/maestro-signals.d.ts +217 -0
  450. package/dist/workflows/maestro-signals.js +155 -0
  451. package/dist/workflows/maestro.d.ts +3 -0
  452. package/dist/workflows/maestro.js +812 -0
  453. package/dist/workflows/scheduler-signals.d.ts +10 -0
  454. package/dist/workflows/scheduler-signals.js +14 -0
  455. package/dist/workflows/scheduler.d.ts +17 -0
  456. package/dist/workflows/scheduler.js +143 -0
  457. package/dist/workflows/session.d.ts +2 -0
  458. package/dist/workflows/session.js +1638 -0
  459. package/dist/workflows/signals.d.ts +297 -0
  460. package/dist/workflows/signals.js +239 -0
  461. package/examples/agents/tempo-composer.md +56 -0
  462. package/examples/agents/tempo-conductor.md +117 -0
  463. package/examples/agents/tempo-critic.md +73 -0
  464. package/examples/agents/tempo-improv.md +74 -0
  465. package/examples/agents/tempo-liner.md +75 -0
  466. package/examples/agents/tempo-roadie.md +61 -0
  467. package/examples/agents/tempo-soloist.md +71 -0
  468. package/examples/agents/tempo-tuner.md +94 -0
  469. package/examples/ensembles/tempo-big-band.yaml +146 -0
  470. package/examples/ensembles/tempo-dev-team.yaml +58 -0
  471. package/examples/ensembles/tempo-headless-jam.yaml +77 -0
  472. package/examples/ensembles/tempo-jam-session.yaml +41 -0
  473. package/examples/ensembles/tempo-mock-jam.yaml +79 -0
  474. package/examples/ensembles/tempo-review-squad.yaml +32 -0
  475. package/package.json +172 -0
  476. package/packaging/launchd/com.agent.tempo.plist +46 -0
  477. package/packaging/systemd/agent-tempo.service +32 -0
  478. package/packaging/windows/install-task.ps1 +71 -0
  479. package/scenarios/conductor-recruit-mock.yaml +33 -0
  480. package/scenarios/echo-roundtrip.yaml +15 -0
  481. package/scenarios/multi-player-handoff.yaml +38 -0
  482. package/scenarios/recruit-cascade.yaml +38 -0
  483. package/scenarios/two-player-conversation.yaml +33 -0
  484. package/workflow-bundle.js +14146 -0
@@ -0,0 +1,1638 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentSessionWorkflow = agentSessionWorkflow;
4
+ const workflow_1 = require("@temporalio/workflow");
5
+ const common_1 = require("@temporalio/common");
6
+ /**
7
+ * Workflow-deterministic clock. The Temporal TS SDK intercepts `new Date()` at the
8
+ * sandbox level to return replay-consistent time, so this wrapper is safe — the
9
+ * name aligns with the project convention (CLAUDE.md: "no `Date.now()` in workflow
10
+ * code, use `workflow.now()` instead") while using the SDK-intercepted constructor.
11
+ */
12
+ function workflowNow() {
13
+ return new Date();
14
+ }
15
+ const attachment_math_1 = require("./attachment-math");
16
+ const signals_1 = require("./signals");
17
+ const validation_1 = require("../utils/validation");
18
+ // ── Outbox Activity Proxies ──
19
+ const { deliverCue, deliverReport, terminateSession, startRecruitedSession, releasePlayer, deliverDetach, deliverDestroy, deliverRestart } = (0, workflow_1.proxyActivities)({
20
+ startToCloseTimeout: '30 seconds',
21
+ retry: { maximumAttempts: 3 },
22
+ });
23
+ function getSpawnProxy(hostname) {
24
+ return (0, workflow_1.proxyActivities)({
25
+ taskQueue: `agent-tempo-${hostname}`,
26
+ startToCloseTimeout: '2 minutes',
27
+ retry: { maximumAttempts: 2 },
28
+ }).spawnProcess;
29
+ }
30
+ /**
31
+ * Host-routed proxy for the #159 Gap 2 hard-terminate activity. Runs on the target's
32
+ * `agent-tempo-{hostname}` task queue so the kill happens where the child process
33
+ * actually lives. Short timeout + low retry — this is a best-effort cleanup and the
34
+ * workflow must not wedge if the host worker is down.
35
+ */
36
+ function getHardTerminateProxy(hostname) {
37
+ return (0, workflow_1.proxyActivities)({
38
+ taskQueue: `agent-tempo-${hostname}`,
39
+ startToCloseTimeout: '10 seconds',
40
+ scheduleToCloseTimeout: '20 seconds',
41
+ retry: { maximumAttempts: 1 },
42
+ }).hardTerminateAttachment;
43
+ }
44
+ /**
45
+ * Shorter-timeout proxy for destroyUpdate. Destroy is terminal/best-effort
46
+ * (§2.5) — if the host worker is offline we don't want to block the workflow's
47
+ * terminal transition for 20s waiting on a schedule-to-close timeout. Test
48
+ * environments without a host worker fail fast in ~5s instead.
49
+ */
50
+ function getHardTerminateProxyForDestroy(hostname) {
51
+ return (0, workflow_1.proxyActivities)({
52
+ taskQueue: `agent-tempo-${hostname}`,
53
+ startToCloseTimeout: '5 seconds',
54
+ scheduleToCloseTimeout: '5 seconds',
55
+ retry: { maximumAttempts: 1 },
56
+ }).hardTerminateAttachment;
57
+ }
58
+ async function agentSessionWorkflow(input) {
59
+ // ── v0.25 Attachment Lifecycle Timers (design §2.3, §9.5) ──
60
+ // PR-C commit 6 (#119a): each attachment carries its own `leaseMs` (negotiated at
61
+ // claim time). No workflow-side default constant — heartbeats extend `expiresAt`
62
+ // by `currentAttachment.leaseMs`.
63
+ /**
64
+ * Legacy CAN-extension constant. Retained solely so sessions that ran on a
65
+ * pre-#249 workflow bundle replay deterministically: the `patched()` branch at
66
+ * the CAN-boundary extension site selects this constant on the non-patched
67
+ * side, matching the exact arg sequence those histories recorded. New runs
68
+ * (and all post-#249 CAN transitions) use `currentAttachment.leaseMs` instead
69
+ * — see the call site below.
70
+ */
71
+ const HEARTBEAT_INTERVAL_MS = 30_000;
72
+ /**
73
+ * Default grace period for `draining → detached` transition after requestDetach. Used when a
74
+ * `requestDetach` signal omits `deadlineMs`. Per-signal overrides are honored via the
75
+ * `drainingDeadlineMs` state variable below (fix for #159 Gap 1a).
76
+ */
77
+ const DEFAULT_DRAINING_DEADLINE_MS = 5_000;
78
+ /** Max duration a messageId can stay in-flight before the safety timer ejects it. */
79
+ const PROCESSING_DEADLINE_MS = 15 * 60 * 1000;
80
+ // Version marker for v0.10 — records a patch marker in workflow history.
81
+ // Future workflow changes that alter the command sequence should use
82
+ // patched('v0.10-<change-name>') to protect in-flight sessions from
83
+ // non-determinism errors during rolling deploys.
84
+ (0, workflow_1.patched)('v0.10-initial');
85
+ (0, workflow_1.patched)('v0.11-check-and-set-status');
86
+ (0, workflow_1.patched)('v0.13-quality-gates');
87
+ (0, workflow_1.patched)('v0.14-worktrees');
88
+ (0, workflow_1.patched)('v0.15-blocked-detection');
89
+ (0, workflow_1.patched)('v0.18-stages');
90
+ (0, workflow_1.patched)('v0.23-hold-release');
91
+ (0, workflow_1.patched)('v0.25-attachment-lifecycle');
92
+ // Issue #172: kept as a replay marker for workflows that predate the
93
+ // simpler hold-on-startup design (v0.26). The state field + interceptor
94
+ // were removed in favor of baking the banner/directive into
95
+ // `SessionInput.messages` at workflow creation, but leaving the patched
96
+ // marker ensures existing replay histories that recorded this command
97
+ // still deserialize cleanly. Safe no-op today.
98
+ (0, workflow_1.patched)('v0.26-pending-startup-context');
99
+ // Ensure search attributes are always current — critical when reconnecting
100
+ // via WorkflowIdConflictPolicy.USE_EXISTING, which skips the attributes
101
+ // passed to client.workflow.start().
102
+ (0, workflow_1.upsertSearchAttributes)({
103
+ AgentTempoEnsemble: [input.metadata.ensemble],
104
+ AgentTempoPlayerId: [input.metadata.playerId],
105
+ AgentTempoHostname: [input.metadata.hostname],
106
+ ...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
107
+ ...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
108
+ AgentTempoIsConductor: [input.metadata.isConductor === true],
109
+ // v0.25 attachment search attributes — initial values for a fresh/restored workflow.
110
+ // Updated on every phase transition below.
111
+ AgentTempoAttachedHost: [input.currentAttachment?.hostname ?? ''],
112
+ AgentTempoAttachmentState: [input.phase ?? 'booting'],
113
+ AgentTempoAttachmentId: [input.currentAttachment?.attachmentId ?? ''],
114
+ });
115
+ // ── State (carried across continue-as-new) ──
116
+ let part = input.part ?? input.autoSummary ?? 'No description set';
117
+ const messages = input.messages ?? [];
118
+ const sentMessages = input.sentMessages ?? [];
119
+ const outbox = input.outbox ?? [];
120
+ let lastActivityTime = workflowNow().getTime();
121
+ let lastOutboundTime = input.lastOutboundTime ?? workflowNow().getTime();
122
+ let lastInboundRRTime = input.lastInboundRRTime ?? 0;
123
+ // ── #399 W2 — wire-extension counters (carried across continueAsNew) ──
124
+ // `activityCount` mirrors the ~20 `lastActivityTime` mutation sites;
125
+ // `receivedCount` / `sentCount` track inbound cues + outbox submissions.
126
+ // All three feed dashboard surfaces via the new `getActivityStateQuery`
127
+ // and `getMessagingStateQuery` queries.
128
+ let activityCount = input.activityCount ?? 0;
129
+ let receivedCount = input.receivedCount ?? 0;
130
+ let sentCount = input.sentCount ?? 0;
131
+ // ── Warm Hold + Pause State ──
132
+ let outboxLocked = input.outboxLocked ?? false;
133
+ let heldMessage = input.heldMessage;
134
+ let paused = input.paused ?? false;
135
+ // ── Player Saveable State (#334 PR-1; ADR 0011) ──
136
+ // Per-key opaque-string artifacts the player itself curates via `save_state`.
137
+ // Carried via continueAsNew (only when populated). Sized at validation:
138
+ // up to PLAYER_STATE_SLOTS_MAX × PLAYER_STATE_CONTENT_MAX.
139
+ const playerState = { ...(input.playerState ?? {}) };
140
+ // ── v0.25 Attachment Lifecycle State (design §2.2) ──
141
+ /** Current attachment lease, or null when detached. */
142
+ let currentAttachment = input.currentAttachment ?? null;
143
+ /** Current phase — authoritative source of lifecycle truth after #175. */
144
+ let phase = input.phase ?? (currentAttachment ? 'attached' : 'booting');
145
+ /** Preferred host for daemon reconcile-on-boot auto-restore. */
146
+ let preferredHost = input.preferredHost ?? currentAttachment?.hostname ?? input.metadata.hostname;
147
+ /** ISO timestamp of when the current `draining` phase started. */
148
+ let drainingSince = input.drainingSince ?? null;
149
+ /**
150
+ * Grace window (ms) for the current `draining` phase, if a `requestDetach` signal supplied one.
151
+ * Fix for #159 Gap 1a: the pre-fix handler discarded `deadlineMs` and always used the 5s default,
152
+ * so callers requesting a longer/shorter window were silently ignored. Reset to `null` whenever
153
+ * `drainingSince` clears so it can't leak into the next detach cycle.
154
+ */
155
+ let drainingDeadlineMs = input.drainingDeadlineMs ?? null;
156
+ /**
157
+ * Monotonic counter bumped by signal/update handlers that *shorten* `nextDeadlineMs()` output
158
+ * (e.g. `requestDetach` creates a new, sooner draining deadline; `forceDetach` nullifies the
159
+ * current attachment expiry). The main-loop `condition(...)` includes `wakeEpoch` in its
160
+ * predicate so state changes outside the existing wake conditions still punch through the
161
+ * already-scheduled timeout — fix for #159 Gap 1b. Signal handlers that *extend* deadlines
162
+ * (e.g. heartbeat) don't need to bump since the pre-existing, earlier deadline firing and
163
+ * being re-checked is harmless.
164
+ */
165
+ let wakeEpoch = 0;
166
+ /** Reason recorded when the last attachment detached (for orphanSummary query). */
167
+ let lastDetachReason;
168
+ /** Metadata about the last-known adapter (for orphanSummary query). */
169
+ let lastAdapterMeta = currentAttachment
170
+ ? { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId }
171
+ : undefined;
172
+ /** ISO timestamp of when the workflow most recently entered `detached`. */
173
+ let detachedSince = null;
174
+ // ── Processing Lifecycle State (fixes #99) ──
175
+ // Tracks messages currently being processed by a blocking adapter. While non-empty,
176
+ // stale detection is suppressed AND the phase refines to `processing`.
177
+ const inFlightMessages = new Set(input.inFlightMessageIds ?? []);
178
+ // processingSince carried as ISO string in v0.25; normalize numeric legacy values.
179
+ const _inputProcessingSince = input.processingSince;
180
+ let processingSince = typeof _inputProcessingSince === 'string'
181
+ ? _inputProcessingSince
182
+ : typeof _inputProcessingSince === 'number'
183
+ ? new Date(_inputProcessingSince).toISOString()
184
+ : (inFlightMessages.size > 0 ? workflowNow().toISOString() : null);
185
+ // ── Destroy State (fixes #102; §8.5 immediate-COMPLETE) ──
186
+ // Once set, the workflow COMPLETES per §2.5 (abandon in-flight, no drain).
187
+ // Adapter recovery code reads `isDestroyed` and exits.
188
+ let destroyed = input.destroyed ?? false;
189
+ let destroyRequested = destroyed;
190
+ /** IDs of outbox entries abandoned by the last `destroy` — written to history event. */
191
+ let destroyAbandonedIds = [];
192
+ // PR-H (#132): the v0.25.1 `updateMetadata({ status: 'terminated' })` shim
193
+ // path is gone. `destroyRequested` is set only by the `destroyUpdate` handler
194
+ // below. Operator-initiated termination flows through the `destroy` verb +
195
+ // its outbox path; adapter graceful exit fires `adapterExited`; MCP-server
196
+ // SIGINT detaches without destroying. See
197
+ // docs/design/session-lifecycle-rebuild-v2.md §2.5, §11.1.
198
+ // ── Helpers ──
199
+ /**
200
+ * Reduce the outbox state list to a short status string for the
201
+ * dashboard's `Messages` KV row (Q5.5 of #399 W2). Returns:
202
+ *
203
+ * - `"empty"` — no pending entries
204
+ * - `"N pending"` — pending entries, oldest within `STALE_MS`
205
+ * - `"N pending (oldest 2m)"` — pending entries, oldest beyond
206
+ * the stale threshold; the magnitude (m / s) is human-rounded so
207
+ * the dashboard reads cleanly without further parsing.
208
+ *
209
+ * `STALE_MS = 30_000` per the brief — anything older than 30s pending
210
+ * is the "outbox is backing up" signal we want to surface.
211
+ */
212
+ function outboxStatus() {
213
+ const STALE_MS = 30_000;
214
+ const nowMs = workflowNow().getTime();
215
+ let count = 0;
216
+ let oldestAge = 0;
217
+ for (const e of outbox) {
218
+ if (e.status !== 'pending')
219
+ continue;
220
+ count++;
221
+ const age = nowMs - Date.parse(e.createdAt);
222
+ if (age > oldestAge)
223
+ oldestAge = age;
224
+ }
225
+ if (count === 0)
226
+ return 'empty';
227
+ if (oldestAge < STALE_MS)
228
+ return `${count} pending`;
229
+ const minutes = Math.floor(oldestAge / 60_000);
230
+ const ageLabel = minutes >= 1 ? `${minutes}m` : `${Math.floor(oldestAge / 1000)}s`;
231
+ return `${count} pending (oldest ${ageLabel})`;
232
+ }
233
+ /** Transition to a new phase, syncing the attachment search attribute. */
234
+ function setPhase(next) {
235
+ if (phase === next)
236
+ return;
237
+ phase = next;
238
+ (0, workflow_1.upsertSearchAttributes)({ AgentTempoAttachmentState: [next] });
239
+ lastActivityTime = workflowNow().getTime();
240
+ activityCount++;
241
+ }
242
+ /** Build the token returned from `claimAttachment`. `leaseMs` is the value the caller
243
+ * supplied (or the default if they didn't), so the adapter knows when to heartbeat. */
244
+ function attachmentTokenFrom(a, leaseMs) {
245
+ return {
246
+ attachmentId: a.attachmentId,
247
+ runId: a.runId,
248
+ expiresAt: a.expiresAt,
249
+ leaseMs,
250
+ };
251
+ }
252
+ /** Compute next time-based deadline for the main loop. Returns +Infinity when no deadline applies. */
253
+ function nextDeadlineMs() {
254
+ const nowMs = workflowNow().getTime();
255
+ const candidates = [];
256
+ if (currentAttachment) {
257
+ candidates.push(new Date(currentAttachment.expiresAt).getTime());
258
+ }
259
+ if (processingSince) {
260
+ candidates.push(new Date(processingSince).getTime() + PROCESSING_DEADLINE_MS);
261
+ }
262
+ if (phase === 'draining' && drainingSince) {
263
+ const window = drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS;
264
+ candidates.push(new Date(drainingSince).getTime() + window);
265
+ }
266
+ if (candidates.length === 0)
267
+ return Number.POSITIVE_INFINITY;
268
+ return Math.max(0, Math.min(...candidates) - nowMs);
269
+ }
270
+ // ── Outbox Update + Query Handlers ──
271
+ (0, workflow_1.setHandler)(signals_1.submitOutboxUpdate, (entryInput) => {
272
+ const entry = {
273
+ ...entryInput,
274
+ id: (0, workflow_1.uuid4)(),
275
+ createdAt: workflowNow().toISOString(),
276
+ status: 'pending',
277
+ };
278
+ outbox.push(entry);
279
+ // #399 W2 — every outbox submission counts as outbound traffic.
280
+ sentCount++;
281
+ // Record in sentMessages for history continuity
282
+ if (entry.type === 'cue') {
283
+ // #357: forward broadcastId so the sender's view reflects the same
284
+ // grouping the receiver sees.
285
+ sentMessages.push({
286
+ id: entry.id,
287
+ to: entry.targetPlayerId,
288
+ text: entry.message,
289
+ timestamp: entry.createdAt,
290
+ ...(entry.broadcastId !== undefined ? { broadcastId: entry.broadcastId } : {}),
291
+ });
292
+ }
293
+ else if (entry.type === 'report') {
294
+ sentMessages.push({ id: entry.id, to: 'conductor', text: `[${entry.reportType}] ${entry.text}`, timestamp: entry.createdAt });
295
+ }
296
+ else if (entry.type === 'stop') {
297
+ sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[stop requested]', timestamp: entry.createdAt });
298
+ }
299
+ else if (entry.type === 'detach') {
300
+ sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[detach requested]', timestamp: entry.createdAt });
301
+ }
302
+ else if (entry.type === 'destroy') {
303
+ sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[destroy requested]', timestamp: entry.createdAt });
304
+ }
305
+ else if (entry.type === 'restart') {
306
+ sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[restart requested]', timestamp: entry.createdAt });
307
+ }
308
+ else if (entry.type === 'release') {
309
+ sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[release requested]', timestamp: entry.createdAt });
310
+ }
311
+ lastActivityTime = workflowNow().getTime();
312
+ activityCount++;
313
+ lastOutboundTime = workflowNow().getTime();
314
+ return entry.id;
315
+ }, {
316
+ validator: (entry) => {
317
+ if (!entry.type)
318
+ throw new common_1.ApplicationFailure('Outbox entry must have a type', 'InvalidOutboxEntry', true);
319
+ },
320
+ });
321
+ (0, workflow_1.setHandler)(signals_1.outboxQuery, () => outbox);
322
+ // ── Player Signal Handlers ──
323
+ (0, workflow_1.setHandler)(signals_1.receiveMessageSignal, (msg) => {
324
+ messages.push({
325
+ id: (0, workflow_1.uuid4)(),
326
+ from: msg.from,
327
+ text: msg.text,
328
+ timestamp: workflowNow().toISOString(),
329
+ delivered: false,
330
+ isMaestro: msg.isMaestro,
331
+ // #357: thread the broadcast id (if any) onto the stored Message
332
+ // so subsequent `allMessages`/`fetchEnsembleChat` queries surface
333
+ // it for TUI grouping.
334
+ ...(msg.broadcastId !== undefined ? { broadcastId: msg.broadcastId } : {}),
335
+ // #318: thread the coat-check ticket (if any) onto the stored
336
+ // Message so `recall` / `fetchPlayerMessages` surface it and the
337
+ // recipient knows to fetch via `coat_check_get`.
338
+ ...(msg.attachmentTicket !== undefined ? { attachmentTicket: msg.attachmentTicket } : {}),
339
+ });
340
+ lastActivityTime = workflowNow().getTime();
341
+ activityCount++;
342
+ // #399 W2 — every inbound cue counts as received traffic.
343
+ receivedCount++;
344
+ // Track inbound messages that expect a response (default: true for backward compat)
345
+ if ((0, workflow_1.patched)('v0.20-response-requested-blocked') && msg.responseRequested !== false) {
346
+ lastInboundRRTime = workflowNow().getTime();
347
+ }
348
+ });
349
+ (0, workflow_1.setHandler)(signals_1.setPartSignal, (newPart) => {
350
+ part = newPart;
351
+ lastActivityTime = workflowNow().getTime();
352
+ activityCount++;
353
+ lastOutboundTime = workflowNow().getTime();
354
+ });
355
+ (0, workflow_1.setHandler)(signals_1.setNameSignal, (newName) => {
356
+ input.metadata.playerId = newName;
357
+ (0, workflow_1.upsertSearchAttributes)({ AgentTempoPlayerId: [newName] });
358
+ lastActivityTime = workflowNow().getTime();
359
+ activityCount++;
360
+ });
361
+ (0, workflow_1.setHandler)(signals_1.markDeliveredSignal, (ids) => {
362
+ for (const msg of messages) {
363
+ if (ids.includes(msg.id)) {
364
+ msg.delivered = true;
365
+ }
366
+ }
367
+ // Any delivery proves the session is alive
368
+ lastActivityTime = workflowNow().getTime();
369
+ activityCount++;
370
+ });
371
+ (0, workflow_1.setHandler)(signals_1.updateMetadataSignal, (update) => {
372
+ if (update.hostname != null)
373
+ input.metadata.hostname = update.hostname;
374
+ if (update.gitBranch != null)
375
+ input.metadata.gitBranch = update.gitBranch;
376
+ if (update.gitRoot != null)
377
+ input.metadata.gitRoot = update.gitRoot;
378
+ if (update.playerType != null)
379
+ input.metadata.playerType = update.playerType;
380
+ if (update.playerTypeDescription != null)
381
+ input.metadata.playerTypeDescription = update.playerTypeDescription;
382
+ if (update.worktreePath != null)
383
+ input.metadata.worktreePath = update.worktreePath;
384
+ if (update.sessionId != null || update.claudeSessionId != null) {
385
+ input.metadata.sessionId = update.sessionId ?? update.claudeSessionId;
386
+ }
387
+ // `update.enableStaleDetection` is silently dropped — attachment phase
388
+ // (driven by the V2 wire surface: claimAttachment / adapterExited /
389
+ // forceDetach / destroy) is authoritative for lifecycle state.
390
+ (0, workflow_1.upsertSearchAttributes)({
391
+ AgentTempoEnsemble: [input.metadata.ensemble],
392
+ AgentTempoPlayerId: [input.metadata.playerId],
393
+ AgentTempoHostname: [input.metadata.hostname],
394
+ ...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
395
+ ...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
396
+ AgentTempoIsConductor: [input.metadata.isConductor === true],
397
+ });
398
+ lastActivityTime = workflowNow().getTime();
399
+ activityCount++;
400
+ });
401
+ (0, workflow_1.setHandler)(signals_1.recordSentMessageSignal, (msg) => {
402
+ sentMessages.push({
403
+ id: (0, workflow_1.uuid4)(),
404
+ to: msg.to,
405
+ text: msg.text,
406
+ timestamp: workflowNow().toISOString(),
407
+ // #357: mirror Message.broadcastId on the sender side so the
408
+ // TUI's local-side projection sees the same fold key.
409
+ ...(msg.broadcastId !== undefined ? { broadcastId: msg.broadcastId } : {}),
410
+ });
411
+ });
412
+ // ── Player Query Handlers ──
413
+ (0, workflow_1.setHandler)(signals_1.getPartQuery, () => part);
414
+ (0, workflow_1.setHandler)(signals_1.getMetadataQuery, () => input.metadata);
415
+ (0, workflow_1.setHandler)(signals_1.pendingMessagesQuery, () => messages.filter((m) => !m.delivered));
416
+ (0, workflow_1.setHandler)(signals_1.allMessagesQuery, () => messages);
417
+ (0, workflow_1.setHandler)(signals_1.allSentMessagesQuery, () => sentMessages);
418
+ // ── #399 W2 — Wire extension queries (Q5.2 / Q5.5 / Q5.6 / Q5.7) ──
419
+ (0, workflow_1.setHandler)(signals_1.getRunIdQuery, () => (0, workflow_1.workflowInfo)().runId);
420
+ (0, workflow_1.setHandler)(signals_1.getMessagingStateQuery, () => ({
421
+ received: receivedCount,
422
+ sent: sentCount,
423
+ outbox: outboxStatus(),
424
+ }));
425
+ (0, workflow_1.setHandler)(signals_1.getActivityStateQuery, () => ({
426
+ activityCount,
427
+ lastActivityAt: new Date(lastActivityTime).toISOString(),
428
+ }));
429
+ (0, workflow_1.setHandler)(signals_1.getLeaseStateQuery, () => {
430
+ if (!currentAttachment)
431
+ return { expiresAt: null, leaseMs: null };
432
+ return {
433
+ expiresAt: Date.parse(currentAttachment.expiresAt),
434
+ leaseMs: currentAttachment.leaseMs,
435
+ };
436
+ });
437
+ // ── Hold / Release Handlers ──
438
+ (0, workflow_1.setHandler)(signals_1.releaseHeldSignal, () => {
439
+ if (heldMessage) {
440
+ // Deliver the stored initial message now that the hold is released
441
+ messages.push({
442
+ id: (0, workflow_1.uuid4)(),
443
+ from: input.metadata.recruitedBy || 'system',
444
+ text: heldMessage,
445
+ timestamp: workflowNow().toISOString(),
446
+ delivered: false,
447
+ });
448
+ heldMessage = undefined;
449
+ }
450
+ outboxLocked = false;
451
+ });
452
+ (0, workflow_1.setHandler)(signals_1.outboxLockedQuery, () => outboxLocked);
453
+ // ── Pause / Resume Handlers ──
454
+ (0, workflow_1.setHandler)(signals_1.setPausedSignal, (value) => {
455
+ paused = value;
456
+ });
457
+ (0, workflow_1.setHandler)(signals_1.pausedQuery, () => paused);
458
+ // ── Processing Lifecycle Handlers (fixes #99; v0.25 phase-aware) ──
459
+ (0, workflow_1.setHandler)(signals_1.processingStartUpdate, ({ messageId, expectedAttachmentId }) => {
460
+ // `expectedAttachmentId` is optional for shim compatibility; when provided, only operate
461
+ // if it matches the current attachment (prevents late updates from a superseded adapter).
462
+ if (expectedAttachmentId && currentAttachment?.attachmentId !== expectedAttachmentId) {
463
+ throw common_1.ApplicationFailure.nonRetryable(`Attachment ${expectedAttachmentId} does not match current ${currentAttachment?.attachmentId ?? 'none'}`, 'AttachmentMismatch');
464
+ }
465
+ const wasEmpty = inFlightMessages.size === 0;
466
+ inFlightMessages.add(messageId);
467
+ if (wasEmpty) {
468
+ processingSince = workflowNow().toISOString();
469
+ // Phase refinement: if we're attached (or awaiting), move to `processing`.
470
+ if (phase === 'attached' || phase === 'awaiting')
471
+ setPhase('processing');
472
+ }
473
+ lastActivityTime = workflowNow().getTime();
474
+ activityCount++;
475
+ return { inFlightCount: inFlightMessages.size };
476
+ }, {
477
+ validator: ({ messageId }) => {
478
+ if (!messageId || typeof messageId !== 'string') {
479
+ throw common_1.ApplicationFailure.nonRetryable('processingStart requires a non-empty messageId', 'InvalidMessageId');
480
+ }
481
+ if (phase === 'gone') {
482
+ throw common_1.ApplicationFailure.nonRetryable('Cannot start processing on destroyed session', 'WorkflowGone');
483
+ }
484
+ },
485
+ });
486
+ (0, workflow_1.setHandler)(signals_1.processingEndUpdate, ({ messageId, expectedAttachmentId }) => {
487
+ if (expectedAttachmentId && currentAttachment?.attachmentId !== expectedAttachmentId) {
488
+ throw common_1.ApplicationFailure.nonRetryable(`Attachment ${expectedAttachmentId} does not match current ${currentAttachment?.attachmentId ?? 'none'}`, 'AttachmentMismatch');
489
+ }
490
+ inFlightMessages.delete(messageId);
491
+ if (inFlightMessages.size === 0) {
492
+ processingSince = null;
493
+ // Phase refinement (§2.2, §2.4; fixes #117): when in-flight drops to 0, move
494
+ // back out of `processing`. If the outbox has nothing left to dispatch, land
495
+ // directly in `awaiting` (idle refinement of attached). Otherwise `attached`,
496
+ // and the main-loop outbox-dispatch drain will refine to `awaiting` once the
497
+ // outbox clears.
498
+ if (phase === 'processing') {
499
+ const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
500
+ setPhase(outboxIdle ? 'awaiting' : 'attached');
501
+ }
502
+ }
503
+ lastActivityTime = workflowNow().getTime();
504
+ activityCount++;
505
+ return { inFlightCount: inFlightMessages.size };
506
+ }, {
507
+ validator: ({ messageId }) => {
508
+ if (!messageId || typeof messageId !== 'string') {
509
+ throw common_1.ApplicationFailure.nonRetryable('processingEnd requires a non-empty messageId', 'InvalidMessageId');
510
+ }
511
+ },
512
+ });
513
+ (0, workflow_1.setHandler)(signals_1.inFlightMessagesQuery, () => [...inFlightMessages]);
514
+ // ── Destroy Handler (fixes #102; design §8.5) ──
515
+ // Terminal: set phase = gone, revoke attachment, emit audit event with abandoned outbox
516
+ // IDs, return from main loop → workflow COMPLETES. Per §2.5: abandon in-flight outbox
517
+ // (no drain wait) — destroy is an explicit operator action; delivery is best-effort.
518
+ //
519
+ // #164: the handler is `async` because it also fires `hardTerminateAttachment` on the
520
+ // host's per-host task queue before the state flip, to prevent an orphaned claude.exe
521
+ // when destroy is invoked while an attachment is live. Unlike `forceDetachUpdate` this
522
+ // is wrapped best-effort — a failure there MUST NOT wedge the workflow, because destroy
523
+ // is terminal by contract. See issue #164 for the orphan repro.
524
+ (0, workflow_1.setHandler)(signals_1.destroyUpdate, async ({ reason, terminatedBy }) => {
525
+ if (phase === 'gone')
526
+ return; // idempotent
527
+ // Record abandoned outbox entries for the history/audit event.
528
+ destroyAbandonedIds = outbox
529
+ .filter((e) => e.status === 'pending' || e.status === 'processing')
530
+ .map((e) => e.id);
531
+ if (destroyAbandonedIds.length > 0) {
532
+ workflow_1.log.warn(`destroy abandoning ${destroyAbandonedIds.length} outbox entr${destroyAbandonedIds.length === 1 ? 'y' : 'ies'}: ${destroyAbandonedIds.join(', ')}` +
533
+ `${reason ? ` (reason: ${reason})` : ''}`);
534
+ }
535
+ else if (reason) {
536
+ workflow_1.log.info(`destroy requested (reason: ${reason})`);
537
+ }
538
+ // #164: await hardTerminate BEFORE flipping `destroyRequested` / `phase`. Must
539
+ // await (fire-and-forget is dropped because the workflow's main loop exits as
540
+ // soon as destroyRequested=true, before the activity has a chance to dispatch).
541
+ // Best-effort: 5s timeout, log-and-continue on failure. `destroyRequested` flips
542
+ // AFTER the activity so the main loop stays alive to dispatch it.
543
+ //
544
+ // #227: the original (#164) guard was `if (currentAttachment)` — correct for the
545
+ // `phase=attached` case but silently skipped the kill when `phase=detached`, leaking
546
+ // `claude.exe` + terminal tab when a destroy ran on a session whose lease had been
547
+ // reaped. The ensemble cascade exposed by #226/#201 made this a reliable orphan
548
+ // generator: destroy → workflow COMPLETES, process survives. Fix: pick the best
549
+ // host we have from workflow state and fire the kill whenever *any* host is known.
550
+ //
551
+ // `hardTerminateAttachment` is already safe to run speculatively — it does image-
552
+ // name PID-reuse guards + command-line matching on `-n <playerName>` AND
553
+ // `--remote-control-session-name-prefix <ensemble>`, so a stale state that no
554
+ // longer corresponds to a live process returns `strategy: 'none'` with a clean
555
+ // log line. No new PID / attach-time wire-protocol fields needed.
556
+ //
557
+ // Host-pick priority (aligns with the reap path's provenance tracking):
558
+ // 1. `currentAttachment.hostname` — `phase=attached` / `processing` / `awaiting`
559
+ // 2. `lastAdapterMeta.hostname` — `phase=detached` (set by forceDetach / reap)
560
+ // 3. `preferredHost` — post-CAN restore before any adapter landed
561
+ // 4. `input.metadata.hostname` — recruit-time fallback (booting path)
562
+ const killHost = currentAttachment?.hostname ??
563
+ lastAdapterMeta?.hostname ??
564
+ preferredHost ??
565
+ input.metadata.hostname;
566
+ if (killHost) {
567
+ try {
568
+ const killResult = await getHardTerminateProxyForDestroy(killHost)({
569
+ ensemble: input.metadata.ensemble,
570
+ playerName: input.metadata.playerId,
571
+ agent: (input.metadata.agentType ?? 'claude'),
572
+ workDir: input.metadata.workDir,
573
+ });
574
+ workflow_1.log.info(`destroy hard-terminate on ${killHost} (phase=${phase}): strategy=${killResult.strategy}, ` +
575
+ `killedPids=[${killResult.killedPids.join(',')}]`);
576
+ }
577
+ catch (err) {
578
+ workflow_1.log.warn(`destroy hard-terminate failed on ${killHost} (continuing, best-effort): ` +
579
+ `${err instanceof Error ? err.message : String(err)}`);
580
+ }
581
+ }
582
+ // Flip destroyRequested AFTER the kill so the main loop stays alive for activity
583
+ // dispatch. Any concurrent claimAttachment / processingStart that arrives during
584
+ // the 5s kill window will still hit the phase!=='gone' guard until we setPhase
585
+ // below; on rare kill-in-progress races, the new work is abandoned by setPhase('gone').
586
+ destroyRequested = true;
587
+ // Revoke attachment (if any) — record metadata for orphanSummary/audit.
588
+ if (currentAttachment) {
589
+ lastAdapterMeta = { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId };
590
+ lastDetachReason = 'destroy';
591
+ currentAttachment = null;
592
+ }
593
+ (0, workflow_1.upsertSearchAttributes)({
594
+ AgentTempoAttachedHost: [''],
595
+ AgentTempoAttachmentId: [''],
596
+ });
597
+ setPhase('gone');
598
+ // Inject a final audit message so the old adapter-completion path has something to show.
599
+ messages.push({
600
+ id: (0, workflow_1.uuid4)(),
601
+ from: terminatedBy || 'system',
602
+ text: `Session destroyed${reason ? `: ${reason}` : ''}.`,
603
+ timestamp: workflowNow().toISOString(),
604
+ delivered: false,
605
+ });
606
+ lastActivityTime = workflowNow().getTime();
607
+ activityCount++;
608
+ });
609
+ (0, workflow_1.setHandler)(signals_1.isDestroyedQuery, () => destroyed || destroyRequested);
610
+ // ── Test-only CAN trigger (#226) ──
611
+ // Force the next main-loop iteration into the `continueAsNew` branch without
612
+ // waiting for the server's history-size threshold. Production code never sends
613
+ // this; the adapter reconnect test uses it to exercise the CAN-boundary path
614
+ // in <1s instead of emitting ~10k filler events. One-shot: the flag is cleared
615
+ // when the main loop acts on it, so repeated signals require repeated sends.
616
+ //
617
+ // The `wakeEpoch` bump is essential — the main loop's `condition()` predicate
618
+ // (see §9.5 loop body below) only wakes on outbox activity, phase changes,
619
+ // destroy, or `wakeEpoch` drift. Without the bump, an idle session would sit
620
+ // asleep until its lease-expiry deadline and the test would time out.
621
+ let forceContinueAsNew = false;
622
+ (0, workflow_1.setHandler)(signals_1.testForceContinueAsNewSignal, () => {
623
+ forceContinueAsNew = true;
624
+ wakeEpoch++;
625
+ lastActivityTime = workflowNow().getTime();
626
+ activityCount++;
627
+ });
628
+ // ── v0.25 Attachment Lifecycle Handlers (design §§8, §9.2, §9.5) ──
629
+ /**
630
+ * `claimAttachment` — transactional claim / renewal of the attachment lease.
631
+ * Pseudocode and behavior per design §9.2.
632
+ */
633
+ (0, workflow_1.setHandler)(signals_1.claimAttachmentUpdate, ({ host, adapterId, adapterClass, leaseMs, expectedAttachmentId }) => {
634
+ if (phase === 'gone') {
635
+ throw common_1.ApplicationFailure.nonRetryable(`Cannot attach to ${(0, workflow_1.workflowInfo)().workflowId}: workflow is terminated`, 'WorkflowGone');
636
+ }
637
+ const now = workflowNow();
638
+ const nowMs = now.getTime();
639
+ // Renewal path: caller provides a valid expectedAttachmentId matching the current
640
+ // attachment, and the lease hasn't expired yet.
641
+ if (currentAttachment &&
642
+ currentAttachment.attachmentId === expectedAttachmentId &&
643
+ new Date(currentAttachment.expiresAt).getTime() > nowMs) {
644
+ currentAttachment.lastHeartbeatAt = now.toISOString();
645
+ currentAttachment.expiresAt = new Date(nowMs + leaseMs).toISOString();
646
+ // Honour the caller's renewal-time leaseMs so subsequent heartbeats extend
647
+ // the lease by the current negotiated value (not the claim-time value).
648
+ currentAttachment.leaseMs = leaseMs;
649
+ lastActivityTime = nowMs;
650
+ activityCount++;
651
+ return attachmentTokenFrom(currentAttachment, leaseMs);
652
+ }
653
+ // Conflict: active lease held by someone else.
654
+ if (currentAttachment && new Date(currentAttachment.expiresAt).getTime() > nowMs) {
655
+ throw common_1.ApplicationFailure.nonRetryable(`Attached on ${currentAttachment.hostname} until ${currentAttachment.expiresAt}`, 'AttachmentConflict');
656
+ }
657
+ // Free or expired — claim fresh.
658
+ const newAttachment = {
659
+ attachmentId: (0, workflow_1.uuid4)(),
660
+ hostname: host,
661
+ adapterId,
662
+ adapterClass,
663
+ claimedAt: now.toISOString(),
664
+ lastHeartbeatAt: now.toISOString(),
665
+ expiresAt: new Date(nowMs + leaseMs).toISOString(),
666
+ leaseMs,
667
+ runId: (0, workflow_1.workflowInfo)().runId,
668
+ };
669
+ currentAttachment = newAttachment;
670
+ lastAdapterMeta = { hostname: newAttachment.hostname, adapterId: newAttachment.adapterId };
671
+ preferredHost = host;
672
+ // Fresh claim abandons any residual in-flight messageIds from the previous adapter.
673
+ inFlightMessages.clear();
674
+ processingSince = null;
675
+ // A fresh claim always supersedes an in-flight drain; clear its window so a later
676
+ // `requestDetach` starts from the default and doesn't inherit a stale value.
677
+ drainingSince = null;
678
+ drainingDeadlineMs = null;
679
+ detachedSince = null;
680
+ setPhase('attached');
681
+ (0, workflow_1.upsertSearchAttributes)({
682
+ AgentTempoAttachedHost: [host],
683
+ AgentTempoAttachmentId: [newAttachment.attachmentId],
684
+ });
685
+ lastActivityTime = nowMs;
686
+ activityCount++;
687
+ return attachmentTokenFrom(newAttachment, leaseMs);
688
+ }, {
689
+ validator: ({ leaseMs }) => {
690
+ if (!Number.isInteger(leaseMs) || leaseMs < 1_000 || leaseMs > 600_000) {
691
+ throw common_1.ApplicationFailure.nonRetryable(`leaseMs must be between 1000 and 600000, got ${leaseMs}`, 'InvalidLease');
692
+ }
693
+ },
694
+ });
695
+ /**
696
+ * `forceDetach` — revoke the current attachment. `expectedAttachmentId` guards against TOCTOU.
697
+ * `gracePeriodMs` is reserved for future use (§8.3); v0.25 PR-A ignores it and detaches
698
+ * immediately — PR-D's `restart` flow passes `gracePeriodMs: 0`.
699
+ *
700
+ * #159 Gap 2: this handler is the canonical "kill then flip" point. Before we null out
701
+ * the attachment and transition to `detached`, we invoke `hardTerminateAttachment` on the
702
+ * reaped host's per-host task queue so the child process tree is actually torn down at
703
+ * the OS level. If the activity throws, the update itself throws and the caller
704
+ * (`deliverRestart`, operator tooling) retries — we DON'T silently flip state while the
705
+ * orphan is still alive, because that produces exactly the bug reported in #159.
706
+ */
707
+ (0, workflow_1.setHandler)(signals_1.forceDetachUpdate, async ({ reason, expectedAttachmentId }) => {
708
+ if (phase === 'gone') {
709
+ throw common_1.ApplicationFailure.nonRetryable('Workflow is terminated', 'WorkflowGone');
710
+ }
711
+ if (!currentAttachment) {
712
+ return { reaped: false };
713
+ }
714
+ if (expectedAttachmentId && currentAttachment.attachmentId !== expectedAttachmentId) {
715
+ // TOCTOU — the expected attachment is already gone; don't reap a fresh one.
716
+ return { reaped: false };
717
+ }
718
+ const reaped = currentAttachment;
719
+ const previousId = reaped.attachmentId;
720
+ // Kill OS process tree on the host where the adapter is actually running. In
721
+ // production `reaped.hostname === input.metadata.hostname` — both are the machine
722
+ // that spawned the child — but `metadata.hostname` is the more stable routing key
723
+ // (attachments can come and go during cross-host restart flows, and test harnesses
724
+ // sometimes set the two fields independently). Failure aborts the update so the
725
+ // workflow state stays in sync with what actually happened on the host.
726
+ const killHost = input.metadata.hostname;
727
+ try {
728
+ const killResult = await getHardTerminateProxy(killHost)({
729
+ ensemble: input.metadata.ensemble,
730
+ playerName: input.metadata.playerId,
731
+ agent: (input.metadata.agentType ?? 'claude'),
732
+ workDir: input.metadata.workDir,
733
+ });
734
+ workflow_1.log.info(`forceDetach hard-terminate on ${killHost}: strategy=${killResult.strategy}, ` +
735
+ `killedPids=[${killResult.killedPids.join(',')}]`);
736
+ }
737
+ catch (err) {
738
+ // Turn into an ApplicationFailure so the caller sees a clean retry signal rather
739
+ // than a raw activity timeout / cancelation.
740
+ throw common_1.ApplicationFailure.nonRetryable(`forceDetach hard-terminate failed on ${killHost}: ` +
741
+ `${err instanceof Error ? err.message : String(err)}. ` +
742
+ `Refusing to flip phase to detached while the OS process may still be live.`, 'HardTerminateFailed');
743
+ }
744
+ lastAdapterMeta = { hostname: reaped.hostname, adapterId: reaped.adapterId };
745
+ lastDetachReason = reason;
746
+ currentAttachment = null;
747
+ inFlightMessages.clear();
748
+ processingSince = null;
749
+ drainingSince = null;
750
+ drainingDeadlineMs = null;
751
+ detachedSince = workflowNow().toISOString();
752
+ setPhase('detached');
753
+ (0, workflow_1.upsertSearchAttributes)({
754
+ AgentTempoAttachedHost: [''],
755
+ AgentTempoAttachmentId: [''],
756
+ });
757
+ // #159 Gap 1b: wake the main loop — `phase === 'detached'` isn't in the predicate
758
+ // and the condition would otherwise sleep on the now-stale lease-expiry deadline.
759
+ wakeEpoch++;
760
+ return { reaped: true, previousAttachmentId: previousId };
761
+ });
762
+ /**
763
+ * Enqueue a spawn outbox entry carrying the claim token. PR-C commit 6 (#118)
764
+ * replaced the double-cast `type: 'recruit'` workaround with a dedicated
765
+ * `SpawnOutboxEntry` discriminated-union variant. The dispatch branch
766
+ * (`case 'spawn':` below) routes through `startRecruitedSession` today and
767
+ * will be extended by PR-D to forward `attachmentId`/`runId`/`resume`/
768
+ * `sessionId`/`adapterId` into the activity signature so the adapter boots
769
+ * into the pre-claimed attachment.
770
+ */
771
+ (0, workflow_1.setHandler)(signals_1.enqueueSpawnUpdate, ({ host, attachmentId, runId, resume, sessionId, adapterId, agentDefinition, agentDefinitionPath, nativeResolvable, model }) => {
772
+ const spawnEntryId = (0, workflow_1.uuid4)();
773
+ const entry = {
774
+ id: spawnEntryId,
775
+ type: 'spawn',
776
+ targetName: input.metadata.playerId,
777
+ workDir: input.metadata.workDir,
778
+ isConductor: input.metadata.isConductor,
779
+ agent: (input.metadata.agentType ?? 'claude'),
780
+ targetHostname: host,
781
+ attachmentId,
782
+ attachmentRunId: runId,
783
+ resumeAttachment: resume,
784
+ sessionId,
785
+ adapterId,
786
+ agentDefinition,
787
+ agentDefinitionPath,
788
+ nativeResolvable,
789
+ // #131 Phase C — claude-api model id carried across restart.
790
+ ...(model !== undefined ? { model } : {}),
791
+ createdAt: workflowNow().toISOString(),
792
+ status: 'pending',
793
+ };
794
+ outbox.push(entry);
795
+ lastActivityTime = workflowNow().getTime();
796
+ activityCount++;
797
+ lastOutboundTime = workflowNow().getTime();
798
+ return { spawnEntryId };
799
+ });
800
+ /** Record a preferred host. Used by `reconcileOnBoot` (§10) in later PRs. */
801
+ (0, workflow_1.setHandler)(signals_1.setPreferredHostUpdate, ({ host }) => {
802
+ preferredHost = host;
803
+ lastActivityTime = workflowNow().getTime();
804
+ activityCount++;
805
+ });
806
+ /**
807
+ * `heartbeat` signal — extend the lease. Last-write-wins via the `attachmentId` guard;
808
+ * heartbeats for superseded attachments are ignored.
809
+ */
810
+ (0, workflow_1.setHandler)(signals_1.heartbeatSignal, ({ attachmentId }) => {
811
+ if (!currentAttachment || currentAttachment.attachmentId !== attachmentId)
812
+ return;
813
+ const now = workflowNow();
814
+ currentAttachment.lastHeartbeatAt = now.toISOString();
815
+ // #119a: extend by the caller-negotiated `leaseMs` stored on the attachment at
816
+ // claim time, not a workflow-side default. Adapters with non-default lease windows
817
+ // (e.g. test harnesses running accelerated clocks) get the lease duration they asked for.
818
+ currentAttachment.expiresAt = new Date(now.getTime() + currentAttachment.leaseMs).toISOString();
819
+ lastActivityTime = now.getTime();
820
+ activityCount++;
821
+ });
822
+ /**
823
+ * `requestDetach` signal — adapter-initiated graceful detach. Transitions to `draining`;
824
+ * main loop reaps to `detached` when outbox is drained OR `drainingDeadline` elapses.
825
+ *
826
+ * Fix for #159 Gap 1a: previously this handler destructured only `reason` and threw away
827
+ * `deadlineMs`, so the workflow always used `DEFAULT_DRAINING_DEADLINE_MS` regardless of
828
+ * what the caller requested. We now store the caller's window in `drainingDeadlineMs` so
829
+ * `nextDeadlineMs()` and the §9.5.c reap block honor it.
830
+ *
831
+ * Fix for #159 Gap 1b: bumping `wakeEpoch` causes the main-loop `condition(...)` predicate
832
+ * to flip, waking it from its pre-existing (longer) timer so it re-computes `nextDeadlineMs()`
833
+ * with the fresh, sooner drainingDeadline. Without this, the signal lands while the loop is
834
+ * asleep on a lease-expiry timer and the phase stays in `draining` until that far-away
835
+ * timer fires — exactly the smoke-test repro in #159.
836
+ */
837
+ (0, workflow_1.setHandler)(signals_1.requestDetachSignal, ({ reason, deadlineMs }) => {
838
+ if (!currentAttachment || phase === 'gone')
839
+ return;
840
+ if (phase === 'draining' || phase === 'detached')
841
+ return; // idempotent
842
+ drainingSince = workflowNow().toISOString();
843
+ drainingDeadlineMs =
844
+ typeof deadlineMs === 'number' && Number.isFinite(deadlineMs) && deadlineMs >= 0
845
+ ? deadlineMs
846
+ : DEFAULT_DRAINING_DEADLINE_MS;
847
+ lastDetachReason = reason;
848
+ setPhase('draining');
849
+ lastActivityTime = workflowNow().getTime();
850
+ activityCount++;
851
+ wakeEpoch++;
852
+ });
853
+ /**
854
+ * `adapterExited` signal — collapses `draining → detached` immediately if `attachmentId` matches.
855
+ */
856
+ (0, workflow_1.setHandler)(signals_1.adapterExitedSignal, ({ attachmentId, reason }) => {
857
+ if (phase === 'detached' || phase === 'gone')
858
+ return;
859
+ if (!currentAttachment || currentAttachment.attachmentId !== attachmentId)
860
+ return;
861
+ lastAdapterMeta = { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId };
862
+ lastDetachReason = reason;
863
+ currentAttachment = null;
864
+ inFlightMessages.clear();
865
+ processingSince = null;
866
+ drainingSince = null;
867
+ drainingDeadlineMs = null;
868
+ detachedSince = workflowNow().toISOString();
869
+ setPhase('detached');
870
+ (0, workflow_1.upsertSearchAttributes)({
871
+ AgentTempoAttachedHost: [''],
872
+ AgentTempoAttachmentId: [''],
873
+ });
874
+ lastActivityTime = workflowNow().getTime();
875
+ activityCount++;
876
+ // Wake main loop; the pre-existing condition timer was sized for the old lease
877
+ // window which no longer applies.
878
+ wakeEpoch++;
879
+ });
880
+ /** `attachmentInfo` query — current phase + attachment state snapshot. */
881
+ (0, workflow_1.setHandler)(signals_1.attachmentInfoQuery, () => ({
882
+ phase,
883
+ ...(currentAttachment ? { currentAttachment } : {}),
884
+ ...(preferredHost ? { preferredHost } : {}),
885
+ inFlightCount: inFlightMessages.size,
886
+ ...(processingSince ? { processingSince } : {}),
887
+ }));
888
+ /** `orphanSummary` query — daemon/CLI restore metadata when phase === 'detached'. */
889
+ (0, workflow_1.setHandler)(signals_1.orphanSummaryQuery, () => ({
890
+ ensemble: input.metadata.ensemble,
891
+ playerId: input.metadata.playerId,
892
+ ...(detachedSince ? { detachedSince } : {}),
893
+ ...(lastDetachReason ? { reason: lastDetachReason } : {}),
894
+ ...(preferredHost ? { preferredHost } : {}),
895
+ ...(lastAdapterMeta ? { lastAdapter: lastAdapterMeta } : {}),
896
+ }));
897
+ // ── Player Saveable State Handlers (#334 PR-1, ADR 0011) ──
898
+ //
899
+ // Validators run pre-handler so size/key/slot-cap rejections never commit
900
+ // history events. Handler bodies trust their inputs and stay trivially
901
+ // deterministic. `workflow.now()` is SDK-intercepted so `savedAt` is
902
+ // replay-deterministic.
903
+ const assertValidPlayerStateKey = (key) => {
904
+ if (typeof key !== 'string' || !validation_1.PLAYER_STATE_KEY_REGEX.test(key) || key.length > validation_1.PLAYER_STATE_KEY_MAX) {
905
+ throw common_1.ApplicationFailure.nonRetryable(`Invalid playerState key "${key}" — must match ${validation_1.PLAYER_STATE_KEY_REGEX} and be ≤ ${validation_1.PLAYER_STATE_KEY_MAX} chars`, 'PlayerStateInvalidKey');
906
+ }
907
+ };
908
+ (0, workflow_1.setHandler)(signals_1.savePlayerStateUpdate, ({ key, content, savedBy }) => {
909
+ playerState[key] = {
910
+ content,
911
+ savedAt: workflowNow().toISOString(),
912
+ savedBy,
913
+ };
914
+ lastActivityTime = workflowNow().getTime();
915
+ activityCount++;
916
+ return { saved: true, savedAt: playerState[key].savedAt };
917
+ }, {
918
+ validator: ({ key, content }) => {
919
+ assertValidPlayerStateKey(key);
920
+ if (typeof content !== 'string') {
921
+ throw common_1.ApplicationFailure.nonRetryable('playerState content must be a string', 'PlayerStateInvalidContent');
922
+ }
923
+ // `TextEncoder` is replay-safe (pure string→bytes); `Buffer` is Node-only
924
+ // and not available in the workflow sandbox.
925
+ if (new TextEncoder().encode(content).length > validation_1.PLAYER_STATE_CONTENT_MAX) {
926
+ throw common_1.ApplicationFailure.nonRetryable(`playerState content exceeds ${validation_1.PLAYER_STATE_CONTENT_MAX} bytes`, 'PlayerStateContentTooLarge');
927
+ }
928
+ if (!(key in playerState) && Object.keys(playerState).length >= validation_1.PLAYER_STATE_SLOTS_MAX) {
929
+ const existingKeys = Object.keys(playerState).sort().join(', ');
930
+ throw common_1.ApplicationFailure.nonRetryable(`playerState slots full (${validation_1.PLAYER_STATE_SLOTS_MAX}). Clear one before saving "${key}". Existing slots: ${existingKeys}`, 'PlayerStateSlotsFull');
931
+ }
932
+ },
933
+ });
934
+ (0, workflow_1.setHandler)(signals_1.clearPlayerStateUpdate, ({ key }) => {
935
+ if (!(key in playerState))
936
+ return { cleared: false };
937
+ delete playerState[key];
938
+ lastActivityTime = workflowNow().getTime();
939
+ activityCount++;
940
+ return { cleared: true };
941
+ }, {
942
+ validator: ({ key }) => assertValidPlayerStateKey(key),
943
+ });
944
+ (0, workflow_1.setHandler)(signals_1.playerStateQuery, ({ key } = {}) => {
945
+ const k = key ?? validation_1.PLAYER_STATE_DEFAULT_KEY;
946
+ return playerState[k] ?? null;
947
+ });
948
+ (0, workflow_1.setHandler)(signals_1.playerStateKeysQuery, () => Object.keys(playerState).sort());
949
+ // ── Conductor State ──
950
+ const commandHistory = input.commandHistory ?? [];
951
+ const reportHistory = input.reportHistory ?? [];
952
+ const qualityGates = input.qualityGates ?? [];
953
+ const worktrees = input.worktrees ?? [];
954
+ const stages = input.stages ?? [];
955
+ // ── Conductor-specific Handlers ──
956
+ if (input.metadata.isConductor) {
957
+ (0, workflow_1.setHandler)(signals_1.commandSignal, (cmd) => {
958
+ commandHistory.push({
959
+ ...cmd,
960
+ timestamp: workflowNow().toISOString(),
961
+ });
962
+ // Deliver command as a message to self so the conductor's Claude session sees it
963
+ messages.push({
964
+ id: (0, workflow_1.uuid4)(),
965
+ from: cmd.source,
966
+ text: cmd.text,
967
+ timestamp: workflowNow().toISOString(),
968
+ delivered: false,
969
+ });
970
+ // Command processing counts as implicit outbound for blocked detection
971
+ lastActivityTime = workflowNow().getTime();
972
+ activityCount++;
973
+ lastOutboundTime = workflowNow().getTime();
974
+ });
975
+ (0, workflow_1.setHandler)(signals_1.playerReportSignal, (report) => {
976
+ reportHistory.push({
977
+ ...report,
978
+ timestamp: workflowNow().toISOString(),
979
+ });
980
+ // Deliver report as a message to self
981
+ messages.push({
982
+ id: (0, workflow_1.uuid4)(),
983
+ from: report.playerId,
984
+ text: `[${report.type}] ${report.text}`,
985
+ timestamp: workflowNow().toISOString(),
986
+ delivered: false,
987
+ });
988
+ // ── Stage tracking: update player status in any active stage ──
989
+ for (const stage of stages) {
990
+ if (stage.status !== 'active')
991
+ continue;
992
+ const playerEntry = stage.players.find((p) => p.playerId === report.playerId);
993
+ if (!playerEntry || playerEntry.status !== 'waiting')
994
+ continue;
995
+ const now = workflowNow().toISOString();
996
+ if (report.type === 'result') {
997
+ playerEntry.status = 'reported';
998
+ playerEntry.reportType = 'result';
999
+ playerEntry.reportText = report.text;
1000
+ playerEntry.reportedAt = now;
1001
+ }
1002
+ else if (report.type === 'blocker') {
1003
+ playerEntry.status = 'blocked';
1004
+ playerEntry.reportType = 'blocker';
1005
+ playerEntry.reportText = report.text;
1006
+ playerEntry.reportedAt = now;
1007
+ // Halt policy: fail stage immediately on any blocker
1008
+ if (stage.failurePolicy === 'halt') {
1009
+ stage.status = 'failed';
1010
+ stage.completedAt = now;
1011
+ messages.push({
1012
+ id: (0, workflow_1.uuid4)(),
1013
+ from: '_stage',
1014
+ text: `[stage failed] "${stage.name}" halted — ${report.playerId} reported blocker: ${report.text}`,
1015
+ timestamp: now,
1016
+ delivered: false,
1017
+ });
1018
+ continue; // Don't check completion for a failed stage
1019
+ }
1020
+ }
1021
+ else {
1022
+ // 'question' or 'update' — no stage effect, player is still working
1023
+ continue;
1024
+ }
1025
+ // Check if all players in the stage are done (reported or blocked)
1026
+ const allDone = stage.players.every((p) => p.status !== 'waiting');
1027
+ if (allDone) {
1028
+ const blocked = stage.players.filter((p) => p.status === 'blocked');
1029
+ if (blocked.length > 0) {
1030
+ // Some players blocked (continue policy — didn't halt above)
1031
+ stage.status = 'failed';
1032
+ stage.completedAt = now;
1033
+ const blockerNames = blocked.map((p) => p.playerId).join(', ');
1034
+ messages.push({
1035
+ id: (0, workflow_1.uuid4)(),
1036
+ from: '_stage',
1037
+ text: `[stage failed] "${stage.name}" completed with ${blocked.length} blocker(s): ${blockerNames}`,
1038
+ timestamp: now,
1039
+ delivered: false,
1040
+ });
1041
+ }
1042
+ else {
1043
+ // All players reported successfully
1044
+ stage.status = 'complete';
1045
+ stage.completedAt = now;
1046
+ messages.push({
1047
+ id: (0, workflow_1.uuid4)(),
1048
+ from: '_stage',
1049
+ text: `[stage complete] "${stage.name}" — all ${stage.players.length} players reported successfully.`,
1050
+ timestamp: now,
1051
+ delivered: false,
1052
+ });
1053
+ }
1054
+ }
1055
+ }
1056
+ });
1057
+ (0, workflow_1.setHandler)(signals_1.historyQuery, () => {
1058
+ const entries = [
1059
+ ...commandHistory.map((c) => ({
1060
+ type: 'command',
1061
+ timestamp: c.timestamp,
1062
+ data: c,
1063
+ })),
1064
+ ...reportHistory.map((r) => ({
1065
+ type: 'report',
1066
+ timestamp: r.timestamp,
1067
+ data: r,
1068
+ })),
1069
+ ];
1070
+ return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1071
+ });
1072
+ // ── Quality Gate Handlers ──
1073
+ /** Derive aggregate gate status from individual criteria. */
1074
+ function deriveGateStatus(gate) {
1075
+ if (gate.criteria.length === 0)
1076
+ return 'open';
1077
+ if (gate.criteria.some((c) => c.status === 'failed'))
1078
+ return 'failed';
1079
+ if (gate.criteria.every((c) => c.status === 'passed'))
1080
+ return 'passed';
1081
+ return 'open';
1082
+ }
1083
+ (0, workflow_1.setHandler)(signals_1.setQualityGateSignal, ({ task, criteria, createdBy }) => {
1084
+ const existing = qualityGates.findIndex((g) => g.task === task);
1085
+ const gate = {
1086
+ task,
1087
+ criteria: criteria.map((text) => ({ text, status: 'pending' })),
1088
+ createdBy,
1089
+ createdAt: workflowNow().toISOString(),
1090
+ status: 'open',
1091
+ };
1092
+ if (existing >= 0) {
1093
+ qualityGates[existing] = gate;
1094
+ }
1095
+ else {
1096
+ qualityGates.push(gate);
1097
+ }
1098
+ });
1099
+ (0, workflow_1.setHandler)(signals_1.evaluateGateCriteriaSignal, ({ task, evaluations, evaluatedBy }) => {
1100
+ const gate = qualityGates.find((g) => g.task === task);
1101
+ if (!gate)
1102
+ return;
1103
+ const now = workflowNow().toISOString();
1104
+ for (const ev of evaluations) {
1105
+ if (ev.index >= 0 && ev.index < gate.criteria.length) {
1106
+ gate.criteria[ev.index].status = ev.status;
1107
+ gate.criteria[ev.index].evaluatedBy = evaluatedBy;
1108
+ gate.criteria[ev.index].evaluatedAt = now;
1109
+ if (ev.notes)
1110
+ gate.criteria[ev.index].notes = ev.notes;
1111
+ }
1112
+ }
1113
+ gate.status = deriveGateStatus(gate);
1114
+ });
1115
+ (0, workflow_1.setHandler)(signals_1.qualityGatesQuery, () => qualityGates);
1116
+ // ── Worktree Handlers ──
1117
+ (0, workflow_1.setHandler)(signals_1.setWorktreeSignal, (entry) => {
1118
+ const existing = worktrees.findIndex((w) => w.player === entry.player);
1119
+ if (existing >= 0) {
1120
+ worktrees[existing] = entry;
1121
+ }
1122
+ else {
1123
+ worktrees.push(entry);
1124
+ }
1125
+ });
1126
+ (0, workflow_1.setHandler)(signals_1.removeWorktreeSignal, (playerName) => {
1127
+ const idx = worktrees.findIndex((w) => w.player === playerName);
1128
+ if (idx >= 0) {
1129
+ worktrees.splice(idx, 1);
1130
+ }
1131
+ });
1132
+ (0, workflow_1.setHandler)(signals_1.worktreesQuery, () => worktrees);
1133
+ // ── Stage Handlers ──
1134
+ (0, workflow_1.setHandler)(signals_1.setStageSignal, ({ name, players, failurePolicy, createdBy }) => {
1135
+ const entry = {
1136
+ name,
1137
+ players: players.map((playerId) => ({
1138
+ playerId,
1139
+ status: 'waiting',
1140
+ })),
1141
+ status: 'active',
1142
+ failurePolicy: failurePolicy || 'halt',
1143
+ createdAt: workflowNow().toISOString(),
1144
+ createdBy,
1145
+ };
1146
+ const existing = stages.findIndex((s) => s.name === name);
1147
+ if (existing >= 0) {
1148
+ stages[existing] = entry;
1149
+ }
1150
+ else {
1151
+ stages.push(entry);
1152
+ }
1153
+ });
1154
+ (0, workflow_1.setHandler)(signals_1.cancelStageSignal, (name) => {
1155
+ const stage = stages.find((s) => s.name === name);
1156
+ if (stage && stage.status === 'active') {
1157
+ stage.status = 'cancelled';
1158
+ stage.completedAt = workflowNow().toISOString();
1159
+ // Notify conductor
1160
+ messages.push({
1161
+ id: (0, workflow_1.uuid4)(),
1162
+ from: '_stage',
1163
+ text: `[stage cancelled] "${name}" was cancelled.`,
1164
+ timestamp: workflowNow().toISOString(),
1165
+ delivered: false,
1166
+ });
1167
+ }
1168
+ });
1169
+ (0, workflow_1.setHandler)(signals_1.stagesQuery, () => stages);
1170
+ }
1171
+ // ── Main Loop ──
1172
+ //
1173
+ // v0.25 design §9.5: the loop is a deadline-race. On each iteration we wait for
1174
+ // - an outbox dispatch opportunity, OR
1175
+ // - a phase transition condition wake, OR
1176
+ // - the nearest time-based deadline (lease expiry, processingDeadline, drainingDeadline).
1177
+ // On wake, we handle time-based deadlines first (§9.5.a–c), then dispatch outbox entries,
1178
+ // then run the legacy stale/blocked heuristics (shim until PR-C), then check continueAsNew.
1179
+ //
1180
+ // The only exit from this loop is `destroyRequested === true` — the workflow never
1181
+ // COMPLETEs implicitly per design §2.2 invariant 2. `destroyRequested` is set
1182
+ // exclusively by the `destroyUpdate` handler (PR-H removed the
1183
+ // `updateMetadata({ status: 'terminated' })` compat shim that previously also
1184
+ // routed onto this flag).
1185
+ const hasPendingOutbox = () => outbox.some((e) => e.status === 'pending');
1186
+ /** Stop entries bypass pause — they must always be dispatched. */
1187
+ const hasPendingStop = () => outbox.some((e) => e.status === 'pending' && e.type === 'stop');
1188
+ const canDispatch = () => !outboxLocked && !paused && hasPendingOutbox();
1189
+ while (!destroyRequested) {
1190
+ // Deadline race: wake on outbox, phase change, destroy, or the nearest time deadline.
1191
+ //
1192
+ // #159 Gap 1b: `wakeEpoch` is captured here and included in the predicate so any handler
1193
+ // that mutates the deadline landscape (e.g. `requestDetach` setting a draining window)
1194
+ // can force re-entry to this loop *before* the pre-scheduled timeout fires. Without
1195
+ // this, a detach signal landing while the loop is asleep on a far-away lease-expiry
1196
+ // timer would leave the workflow in `draining` until that old timer fired.
1197
+ const epochAtWait = wakeEpoch;
1198
+ const deadlineMs = nextDeadlineMs();
1199
+ // NOTE: This 5-min fallback wake is LOAD-BEARING despite an old "PR-C shim"
1200
+ // framing that surfaced in researcher's tier-2 cleanup audit (2026-04-26).
1201
+ // While #175 removed the legacy stale/blocked detection block this originally
1202
+ // fed (see ~§1527 below), the wake itself remains essential as the loop's
1203
+ // periodic re-evaluation tick for state changes from handlers that mutate
1204
+ // `nextDeadlineMs()` inputs WITHOUT bumping `wakeEpoch`:
1205
+ //
1206
+ // - `claimAttachmentUpdate` (renewal + fresh paths) — sets
1207
+ // `currentAttachment.expiresAt` → new lease-expiry deadline
1208
+ // - `processingStartUpdate` — sets `processingSince` → new
1209
+ // `PROCESSING_DEADLINE_MS` deadline
1210
+ // - `processingEndUpdate` — clears `processingSince` → cancels processing
1211
+ // deadline
1212
+ // - `destroyUpdate` (async hard-terminate-then-flip path)
1213
+ //
1214
+ // Without the fallback wake, a workflow waiting in `condition(predicate)` on
1215
+ // an `Infinity` deadline (booting / detached, no draining, no processing)
1216
+ // never re-evaluates `nextDeadlineMs()` after one of these handlers fires —
1217
+ // the freshly-set lease-expiry timer is never picked up, lease expiry is
1218
+ // never reaped, and the workflow stalls until external state forces the
1219
+ // predicate true. Smoking-gun test that fails without the fallback:
1220
+ // `test/session-phase-processing.test.ts:54` "attached -> processing ->
1221
+ // awaiting via processingStart/End (#117 fix)" — times out at 10s because
1222
+ // the loop never makes progress after `processingStart` lands on a fresh
1223
+ // claim.
1224
+ //
1225
+ // Removing this fallback safely is a "main-loop wake-discipline cleanup"
1226
+ // separate from the audit's framing — adds `wakeEpoch++` to each affected
1227
+ // handler, gates with `patched()` markers for replay-determinism (live
1228
+ // workflow histories already recorded the existing `Timer 5min` events),
1229
+ // and adds a regression test covering the handler-induced-deadline pickup.
1230
+ // Estimated 4–6 handler edits + tests, separate dedicated PR. See the
1231
+ // 2026-04-26 forensics for the full mechanism walkthrough — link from this
1232
+ // file's PR history.
1233
+ //
1234
+ // Until that cleanup happens, DO NOT remove this fallback. The
1235
+ // `Math.min(deadlineMs, 5 * 60 * 1000)` cap is part of the same mechanism:
1236
+ // it ensures every deadline (even hour-long lease-expiry timers) is
1237
+ // re-evaluated at least every 5 min so handler-induced deadline shortenings
1238
+ // can't be missed.
1239
+ const conditionPromise = (0, workflow_1.condition)(() => destroyRequested ||
1240
+ canDispatch() ||
1241
+ hasPendingStop() ||
1242
+ phase === 'gone' ||
1243
+ wakeEpoch !== epochAtWait, deadlineMs === Number.POSITIVE_INFINITY ? '5 minutes' : Math.min(deadlineMs, 5 * 60 * 1000));
1244
+ await conditionPromise;
1245
+ if (destroyRequested)
1246
+ break;
1247
+ // ── §9.5.a: Lease expiry — reap attachment and transition to `detached`. ──
1248
+ if (currentAttachment && new Date(currentAttachment.expiresAt).getTime() <= workflowNow().getTime()) {
1249
+ const reaped = currentAttachment;
1250
+ lastAdapterMeta = { hostname: reaped.hostname, adapterId: reaped.adapterId };
1251
+ lastDetachReason = 'heartbeat-timeout';
1252
+ currentAttachment = null;
1253
+ inFlightMessages.clear();
1254
+ processingSince = null;
1255
+ drainingSince = null;
1256
+ drainingDeadlineMs = null;
1257
+ detachedSince = workflowNow().toISOString();
1258
+ setPhase('detached');
1259
+ (0, workflow_1.upsertSearchAttributes)({
1260
+ AgentTempoAttachedHost: [''],
1261
+ AgentTempoAttachmentId: [''],
1262
+ });
1263
+ workflow_1.log.warn(`lease expired for attachment ${reaped.attachmentId} (host=${reaped.hostname})`);
1264
+ }
1265
+ // ── §9.5.b: processingDeadline — force exit from `processing` if a messageId is wedged. ──
1266
+ if (processingSince !== null &&
1267
+ workflowNow().getTime() - new Date(processingSince).getTime() > PROCESSING_DEADLINE_MS) {
1268
+ const abandoned = [...inFlightMessages];
1269
+ workflow_1.log.warn(`processingDeadline exceeded (${Math.round(PROCESSING_DEADLINE_MS / 1000)}s); ` +
1270
+ `ejecting ${abandoned.length} in-flight message(s): ${abandoned.join(', ')}`);
1271
+ inFlightMessages.clear();
1272
+ processingSince = null;
1273
+ if (phase === 'processing')
1274
+ setPhase('attached');
1275
+ }
1276
+ // ── §9.5.c: drainingDeadline — force exit from `draining` to `detached`. ──
1277
+ // #159 Gap 1a: use the caller-supplied `drainingDeadlineMs` when present; fall back to
1278
+ // `DEFAULT_DRAINING_DEADLINE_MS` otherwise. `nextDeadlineMs()` uses the same value, so
1279
+ // the condition wake timing and the reap threshold stay in sync.
1280
+ //
1281
+ // #159 Gap 2: before flipping to `detached`, kill the OS child process on the host
1282
+ // where the adapter was running. If we skipped this step the workflow would happily
1283
+ // report `phase=detached` while an orphaned `claude.exe` kept holding the session
1284
+ // lock — and the next `recruit`/`restart` would collide with its own past self.
1285
+ // Best-effort: errors from the activity are logged but don't block the state flip
1286
+ // (the alternative is a workflow wedged in `draining` forever when the host worker
1287
+ // is down, which is worse than a lingering process that operators can clean up).
1288
+ if (phase === 'draining' &&
1289
+ drainingSince !== null &&
1290
+ workflowNow().getTime() - new Date(drainingSince).getTime() >
1291
+ (drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS)) {
1292
+ const window = drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS;
1293
+ const reaped = currentAttachment;
1294
+ if (reaped) {
1295
+ // Same routing consideration as in `forceDetachUpdate`: use `metadata.hostname`
1296
+ // as the stable key. Best-effort only — a failure here (e.g. host worker down)
1297
+ // would otherwise wedge the workflow in `draining` forever, which is worse than
1298
+ // a lingering OS process that operators can clean up by hand.
1299
+ const killHost = input.metadata.hostname;
1300
+ try {
1301
+ const killResult = await getHardTerminateProxy(killHost)({
1302
+ ensemble: input.metadata.ensemble,
1303
+ playerName: input.metadata.playerId,
1304
+ agent: (input.metadata.agentType ?? 'claude'),
1305
+ workDir: input.metadata.workDir,
1306
+ });
1307
+ workflow_1.log.info(`drainingDeadline hard-terminate on ${killHost}: strategy=${killResult.strategy}, ` +
1308
+ `killedPids=[${killResult.killedPids.join(',')}]`);
1309
+ }
1310
+ catch (err) {
1311
+ workflow_1.log.warn(`drainingDeadline hard-terminate failed for ${killHost} ` +
1312
+ `(continuing with state flip): ${err instanceof Error ? err.message : String(err)}`);
1313
+ }
1314
+ }
1315
+ lastDetachReason = lastDetachReason ?? 'force';
1316
+ currentAttachment = null;
1317
+ inFlightMessages.clear();
1318
+ processingSince = null;
1319
+ drainingSince = null;
1320
+ drainingDeadlineMs = null;
1321
+ detachedSince = workflowNow().toISOString();
1322
+ setPhase('detached');
1323
+ (0, workflow_1.upsertSearchAttributes)({
1324
+ AgentTempoAttachedHost: [''],
1325
+ AgentTempoAttachmentId: [''],
1326
+ });
1327
+ if (reaped) {
1328
+ workflow_1.log.info(`drainingDeadline exceeded (${Math.round(window / 1000)}s); ` +
1329
+ `reaping attachment ${reaped.attachmentId}`);
1330
+ }
1331
+ }
1332
+ // ── Outbox Dispatch ──
1333
+ while (hasPendingOutbox() && !destroyRequested) {
1334
+ // When paused or locked, only dispatch stop entries (bypass)
1335
+ const nextEntry = (canDispatch())
1336
+ ? outbox.find((e) => e.status === 'pending')
1337
+ : outbox.find((e) => e.status === 'pending' && e.type === 'stop') ?? null;
1338
+ if (!nextEntry)
1339
+ break;
1340
+ const entry = nextEntry;
1341
+ entry.status = 'processing';
1342
+ try {
1343
+ switch (entry.type) {
1344
+ case 'cue':
1345
+ await deliverCue({
1346
+ ensemble: input.metadata.ensemble,
1347
+ fromPlayerId: input.metadata.playerId,
1348
+ targetPlayerId: entry.targetPlayerId,
1349
+ message: entry.message,
1350
+ // #357: thread broadcast id so the target's `receiveMessage`
1351
+ // signal carries it onto the stored Message.
1352
+ ...(entry.broadcastId !== undefined ? { broadcastId: entry.broadcastId } : {}),
1353
+ // #318: thread coat-check ticket so the target can pull the
1354
+ // full content body via `coat_check_get`.
1355
+ ...(entry.attachmentTicket !== undefined ? { attachmentTicket: entry.attachmentTicket } : {}),
1356
+ });
1357
+ break;
1358
+ case 'report':
1359
+ await deliverReport({
1360
+ ensemble: input.metadata.ensemble,
1361
+ fromPlayerId: input.metadata.playerId,
1362
+ text: entry.text,
1363
+ reportType: entry.reportType,
1364
+ });
1365
+ break;
1366
+ case 'stop':
1367
+ await terminateSession({
1368
+ ensemble: input.metadata.ensemble,
1369
+ targetPlayerId: entry.targetPlayerId,
1370
+ terminatedBy: input.metadata.playerId,
1371
+ });
1372
+ break;
1373
+ case 'recruit': {
1374
+ const tc = input.temporalConfig;
1375
+ const recruitResult = await startRecruitedSession({
1376
+ ensemble: input.metadata.ensemble,
1377
+ targetName: entry.targetName,
1378
+ workDir: entry.workDir,
1379
+ isConductor: entry.isConductor,
1380
+ initialMessage: entry.initialMessage,
1381
+ fromPlayerId: input.metadata.playerId,
1382
+ agent: entry.agent,
1383
+ systemPrompt: entry.systemPrompt,
1384
+ taskQueue: tc?.taskQueue || 'agent-tempo',
1385
+ agentDefinition: entry.agentDefinition,
1386
+ agentDefinitionDescription: entry.agentDefinitionDescription,
1387
+ allowedTools: entry.allowedTools,
1388
+ claudeBin: entry.claudeBin,
1389
+ held: entry.held,
1390
+ // #131 Phase C — claude-api model id; activity persists it onto
1391
+ // SessionMetadata.model so restart/encore/migrate can recover it.
1392
+ ...(entry.model !== undefined ? { model: entry.model } : {}),
1393
+ });
1394
+ // Warm hold: process always spawns. When held, the workflow's outbox
1395
+ // is locked and the initial message is deferred until release.
1396
+ const targetHost = entry.targetHostname || input.metadata.hostname;
1397
+ const spawnFn = getSpawnProxy(targetHost);
1398
+ await spawnFn({
1399
+ targetName: entry.targetName,
1400
+ workDir: entry.workDir,
1401
+ isConductor: entry.isConductor,
1402
+ agent: entry.agent,
1403
+ systemPrompt: entry.systemPrompt,
1404
+ ensemble: input.metadata.ensemble,
1405
+ temporalAddress: tc?.temporalAddress || 'localhost:7233',
1406
+ temporalNamespace: tc?.temporalNamespace || 'default',
1407
+ agentDefinition: entry.agentDefinition,
1408
+ agentDefinitionPath: entry.agentDefinitionPath,
1409
+ nativeResolvable: entry.nativeResolvable,
1410
+ sessionId: recruitResult.sessionId,
1411
+ allowedTools: entry.allowedTools,
1412
+ claudeBin: entry.claudeBin,
1413
+ mockMode: entry.mockMode,
1414
+ mockScenario: entry.mockScenario,
1415
+ // #131 Phase C — forward to spawnProcess so spawnClaudeApiAdapter
1416
+ // can plumb it into the subprocess env (AGENT_TEMPO_API_MODEL).
1417
+ ...(entry.model !== undefined ? { model: entry.model } : {}),
1418
+ });
1419
+ break;
1420
+ }
1421
+ case 'release': {
1422
+ // Warm hold release — signal the target to unlock outbox and deliver held message.
1423
+ // No spawning needed — the process is already running.
1424
+ await releasePlayer({
1425
+ ensemble: input.metadata.ensemble,
1426
+ targetPlayerId: entry.targetPlayerId,
1427
+ });
1428
+ break;
1429
+ }
1430
+ case 'detach': {
1431
+ // PR-D: route the `detach` verb through the outbox (QA B1). The
1432
+ // activity resolves the target and signals `requestDetachSignal`.
1433
+ await deliverDetach({
1434
+ ensemble: input.metadata.ensemble,
1435
+ targetPlayerId: entry.targetPlayerId,
1436
+ ...(entry.reason !== undefined ? { reason: entry.reason } : {}),
1437
+ ...(entry.deadlineMs !== undefined ? { deadlineMs: entry.deadlineMs } : {}),
1438
+ });
1439
+ break;
1440
+ }
1441
+ case 'destroy': {
1442
+ // PR-D: route the `destroy` verb through the outbox (QA B2).
1443
+ await deliverDestroy({
1444
+ ensemble: input.metadata.ensemble,
1445
+ targetPlayerId: entry.targetPlayerId,
1446
+ terminatedBy: input.metadata.playerId,
1447
+ ...(entry.reason !== undefined ? { reason: entry.reason } : {}),
1448
+ ...(entry.notifyConductor !== undefined ? { notifyConductor: entry.notifyConductor } : {}),
1449
+ });
1450
+ break;
1451
+ }
1452
+ case 'restart': {
1453
+ // PR-D: route the `restart`/`migrate` verbs through the outbox
1454
+ // (QA B3). The activity owns the §8.2 algorithm: graceful detach
1455
+ // → optional force → claim → context replay → enqueueSpawn.
1456
+ // #334 PR-2: forward `loadFromState` + `transcript` so the
1457
+ // activity can seed the restarted session from a saved-state slot.
1458
+ await deliverRestart({
1459
+ ensemble: input.metadata.ensemble,
1460
+ targetPlayerId: entry.targetPlayerId,
1461
+ invokerPlayerId: entry.invokerPlayerId ?? input.metadata.playerId,
1462
+ ...(entry.force !== undefined ? { force: entry.force } : {}),
1463
+ ...(entry.host !== undefined ? { host: entry.host } : {}),
1464
+ ...(entry.fresh !== undefined ? { fresh: entry.fresh } : {}),
1465
+ ...(entry.contextMessages !== undefined ? { contextMessages: entry.contextMessages } : {}),
1466
+ ...(entry.loadFromState !== undefined ? { loadFromState: entry.loadFromState } : {}),
1467
+ ...(entry.transcript !== undefined ? { transcript: entry.transcript } : {}),
1468
+ });
1469
+ break;
1470
+ }
1471
+ case 'spawn': {
1472
+ // PR-D: forward the pre-claimed attachment token + pinned runId +
1473
+ // resolved adapterId to the spawn activity. The child process picks
1474
+ // these up from env in `BaseAttachment.startV2Lifecycle(workflowId,
1475
+ // expectedAttachmentId)` and renews the lease rather than claiming
1476
+ // fresh. Design §8.2 step 5.
1477
+ const spawnTc = input.temporalConfig;
1478
+ const spawnHost = entry.targetHostname;
1479
+ const spawnFn = getSpawnProxy(spawnHost);
1480
+ await spawnFn({
1481
+ targetName: entry.targetName,
1482
+ workDir: entry.workDir,
1483
+ isConductor: entry.isConductor,
1484
+ agent: entry.agent,
1485
+ ensemble: input.metadata.ensemble,
1486
+ temporalAddress: spawnTc?.temporalAddress || 'localhost:7233',
1487
+ temporalNamespace: spawnTc?.temporalNamespace || 'default',
1488
+ sessionId: entry.sessionId,
1489
+ resume: entry.resumeAttachment,
1490
+ attachmentId: entry.attachmentId,
1491
+ attachmentRunId: entry.attachmentRunId,
1492
+ adapterId: entry.adapterId,
1493
+ agentDefinition: entry.agentDefinition,
1494
+ agentDefinitionPath: entry.agentDefinitionPath,
1495
+ nativeResolvable: entry.nativeResolvable,
1496
+ // #131 Phase C — claude-api model carried across restart via the
1497
+ // spawn outbox entry (sourced from durable SessionMetadata.model
1498
+ // by deliverRestart).
1499
+ ...(entry.model !== undefined ? { model: entry.model } : {}),
1500
+ });
1501
+ break;
1502
+ }
1503
+ }
1504
+ entry.status = 'delivered';
1505
+ entry.deliveredAt = workflowNow().toISOString();
1506
+ }
1507
+ catch (err) {
1508
+ entry.status = 'failed';
1509
+ entry.error = String(err);
1510
+ // PR-D §8.4: spawn-entry failure rollback. When `restart` or `migrate`
1511
+ // creates an attachment + enqueues a spawn, a subsequent spawn
1512
+ // activity failure leaves the session `attached` with no adapter — the
1513
+ // worst steady state. Force-detach the just-created attachment so the
1514
+ // session lands in `detached` and `restart` can be retried. Guard with
1515
+ // `expectedAttachmentId` (TOCTOU: another claim may have superseded).
1516
+ if (entry.type === 'spawn' &&
1517
+ entry.attachmentId &&
1518
+ currentAttachment?.attachmentId === entry.attachmentId) {
1519
+ lastAdapterMeta = {
1520
+ hostname: currentAttachment.hostname,
1521
+ adapterId: currentAttachment.adapterId,
1522
+ };
1523
+ lastDetachReason = 'spawn-failed';
1524
+ currentAttachment = null;
1525
+ inFlightMessages.clear();
1526
+ processingSince = null;
1527
+ drainingSince = null;
1528
+ drainingDeadlineMs = null;
1529
+ detachedSince = workflowNow().toISOString();
1530
+ setPhase('detached');
1531
+ (0, workflow_1.upsertSearchAttributes)({
1532
+ AgentTempoAttachedHost: [''],
1533
+ AgentTempoAttachmentId: [''],
1534
+ });
1535
+ workflow_1.log.warn(`spawn failed for "${entry.targetName}"; rolled back attachment ${entry.attachmentId} → detached`);
1536
+ }
1537
+ }
1538
+ }
1539
+ // ── §2.2 phase refinement: attached → awaiting when idle ──
1540
+ // Issue #117: after outbox drain completes, if the attachment is still held,
1541
+ // no messages are in flight, and no outbox entries are pending/processing,
1542
+ // the session is in its idle steady state. Transition to `awaiting` so
1543
+ // external observers (AgentTempoAttachmentState search attribute, TUI,
1544
+ // monitoring) see the correct phase. `processingStart` (line 502) already
1545
+ // guards for `awaiting`, so the next inbound message lifts us to `processing`.
1546
+ if (phase === 'attached' && inFlightMessages.size === 0) {
1547
+ const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
1548
+ if (outboxIdle)
1549
+ setPhase('awaiting');
1550
+ }
1551
+ // Legacy stale/blocked detection + `_heartbeat`/`_ping` probe removed in #175.
1552
+ // The phase machine (lease expiry, `processingDeadline`, `adapterExited`) is now
1553
+ // the single source of liveness truth; see §§9.5.a/b above.
1554
+ // Prevent unbounded history growth — let the SDK decide when. The
1555
+ // `forceContinueAsNew` flag (#226 test-only) piggybacks on this branch so
1556
+ // the test fixture exercises the exact production CAN path, including the
1557
+ // §2.3 lease extension below.
1558
+ const info = (0, workflow_1.workflowInfo)();
1559
+ if (info.continueAsNewSuggested || forceContinueAsNew) {
1560
+ forceContinueAsNew = false;
1561
+ await (0, workflow_1.condition)(workflow_1.allHandlersFinished);
1562
+ // ── CAN-boundary lease extension (design §2.3) ──
1563
+ // The CAN transition is not instantaneous. If we write the old expiresAt into the
1564
+ // new execution and the transition takes ~100–500ms, the new execution's first main
1565
+ // loop check could reap a healthy attachment as expired. Extend the lease so a
1566
+ // normally-beating adapter has room to land its next heartbeat.
1567
+ //
1568
+ // #249 Bug 3: pre-fix this used a hardcoded 30s constant, but the claude-code
1569
+ // adapter's `heartbeatMs` is 60s → CAN would grant 30s of runway when the adapter
1570
+ // needed 60s minimum, so the first post-CAN main-loop tick reaped the healthy
1571
+ // attachment before its next heartbeat could land. Post-fix we use
1572
+ // `currentAttachment.leaseMs` (= 3 × heartbeatMs, negotiated at claim time) which
1573
+ // matches what the adapter signed up for and covers at least one full heartbeat
1574
+ // interval for every adapter class.
1575
+ //
1576
+ // The `patched()` gate keeps replay of pre-#249 workflow runs deterministic:
1577
+ // histories that CAN'd on the old bundle recorded `extendAttachmentForCAN(…, 30_000, …)`,
1578
+ // so replaying those runs must pick the legacy constant. New runs (and in-flight
1579
+ // runs that CAN *after* the deploy) take the patched branch.
1580
+ //
1581
+ // Math lives in `./attachment-math.ts` for direct unit testability (#127).
1582
+ //
1583
+ // #255 cleanup: the `patched()` call stays at the eager/unconditional
1584
+ // position it was introduced in — relocating it inside the
1585
+ // `currentAttachment ?` branch would skip marker recording on histories
1586
+ // that hit the CAN site with a null attachment, risking replay
1587
+ // non-determinism against those recordings. The dead-code cleanup is
1588
+ // strictly the removal of the `?? HEARTBEAT_INTERVAL_MS` fallback that
1589
+ // used to sit inside the ternary: on the patched branch it never fires
1590
+ // (Attachment.leaseMs is required), and on the pre-patched branch the
1591
+ // fallback is replaced by the bare constant — same value either way.
1592
+ const usePatchedLease = (0, workflow_1.patched)('v0.26-can-lease-from-attachment');
1593
+ const extendedAttachment = currentAttachment
1594
+ ? (0, attachment_math_1.extendAttachmentForCAN)(currentAttachment, usePatchedLease ? currentAttachment.leaseMs : HEARTBEAT_INTERVAL_MS, workflowNow().getTime())
1595
+ : undefined;
1596
+ await (0, workflow_1.continueAsNew)({
1597
+ ...input,
1598
+ part,
1599
+ messages: messages.filter((m) => !m.delivered),
1600
+ sentMessages: sentMessages.slice(-50),
1601
+ outbox: outbox.filter((e) => e.status === 'pending' || e.status === 'processing'),
1602
+ lastInboundRRTime,
1603
+ lastOutboundTime,
1604
+ // #399 W2 — counters carried across continueAsNew so the
1605
+ // dashboard's "Messages" + "tempo" surfaces stay monotonic.
1606
+ receivedCount,
1607
+ sentCount,
1608
+ activityCount,
1609
+ outboxLocked,
1610
+ heldMessage,
1611
+ paused,
1612
+ inFlightMessageIds: [...inFlightMessages],
1613
+ processingSince: processingSince ?? undefined,
1614
+ destroyed: destroyed || destroyRequested,
1615
+ // v0.25 attachment state — each carried forward with the lease extension applied.
1616
+ ...(extendedAttachment ? { currentAttachment: extendedAttachment } : {}),
1617
+ ...(preferredHost ? { preferredHost } : {}),
1618
+ phase,
1619
+ ...(drainingSince ? { drainingSince } : {}),
1620
+ ...(drainingDeadlineMs !== null ? { drainingDeadlineMs } : {}),
1621
+ // #334 PR-1 — carry player saveable state only when populated.
1622
+ // Empty maps are omitted from the CAN payload to keep the wire
1623
+ // small for the common no-state case (same idiom as currentAttachment).
1624
+ ...(Object.keys(playerState).length > 0 ? { playerState } : {}),
1625
+ ...(input.metadata.isConductor ? { commandHistory, reportHistory, qualityGates, worktrees, stages } : {}),
1626
+ });
1627
+ }
1628
+ }
1629
+ // ── Exit path ──
1630
+ // Single terminal state: `destroyRequested` (from the `destroy` update OR from the
1631
+ // quarantined `updateMetadata({ status: 'terminated' })` test-compat shim — both
1632
+ // route through §2.5 abandon-in-flight semantics). PR-C commit 4 retired the
1633
+ // v0.24 legacy 2-min drain-wait branch; callers expecting drain semantics should
1634
+ // request it before destroy (e.g. via `requestDetach` + wait for phase=detached).
1635
+ await (0, workflow_1.condition)(workflow_1.allHandlersFinished);
1636
+ // Finalize `destroyed = true` so `isDestroyed` queries against the completed run return true.
1637
+ destroyed = true;
1638
+ }