@fastagent-sh/fastagent 0.21.1 → 0.22.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 (388) hide show
  1. package/README.md +9 -12
  2. package/dist/agent.d.ts +24 -37
  3. package/dist/agent.js +7 -15
  4. package/dist/atomic-write.d.ts +13 -17
  5. package/dist/atomic-write.js +18 -29
  6. package/dist/bind.d.ts +12 -22
  7. package/dist/bind.js +18 -27
  8. package/dist/channel.d.ts +11 -33
  9. package/dist/channels/agentcore-limits.d.ts +2 -13
  10. package/dist/channels/agentcore-limits.js +2 -13
  11. package/dist/channels/agentcore-protocol.d.ts +67 -58
  12. package/dist/channels/agentcore-protocol.js +23 -15
  13. package/dist/channels/agentcore-service.d.ts +19 -33
  14. package/dist/channels/agentcore-service.js +176 -59
  15. package/dist/channels/agentcore.d.ts +39 -33
  16. package/dist/channels/agentcore.js +90 -224
  17. package/dist/channels/body.d.ts +15 -2
  18. package/dist/channels/body.js +24 -2
  19. package/dist/channels/busy.d.ts +1 -21
  20. package/dist/channels/busy.js +2 -39
  21. package/dist/channels/control.d.ts +17 -62
  22. package/dist/channels/control.js +74 -182
  23. package/dist/channels/define-channel.d.ts +39 -0
  24. package/dist/channels/define-channel.js +9 -0
  25. package/dist/channels/discover.d.ts +10 -31
  26. package/dist/channels/discover.js +37 -31
  27. package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
  28. package/dist/channels/feishu/bootstrap-token.js +7 -23
  29. package/dist/channels/feishu/card.d.ts +14 -28
  30. package/dist/channels/feishu/card.js +16 -30
  31. package/dist/channels/feishu/cloud.d.ts +1 -5
  32. package/dist/channels/feishu/context-buffer.d.ts +4 -23
  33. package/dist/channels/feishu/context-buffer.js +8 -31
  34. package/dist/channels/feishu/crypto.d.ts +10 -5
  35. package/dist/channels/feishu/crypto.js +12 -19
  36. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  37. package/dist/channels/feishu/feishu-api.js +5 -4
  38. package/dist/channels/feishu/feishu.d.ts +4 -10
  39. package/dist/channels/feishu/feishu.js +113 -227
  40. package/dist/channels/feishu/invoke-turn.d.ts +5 -4
  41. package/dist/channels/feishu/invoke-turn.js +36 -54
  42. package/dist/channels/feishu/model.d.ts +4 -21
  43. package/dist/channels/feishu/model.js +1 -8
  44. package/dist/channels/feishu/normalize.d.ts +2 -10
  45. package/dist/channels/feishu/normalize.js +7 -42
  46. package/dist/channels/feishu/parse.d.ts +9 -29
  47. package/dist/channels/feishu/parse.js +8 -26
  48. package/dist/channels/feishu/preview.d.ts +12 -14
  49. package/dist/channels/feishu/preview.js +108 -207
  50. package/dist/channels/feishu/register-app.d.ts +8 -23
  51. package/dist/channels/feishu/register-app.js +7 -20
  52. package/dist/channels/feishu/register-webhook.d.ts +2 -9
  53. package/dist/channels/feishu/register-webhook.js +9 -33
  54. package/dist/channels/feishu/scaffold/channel.ts +27 -19
  55. package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
  56. package/dist/channels/feishu/setup-mode.d.ts +11 -36
  57. package/dist/channels/feishu/setup-mode.js +7 -26
  58. package/dist/channels/feishu/ws-ingress.d.ts +1 -2
  59. package/dist/channels/feishu/ws-ingress.js +6 -8
  60. package/dist/channels/http.d.ts +15 -15
  61. package/dist/channels/http.js +19 -9
  62. package/dist/channels/kit/attachment-path.d.ts +4 -5
  63. package/dist/channels/kit/attachment-path.js +10 -34
  64. package/dist/channels/kit/context-buffer.d.ts +14 -21
  65. package/dist/channels/kit/context-buffer.js +25 -38
  66. package/dist/channels/kit/delivery.d.ts +35 -0
  67. package/dist/channels/kit/delivery.js +129 -0
  68. package/dist/channels/kit/event-stream.d.ts +5 -0
  69. package/dist/channels/kit/event-stream.js +29 -0
  70. package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
  71. package/dist/channels/kit/invoke-turn-kit.js +89 -32
  72. package/dist/channels/kit/preview-kit.d.ts +18 -72
  73. package/dist/channels/kit/preview-kit.js +22 -88
  74. package/dist/channels/kit/signature.d.ts +3 -9
  75. package/dist/channels/kit/signature.js +3 -9
  76. package/dist/channels/kit/state.d.ts +1 -2
  77. package/dist/channels/kit/state.js +5 -14
  78. package/dist/channels/kit/stop-command.d.ts +1 -2
  79. package/dist/channels/kit/stop-command.js +6 -9
  80. package/dist/channels/kit/tasks.d.ts +1 -1
  81. package/dist/channels/kit/tasks.js +8 -21
  82. package/dist/channels/kit/text.d.ts +2 -16
  83. package/dist/channels/kit/text.js +23 -31
  84. package/dist/channels/kit/thread-participants.d.ts +4 -17
  85. package/dist/channels/kit/thread-participants.js +8 -55
  86. package/dist/channels/kit/transport.d.ts +26 -0
  87. package/dist/channels/kit/transport.js +20 -0
  88. package/dist/channels/kit/turn-queue.d.ts +7 -11
  89. package/dist/channels/kit/turn-queue.js +21 -39
  90. package/dist/channels/kit/turn-runner.d.ts +32 -18
  91. package/dist/channels/kit/turn-runner.js +98 -53
  92. package/dist/channels/kit/turn-store.d.ts +32 -32
  93. package/dist/channels/kit/turn-store.js +45 -88
  94. package/dist/channels/lark/lark.d.ts +1 -5
  95. package/dist/channels/lark/onboard.d.ts +3 -12
  96. package/dist/channels/lark/onboard.js +3 -13
  97. package/dist/channels/lark/scaffold/channel.ts +27 -19
  98. package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
  99. package/dist/channels/registration.d.ts +9 -35
  100. package/dist/channels/registration.js +6 -37
  101. package/dist/channels/secret.js +2 -5
  102. package/dist/channels/serve.d.ts +47 -44
  103. package/dist/channels/serve.js +165 -101
  104. package/dist/channels/slack/config-api.d.ts +4 -27
  105. package/dist/channels/slack/config-api.js +5 -34
  106. package/dist/channels/slack/context-buffer.d.ts +2 -3
  107. package/dist/channels/slack/context-buffer.js +5 -5
  108. package/dist/channels/slack/invoke-turn.d.ts +3 -1
  109. package/dist/channels/slack/invoke-turn.js +25 -34
  110. package/dist/channels/slack/manifest.js +1 -3
  111. package/dist/channels/slack/model.d.ts +1 -2
  112. package/dist/channels/slack/model.js +1 -1
  113. package/dist/channels/slack/onboard.d.ts +4 -3
  114. package/dist/channels/slack/onboard.js +8 -11
  115. package/dist/channels/slack/onboarding-state.d.ts +3 -5
  116. package/dist/channels/slack/onboarding-state.js +9 -6
  117. package/dist/channels/slack/parse.d.ts +4 -13
  118. package/dist/channels/slack/parse.js +7 -28
  119. package/dist/channels/slack/preview.d.ts +12 -2
  120. package/dist/channels/slack/preview.js +292 -269
  121. package/dist/channels/slack/reaction.d.ts +11 -3
  122. package/dist/channels/slack/reaction.js +19 -8
  123. package/dist/channels/slack/register-webhook.js +4 -7
  124. package/dist/channels/slack/scaffold/channel.ts +20 -13
  125. package/dist/channels/slack/scaffold/slack-send.ts +2 -2
  126. package/dist/channels/slack/setup-server.d.ts +1 -5
  127. package/dist/channels/slack/setup-server.js +1 -5
  128. package/dist/channels/slack/shared-api.d.ts +1 -4
  129. package/dist/channels/slack/shared-api.js +2 -13
  130. package/dist/channels/slack/slack-api.d.ts +2 -1
  131. package/dist/channels/slack/slack-api.js +6 -4
  132. package/dist/channels/slack/slack.d.ts +11 -14
  133. package/dist/channels/slack/slack.js +121 -122
  134. package/dist/channels/sse.d.ts +1 -1
  135. package/dist/channels/sse.js +1 -1
  136. package/dist/channels/telegram/context-buffer.d.ts +7 -22
  137. package/dist/channels/telegram/context-buffer.js +6 -23
  138. package/dist/channels/telegram/invoke-turn.d.ts +7 -7
  139. package/dist/channels/telegram/invoke-turn.js +24 -43
  140. package/dist/channels/telegram/parse.d.ts +24 -51
  141. package/dist/channels/telegram/parse.js +32 -84
  142. package/dist/channels/telegram/preview.d.ts +10 -1
  143. package/dist/channels/telegram/preview.js +79 -100
  144. package/dist/channels/telegram/register-webhook.js +5 -23
  145. package/dist/channels/telegram/scaffold/channel.ts +25 -18
  146. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
  147. package/dist/channels/telegram/telegram-api.d.ts +4 -3
  148. package/dist/channels/telegram/telegram-api.js +9 -7
  149. package/dist/channels/telegram/telegram.d.ts +4 -18
  150. package/dist/channels/telegram/telegram.js +38 -94
  151. package/dist/channels/telegram/turn-store.d.ts +11 -14
  152. package/dist/channels/telegram/turn-store.js +8 -6
  153. package/dist/channels/wait-health.d.ts +4 -6
  154. package/dist/channels/wait-health.js +10 -14
  155. package/dist/cli/add-feishu.d.ts +2 -12
  156. package/dist/cli/add-feishu.js +24 -71
  157. package/dist/cli/add-slack.d.ts +5 -3
  158. package/dist/cli/add-slack.js +53 -76
  159. package/dist/cli/auth-view.d.ts +2 -16
  160. package/dist/cli/auth-view.js +2 -16
  161. package/dist/cli/commands/add.js +25 -40
  162. package/dist/cli/commands/chat.d.ts +0 -1
  163. package/dist/cli/commands/chat.js +5 -6
  164. package/dist/cli/commands/deploy/agentcore.js +60 -65
  165. package/dist/cli/commands/deploy/docker.js +50 -23
  166. package/dist/cli/commands/deploy/fly.js +23 -61
  167. package/dist/cli/commands/deploy/railway.js +23 -18
  168. package/dist/cli/commands/deploy/shared.d.ts +38 -57
  169. package/dist/cli/commands/deploy/shared.js +40 -59
  170. package/dist/cli/commands/deploy.d.ts +10 -27
  171. package/dist/cli/commands/deploy.js +38 -60
  172. package/dist/cli/commands/destroy.d.ts +4 -0
  173. package/dist/cli/commands/destroy.js +56 -0
  174. package/dist/cli/commands/dev.d.ts +2 -1
  175. package/dist/cli/commands/dev.js +20 -23
  176. package/dist/cli/commands/info.d.ts +0 -2
  177. package/dist/cli/commands/info.js +83 -49
  178. package/dist/cli/commands/init.d.ts +1 -2
  179. package/dist/cli/commands/init.js +16 -55
  180. package/dist/cli/commands/invoke.d.ts +0 -1
  181. package/dist/cli/commands/invoke.js +5 -7
  182. package/dist/cli/commands/login.d.ts +2 -1
  183. package/dist/cli/commands/login.js +33 -38
  184. package/dist/cli/commands/logs.js +3 -4
  185. package/dist/cli/commands/models.js +1 -5
  186. package/dist/cli/commands/routine-run.d.ts +6 -0
  187. package/dist/cli/commands/routine-run.js +47 -0
  188. package/dist/cli/commands/routine.d.ts +25 -0
  189. package/dist/cli/commands/routine.js +122 -0
  190. package/dist/cli/commands/start.d.ts +31 -3
  191. package/dist/cli/commands/start.js +131 -81
  192. package/dist/cli/commands/tool.js +26 -12
  193. package/dist/cli/fail.d.ts +13 -23
  194. package/dist/cli/fail.js +20 -23
  195. package/dist/cli/invoke-stream.d.ts +1 -8
  196. package/dist/cli/kernel.d.ts +6 -38
  197. package/dist/cli/kernel.js +14 -77
  198. package/dist/cli/models-view.d.ts +1 -9
  199. package/dist/cli/models-view.js +11 -22
  200. package/dist/cli/program.d.ts +5 -6
  201. package/dist/cli/program.js +120 -157
  202. package/dist/cli/serve.d.ts +57 -41
  203. package/dist/cli/serve.js +94 -121
  204. package/dist/cli/shared.d.ts +15 -61
  205. package/dist/cli/shared.js +55 -181
  206. package/dist/cli.js +1 -8
  207. package/dist/collect.d.ts +6 -0
  208. package/dist/collect.js +6 -0
  209. package/dist/core.d.ts +4 -1
  210. package/dist/core.js +9 -7
  211. package/dist/declared-secrets.d.ts +55 -0
  212. package/dist/declared-secrets.js +88 -0
  213. package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
  214. package/dist/deploy/agentcore/aws-cli.js +64 -0
  215. package/dist/deploy/agentcore/destroy.d.ts +51 -0
  216. package/dist/deploy/agentcore/destroy.js +273 -0
  217. package/dist/deploy/agentcore/forwarder.js +28 -79
  218. package/dist/deploy/agentcore/logs.d.ts +2 -18
  219. package/dist/deploy/agentcore/logs.js +27 -51
  220. package/dist/deploy/agentcore/plan.d.ts +85 -105
  221. package/dist/deploy/agentcore/plan.js +152 -314
  222. package/dist/deploy/agentcore/run.d.ts +28 -28
  223. package/dist/deploy/agentcore/run.js +227 -306
  224. package/dist/deploy/agentcore/zip.d.ts +2 -15
  225. package/dist/deploy/agentcore/zip.js +3 -17
  226. package/dist/deploy/build-context.d.ts +21 -0
  227. package/dist/deploy/build-context.js +137 -0
  228. package/dist/deploy/channel-ingress.d.ts +26 -39
  229. package/dist/deploy/channel-ingress.js +57 -28
  230. package/dist/deploy/container.d.ts +20 -34
  231. package/dist/deploy/container.js +49 -100
  232. package/dist/deploy/docker/plan.d.ts +12 -11
  233. package/dist/deploy/docker/plan.js +43 -24
  234. package/dist/deploy/docker/run.d.ts +22 -25
  235. package/dist/deploy/docker/run.js +39 -37
  236. package/dist/deploy/fly/plan.d.ts +15 -44
  237. package/dist/deploy/fly/plan.js +41 -73
  238. package/dist/deploy/fly/run.d.ts +16 -51
  239. package/dist/deploy/fly/run.js +42 -95
  240. package/dist/deploy/hosts.d.ts +24 -3
  241. package/dist/deploy/hosts.js +24 -3
  242. package/dist/deploy/preflight.d.ts +47 -41
  243. package/dist/deploy/preflight.js +282 -383
  244. package/dist/deploy/railway/plan.d.ts +16 -42
  245. package/dist/deploy/railway/plan.js +42 -60
  246. package/dist/deploy/railway/run.d.ts +30 -68
  247. package/dist/deploy/railway/run.js +86 -100
  248. package/dist/deploy/registration-gate.d.ts +6 -13
  249. package/dist/deploy/residency.d.ts +37 -0
  250. package/dist/deploy/residency.js +24 -0
  251. package/dist/deploy/runner.d.ts +10 -9
  252. package/dist/deploy/runner.js +7 -18
  253. package/dist/deploy/secrets.d.ts +40 -41
  254. package/dist/deploy/secrets.js +117 -103
  255. package/dist/deploy/workspace.d.ts +50 -0
  256. package/dist/deploy/workspace.js +197 -0
  257. package/dist/dev-supervisor.d.ts +1 -9
  258. package/dist/dev-supervisor.js +21 -64
  259. package/dist/effect-port.d.ts +50 -0
  260. package/dist/effect-port.js +77 -0
  261. package/dist/engines/pi/agent-session-factory.d.ts +50 -69
  262. package/dist/engines/pi/agent-session-factory.js +249 -146
  263. package/dist/engines/pi/auth.d.ts +15 -11
  264. package/dist/engines/pi/auth.js +96 -99
  265. package/dist/engines/pi/chat.d.ts +1 -5
  266. package/dist/engines/pi/chat.js +2 -14
  267. package/dist/engines/pi/config.d.ts +53 -51
  268. package/dist/engines/pi/config.js +83 -92
  269. package/dist/engines/pi/create.d.ts +60 -75
  270. package/dist/engines/pi/create.js +108 -141
  271. package/dist/engines/pi/definition.d.ts +14 -58
  272. package/dist/engines/pi/definition.js +21 -85
  273. package/dist/engines/pi/invoke-session.d.ts +5 -25
  274. package/dist/engines/pi/invoke-session.js +233 -244
  275. package/dist/engines/pi/login.d.ts +95 -35
  276. package/dist/engines/pi/login.js +259 -107
  277. package/dist/engines/pi/machine.d.ts +31 -0
  278. package/dist/engines/pi/machine.js +106 -0
  279. package/dist/engines/pi/models.d.ts +70 -89
  280. package/dist/engines/pi/models.js +188 -81
  281. package/dist/engines/pi/open.d.ts +65 -53
  282. package/dist/engines/pi/open.js +129 -69
  283. package/dist/engines/pi/report.d.ts +3 -12
  284. package/dist/engines/pi/report.js +2 -16
  285. package/dist/engines/pi/search-tools.d.ts +1 -9
  286. package/dist/engines/pi/search-tools.js +26 -57
  287. package/dist/engines/pi/service.d.ts +2 -16
  288. package/dist/engines/pi/service.js +2 -16
  289. package/dist/engines/pi/session-builder.d.ts +5 -9
  290. package/dist/engines/pi/session-builder.js +44 -82
  291. package/dist/engines/pi/session-control.js +286 -269
  292. package/dist/engines/pi/session-effects.d.ts +14 -0
  293. package/dist/engines/pi/session-effects.js +30 -0
  294. package/dist/engines/pi/session-inheritance.d.ts +9 -18
  295. package/dist/engines/pi/session-inheritance.js +103 -160
  296. package/dist/engines/pi/session-markers.d.ts +31 -30
  297. package/dist/engines/pi/session-markers.js +26 -25
  298. package/dist/engines/pi/session-settings.d.ts +6 -22
  299. package/dist/engines/pi/session-settings.js +10 -21
  300. package/dist/engines/pi/session-store.d.ts +26 -92
  301. package/dist/engines/pi/session-store.js +96 -310
  302. package/dist/engines/pi/tool-context.d.ts +17 -45
  303. package/dist/engines/pi/tool-context.js +7 -26
  304. package/dist/engines/pi/tool.d.ts +45 -36
  305. package/dist/engines/pi/tool.js +43 -57
  306. package/dist/engines/pi/turn-kit.d.ts +14 -52
  307. package/dist/engines/pi/turn-kit.js +18 -67
  308. package/dist/engines/pi/wake-tool.d.ts +2 -10
  309. package/dist/engines/pi/wake-tool.js +7 -27
  310. package/dist/env.d.ts +27 -28
  311. package/dist/env.js +45 -50
  312. package/dist/index.js +0 -2
  313. package/dist/lark.d.ts +1 -2
  314. package/dist/lark.js +1 -2
  315. package/dist/loader.d.ts +14 -55
  316. package/dist/loader.js +24 -43
  317. package/dist/log.d.ts +2 -12
  318. package/dist/log.js +3 -24
  319. package/dist/node.d.ts +2 -15
  320. package/dist/node.js +2 -18
  321. package/dist/observe.d.ts +2 -8
  322. package/dist/open-url.d.ts +1 -1
  323. package/dist/open-url.js +1 -1
  324. package/dist/paths.d.ts +70 -134
  325. package/dist/paths.js +165 -232
  326. package/dist/pi.d.ts +4 -6
  327. package/dist/pi.js +3 -3
  328. package/dist/proxy.d.ts +16 -6
  329. package/dist/proxy.js +52 -16
  330. package/dist/runtime.d.ts +18 -12
  331. package/dist/runtime.js +18 -11
  332. package/dist/scaffold/add-channel.d.ts +14 -31
  333. package/dist/scaffold/add-channel.js +48 -112
  334. package/dist/scaffold/init.d.ts +9 -31
  335. package/dist/scaffold/init.js +45 -138
  336. package/dist/scaffold/templates/env.example +6 -4
  337. package/dist/scaffold/templates/fastagent.config.ts +36 -0
  338. package/dist/scaffold/templates.d.ts +3 -3
  339. package/dist/scaffold/templates.js +5 -8
  340. package/dist/scaffold/vendor-skill.d.ts +2 -5
  341. package/dist/scaffold/vendor-skill.js +19 -29
  342. package/dist/schedule/cron.d.ts +9 -5
  343. package/dist/schedule/cron.js +9 -11
  344. package/dist/schedule/discover.d.ts +12 -12
  345. package/dist/schedule/discover.js +43 -34
  346. package/dist/schedule/routine.d.ts +43 -0
  347. package/dist/schedule/routine.js +32 -0
  348. package/dist/schedule/run.d.ts +55 -0
  349. package/dist/schedule/run.js +146 -0
  350. package/dist/schedule/scheduler.d.ts +44 -55
  351. package/dist/schedule/scheduler.js +291 -265
  352. package/dist/schedule/state.d.ts +105 -10
  353. package/dist/schedule/state.js +293 -19
  354. package/dist/schedule/wake-alarm.d.ts +10 -23
  355. package/dist/schedule/wake-alarm.js +91 -121
  356. package/dist/schedule/wakeups.d.ts +23 -39
  357. package/dist/schedule/wakeups.js +33 -75
  358. package/dist/secrets-gate.d.ts +32 -0
  359. package/dist/secrets-gate.js +33 -0
  360. package/dist/service.d.ts +137 -98
  361. package/dist/service.js +250 -252
  362. package/dist/session-remote.d.ts +33 -44
  363. package/dist/session-remote.js +203 -192
  364. package/dist/session.d.ts +166 -229
  365. package/dist/session.js +33 -55
  366. package/dist/tunnel.d.ts +8 -36
  367. package/dist/tunnel.js +24 -65
  368. package/dist/version.d.ts +2 -2
  369. package/dist/version.js +2 -2
  370. package/package.json +10 -16
  371. package/dist/channels/agentcore-state.d.ts +0 -77
  372. package/dist/channels/agentcore-state.js +0 -276
  373. package/dist/channels/github/github.d.ts +0 -31
  374. package/dist/channels/github/github.js +0 -89
  375. package/dist/channels/github/scaffold/channel.ts +0 -25
  376. package/dist/cli/commands/attach.d.ts +0 -108
  377. package/dist/cli/commands/attach.js +0 -637
  378. package/dist/cli/commands/fire.d.ts +0 -7
  379. package/dist/cli/commands/fire.js +0 -38
  380. package/dist/cli/commands/schedule.d.ts +0 -12
  381. package/dist/cli/commands/schedule.js +0 -87
  382. package/dist/github.d.ts +0 -3
  383. package/dist/github.js +0 -2
  384. package/dist/scaffold/templates/fastagent.config.mjs +0 -23
  385. package/dist/schedule/audit.d.ts +0 -18
  386. package/dist/schedule/audit.js +0 -57
  387. package/dist/schedule/schedule.d.ts +0 -35
  388. package/dist/schedule/schedule.js +0 -23
@@ -1,286 +1,312 @@
1
- /**
2
- * The scheduler: a time-trigger that fires the agent on each schedule's cron. SINGLE-PROCESS (like all
3
- * fastagent state today) — a deployment with schedules must keep one machine running, since cron has no
4
- * external wake-up (`deploy` enforces that). Started/stopped by the serve path (dev/start).
5
- *
6
- * Fire model:
7
- * - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
8
- * conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
9
- * - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
10
- * - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
11
- * already past (the process was down across it), fire once and advance — not once per missed slot.
12
- * lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
13
- * "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
14
- */
1
+ /** Resident cron and wake polling. */
2
+ import * as Cause from "effect/Cause";
3
+ import * as Clock from "effect/Clock";
4
+ import * as Effect from "effect/Effect";
15
5
  import { SESSION_BUSY_CODE } from "../agent.js";
6
+ import { PortFailure } from "../effect-port.js";
16
7
  import { beginWork } from "../channels/busy.js";
17
8
  import { log } from "../log.js";
18
- import { appendRun } from "./audit.js";
19
9
  import { nextRun } from "./cron.js";
20
- import { loadFires, saveFires } from "./state.js";
10
+ import { routineSession } from "./routine.js";
11
+ import { claimSlot, latestFire, settleClaim } from "./state.js";
21
12
  import { deferWakeup, takeFirstDueWakeup } from "./wakeups.js";
22
- /** A schedule's turns share this stable session — a continuing conversation, like the telegram channel's
23
- * per-chat session. Derived at RUNTIME from the name (never an authored field). */
24
- export function scheduleSession(name) {
25
- return `schedule:${name}`;
13
+ /**
14
+ * Account for a claim whose turn never reported. The slot is claimed BEFORE the turn runs and shutdown does not wait
15
+ * for that turn (see `AgentService.close`), so a restart landing mid-fire leaves a claim that the next boot skips —
16
+ * the exact silence the history exists to prevent. Settled once, at the next boot, which also makes it idempotent.
17
+ *
18
+ * The claim is the ONE record here: it IS the decision, and the outcome is written back into it.
19
+ *
20
+ * NOT re-fired: a turn that kills its own process would then replay on every boot. Cron only: a killed wake-up
21
+ * leaves no claim behind to reconcile (`takeFirstDueWakeup` removes it before the turn starts).
22
+ *
23
+ * THE NEWEST CLAIM ONLY. A schedule's loop runs one turn at a time, so a killed process leaves exactly one claim
24
+ * unsettled — the one it was in. Walking the whole retained window would add nothing a resident process can
25
+ * produce, and would instead rewrite every claim an OLDER FORMAT left without an outcome, turning a window of runs
26
+ * that actually succeeded into `interrupted`.
27
+ *
28
+ * KNOWN FALSE POSITIVE: a second scheduler booting while the first is mid-turn sees a claim nothing accounts for
29
+ * YET, and reports it; the first process then overwrites the outcome when its turn settles. Telling them apart needs
30
+ * the claimer's liveness, which is a lease, not a claim — and the whole point of the claim is that it needs none.
31
+ */
32
+ function markInterruptedFire(stateRoot, name, fire) {
33
+ if (fire === undefined || fire.outcome !== undefined)
34
+ return;
35
+ log.warn(`[schedule] ${name}: the fire claimed at ${fire.firedAt} never finished — the process stopped mid-turn ` +
36
+ `and that slot stays skipped (see \`fastagent routine history ${name}\`)`);
37
+ // `fire.slot` came from a claim file name, which `listClaims` admits only when it round-trips through this same
38
+ // conversion — so the Date is valid and `settleClaim` writes back the file it was read from. No duration: this
39
+ // turn was never timed, and the process that could have timed it is gone.
40
+ settleClaim(stateRoot, name, new Date(fire.slot), "interrupted");
26
41
  }
27
- // A single setTimeout maxes out at ~24.8 days and drifts over long sleeps; cap each wait so a long
28
- // interval (or a suspended machine) re-checks against the wall clock rather than firing wildly early/late.
29
- const MAX_WAIT_MS = 6 * 60 * 60 * 1000; // 6h
30
- // How often to poll the agent's self-scheduled wake-ups (wakeups.ts). A wake fires within this of its
31
- // due time — fine for "wake me in N minutes"; cheap (reads a small JSON, writes only when one is due).
32
- const WAKEUP_POLL_MS = 30 * 1000;
33
- /** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total — never throws (its callers are
34
- * void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
35
- * material — `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
36
- * it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
37
- * reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). Module-level
38
- * (not a scheduler closure) so {@link fireScheduleOnce} — the external-clock fire path — shares it. */
39
- async function runTurn(agent, label, session, prompt) {
40
- const startedAt = Date.now();
41
- log.info(`[schedule] ${label} firing (session=${session})`);
42
- try {
43
- let failed;
44
- let busy = false;
45
- let reply = "";
46
- for await (const e of agent.invoke({ session }, { text: prompt })) {
47
- if (e.type === "text")
48
- reply += e.delta;
49
- if (e.type === "failed") {
50
- failed = e.details;
51
- busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
52
- }
53
- }
54
- if (failed)
55
- log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
56
- else
57
- log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
58
- return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
59
- }
60
- catch (e) {
61
- // invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
62
- // is not the busy case, so don't defer on it.
63
- log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
64
- return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
65
- }
42
+ /**
43
+ * The longest a single armed wait sleeps before looking at the wall clock again.
44
+ *
45
+ * WHAT IT BUYS, and only this: a wait LONGER than one cap (a daily, weekly or monthly cron) re-reads the wall
46
+ * clock every 6 hours, so a clock that jumped forward is noticed within that, instead of after the whole
47
+ * original delay. MEASURED (2026-09-22): the same jump under a wait SHORTER than the cap changes nothing — a
48
+ * minute cron whose wall clock jumped 10 minutes past its slot still fired at t+51.4s, when its one timer was
49
+ * always going to. So this is not a resume guarantee and must not be read as one; it bounds lateness for the
50
+ * long waits at 6 hours.
51
+ *
52
+ * NOT about `setTimeout`'s 2^31-1 ms ceiling: `Effect.sleep` already splits a longer delay by itself
53
+ * (effect/src/internal/effect.ts, `sleepMillis`).
54
+ */
55
+ const MAX_WAIT_MS = 6 * 60 * 60 * 1000;
56
+ const WAKEUP_POLL_MS = 30_000;
57
+ /**
58
+ * ONE LINE, ALWAYS: the tail a failure contributes to its log line, as `: <text>`, or nothing when there is none.
59
+ *
60
+ * `log.ts` prefixes only the first line, so a newline anywhere in here emits a second line that is byte-for-byte a
61
+ * real record (`ERROR [schedule] daily failed (1ms): DISK ON FIRE`) — and a failure detail can carry model or
62
+ * provider text. Folding also keeps the line-oriented consumers — `docker logs`, journald, CloudWatch — reading one
63
+ * fire as one event.
64
+ */
65
+ function oneLine(text) {
66
+ const folded = text.replace(/\s+/g, " ").trim();
67
+ return folded === "" ? "" : `: ${folded}`;
66
68
  }
67
69
  /**
68
- * Fire ONE schedule's turn: claim (persist lastFired BEFORE invoking, so a crash mid-turn does not
69
- * re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
70
- * serving surface (the AgentCore adapter).
70
+ * The iterator is a Promise port inside an uninterruptible claimed occurrence, including its cleanup.
71
71
  *
72
- * `slot` is the external clock's idempotency key — the cron instant this fire is FOR. External
73
- * delivery (EventBridge-style) is at-least-once, so a duplicate slot must not double-fire: when
74
- * lastFired ≥ slot the fire is SKIPPED (at-most-once per slot, same trade as the resident claim —
75
- * "a digest late once" beats "twice"). The resident scheduler omits `slot`: its timers fire each
76
- * slot exactly once, so the unconditional claim is already correct.
72
+ * WHAT THE TURN SAID IS NOT LOGGED. It is already durable: every fire runs in the session named in the line below
73
+ * (`routine:<name>` for a cron, the asking conversation for a wake-up), and a session is persisted under
74
+ * EXPORTED for the API path (`POST /run`), which runs the same turn without a claim: it has no occurrence to
75
+ * claim, because nobody's grid produced it (schedule/run.ts). The resident clock's own claim/run/settle is
76
+ * {@link fireRoutineOnce}.
77
77
  *
78
- * A state fault while claiming (loadFires/saveFires — both before the invoke, so nothing ran)
79
- * THROWS: the resident path catches it at its single skip+audit boundary ({@link createScheduler}'s
80
- * fireThenReArm — skipping rather than firing unclaimed also avoids an infinite catch-up loop on
81
- * restart), and the external path lets it surface as a failed request (visible in the clock's logs).
78
+ * `<stateRoot>/sessions/` like any other. Copying the reply into the log would be a second store of the same text,
79
+ * unstructured, in a stream with a wider audience and a bound that differs per host.
82
80
  */
83
- export async function fireScheduleOnce(opts) {
84
- const { agent, stateRoot, schedule: s, slot, now = () => new Date() } = opts;
85
- const fires = loadFires(stateRoot);
86
- const last = fires[s.name];
87
- if (slot && last && new Date(last).getTime() >= slot.getTime()) {
88
- const reason = `slot ${slot.toISOString()} already fired (lastFired=${last})`;
89
- log.info(`[schedule] ${s.name}: skipping — ${reason}`);
90
- return { fired: false, skippedReason: reason, ms: 0 };
91
- }
92
- // An external delivery claims the cron instant it represents, not its (possibly much later)
93
- // delivery time. Otherwise a delayed fire can make the next distinct slot look like a duplicate.
94
- fires[s.name] = (slot ?? now()).toISOString();
95
- saveFires(stateRoot, fires);
96
- const firedAt = now().toISOString();
97
- const r = await runTurn(agent, s.name, scheduleSession(s.name), s.prompt);
98
- appendRun(stateRoot, {
99
- name: s.name,
100
- session: scheduleSession(s.name),
101
- firedAt,
102
- ms: r.ms,
103
- outcome: r.failed ? "failed" : "completed",
104
- reply: r.failed ? undefined : r.reply,
105
- error: r.failed,
81
+ export function runTurn(agent, label, session, prompt) {
82
+ return Effect.gen(function* () {
83
+ const clock = yield* Clock.Clock;
84
+ const startedAt = clock.currentTimeMillisUnsafe();
85
+ const elapsed = () => clock.currentTimeMillisUnsafe() - startedAt;
86
+ log.info(`[schedule] ${label} firing (session=${session})`);
87
+ return yield* Effect.tryPromise({
88
+ try: async () => {
89
+ let failed;
90
+ let busy = false;
91
+ // The text deltas are iterated and dropped. Accumulating them would be a second copy of what the session
92
+ // already holds, and no caller here reads it — the outcome is the whole record (docs/design/core.md §8).
93
+ for await (const e of agent.invoke({ session }, { text: prompt })) {
94
+ if (e.type === "failed") {
95
+ failed = e.details;
96
+ busy = e.code === SESSION_BUSY_CODE;
97
+ }
98
+ }
99
+ return { busy: failed !== undefined && busy, failed };
100
+ },
101
+ catch: (cause) => new PortFailure(cause),
102
+ }).pipe(Effect.match({
103
+ onSuccess: (result) => {
104
+ // BUSY IS NOT A FAILURE, and this line is where that distinction has to land: the claim file and the
105
+ // route's reply both say `skipped`, but `docker logs` / CloudWatch is the first place anyone looks, and
106
+ // it kept saying `ERROR … failed: busy`. "The previous turn is still running" and "the model call died"
107
+ // need different reactions — a session-busy rejection carries `failed` only because that is the SPEC's
108
+ // one terminal event shape, which is a wire detail, not a verdict.
109
+ if (result.busy)
110
+ log.info(`[schedule] ${label}: occurrence skipped (session busy) (${elapsed()}ms)`);
111
+ else if (result.failed)
112
+ log.error(`[schedule] ${label} failed (${elapsed()}ms)${oneLine(result.failed)}`);
113
+ else
114
+ log.info(`[schedule] ${label} completed (${elapsed()}ms)`);
115
+ return { ...result, ms: elapsed() };
116
+ },
117
+ onFailure: (error) => {
118
+ // An iterator throw violates SPEC MUST 2 and is never a replay-safe busy rejection.
119
+ log.error(`[schedule] ${label} errored (${elapsed()}ms)${oneLine(String(error.cause))}`);
120
+ return { busy: false, failed: String(error.cause), ms: elapsed() };
121
+ },
122
+ }));
106
123
  });
107
- return { fired: true, failed: r.failed, ms: r.ms };
108
124
  }
109
- export function createScheduler({ agent, stateRoot, schedules, now = () => new Date(), externalClock = false, }) {
110
- const timers = new Map();
111
- let wakeupTimer;
112
- let stopped = false;
113
- /** The resident fire path — the shared claim+run+audit, without a slot (see {@link fireScheduleOnce}). */
114
- async function fire(s) {
115
- await fireScheduleOnce({ agent, stateRoot, schedule: s, now });
116
- }
117
- /**
118
- * The woken turn's prompt arrives ENVELOPED: without it the model sees its own instruction as a bare
119
- * user message — in a chat session it may answer a "user" who said nothing — and it has no way to know
120
- * the wake-up's id (buried in a long-past tool result), which `unwake` needs. Static cron fires stay raw
121
- * on purpose: their prompt is the AUTHOR's instruction in a dedicated `schedule:<name>` session, where
122
- * every turn is a fire and an envelope would only dilute it.
123
- */
124
- function wakeEnvelope(w) {
125
- const tag = w.cron
126
- ? `[wake-up ${w.id} fired — YOUR recurring self-scheduled turn (cron "${w.cron}"${w.tz ? ` ${w.tz}` : ""}), not a user message; unwake({ id: "${w.id}" }) stops it]`
127
- : `[wake-up ${w.id} fired — YOUR self-scheduled turn, not a user message]`;
128
- return `${tag} ${w.prompt}`;
129
- }
130
- /**
131
- * Fire every due self-scheduled wake-up, ONE at a time (claim → fire → claim next) so a crash loses at
132
- * most one occurrence. Each fires back into the session it was set in. Busy handling splits by kind: a
133
- * ONE-SHOT that failed because the session was BUSY (`code: session_busy` — the turn never started; the
134
- * very case "wake me in 10 min" hits while the user is still chatting) is deferred to the next poll,
135
- * bounded, because a dropped one-shot has no "next time"; a RECURRING busy occurrence is skipped
136
- * immediately (its claim already advanced the entry — the next occurrence comes by definition). Any
137
- * other failure is terminal for that occurrence (the turn ran — re-running risks duplicate side effects).
138
- */
139
- async function pollWakeups() {
140
- for (;;) {
141
- if (stopped)
142
- break; // stop() must halt an in-flight drain, like it clears the cron timers
143
- const w = takeFirstDueWakeup(stateRoot, now());
144
- if (!w)
145
- break;
146
- const label = `wake ${w.id.slice(0, 8)}`;
147
- const firedAt = now().toISOString();
148
- // A wake turn runs in the BACKGROUND (no open request tracks it) — count it as in-flight work
149
- // (busy.ts) so a serving surface that must not idle mid-turn (the AgentCore /ping) sees it.
150
- const workDone = beginWork();
151
- const r = await runTurn(agent, label, w.session, wakeEnvelope(w)).finally(workDone);
152
- // Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
153
- // terminal for this occurrence, since a turn that DID start may have run side effects). ONE-SHOT: defer (bounded) — it has no "next time", dropping it
154
- // would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
155
- // takeFirstDueWakeup), so a busy occurrence is simply SKIPPED and audited — the next one comes by
156
- // definition, and never touching the stored entry here is what keeps unwake/cancel race-free.
157
- let kept = false;
158
- if (r.busy && !w.cron) {
159
- kept = deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS));
160
- if (kept)
161
- log.info(`[schedule] ${label}: session busy — retrying next poll`);
162
- else
163
- log.error(`[schedule] ${label}: dropped after too many busy retries`);
164
- }
165
- else if (r.busy && w.cron) {
166
- log.error(`[schedule] ${label}: occurrence skipped (session busy); next fires per cron`);
167
- }
168
- // Audit honesty: `deferred` ONLY when the same occurrence was actually re-scheduled. A busy one-shot
169
- // dropped at the ceiling, and a busy recurring occurrence (skipped — its recurrence survives), are
170
- // both FINAL for that occurrence → `failed`.
171
- appendRun(stateRoot, {
172
- name: "wake",
173
- session: w.session,
174
- firedAt,
125
+ /** Shared resident/external claim → run → settle. */
126
+ export function fireRoutineOnce(opts) {
127
+ return Effect.gen(function* () {
128
+ const clock = yield* Clock.Clock;
129
+ const { agent, stateRoot, routine: s, slot, now = () => new Date(clock.currentTimeMillisUnsafe()) } = opts;
130
+ const firedAt = now();
131
+ const skippedReason = yield* Effect.try({
132
+ try: () => {
133
+ // The DECISION to fire is the claim, and it is atomic: several schedulers over one state root — two
134
+ // `start`s, a restart overlapping its predecessor, an external clock racing the resident one — take the
135
+ // same slot, and exactly one of them runs it. It is also the ONLY state write on this path, so a state
136
+ // failure cannot burn a slot that was already claimed.
137
+ const claim = claimSlot(stateRoot, s.name, slot, firedAt);
138
+ if (claim.taken)
139
+ return undefined;
140
+ if (claim.why === "duplicate") {
141
+ // Ordinary: the same delivery arrived twice, or another scheduler has this slot and is running it.
142
+ const reason = `slot ${slot.toISOString()} is already claimed — a duplicate delivery, or another scheduler has it`;
143
+ log.info(`[schedule] ${s.name}: skipping — ${reason}`);
144
+ return reason;
145
+ }
146
+ // Not ordinary: this instant will never run. The common way in is a wall clock that moved backwards (a VM
147
+ // resume, a host clock correction), which keeps producing slots behind the newest claim. No claim was taken,
148
+ // so there is nothing to record: it is a WARN where the benign duplicate is an info line.
149
+ const reason = `slot ${slot.toISOString()} is stale: ${claim.newest} was already claimed, so this instant is skipped`;
150
+ log.warn(`[schedule] ${s.name}: skipping — ${reason}`);
151
+ return reason;
152
+ },
153
+ catch: (cause) => new PortFailure(cause),
154
+ });
155
+ if (skippedReason !== undefined)
156
+ return { fired: false, skippedReason, ms: 0 };
157
+ const r = yield* runTurn(agent, s.name, routineSession(s.name), s.prompt);
158
+ // OVERLAP IS NOT FAILURE. The claim still stands — this occurrence is decided and will not be retried — but
159
+ // what decided it was the previous turn still holding `routine:<name>`, which is the policy every scheduler
160
+ // names (k8s `concurrencyPolicy: Forbid`, Temporal's `Skip`) and none of them reports as an error.
161
+ if (r.busy) {
162
+ settleClaim(stateRoot, s.name, slot, "skipped", r.ms);
163
+ return {
164
+ fired: false,
165
+ skippedReason: `the previous turn of "${s.name}" is still running — this occurrence is skipped, the next fires per cron`,
166
+ skipped: true,
175
167
  ms: r.ms,
176
- outcome: r.busy ? (kept ? "deferred" : "failed") : r.failed ? "failed" : "completed",
177
- reply: r.failed || r.busy ? undefined : r.reply,
178
- error: r.busy
179
- ? kept
180
- ? undefined
181
- : w.cron
182
- ? "occurrence skipped (session busy); the recurrence continues"
183
- : "dropped after too many busy retries"
184
- : r.failed,
185
- });
186
- }
187
- }
188
- /** Drain due wake-ups, then chain the next poll AFTER — never overlapping two drains. TOTAL: a state-IO
189
- * fault (an unreadable store) is caught + logged and the chain continues, never a crash / a silent stop
190
- * (this is `void`-scheduled, so an escaping throw would be an unhandled rejection). */
191
- async function pumpWakeups() {
192
- if (stopped)
193
- return;
194
- try {
195
- await pollWakeups();
196
- }
197
- catch (e) {
198
- log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(e)}`);
199
- }
200
- if (stopped)
201
- return;
202
- wakeupTimer = setTimeout(() => void pumpWakeups(), WAKEUP_POLL_MS);
203
- }
204
- /** Arm a timer for `at`, capped so a long wait re-checks the wall clock instead of trusting one sleep. */
205
- function arm(s, at) {
206
- if (stopped)
207
- return;
208
- const delay = Math.min(Math.max(0, at.getTime() - now().getTime()), MAX_WAIT_MS);
209
- timers.set(s.name, setTimeout(() => {
210
- if (stopped)
211
- return;
212
- if (now().getTime() >= at.getTime())
213
- void fireThenReArm(s);
214
- else
215
- arm(s, at); // woke early (the cap) — keep waiting for the real instant
216
- }, delay));
217
- }
218
- /** Fire, then arm the NEXT run computed from now — so a slow fire never double-fires the same slot and
219
- * missed slots collapse to the single catch-up already done. TOTAL, like pumpWakeups: this is
220
- * void-scheduled from a timer, so an escaping throw (a state-IO fault while claiming — an unreadable
221
- * fires.json, an unpersistable claim) would be an unhandled rejection = the WHOLE service crashing over
222
- * one skipped fire. Log it and keep the schedule armed instead; boot-time state faults still fail `start()`
223
- * loudly (an operator fixes those before serving). */
224
- async function fireThenReArm(s) {
225
- try {
226
- await fire(s);
168
+ };
227
169
  }
228
- catch (e) {
229
- // Audited too (appendRun is total, and runs.jsonl ≠ the broken state file) — a skipped fire must
230
- // show up in `schedule history`, not only in stderr.
231
- log.error(`[schedule] ${s.name}: fire failed (skipping this run, schedule stays armed): ${String(e)}`);
232
- appendRun(stateRoot, {
233
- name: s.name,
234
- session: scheduleSession(s.name),
235
- firedAt: now().toISOString(),
236
- ms: 0,
237
- outcome: "failed",
238
- error: `run skipped — fire failed: ${String(e)}`,
170
+ settleClaim(stateRoot, s.name, slot, r.failed ? "failed" : "completed", r.ms);
171
+ return { fired: true, failed: r.failed, ms: r.ms };
172
+ }).pipe(Effect.uninterruptible);
173
+ }
174
+ /** Wakes re-enter a chat session; identify the agent's own instruction and its cancellation handle. */
175
+ function wakeEnvelope(w) {
176
+ const tag = w.cron
177
+ ? `[wake-up ${w.id} fired — YOUR recurring self-scheduled turn (cron "${w.cron}"${w.tz ? ` ${w.tz}` : ""}), not a user message; unwake({ id: "${w.id}" }) stops it]`
178
+ : `[wake-up ${w.id} fired — YOUR self-scheduled turn, not a user message]`;
179
+ return `${tag} ${w.prompt}`;
180
+ }
181
+ export function createScheduler(options) {
182
+ return Effect.gen(function* () {
183
+ const clock = yield* Clock.Clock;
184
+ const fork = Effect.runForkWith(yield* Effect.context());
185
+ const { agent, stateRoot, routines, now = () => new Date(clock.currentTimeMillisUnsafe()), externalClock = false, } = options;
186
+ const loops = new Set();
187
+ let stopped = false;
188
+ const launch = (label, work) => {
189
+ fork(Effect.withFiber((fiber) => {
190
+ // Publish ownership before a synchronous invoke callback can re-enter stop().
191
+ loops.add(fiber);
192
+ return work.pipe(Effect.catchCause((cause) => Effect.sync(() => {
193
+ if (!Cause.hasInterruptsOnly(cause))
194
+ log.error(`[schedule] ${label}: stopped unexpectedly: ${String(Cause.squash(cause))}`);
195
+ })), Effect.ensuring(Effect.sync(() => {
196
+ loops.delete(fiber);
197
+ })));
198
+ }));
199
+ };
200
+ const cronLoop = (s, first) => Effect.gen(function* () {
201
+ let due = first;
202
+ while (due) {
203
+ // SLEEP, THEN LOOK AGAIN, in both directions: forward, a jumped clock is noticed a cap early
204
+ // ({@link MAX_WAIT_MS}); backward — an NTP correction, a VM restored from a snapshot — waking on the
205
+ // timer alone would fire a slot that has not arrived yet and CLAIM it, burning an occurrence that was
206
+ // still ahead.
207
+ while (now().getTime() < due.getTime()) {
208
+ yield* Effect.sleep(Math.min(due.getTime() - now().getTime(), MAX_WAIT_MS));
209
+ }
210
+ // `slot: due` is what two schedulers have in common: both compute the same cron instant, so both try to
211
+ // claim the same name and exactly one wins. Claiming `now()` would give them different names.
212
+ yield* fireRoutineOnce({ agent, stateRoot, routine: s, slot: due, now }).pipe(Effect.catchTag("PortFailure", (error) => Effect.sync(() => {
213
+ // Nothing to record: this fault happens BEFORE the claim exists (that is what keeps the slot
214
+ // unburned), so there is no claim to write an outcome into.
215
+ log.error(`[schedule] ${s.name}: fire failed (skipping this run, schedule stays armed): ${String(error.cause)}`);
216
+ })), Effect.uninterruptible);
217
+ due = s.cron === undefined ? undefined : nextRun(s.cron, s.tz, now());
218
+ }
219
+ });
220
+ const wakeOnce = Effect.gen(function* () {
221
+ const w = yield* Effect.try({
222
+ try: () => takeFirstDueWakeup(stateRoot, now()),
223
+ catch: (cause) => new PortFailure(cause),
239
224
  });
240
- }
241
- const due = nextRun(s.cron, s.tz, now());
242
- if (due)
243
- arm(s, due);
244
- }
245
- return {
246
- start() {
247
- stopped = false;
248
- // External-clock mode: no cron timers, no boot catch-up — slot delivery (including instants
249
- // that passed while this process was down) belongs to the external clock; only the wake-up
250
- // pump below runs. See SchedulerOptions.externalClock.
251
- const fires = externalClock ? {} : loadFires(stateRoot);
252
- const current = now();
253
- for (const s of externalClock ? [] : schedules) {
254
- // Anchor on the last fire (catch-up basis), or `now` on a first-ever run so a brand-new schedule
255
- // never back-fires before the process first booted.
256
- const lastFired = fires[s.name];
257
- const anchor = lastFired ? new Date(lastFired) : current;
258
- const due = nextRun(s.cron, s.tz, anchor);
259
- if (!due) {
260
- log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
261
- continue;
225
+ if (!w)
226
+ return false;
227
+ yield* Effect.acquireUseRelease(Effect.sync(beginWork), () => Effect.gen(function* () {
228
+ const label = `wake ${w.id.slice(0, 8)}`;
229
+ const r = yield* runTurn(agent, label, w.session, wakeEnvelope(w));
230
+ let kept = false;
231
+ if (r.busy && !w.cron) {
232
+ kept = yield* Effect.try({
233
+ try: () => deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS)),
234
+ catch: (cause) => new PortFailure(cause),
235
+ });
236
+ if (kept)
237
+ log.info(`[schedule] ${label}: session busy — retrying next poll`);
238
+ else
239
+ log.error(`[schedule] ${label}: dropped after too many busy retries`);
262
240
  }
263
- if (due.getTime() <= current.getTime()) {
264
- log.info(`[schedule] ${s.name}: catching up a missed run`);
265
- void fireThenReArm(s); // overdue → fire once, then arm the next
241
+ else if (r.busy && w.cron) {
242
+ // `runTurn` already said it was skipped for a busy session; this adds the one thing it cannot know —
243
+ // that a recurring wake has a next occurrence, so nothing is deferred and nothing is lost.
244
+ log.info(`[schedule] ${label}: next fires per cron`);
266
245
  }
267
- else {
268
- arm(s, due);
246
+ // A wake-up has no claim to settle (it is removed from the store before the turn starts), so its whole
247
+ // record is the log lines above and `runTurn`'s — deferred, dropped, failed, completed.
248
+ }), (done) => Effect.sync(done));
249
+ return true;
250
+ }).pipe(
251
+ // Log storage faults before a pending stop can replace the typed failure with interruption.
252
+ Effect.catchTag("PortFailure", (error) => Effect.sync(() => {
253
+ log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(error.cause)}`);
254
+ return false;
255
+ })), Effect.uninterruptible);
256
+ const wakeLoop = Effect.gen(function* () {
257
+ for (;;) {
258
+ while (yield* wakeOnce) {
259
+ // Claim only after the preceding occurrence settles.
269
260
  }
261
+ yield* Effect.sleep(WAKEUP_POLL_MS);
270
262
  }
271
- // Self-scheduled wake-ups: drain now (catch any overdue while the process was down), then chain the
272
- // next poll AFTER this drain finishes — a CHAIN, not setInterval, so a wake turn longer than the poll
273
- // interval never overlaps two drains (which would break the one-at-a-time claim→fire→claim promise).
274
- void pumpWakeups();
275
- },
276
- stop() {
277
- stopped = true;
278
- for (const t of timers.values())
279
- clearTimeout(t);
280
- timers.clear();
281
- if (wakeupTimer)
282
- clearTimeout(wakeupTimer);
283
- wakeupTimer = undefined;
284
- },
285
- };
263
+ });
264
+ return {
265
+ start() {
266
+ stopped = false;
267
+ // ONE claim read per schedule — the newest — for the two planes that must agree about it: the reconciler
268
+ // (was that fire ever reported?) and catch-up (where does the next run resume?). A read fault on it stays
269
+ // synchronous, before any timers or turns are started: a schedule whose newest claim cannot be read is not
270
+ // one to run blind, it is a boot failure. The rest of the window is history, and history does not gate a
271
+ // boot (`latestFire`).
272
+ const lastFires = new Map();
273
+ if (!externalClock) {
274
+ for (const s of routines) {
275
+ const fire = latestFire(stateRoot, s.name);
276
+ if (fire !== undefined)
277
+ lastFires.set(s.name, fire.firedAt);
278
+ markInterruptedFire(stateRoot, s.name, fire);
279
+ }
280
+ }
281
+ const current = now();
282
+ for (const s of externalClock ? [] : routines) {
283
+ if (stopped)
284
+ break;
285
+ // NO CRON, NO CLOCK. A routine without one is not unscheduled by accident — it is reached by name
286
+ // (`POST /run`, `fastagent routine run`), so there is nothing here to arm and nothing to warn about.
287
+ if (s.cron === undefined)
288
+ continue;
289
+ // FROM THE LAST FIRE, or from now when there was none. A routine that has never fired does not catch
290
+ // up the occurrence it missed while the process was down: nothing recorded that it was ever armed then,
291
+ // so "missed" is not a fact this process has. Every boot after the first resumes from the claim.
292
+ const last = lastFires.get(s.name);
293
+ const due = nextRun(s.cron, s.tz, last ? new Date(last) : current);
294
+ if (!due) {
295
+ log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
296
+ continue;
297
+ }
298
+ if (due.getTime() <= current.getTime())
299
+ log.info(`[schedule] ${s.name}: catching up a missed run`);
300
+ launch(s.name, cronLoop(s, due));
301
+ }
302
+ if (!stopped)
303
+ launch("wake-up poll", wakeLoop);
304
+ },
305
+ stop() {
306
+ stopped = true;
307
+ for (const loop of loops)
308
+ loop.interruptUnsafe();
309
+ },
310
+ };
311
+ });
286
312
  }