@aitne/daemon 0.1.3 → 0.1.6

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 (421) hide show
  1. package/dist/adapters/notification-manager.d.ts +12 -0
  2. package/dist/adapters/notification-manager.d.ts.map +1 -1
  3. package/dist/adapters/notification-manager.js +39 -1
  4. package/dist/adapters/notification-manager.js.map +1 -1
  5. package/dist/adapters/whatsapp-adapter.d.ts.map +1 -1
  6. package/dist/adapters/whatsapp-adapter.js +0 -1
  7. package/dist/adapters/whatsapp-adapter.js.map +1 -1
  8. package/dist/api/integration-route-gate.d.ts +15 -11
  9. package/dist/api/integration-route-gate.d.ts.map +1 -1
  10. package/dist/api/integration-route-gate.js +60 -23
  11. package/dist/api/integration-route-gate.js.map +1 -1
  12. package/dist/api/json-body.d.ts +22 -7
  13. package/dist/api/json-body.d.ts.map +1 -1
  14. package/dist/api/json-body.js +27 -8
  15. package/dist/api/json-body.js.map +1 -1
  16. package/dist/api/routes/agent.d.ts.map +1 -1
  17. package/dist/api/routes/agent.js +25 -0
  18. package/dist/api/routes/agent.js.map +1 -1
  19. package/dist/api/routes/backends.d.ts.map +1 -1
  20. package/dist/api/routes/backends.js +96 -1
  21. package/dist/api/routes/backends.js.map +1 -1
  22. package/dist/api/routes/books.js +1 -1
  23. package/dist/api/routes/books.js.map +1 -1
  24. package/dist/api/routes/commands.d.ts.map +1 -1
  25. package/dist/api/routes/commands.js +16 -13
  26. package/dist/api/routes/commands.js.map +1 -1
  27. package/dist/api/routes/context.d.ts.map +1 -1
  28. package/dist/api/routes/context.js +26 -3
  29. package/dist/api/routes/context.js.map +1 -1
  30. package/dist/api/routes/dashboard.d.ts.map +1 -1
  31. package/dist/api/routes/dashboard.js +103 -5
  32. package/dist/api/routes/dashboard.js.map +1 -1
  33. package/dist/api/routes/fs.d.ts +23 -0
  34. package/dist/api/routes/fs.d.ts.map +1 -0
  35. package/dist/api/routes/fs.js +156 -0
  36. package/dist/api/routes/fs.js.map +1 -0
  37. package/dist/api/routes/fs.logic.d.ts +62 -0
  38. package/dist/api/routes/fs.logic.d.ts.map +1 -0
  39. package/dist/api/routes/fs.logic.js +137 -0
  40. package/dist/api/routes/fs.logic.js.map +1 -0
  41. package/dist/api/routes/github.d.ts.map +1 -1
  42. package/dist/api/routes/github.js +38 -5
  43. package/dist/api/routes/github.js.map +1 -1
  44. package/dist/api/routes/health.d.ts.map +1 -1
  45. package/dist/api/routes/health.js +4 -2
  46. package/dist/api/routes/health.js.map +1 -1
  47. package/dist/api/routes/integrations.d.ts +35 -6
  48. package/dist/api/routes/integrations.d.ts.map +1 -1
  49. package/dist/api/routes/integrations.js +192 -15
  50. package/dist/api/routes/integrations.js.map +1 -1
  51. package/dist/api/routes/mail.d.ts.map +1 -1
  52. package/dist/api/routes/mail.js +112 -46
  53. package/dist/api/routes/mail.js.map +1 -1
  54. package/dist/api/routes/metrics.d.ts +1 -0
  55. package/dist/api/routes/metrics.d.ts.map +1 -1
  56. package/dist/api/routes/metrics.js +24 -0
  57. package/dist/api/routes/metrics.js.map +1 -1
  58. package/dist/api/routes/observations.d.ts.map +1 -1
  59. package/dist/api/routes/observations.js +696 -30
  60. package/dist/api/routes/observations.js.map +1 -1
  61. package/dist/api/routes/setup-migrate.d.ts +9 -1
  62. package/dist/api/routes/setup-migrate.d.ts.map +1 -1
  63. package/dist/api/routes/setup-migrate.js +4 -2
  64. package/dist/api/routes/setup-migrate.js.map +1 -1
  65. package/dist/api/routes/skills.d.ts +9 -1
  66. package/dist/api/routes/skills.d.ts.map +1 -1
  67. package/dist/api/routes/skills.js +77 -17
  68. package/dist/api/routes/skills.js.map +1 -1
  69. package/dist/api/routes/voice.d.ts.map +1 -1
  70. package/dist/api/routes/voice.js +62 -4
  71. package/dist/api/routes/voice.js.map +1 -1
  72. package/dist/api/routes/wiki.d.ts +4 -0
  73. package/dist/api/routes/wiki.d.ts.map +1 -0
  74. package/dist/api/routes/wiki.js +1075 -0
  75. package/dist/api/routes/wiki.js.map +1 -0
  76. package/dist/api/server.d.ts +13 -0
  77. package/dist/api/server.d.ts.map +1 -1
  78. package/dist/api/server.js +27 -1
  79. package/dist/api/server.js.map +1 -1
  80. package/dist/bootstrap/adapters.d.ts +109 -0
  81. package/dist/bootstrap/adapters.d.ts.map +1 -0
  82. package/dist/bootstrap/adapters.js +237 -0
  83. package/dist/bootstrap/adapters.js.map +1 -0
  84. package/dist/bootstrap/catchup.d.ts +23 -0
  85. package/dist/bootstrap/catchup.d.ts.map +1 -0
  86. package/dist/bootstrap/catchup.js +124 -0
  87. package/dist/bootstrap/catchup.js.map +1 -0
  88. package/dist/bootstrap/schedule-helpers.d.ts +18 -0
  89. package/dist/bootstrap/schedule-helpers.d.ts.map +1 -0
  90. package/dist/bootstrap/schedule-helpers.js +96 -0
  91. package/dist/bootstrap/schedule-helpers.js.map +1 -0
  92. package/dist/bootstrap/services.d.ts +60 -0
  93. package/dist/bootstrap/services.d.ts.map +1 -0
  94. package/dist/bootstrap/services.js +209 -0
  95. package/dist/bootstrap/services.js.map +1 -0
  96. package/dist/config.d.ts.map +1 -1
  97. package/dist/config.js +26 -0
  98. package/dist/config.js.map +1 -1
  99. package/dist/core/agent-core.d.ts +25 -0
  100. package/dist/core/agent-core.d.ts.map +1 -1
  101. package/dist/core/agent-core.js.map +1 -1
  102. package/dist/core/backends/backend-router.d.ts +28 -1
  103. package/dist/core/backends/backend-router.d.ts.map +1 -1
  104. package/dist/core/backends/backend-router.js +58 -4
  105. package/dist/core/backends/backend-router.js.map +1 -1
  106. package/dist/core/backends/claude-auth.d.ts +70 -0
  107. package/dist/core/backends/claude-auth.d.ts.map +1 -0
  108. package/dist/core/backends/claude-auth.js +198 -0
  109. package/dist/core/backends/claude-auth.js.map +1 -0
  110. package/dist/core/backends/claude-code-core.d.ts +47 -119
  111. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  112. package/dist/core/backends/claude-code-core.js +166 -1561
  113. package/dist/core/backends/claude-code-core.js.map +1 -1
  114. package/dist/core/backends/claude-delegated.d.ts +86 -0
  115. package/dist/core/backends/claude-delegated.d.ts.map +1 -0
  116. package/dist/core/backends/claude-delegated.js +801 -0
  117. package/dist/core/backends/claude-delegated.js.map +1 -0
  118. package/dist/core/backends/claude-errors.d.ts +39 -0
  119. package/dist/core/backends/claude-errors.d.ts.map +1 -0
  120. package/dist/core/backends/claude-errors.js +71 -0
  121. package/dist/core/backends/claude-errors.js.map +1 -0
  122. package/dist/core/backends/claude-probe.d.ts +103 -0
  123. package/dist/core/backends/claude-probe.d.ts.map +1 -0
  124. package/dist/core/backends/claude-probe.js +336 -0
  125. package/dist/core/backends/claude-probe.js.map +1 -0
  126. package/dist/core/backends/claude-tool-collection.d.ts +135 -0
  127. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -0
  128. package/dist/core/backends/claude-tool-collection.js +1093 -0
  129. package/dist/core/backends/claude-tool-collection.js.map +1 -0
  130. package/dist/core/backends/codex-core.d.ts.map +1 -1
  131. package/dist/core/backends/codex-core.js +36 -0
  132. package/dist/core/backends/codex-core.js.map +1 -1
  133. package/dist/core/backends/gemini-cli-core.d.ts +45 -5
  134. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  135. package/dist/core/backends/gemini-cli-core.js +146 -36
  136. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  137. package/dist/core/backends/plan-presets.d.ts +3 -1
  138. package/dist/core/backends/plan-presets.d.ts.map +1 -1
  139. package/dist/core/backends/plan-presets.js +42 -2
  140. package/dist/core/backends/plan-presets.js.map +1 -1
  141. package/dist/core/backends/prompt-utils.d.ts +1 -0
  142. package/dist/core/backends/prompt-utils.d.ts.map +1 -1
  143. package/dist/core/backends/prompt-utils.js +60 -3
  144. package/dist/core/backends/prompt-utils.js.map +1 -1
  145. package/dist/core/bang-commands/commands-help.d.ts +5 -0
  146. package/dist/core/bang-commands/commands-help.d.ts.map +1 -0
  147. package/dist/core/bang-commands/commands-help.js +69 -0
  148. package/dist/core/bang-commands/commands-help.js.map +1 -0
  149. package/dist/core/bang-commands/commands-wiki.d.ts +75 -0
  150. package/dist/core/bang-commands/commands-wiki.d.ts.map +1 -0
  151. package/dist/core/bang-commands/commands-wiki.js +574 -0
  152. package/dist/core/bang-commands/commands-wiki.js.map +1 -0
  153. package/dist/core/bang-commands/index.d.ts +4 -2
  154. package/dist/core/bang-commands/index.d.ts.map +1 -1
  155. package/dist/core/bang-commands/index.js +15 -1
  156. package/dist/core/bang-commands/index.js.map +1 -1
  157. package/dist/core/bang-commands/registry.d.ts +47 -4
  158. package/dist/core/bang-commands/registry.d.ts.map +1 -1
  159. package/dist/core/bang-commands/registry.js +85 -15
  160. package/dist/core/bang-commands/registry.js.map +1 -1
  161. package/dist/core/context-builder.d.ts +53 -12
  162. package/dist/core/context-builder.d.ts.map +1 -1
  163. package/dist/core/context-builder.js +240 -92
  164. package/dist/core/context-builder.js.map +1 -1
  165. package/dist/core/daemon-api-cli.d.ts.map +1 -1
  166. package/dist/core/daemon-api-cli.js +50 -2
  167. package/dist/core/daemon-api-cli.js.map +1 -1
  168. package/dist/core/dispatcher-date-utils.d.ts +49 -0
  169. package/dist/core/dispatcher-date-utils.d.ts.map +1 -0
  170. package/dist/core/dispatcher-date-utils.js +132 -0
  171. package/dist/core/dispatcher-date-utils.js.map +1 -0
  172. package/dist/core/dispatcher-error-handling.d.ts +159 -0
  173. package/dist/core/dispatcher-error-handling.d.ts.map +1 -0
  174. package/dist/core/dispatcher-error-handling.js +393 -0
  175. package/dist/core/dispatcher-error-handling.js.map +1 -0
  176. package/dist/core/dispatcher-hourly-check.d.ts +150 -0
  177. package/dist/core/dispatcher-hourly-check.d.ts.map +1 -0
  178. package/dist/core/dispatcher-hourly-check.js +665 -0
  179. package/dist/core/dispatcher-hourly-check.js.map +1 -0
  180. package/dist/core/dispatcher-message-handler.d.ts +170 -0
  181. package/dist/core/dispatcher-message-handler.d.ts.map +1 -0
  182. package/dist/core/dispatcher-message-handler.js +1064 -0
  183. package/dist/core/dispatcher-message-handler.js.map +1 -0
  184. package/dist/core/dispatcher-morning-routine.d.ts +169 -0
  185. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -0
  186. package/dist/core/dispatcher-morning-routine.js +449 -0
  187. package/dist/core/dispatcher-morning-routine.js.map +1 -0
  188. package/dist/core/dispatcher-prompt.d.ts +107 -0
  189. package/dist/core/dispatcher-prompt.d.ts.map +1 -0
  190. package/dist/core/dispatcher-prompt.js +227 -0
  191. package/dist/core/dispatcher-prompt.js.map +1 -0
  192. package/dist/core/dispatcher-repository-helpers.d.ts +39 -0
  193. package/dist/core/dispatcher-repository-helpers.d.ts.map +1 -0
  194. package/dist/core/dispatcher-repository-helpers.js +86 -0
  195. package/dist/core/dispatcher-repository-helpers.js.map +1 -0
  196. package/dist/core/dispatcher-result-processor.d.ts +168 -0
  197. package/dist/core/dispatcher-result-processor.d.ts.map +1 -0
  198. package/dist/core/dispatcher-result-processor.js +533 -0
  199. package/dist/core/dispatcher-result-processor.js.map +1 -0
  200. package/dist/core/dispatcher-scheduled-tasks.d.ts +406 -0
  201. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -0
  202. package/dist/core/dispatcher-scheduled-tasks.js +1032 -0
  203. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -0
  204. package/dist/core/dispatcher-types.d.ts +411 -0
  205. package/dist/core/dispatcher-types.d.ts.map +1 -0
  206. package/dist/core/dispatcher-types.js +106 -0
  207. package/dist/core/dispatcher-types.js.map +1 -0
  208. package/dist/core/dispatcher.d.ts +122 -610
  209. package/dist/core/dispatcher.d.ts.map +1 -1
  210. package/dist/core/dispatcher.js +365 -3521
  211. package/dist/core/dispatcher.js.map +1 -1
  212. package/dist/core/integration-health.d.ts +18 -10
  213. package/dist/core/integration-health.d.ts.map +1 -1
  214. package/dist/core/integration-health.js +31 -1
  215. package/dist/core/integration-health.js.map +1 -1
  216. package/dist/core/integration-lifecycle.d.ts +65 -0
  217. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  218. package/dist/core/integration-lifecycle.js +163 -14
  219. package/dist/core/integration-lifecycle.js.map +1 -1
  220. package/dist/core/integration-main-backend.d.ts +40 -0
  221. package/dist/core/integration-main-backend.d.ts.map +1 -1
  222. package/dist/core/integration-main-backend.js +89 -2
  223. package/dist/core/integration-main-backend.js.map +1 -1
  224. package/dist/core/management-md.d.ts +51 -17
  225. package/dist/core/management-md.d.ts.map +1 -1
  226. package/dist/core/management-md.js +233 -56
  227. package/dist/core/management-md.js.map +1 -1
  228. package/dist/core/metrics.d.ts +127 -0
  229. package/dist/core/metrics.d.ts.map +1 -1
  230. package/dist/core/metrics.js +256 -1
  231. package/dist/core/metrics.js.map +1 -1
  232. package/dist/core/output-language-policy.d.ts +74 -0
  233. package/dist/core/output-language-policy.d.ts.map +1 -0
  234. package/dist/core/output-language-policy.js +194 -0
  235. package/dist/core/output-language-policy.js.map +1 -0
  236. package/dist/core/prompts.d.ts +3 -1
  237. package/dist/core/prompts.d.ts.map +1 -1
  238. package/dist/core/prompts.js +161 -3
  239. package/dist/core/prompts.js.map +1 -1
  240. package/dist/core/repository-management-docs.d.ts +24 -0
  241. package/dist/core/repository-management-docs.d.ts.map +1 -1
  242. package/dist/core/repository-management-docs.js +210 -26
  243. package/dist/core/repository-management-docs.js.map +1 -1
  244. package/dist/core/roadmap-validate.js +13 -1
  245. package/dist/core/roadmap-validate.js.map +1 -1
  246. package/dist/core/routine-acquisition-plan.d.ts +182 -0
  247. package/dist/core/routine-acquisition-plan.d.ts.map +1 -0
  248. package/dist/core/routine-acquisition-plan.js +367 -0
  249. package/dist/core/routine-acquisition-plan.js.map +1 -0
  250. package/dist/core/routine-fetch-window-retry.d.ts +109 -0
  251. package/dist/core/routine-fetch-window-retry.d.ts.map +1 -0
  252. package/dist/core/routine-fetch-window-retry.js +210 -0
  253. package/dist/core/routine-fetch-window-retry.js.map +1 -0
  254. package/dist/core/routine-fetch-window-runner.d.ts +427 -0
  255. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -0
  256. package/dist/core/routine-fetch-window-runner.js +1591 -0
  257. package/dist/core/routine-fetch-window-runner.js.map +1 -0
  258. package/dist/core/routine-windows.d.ts +171 -0
  259. package/dist/core/routine-windows.d.ts.map +1 -0
  260. package/dist/core/routine-windows.js +377 -0
  261. package/dist/core/routine-windows.js.map +1 -0
  262. package/dist/core/scheduler.d.ts +50 -2
  263. package/dist/core/scheduler.d.ts.map +1 -1
  264. package/dist/core/scheduler.js +88 -7
  265. package/dist/core/scheduler.js.map +1 -1
  266. package/dist/core/skill-curation/declarations.d.ts.map +1 -1
  267. package/dist/core/skill-curation/declarations.js +11 -12
  268. package/dist/core/skill-curation/declarations.js.map +1 -1
  269. package/dist/core/skill-source-paths.d.ts +14 -0
  270. package/dist/core/skill-source-paths.d.ts.map +1 -0
  271. package/dist/core/skill-source-paths.js +82 -0
  272. package/dist/core/skill-source-paths.js.map +1 -0
  273. package/dist/core/skills-compiler.d.ts +29 -0
  274. package/dist/core/skills-compiler.d.ts.map +1 -1
  275. package/dist/core/skills-compiler.js +166 -30
  276. package/dist/core/skills-compiler.js.map +1 -1
  277. package/dist/core/skills-manifest.d.ts.map +1 -1
  278. package/dist/core/skills-manifest.js +72 -0
  279. package/dist/core/skills-manifest.js.map +1 -1
  280. package/dist/core/system-reset.d.ts +25 -0
  281. package/dist/core/system-reset.d.ts.map +1 -1
  282. package/dist/core/system-reset.js +72 -2
  283. package/dist/core/system-reset.js.map +1 -1
  284. package/dist/core/wiki/approval-queue.d.ts +31 -0
  285. package/dist/core/wiki/approval-queue.d.ts.map +1 -0
  286. package/dist/core/wiki/approval-queue.js +44 -0
  287. package/dist/core/wiki/approval-queue.js.map +1 -0
  288. package/dist/core/wiki/bridge.d.ts +74 -0
  289. package/dist/core/wiki/bridge.d.ts.map +1 -0
  290. package/dist/core/wiki/bridge.js +405 -0
  291. package/dist/core/wiki/bridge.js.map +1 -0
  292. package/dist/core/wiki/compile-lock.d.ts +42 -0
  293. package/dist/core/wiki/compile-lock.d.ts.map +1 -0
  294. package/dist/core/wiki/compile-lock.js +55 -0
  295. package/dist/core/wiki/compile-lock.js.map +1 -0
  296. package/dist/core/wiki/compile-preview.d.ts +8 -0
  297. package/dist/core/wiki/compile-preview.d.ts.map +1 -0
  298. package/dist/core/wiki/compile-preview.js +200 -0
  299. package/dist/core/wiki/compile-preview.js.map +1 -0
  300. package/dist/core/wiki/cost-estimate.d.ts +30 -0
  301. package/dist/core/wiki/cost-estimate.d.ts.map +1 -0
  302. package/dist/core/wiki/cost-estimate.js +243 -0
  303. package/dist/core/wiki/cost-estimate.js.map +1 -0
  304. package/dist/core/wiki/dispatcher.d.ts +48 -0
  305. package/dist/core/wiki/dispatcher.d.ts.map +1 -0
  306. package/dist/core/wiki/dispatcher.js +92 -0
  307. package/dist/core/wiki/dispatcher.js.map +1 -0
  308. package/dist/core/wiki/git-precompile.d.ts +86 -0
  309. package/dist/core/wiki/git-precompile.d.ts.map +1 -0
  310. package/dist/core/wiki/git-precompile.js +96 -0
  311. package/dist/core/wiki/git-precompile.js.map +1 -0
  312. package/dist/core/wiki/import-migrate.d.ts +38 -0
  313. package/dist/core/wiki/import-migrate.d.ts.map +1 -0
  314. package/dist/core/wiki/import-migrate.js +310 -0
  315. package/dist/core/wiki/import-migrate.js.map +1 -0
  316. package/dist/core/wiki/import-probe.d.ts +76 -0
  317. package/dist/core/wiki/import-probe.d.ts.map +1 -0
  318. package/dist/core/wiki/import-probe.js +245 -0
  319. package/dist/core/wiki/import-probe.js.map +1 -0
  320. package/dist/core/wiki/index-cache.d.ts +39 -0
  321. package/dist/core/wiki/index-cache.d.ts.map +1 -0
  322. package/dist/core/wiki/index-cache.js +152 -0
  323. package/dist/core/wiki/index-cache.js.map +1 -0
  324. package/dist/core/wiki/multi-url-dispatch.d.ts +52 -0
  325. package/dist/core/wiki/multi-url-dispatch.d.ts.map +1 -0
  326. package/dist/core/wiki/multi-url-dispatch.js +72 -0
  327. package/dist/core/wiki/multi-url-dispatch.js.map +1 -0
  328. package/dist/core/wiki/wiki-fts.d.ts +75 -0
  329. package/dist/core/wiki/wiki-fts.d.ts.map +1 -0
  330. package/dist/core/wiki/wiki-fts.js +265 -0
  331. package/dist/core/wiki/wiki-fts.js.map +1 -0
  332. package/dist/core/wiki/workspaces.d.ts +101 -0
  333. package/dist/core/wiki/workspaces.d.ts.map +1 -0
  334. package/dist/core/wiki/workspaces.js +352 -0
  335. package/dist/core/wiki/workspaces.js.map +1 -0
  336. package/dist/core/wiki/write-strategy.d.ts +70 -0
  337. package/dist/core/wiki/write-strategy.d.ts.map +1 -0
  338. package/dist/core/wiki/write-strategy.js +112 -0
  339. package/dist/core/wiki/write-strategy.js.map +1 -0
  340. package/dist/core/workdir.d.ts +8 -1
  341. package/dist/core/workdir.d.ts.map +1 -1
  342. package/dist/core/workdir.js +4 -1
  343. package/dist/core/workdir.js.map +1 -1
  344. package/dist/db/observations.d.ts +45 -2
  345. package/dist/db/observations.d.ts.map +1 -1
  346. package/dist/db/observations.js +112 -14
  347. package/dist/db/observations.js.map +1 -1
  348. package/dist/db/schema.d.ts.map +1 -1
  349. package/dist/db/schema.js +135 -25
  350. package/dist/db/schema.js.map +1 -1
  351. package/dist/db/wiki-store.d.ts +3 -0
  352. package/dist/db/wiki-store.d.ts.map +1 -0
  353. package/dist/db/wiki-store.js +7 -0
  354. package/dist/db/wiki-store.js.map +1 -0
  355. package/dist/index.js +159 -610
  356. package/dist/index.js.map +1 -1
  357. package/dist/messaging/url-extract.d.ts +8 -0
  358. package/dist/messaging/url-extract.d.ts.map +1 -0
  359. package/dist/messaging/url-extract.js +41 -0
  360. package/dist/messaging/url-extract.js.map +1 -0
  361. package/dist/observers/delegated-sync-worker.d.ts +52 -1
  362. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  363. package/dist/observers/delegated-sync-worker.js +75 -18
  364. package/dist/observers/delegated-sync-worker.js.map +1 -1
  365. package/dist/observers/imminent-event-scheduler.d.ts +20 -7
  366. package/dist/observers/imminent-event-scheduler.d.ts.map +1 -1
  367. package/dist/observers/imminent-event-scheduler.js +134 -29
  368. package/dist/observers/imminent-event-scheduler.js.map +1 -1
  369. package/dist/observers/mail-poller.d.ts +12 -5
  370. package/dist/observers/mail-poller.d.ts.map +1 -1
  371. package/dist/observers/mail-poller.js +36 -14
  372. package/dist/observers/mail-poller.js.map +1 -1
  373. package/dist/observers/manager.d.ts +37 -5
  374. package/dist/observers/manager.d.ts.map +1 -1
  375. package/dist/observers/manager.js +28 -10
  376. package/dist/observers/manager.js.map +1 -1
  377. package/dist/safety/always-disallowed.d.ts +65 -0
  378. package/dist/safety/always-disallowed.d.ts.map +1 -1
  379. package/dist/safety/always-disallowed.js +106 -10
  380. package/dist/safety/always-disallowed.js.map +1 -1
  381. package/dist/safety/audit.d.ts +46 -1
  382. package/dist/safety/audit.d.ts.map +1 -1
  383. package/dist/safety/audit.js +79 -16
  384. package/dist/safety/audit.js.map +1 -1
  385. package/dist/safety/risk-classifier.d.ts.map +1 -1
  386. package/dist/safety/risk-classifier.js +29 -0
  387. package/dist/safety/risk-classifier.js.map +1 -1
  388. package/dist/services/delegated-backend-invoker.d.ts +1 -51
  389. package/dist/services/delegated-backend-invoker.d.ts.map +1 -1
  390. package/dist/services/delegated-backend-invoker.js +41 -480
  391. package/dist/services/delegated-backend-invoker.js.map +1 -1
  392. package/dist/services/delegated-invoker-audit.d.ts +94 -0
  393. package/dist/services/delegated-invoker-audit.d.ts.map +1 -0
  394. package/dist/services/delegated-invoker-audit.js +238 -0
  395. package/dist/services/delegated-invoker-audit.js.map +1 -0
  396. package/dist/services/delegated-invoker-cache-hits.d.ts +34 -0
  397. package/dist/services/delegated-invoker-cache-hits.d.ts.map +1 -0
  398. package/dist/services/delegated-invoker-cache-hits.js +104 -0
  399. package/dist/services/delegated-invoker-cache-hits.js.map +1 -0
  400. package/dist/services/delegated-invoker-janitors.d.ts +28 -0
  401. package/dist/services/delegated-invoker-janitors.d.ts.map +1 -0
  402. package/dist/services/delegated-invoker-janitors.js +104 -0
  403. package/dist/services/delegated-invoker-janitors.js.map +1 -0
  404. package/dist/services/delegated-invoker-utils.d.ts +42 -0
  405. package/dist/services/delegated-invoker-utils.d.ts.map +1 -0
  406. package/dist/services/delegated-invoker-utils.js +100 -0
  407. package/dist/services/delegated-invoker-utils.js.map +1 -0
  408. package/dist/services/delegated-task-runtime.d.ts +1 -1
  409. package/dist/services/delegated-task-runtime.js +1 -1
  410. package/dist/services/integrations/snapshot-partitions.d.ts +5 -0
  411. package/dist/services/integrations/snapshot-partitions.d.ts.map +1 -1
  412. package/dist/services/integrations/snapshot-partitions.js +12 -0
  413. package/dist/services/integrations/snapshot-partitions.js.map +1 -1
  414. package/dist/services/voice/transcriber-impl.d.ts.map +1 -1
  415. package/dist/services/voice/transcriber-impl.js +7 -8
  416. package/dist/services/voice/transcriber-impl.js.map +1 -1
  417. package/dist/settings/runtime-settings.d.ts +12 -1
  418. package/dist/settings/runtime-settings.d.ts.map +1 -1
  419. package/dist/settings/runtime-settings.js +59 -1
  420. package/dist/settings/runtime-settings.js.map +1 -1
  421. package/package.json +2 -2
@@ -0,0 +1,1591 @@
1
+ /**
2
+ * `RoutineFetchWindowRunner` — pre-pass fan-out coordinator.
3
+ *
4
+ * ROUTINE_DATA_ACQUISITION_DESIGN.md §6.1.1 + PRE_PASS_FAN_OUT_DESIGN.md
5
+ * — every routine dispatcher (morning_routine, today_refresh,
6
+ * hourly_check, evening / weekly / monthly review) calls this runner
7
+ * immediately before dispatching the parent session. The runner:
8
+ *
9
+ * 1. Reads the per-routine plan from `ROUTINE_WINDOWS` and the current
10
+ * integration state, fans rows out per-account where applicable,
11
+ * resolves each row's predicate (`direct` / `delegated-same` /
12
+ * `delegated-cross` / `native` / skip), and partitions the plan
13
+ * by `IntegrationKey` via `splitAcquisitionPlanByIntegration`.
14
+ * 2. Spawns one lite-tier `routine.fetch_window` sub-session per
15
+ * integration in parallel (bounded by `prePassFanOutConcurrency`).
16
+ * Each sub-session sees exactly one partial — the
17
+ * `{integration_partial}` placeholder in the
18
+ * `routine.fetch_window` task-flow is replaced with the body of
19
+ * `_partials/<integration prePassPartial>` so the lite-tier model
20
+ * never has to disambiguate cross-API argument names. Each
21
+ * sub-session runs through an independent retry loop bounded by
22
+ * `prePassMaxAttemptsPerIntegration`, `prePassBackoffMs`, and the
23
+ * per-integration / per-routine USD budget caps.
24
+ * 3. Merges the sub-reports into a single `FetchReport` (additive
25
+ * `<integration>` children on the `<fetch_report>` XML block) and
26
+ * returns it plus the rendered block.
27
+ * 4. The caller grafts the block into the **parent** routine event's
28
+ * `event.data.fetchReportBlock`. ContextBuilder injects it verbatim
29
+ * into the parent session's prompt (mirrors the `<gate_decision>`
30
+ * pattern used by hourly_check Stage 3).
31
+ *
32
+ * Failure-mode contract (PRE_PASS_FAN_OUT_DESIGN.md §5):
33
+ *
34
+ * - **No applicable rows** (routine has no windows in `ROUTINE_WINDOWS`,
35
+ * every integration is disabled, every account list is empty) — the
36
+ * runner returns an empty `<fetch_report>` with `status="skipped"` and
37
+ * `fetched=posted=duplicates=0`. The parent routine still runs; the
38
+ * block is informational only.
39
+ * - **Pre-pass session errors** (binding resolve fails, agent throws,
40
+ * JSON parse fails) — recorded per-attempt; the retry loop fires up
41
+ * to `maxAttempts` before giving up. Final per-integration status
42
+ * surfaces in `<integration status="failed">`. Pre-pass cost gains
43
+ * are forfeit for that integration; siblings are unaffected.
44
+ * Throwing here would otherwise propagate up and abort the parent
45
+ * routine — the opposite of P3 ("Lite for Fetch, Medium for Decide").
46
+ * - **Partial success** — the report's `errors` array carries per-row
47
+ * failures (`no-surface`, `fetch-failed`, `budget-exhausted`,
48
+ * `budget-cap`, `global-budget-cap`). The block surfaces them so the
49
+ * parent prompt can decide whether to treat its observations view as
50
+ * complete.
51
+ */
52
+ import { EventPriority, INTEGRATION_KEYS, createEvent, getAgentDayDateStr, getIntegrationDescriptor, isRoutineEvent, } from "@aitne/shared";
53
+ import { readIntegrations } from "../db/integrations-store.js";
54
+ import { renderPartialForFanOut } from "./prompts.js";
55
+ import { ROUTINE_WINDOWS, routineHasWindows, } from "./routine-windows.js";
56
+ import { buildAcquisitionTimestamps, splitAcquisitionPlanByIntegration, } from "./routine-acquisition-plan.js";
57
+ import { RETRY_REASONS, buildPriorAttemptHintBlock, defaultRetryDecision, } from "./routine-fetch-window-retry.js";
58
+ import { createLogger } from "../logging.js";
59
+ const logger = createLogger("routine-fetch-window-runner");
60
+ // ── Module helpers ────────────────────────────────────────────────────────
61
+ /** The ProcessKey + event type the pre-pass session always runs under. */
62
+ const FETCH_WINDOW_PROCESS_KEY = "routine.fetch_window";
63
+ const FETCH_WINDOW_EVENT_TYPE = "routine.fetch_window";
64
+ /**
65
+ * PRE_PASS_FAN_OUT_DESIGN.md §4.2 — the single placeholder the
66
+ * `routine.fetch_window.md` task-flow carries in place of inline
67
+ * integration partials. The runner substitutes this with the
68
+ * integration-specific partial body loaded via
69
+ * `renderPartialForFanOut`. Kept as a constant so the task-flow file
70
+ * and the substitution call cannot drift apart.
71
+ */
72
+ const FETCH_WINDOW_INTEGRATION_PARTIAL_PLACEHOLDER = "{integration_partial}";
73
+ /**
74
+ * Daemon REST surfaces the pre-pass partials may target. Curl prefixes
75
+ * are constructed with the configured `apiPort` at dispatch time so a
76
+ * non-default port survives the clamp. Everything OTHER than these
77
+ * prefixes is denied — the pre-pass cannot reach `/api/notify`,
78
+ * `/api/context/*`, `/api/agent/*`, etc., even though Bash(curl *) is
79
+ * the project default. This is the daemon-side enforcement that backs
80
+ * the agent profile's "no notify, no context writes" guardrails (P3:
81
+ * Lite for Fetch — the pre-pass has zero business making decisions).
82
+ *
83
+ * Each pattern uses a wildcard `*` between `curl` and the URL so the
84
+ * SDK's glob matcher accepts both flag orderings the Haiku fetcher
85
+ * actually emits:
86
+ *
87
+ * - `curl http://localhost:.../api/observations -X POST -d @-` (URL first)
88
+ * - `curl -X POST -H 'Content-Type: …' http://localhost:.../api/observations -d @-`
89
+ * (flags first)
90
+ *
91
+ * The original prefix-anchored form (`Bash(curl http://localhost:.../api/observations*)`)
92
+ * silently denied the flags-first invocation, manifesting as `posted=0,
93
+ * fetched=N` reports — Haiku fetched via MCP, then could not POST a single
94
+ * observation. The curl PreToolUse hook in `claude-tool-collection.ts`
95
+ * remains the host/port/exfil chokepoint; this clamp now restricts only
96
+ * the daemon-API namespace, which is what we actually need.
97
+ *
98
+ * `jq *` stays allowed because direct-mode partials pipe curl output
99
+ * through jq for compact projection before posting to /api/observations.
100
+ *
101
+ * The clamp is Claude-only — Codex/Gemini have no per-spawn allowedTools
102
+ * surface today (CLAUDE.md acknowledges the gap). The
103
+ * `process_backend_config` envelope (`max_turns=20`, `max_budget_usd=0.20`)
104
+ * remains the floor on those backends.
105
+ */
106
+ function buildPrePassDaemonRestPatterns(apiPort) {
107
+ const root = `http://localhost:${apiPort}/api`;
108
+ return [
109
+ // Observations — the only write surface the pre-pass touches.
110
+ // Catches both POST /api/observations and GET /api/observations*.
111
+ `Bash(curl *${root}/observations*)`,
112
+ // Direct-mode mail / calendar / notion reads.
113
+ `Bash(curl *${root}/mail/*)`,
114
+ `Bash(curl *${root}/calendar/*)`,
115
+ `Bash(curl *${root}/notion/*)`,
116
+ // delegated-cross proxy. Only Gmail / Google Calendar / Notion
117
+ // expose this; user-managed Outlook has no proxy and the runner
118
+ // collapses cross-backend bindings to delegated-same per
119
+ // routine-acquisition-plan.ts:resolveFetchMode.
120
+ `Bash(curl *${root}/integrations/*)`,
121
+ // Compact-projection helper used by the partials.
122
+ "Bash(jq *)",
123
+ ];
124
+ }
125
+ /**
126
+ * Project the active integrations into the per-backend MCP tool names
127
+ * the pre-pass needs. Includes both delegated-same bindings (where the
128
+ * connector is registered through the daemon's Claude SDK) AND native
129
+ * bindings (where the connector is loaded by the user but the same
130
+ * descriptor declares which capability tool names exist). User-managed
131
+ * descriptors (`backendConnectors[backend]` undefined for Outlook) are
132
+ * skipped — those rows surface as `no-surface` errors per the partial
133
+ * contract, which is the documented behaviour.
134
+ *
135
+ * Cross-backend delegated bindings contribute zero MCP tools because
136
+ * the partial reaches them through `/api/integrations/<key>/exec`, not
137
+ * via the session backend's MCP namespace.
138
+ */
139
+ function collectIntegrationToolsForBackend(integrations, backend) {
140
+ const out = new Set();
141
+ for (const key of INTEGRATION_KEYS) {
142
+ const state = integrations[key];
143
+ if (!state)
144
+ continue;
145
+ let active = false;
146
+ if (state.mode === "delegated" && state.delegatedBackend === backend) {
147
+ active = true;
148
+ }
149
+ else if (state.mode === "native" && state.nativeBackend === backend) {
150
+ active = true;
151
+ }
152
+ if (!active)
153
+ continue;
154
+ const connector = getIntegrationDescriptor(key).backendConnectors[backend];
155
+ if (!connector)
156
+ continue; // user-managed (no descriptor connector for this backend)
157
+ for (const toolNames of Object.values(connector.capabilityTools)) {
158
+ for (const toolName of toolNames) {
159
+ out.add(connector.toolNamespace + toolName);
160
+ }
161
+ }
162
+ }
163
+ return Array.from(out);
164
+ }
165
+ /**
166
+ * Compose the per-execute `allowedToolsOverride` for the pre-pass. The
167
+ * override REPLACES the SDK's default allowlist (no union per
168
+ * claude-code-core.ts:437) so the list must be exhaustive for every
169
+ * surface the partials use under any (integration, mode) cell. Mode
170
+ * coverage:
171
+ *
172
+ * - `direct`: daemon REST → curl prefix.
173
+ * - `delegated-same`: session backend MCP → integration tool name.
174
+ * - `delegated-cross`: daemon delegation proxy → curl prefix
175
+ * (`/api/integrations/<key>/exec`).
176
+ * - `native` (descriptor-bound): session backend MCP → integration
177
+ * tool name.
178
+ * - `native` (user-managed) / no-surface: nothing in the override —
179
+ * the partial records `no-surface` and the runner's report carries
180
+ * the gap forward to the parent routine.
181
+ *
182
+ * `ToolSearch` is appended for Claude sessions whenever at least one
183
+ * descriptor-bound MCP tool is present. Claude Code 2.1+ defers large
184
+ * MCP tool manifests (`mcp__claude_ai_Gmail__*`,
185
+ * `mcp__claude_ai_Google_Calendar__*`, `mcp__claude_ai_Notion__*`, …)
186
+ * behind `ToolSearch`, so the model must call `ToolSearch` to load a
187
+ * deferred tool's schema before it can be invoked. Without `ToolSearch`
188
+ * allowed, the Haiku fetcher emits a denied ToolSearch call on its
189
+ * first turn, gives up, and returns text with no JSON — the parent
190
+ * routine then sees `<fetch_report status="failed" reason="no-json-object">`.
191
+ * Mirrors the same workaround in `claude-delegated.ts` (delegated proxy
192
+ * `allowedTools: [toolName, "ToolSearch"]`). Codex / Gemini have no
193
+ * per-spawn allowedTools surface today and ignore the override entirely
194
+ * (CLAUDE.md acknowledges the gap), so the `ToolSearch` widening is
195
+ * gated on `sessionBackend === "claude"` to keep the list minimal for
196
+ * other backends.
197
+ *
198
+ * Exported for unit testing — the runner consumes it via
199
+ * `composePrePassAllowedTools` at dispatch time.
200
+ */
201
+ export function composePrePassAllowedTools(apiPort, integrations, sessionBackend) {
202
+ const integrationTools = collectIntegrationToolsForBackend(integrations, sessionBackend);
203
+ const needsDeferredDiscovery = sessionBackend === "claude" && integrationTools.length > 0;
204
+ return [
205
+ ...buildPrePassDaemonRestPatterns(apiPort),
206
+ ...integrationTools,
207
+ ...(needsDeferredDiscovery ? ["ToolSearch"] : []),
208
+ ];
209
+ }
210
+ /**
211
+ * Map a `MailAccount.kind` onto the integration key the registry uses
212
+ * for routing. Today: `gmail` → `gmail`, `outlook` → `outlook_mail`.
213
+ * Yahoo / iCloud / IMAP accounts are not tied to a routed integration
214
+ * and therefore do not participate in the pre-pass fan-out today.
215
+ */
216
+ function mailAccountIntegrationKey(account) {
217
+ switch (account.kind) {
218
+ case "gmail":
219
+ return "gmail";
220
+ case "outlook":
221
+ return "outlook_mail";
222
+ default:
223
+ return null;
224
+ }
225
+ }
226
+ /**
227
+ * Derive the canonical `RoutineWindowKey` from a routine event. The
228
+ * caller's intent is conveyed by `event.type` (always `routine.<name>`),
229
+ * with `RoutineEvent.routine` carrying the same suffix without the
230
+ * `routine.` prefix. Returns null for routines outside the catalog so
231
+ * the caller can short-circuit before touching plan assembly.
232
+ */
233
+ export function routineWindowKeyFromEvent(event) {
234
+ if (!isRoutineEvent(event))
235
+ return null;
236
+ const candidate = `routine.${event.routine}`;
237
+ return (ROUTINE_WINDOWS[candidate] !== undefined
238
+ ? candidate
239
+ : null);
240
+ }
241
+ /**
242
+ * Walk `text` and return every balanced `{...}` slice (top-level objects
243
+ * only; nested braces are honoured). Strings are tracked so brace
244
+ * characters inside `"..."` literals don't unbalance the scan. Used by
245
+ * `parseFetchWindowOutput` to pick the LAST top-level object on stdout —
246
+ * agents occasionally emit a think-aloud line carrying a JSON snippet
247
+ * before the verdict, and the fetcher's contract is "the last
248
+ * top-level JSON object wins."
249
+ *
250
+ * Exported for direct unit testing; the runner consumes it via
251
+ * `parseFetchWindowOutput`.
252
+ */
253
+ export function extractBalancedJsonObjects(text) {
254
+ const out = [];
255
+ let depth = 0;
256
+ let start = -1;
257
+ let inString = false;
258
+ let escape = false;
259
+ for (let i = 0; i < text.length; i++) {
260
+ const ch = text[i];
261
+ if (inString) {
262
+ if (escape) {
263
+ escape = false;
264
+ }
265
+ else if (ch === "\\") {
266
+ escape = true;
267
+ }
268
+ else if (ch === '"') {
269
+ inString = false;
270
+ }
271
+ continue;
272
+ }
273
+ if (ch === '"') {
274
+ inString = true;
275
+ continue;
276
+ }
277
+ if (ch === "{") {
278
+ if (depth === 0)
279
+ start = i;
280
+ depth++;
281
+ }
282
+ else if (ch === "}") {
283
+ if (depth > 0) {
284
+ depth--;
285
+ if (depth === 0 && start >= 0) {
286
+ out.push(text.slice(start, i + 1));
287
+ start = -1;
288
+ }
289
+ }
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+ /** Strict-JSON parse of the fetcher's single-line output. */
295
+ export function parseFetchWindowOutput(output) {
296
+ const trimmed = (output ?? "").trim();
297
+ if (!trimmed)
298
+ return { parseError: "empty-output" };
299
+ // Tolerate code fences without making them mandatory — mirrors
300
+ // `parseStage2Verdict` in dispatcher-types.ts.
301
+ const stripped = trimmed
302
+ .replace(/^```(?:json)?\s*/i, "")
303
+ .replace(/```\s*$/i, "")
304
+ .trim();
305
+ const candidates = extractBalancedJsonObjects(stripped);
306
+ if (candidates.length === 0)
307
+ return { parseError: "no-json-object" };
308
+ const objText = candidates[candidates.length - 1];
309
+ let parsed;
310
+ try {
311
+ parsed = JSON.parse(objText);
312
+ }
313
+ catch (err) {
314
+ return { parseError: `invalid-json: ${err.message}` };
315
+ }
316
+ if (!parsed || typeof parsed !== "object") {
317
+ return { parseError: "not-an-object" };
318
+ }
319
+ const obj = parsed;
320
+ const fetched = typeof obj.fetched === "number" ? obj.fetched : 0;
321
+ const posted = typeof obj.posted === "number" ? obj.posted : 0;
322
+ const duplicates = typeof obj.duplicates === "number" ? obj.duplicates : 0;
323
+ const errors = Array.isArray(obj.errors)
324
+ ? obj.errors
325
+ .filter((row) => typeof row === "object" && row !== null)
326
+ .map((row) => ({ ...row }))
327
+ : [];
328
+ const status = errors.length > 0 ? "partial" : "success";
329
+ return {
330
+ status,
331
+ fetched,
332
+ posted,
333
+ duplicates,
334
+ errors,
335
+ skipped: false,
336
+ };
337
+ }
338
+ function xmlEscape(value) {
339
+ return value
340
+ .replace(/&/g, "&amp;")
341
+ .replace(/"/g, "&quot;")
342
+ .replace(/</g, "&lt;")
343
+ .replace(/>/g, "&gt;");
344
+ }
345
+ /**
346
+ * Render the `<fetch_report>` XML block injected into the parent
347
+ * routine's prompt. Keep the schema narrow — every additional attribute
348
+ * costs prompt tokens on the cache-warm parent session.
349
+ *
350
+ * `meta.routine` accepts any string so the no-routine-key skip path can
351
+ * render the parent event's actual type (e.g. `routine.skill_curation`)
352
+ * instead of borrowing a catalog entry as a placeholder. The renderer
353
+ * strips the `routine.` prefix verbatim — callers may pass either the
354
+ * fully-qualified ProcessKey or a bare suffix.
355
+ */
356
+ export function renderFetchReportBlock(report, meta) {
357
+ const routineAttr = meta.routine.replace(/^routine\./, "");
358
+ const lines = [
359
+ `<fetch_report routine="${xmlEscape(routineAttr)}" agent_day="${xmlEscape(meta.agentDay)}" status="${xmlEscape(report.status)}" fetched="${report.fetched}" posted="${report.posted}" duplicates="${report.duplicates}">`,
360
+ ];
361
+ if (report.failureReason) {
362
+ lines.push(` <failure>${xmlEscape(report.failureReason)}</failure>`);
363
+ }
364
+ for (const err of report.errors) {
365
+ const type = typeof err.type === "string" ? err.type : "unknown";
366
+ // Compact, attribute-shaped serialisation: every string-typed key
367
+ // becomes an XML attribute; nested objects are collapsed to JSON
368
+ // text content so the block stays parseable both as XML and as a
369
+ // line-by-line scan target.
370
+ const attrEntries = Object.entries(err).filter(([k, v]) => k !== "type" && (typeof v === "string" || typeof v === "number"));
371
+ const attrs = attrEntries
372
+ .map(([k, v]) => `${xmlEscape(k)}="${xmlEscape(String(v))}"`)
373
+ .join(" ");
374
+ const nested = Object.entries(err).filter(([k, v]) => k !== "type"
375
+ && typeof v !== "string"
376
+ && typeof v !== "number");
377
+ if (nested.length > 0) {
378
+ lines.push(` <error type="${xmlEscape(type)}"${attrs ? " " + attrs : ""}>${xmlEscape(JSON.stringify(Object.fromEntries(nested)))}</error>`);
379
+ }
380
+ else {
381
+ lines.push(` <error type="${xmlEscape(type)}"${attrs ? " " + attrs : ""} />`);
382
+ }
383
+ }
384
+ lines.push("</fetch_report>");
385
+ return lines.join("\n");
386
+ }
387
+ // ── Fan-out aggregation (PRE_PASS_FAN_OUT_DESIGN.md §4.5) ─────────────────
388
+ /**
389
+ * Resolve the aggregate `<fetch_report>` status from a set of
390
+ * sub-reports' final statuses, per §4.5:
391
+ *
392
+ * - `success` iff every non-skipped sub-report is `success`. Skipped
393
+ * sub-reports do not count against success.
394
+ * - `failed` iff every non-skipped sub-report is `failed`.
395
+ * - `partial` for any other mix (incl. one success + one failed).
396
+ * - `skipped` only when the input is empty (the caller handles
397
+ * "every sub-report skipped" separately — the runner short-circuits
398
+ * before fan-out when no integrations are active).
399
+ *
400
+ * Exported for unit testing the status-resolution branch in isolation.
401
+ */
402
+ export function aggregateFanOutStatus(subReports) {
403
+ if (subReports.length === 0)
404
+ return "skipped";
405
+ const nonSkipped = subReports.filter((r) => r.status !== "skipped");
406
+ if (nonSkipped.length === 0)
407
+ return "skipped";
408
+ const allSuccess = nonSkipped.every((r) => r.status === "success");
409
+ if (allSuccess)
410
+ return "success";
411
+ const allFailed = nonSkipped.every((r) => r.status === "failed");
412
+ if (allFailed)
413
+ return "failed";
414
+ return "partial";
415
+ }
416
+ /**
417
+ * Render the additive `<integration>` children + the `<error>`
418
+ * grandchildren that go inside a fan-out `<fetch_report>`. The parent
419
+ * `<fetch_report ...>` open/close lines are produced by
420
+ * `renderFetchReportBlock`; this helper produces only the body lines so
421
+ * the two can compose cleanly.
422
+ */
423
+ function renderPerIntegrationLines(subReports) {
424
+ const lines = [];
425
+ for (const sub of subReports) {
426
+ const errors = errorsForSubReport(sub);
427
+ const openAttrs = [
428
+ `key="${xmlEscape(sub.integrationKey)}"`,
429
+ `status="${xmlEscape(sub.status)}"`,
430
+ `fetched="${sub.fetched}"`,
431
+ `posted="${sub.posted}"`,
432
+ `duplicates="${sub.duplicates}"`,
433
+ `attempts="${sub.attempts.length}"`,
434
+ ];
435
+ if (errors.length === 0) {
436
+ lines.push(` <integration ${openAttrs.join(" ")} />`);
437
+ continue;
438
+ }
439
+ lines.push(` <integration ${openAttrs.join(" ")}>`);
440
+ for (const err of errors) {
441
+ const type = typeof err.type === "string" ? err.type : "unknown";
442
+ const attrEntries = Object.entries(err).filter(([k, v]) => k !== "type" && (typeof v === "string" || typeof v === "number"));
443
+ const attrs = attrEntries
444
+ .map(([k, v]) => `${xmlEscape(k)}="${xmlEscape(String(v))}"`)
445
+ .join(" ");
446
+ lines.push(` <error type="${xmlEscape(type)}"${attrs ? " " + attrs : ""} />`);
447
+ }
448
+ lines.push(` </integration>`);
449
+ }
450
+ return lines;
451
+ }
452
+ /**
453
+ * Flatten every attempt's `errors` into a single sequence — the runner
454
+ * always pushes at least one record per loop iteration, so the union of
455
+ * attempts' errors is the canonical list (and equals `sub.errors`,
456
+ * which is set from `flatMap(attempts, e => e.errors)` at sub-report
457
+ * construction time). Kept as a helper so the rendering / merge call
458
+ * sites read declaratively.
459
+ */
460
+ function errorsForSubReport(sub) {
461
+ return sub.attempts.flatMap((att) => att.errors);
462
+ }
463
+ function attemptDurationMs(att) {
464
+ const start = Date.parse(att.startedAt);
465
+ const end = Date.parse(att.endedAt);
466
+ if (!Number.isFinite(start) || !Number.isFinite(end))
467
+ return 0;
468
+ return Math.max(0, end - start);
469
+ }
470
+ function pickFinalErrorMessage(sub) {
471
+ if (sub.status !== "failed")
472
+ return undefined;
473
+ const finalAttempt = sub.attempts[sub.attempts.length - 1];
474
+ const firstError = finalAttempt?.errors?.[0];
475
+ if (!firstError)
476
+ return undefined;
477
+ const message = firstError.message ?? firstError.reason ?? firstError.kind;
478
+ return typeof message === "string" ? message : undefined;
479
+ }
480
+ export function summarizeIntegrationReport(sub) {
481
+ const costUsd = sub.attempts.reduce((sum, att) => sum + att.costUsd, 0);
482
+ const durationMs = sub.attempts.reduce((sum, att) => sum + attemptDurationMs(att), 0);
483
+ const finalError = pickFinalErrorMessage(sub);
484
+ return {
485
+ key: sub.integrationKey,
486
+ status: sub.status,
487
+ attempts: sub.attempts.length,
488
+ fetched: sub.fetched,
489
+ posted: sub.posted,
490
+ duplicates: sub.duplicates,
491
+ costUsd,
492
+ durationMs,
493
+ ...(finalError ? { finalError } : {}),
494
+ };
495
+ }
496
+ export function summarizeFetchReport(report) {
497
+ const perIntegration = report.perIntegration ?? [];
498
+ const costUsd = perIntegration.reduce((sum, sub) => sum + sub.attempts.reduce((s, att) => s + att.costUsd, 0), 0);
499
+ return {
500
+ status: report.status,
501
+ fetched: report.fetched,
502
+ posted: report.posted,
503
+ duplicates: report.duplicates,
504
+ costUsd,
505
+ };
506
+ }
507
+ /**
508
+ * Merge fan-out sub-reports into a single `FetchReport` + the rendered
509
+ * `<fetch_report>` XML block the parent routine sees. §4.5.
510
+ *
511
+ * - Counts (`fetched`, `posted`, `duplicates`): arithmetic sum.
512
+ * - `errors`: concatenation, each error tagged with
513
+ * `integration: <key>` (and the per-attempt rows already carry
514
+ * `attempt: <n>` via the runner's per-attempt error-recording —
515
+ * `mergeSubReports` does not invent annotations beyond `integration`).
516
+ * - `status`: `aggregateFanOutStatus`.
517
+ * - `failureReason`: only when aggregate is `failed`; one-line summary
518
+ * listing the failed integrations and their attempt counts.
519
+ * - `perIntegration`: sorted by `INTEGRATION_KEYS` order regardless of
520
+ * completion order (deterministic — §4.6).
521
+ *
522
+ * Pure function: no side effects, no DB / clock dependencies.
523
+ */
524
+ export function mergeSubReports(subReports, routine, agentDay) {
525
+ // Deterministic ordering by INTEGRATION_KEYS enumeration order so the
526
+ // block is stable across runs regardless of which sub-session
527
+ // finished first.
528
+ const ordered = INTEGRATION_KEYS
529
+ .map((k) => subReports.find((r) => r.integrationKey === k))
530
+ .filter((r) => r !== undefined);
531
+ // Defensive — preserve any non-canonical keys at the tail rather
532
+ // than dropping them. Today every SubReport's integrationKey comes
533
+ // from the canonical enum, but a future key added to the registry
534
+ // without the catalog catching up would otherwise be silently
535
+ // suppressed.
536
+ for (const r of subReports) {
537
+ if (!ordered.includes(r))
538
+ ordered.push(r);
539
+ }
540
+ let fetched = 0;
541
+ let posted = 0;
542
+ let duplicates = 0;
543
+ const errors = [];
544
+ for (const sub of ordered) {
545
+ fetched += sub.fetched;
546
+ posted += sub.posted;
547
+ duplicates += sub.duplicates;
548
+ for (const err of errorsForSubReport(sub)) {
549
+ errors.push({ ...err, integration: sub.integrationKey });
550
+ }
551
+ }
552
+ const status = aggregateFanOutStatus(ordered);
553
+ let failureReason;
554
+ if (status === "failed") {
555
+ const failedSummaries = ordered
556
+ .filter((r) => r.status === "failed")
557
+ .map((r) => `${r.integrationKey} (${r.attempts.length} attempts)`);
558
+ failureReason = failedSummaries.length > 0
559
+ ? `${failedSummaries.length} integrations failed: ${failedSummaries.join(", ")}`
560
+ : "all sub-sessions failed";
561
+ }
562
+ const report = {
563
+ status,
564
+ fetched,
565
+ posted,
566
+ duplicates,
567
+ errors,
568
+ skipped: status === "skipped",
569
+ ...(failureReason !== undefined ? { failureReason } : {}),
570
+ perIntegration: ordered,
571
+ };
572
+ // Render: open/close come from a `renderFetchReportBlock`-shaped
573
+ // header line; body interleaves the per-integration children. We
574
+ // emit a fresh string rather than calling `renderFetchReportBlock`
575
+ // because the aggregated block carries `<integration>` children that
576
+ // the short-circuit (skipped / failed) blocks emitted by
577
+ // `renderFetchReportBlock` do not — keeping the two render paths
578
+ // explicit avoids cross-contaminating their shapes.
579
+ const routineAttr = routine.replace(/^routine\./, "");
580
+ const headerAttrs = [
581
+ `routine="${xmlEscape(routineAttr)}"`,
582
+ `agent_day="${xmlEscape(agentDay)}"`,
583
+ `status="${xmlEscape(status)}"`,
584
+ `fetched="${fetched}"`,
585
+ `posted="${posted}"`,
586
+ `duplicates="${duplicates}"`,
587
+ ];
588
+ const lines = [`<fetch_report ${headerAttrs.join(" ")}>`];
589
+ if (failureReason !== undefined) {
590
+ lines.push(` <failure>${xmlEscape(failureReason)}</failure>`);
591
+ }
592
+ lines.push(...renderPerIntegrationLines(ordered));
593
+ lines.push("</fetch_report>");
594
+ return { report, block: lines.join("\n") };
595
+ }
596
+ /**
597
+ * Race-free local budget guard for one fan-out coordinator. Reservations
598
+ * mutate a separate `reservedUsd` bucket before async work starts; commit
599
+ * releases that reservation and records the measured spend.
600
+ */
601
+ class FanOutBudgetGuard {
602
+ capUsd;
603
+ reservedUsd = 0;
604
+ spentUsd = 0;
605
+ constructor(capUsd) {
606
+ this.capUsd = capUsd;
607
+ }
608
+ reserve(estimateUsd) {
609
+ // Defensive normalisation: a non-finite estimate (NaN / undefined coerced
610
+ // through Math.max) would poison `reservedUsd` permanently — every
611
+ // subsequent reserve() would arithmetic-NaN and report `false` for the
612
+ // headroom check. The binding contract today guarantees a finite number,
613
+ // but the guard is one strict layer below where a malformed config or a
614
+ // future backend shape could leak through.
615
+ const estimate = Number.isFinite(estimateUsd)
616
+ ? Math.max(0, estimateUsd)
617
+ : 0;
618
+ if (!Number.isFinite(this.capUsd)) {
619
+ return { ok: true, remaining: Number.POSITIVE_INFINITY, estimateUsd: estimate };
620
+ }
621
+ const remaining = this.capUsd - this.spentUsd - this.reservedUsd;
622
+ if (estimate > remaining) {
623
+ return { ok: false, remaining: Math.max(0, remaining), estimateUsd: estimate };
624
+ }
625
+ this.reservedUsd += estimate;
626
+ return {
627
+ ok: true,
628
+ remaining: Math.max(0, this.capUsd - this.spentUsd - this.reservedUsd),
629
+ estimateUsd: estimate,
630
+ };
631
+ }
632
+ commit(reservation, actualUsd) {
633
+ if (!reservation.ok)
634
+ return;
635
+ this.reservedUsd = Math.max(0, this.reservedUsd - reservation.estimateUsd);
636
+ // Same defensive guard as reserve() — NaN actualUsd from a misbehaving
637
+ // backend would otherwise corrupt the spend counter and silently disable
638
+ // the cap for the remainder of the routine.
639
+ if (Number.isFinite(actualUsd)) {
640
+ this.spentUsd += Math.max(0, actualUsd);
641
+ }
642
+ }
643
+ get spent() {
644
+ return this.spentUsd;
645
+ }
646
+ }
647
+ function clampInt(value, min, max, fallback) {
648
+ if (typeof value !== "number" || !Number.isFinite(value))
649
+ return fallback;
650
+ return Math.min(max, Math.max(min, Math.trunc(value)));
651
+ }
652
+ function nonNegativeNumber(value, fallback) {
653
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
654
+ return fallback;
655
+ }
656
+ return value;
657
+ }
658
+ function sleep(ms) {
659
+ if (ms <= 0)
660
+ return Promise.resolve();
661
+ return new Promise((resolve) => setTimeout(resolve, ms));
662
+ }
663
+ async function runWithConcurrency(tasks, concurrency) {
664
+ if (tasks.length === 0)
665
+ return [];
666
+ if (concurrency === null || concurrency >= tasks.length) {
667
+ return Promise.all(tasks.map((task) => task()));
668
+ }
669
+ const limit = Math.max(1, Math.trunc(concurrency));
670
+ const results = new Array(tasks.length);
671
+ let next = 0;
672
+ async function worker() {
673
+ for (;;) {
674
+ const index = next;
675
+ next += 1;
676
+ if (index >= tasks.length)
677
+ return;
678
+ results[index] = await tasks[index]();
679
+ }
680
+ }
681
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
682
+ await Promise.all(workers);
683
+ return results;
684
+ }
685
+ // ── Runner ────────────────────────────────────────────────────────────────
686
+ export class RoutineFetchWindowRunner {
687
+ db;
688
+ config;
689
+ contextBuilder;
690
+ agentRouter;
691
+ audit;
692
+ prompt;
693
+ getActiveMailAccounts;
694
+ getEventBroadcaster;
695
+ constructor(deps) {
696
+ this.db = deps.db;
697
+ this.config = deps.config;
698
+ this.contextBuilder = deps.contextBuilder;
699
+ this.agentRouter = deps.agentRouter;
700
+ this.audit = deps.audit;
701
+ this.prompt = deps.prompt;
702
+ this.getActiveMailAccounts = deps.getActiveMailAccounts;
703
+ this.getEventBroadcaster = deps.getEventBroadcaster ?? null;
704
+ }
705
+ /**
706
+ * Broadcast a single pre-pass progress event to the dashboard SSE
707
+ * channel. Failure is contained — the broadcaster contract is
708
+ * fire-and-forget and a misbehaving writer must not affect the
709
+ * runner's return value or the parent routine's dispatch.
710
+ *
711
+ * Schema (default `event` SSE channel, matches existing
712
+ * `kind: "main_backend_changed"` / `"routine_started"` pattern):
713
+ * { kind, routine, source, correlationId, timestamp, status? }
714
+ * `status` is set on `prepass_completed` only and reflects the
715
+ * FetchReport.status field (success / partial / failed / skipped).
716
+ */
717
+ broadcastPrepassProgress(kind, parentEvent, extra) {
718
+ const broadcaster = this.getEventBroadcaster?.();
719
+ if (!broadcaster)
720
+ return;
721
+ try {
722
+ broadcaster.broadcastEvent({
723
+ kind,
724
+ routine: isRoutineEvent(parentEvent) ? parentEvent.routine : null,
725
+ source: parentEvent.source,
726
+ correlationId: parentEvent.correlationId,
727
+ timestamp: new Date().toISOString(),
728
+ ...(extra?.status ? { status: extra.status } : {}),
729
+ ...(extra?.reason ? { reason: extra.reason } : {}),
730
+ ...Object.fromEntries(Object.entries(extra ?? {}).filter(([key]) => key !== "status" && key !== "reason")),
731
+ });
732
+ }
733
+ catch {
734
+ // Intentionally silent — broadcaster failures are already logged at
735
+ // the EventBroadcaster.broadcastNamedEvent layer. Re-logging here
736
+ // would spam during transient SSE client churn.
737
+ }
738
+ }
739
+ /**
740
+ * Execute the pre-pass for `parentEvent`. Returns the fetch report
741
+ * and rendered `<fetch_report>` block; callers graft the block into
742
+ * the parent event's `event.data.fetchReportBlock` so ContextBuilder
743
+ * injects it into the parent prompt.
744
+ *
745
+ * `routineKey` overrides the auto-derived window key — used by
746
+ * morning_routine to opt into `routine.morning_routine_initial`'s
747
+ * plan when `yesterday.md` is absent (their plans currently coincide,
748
+ * but the seam exists for future divergence).
749
+ */
750
+ async run(parentEvent, routineKey) {
751
+ // B2 observability — announce pre-pass entry so the dashboard can
752
+ // render "Fetching your mail and Notion data…" as a sub-step of the
753
+ // parent routine's `routine_started` envelope. `prepass_completed`
754
+ // (with status) fires from the single exit point at the bottom of
755
+ // `runImpl` via the try/finally below. Symmetric: every started
756
+ // emit has a matching completed emit, even on the skipped paths
757
+ // (skipping is itself information the user wants to see).
758
+ this.broadcastPrepassProgress("prepass_started", parentEvent);
759
+ let outcome;
760
+ try {
761
+ outcome = await this.runImpl(parentEvent, routineKey);
762
+ return outcome;
763
+ }
764
+ finally {
765
+ // §7.2 — `prepass_completed` payload contract:
766
+ // { kind, routine, source, correlationId, timestamp, status, reason?,
767
+ // aggregate: {status, fetched, posted, duplicates, costUsd},
768
+ // perIntegration: [{key, status, attempts, fetched, posted,
769
+ // duplicates, costUsd, durationMs, finalError?}] }
770
+ // The aggregate + perIntegration arrays let the dashboard render
771
+ // the per-integration progress card the design called for without
772
+ // re-querying the daemon. Skipped / failed short-circuit paths
773
+ // (no-routine-key / no-windows / empty-plan / plan-assembly-failed)
774
+ // produce reports without `perIntegration`; in those cases the
775
+ // aggregate still carries the headline (fetched/posted = 0,
776
+ // costUsd = 0) and `perIntegration` is the empty array.
777
+ const report = outcome?.report;
778
+ const perIntegration = (report?.perIntegration ?? []).map(summarizeIntegrationReport);
779
+ const aggregate = report
780
+ ? summarizeFetchReport(report)
781
+ : undefined;
782
+ this.broadcastPrepassProgress("prepass_completed", parentEvent, {
783
+ status: report?.status,
784
+ ...(report?.failureReason ? { reason: report.failureReason } : {}),
785
+ ...(aggregate ? { aggregate } : {}),
786
+ perIntegration,
787
+ });
788
+ }
789
+ }
790
+ async runImpl(parentEvent, routineKey) {
791
+ const key = routineKey ?? routineWindowKeyFromEvent(parentEvent);
792
+ const agentDay = getAgentDayDateStr(this.config.timezone || undefined, this.config.dayBoundaryHour);
793
+ if (!key) {
794
+ const report = {
795
+ status: "skipped",
796
+ fetched: 0,
797
+ posted: 0,
798
+ duplicates: 0,
799
+ errors: [],
800
+ skipped: true,
801
+ failureReason: "no-routine-window-key",
802
+ };
803
+ // Surface the parent's actual event type rather than borrowing a
804
+ // catalog entry — the report attribution would otherwise lie about
805
+ // which routine the pre-pass was attempted for, hiding the
806
+ // misroute behind a plausible-looking placeholder.
807
+ const block = renderFetchReportBlock(report, {
808
+ routine: parentEvent.type,
809
+ agentDay,
810
+ });
811
+ return { report, block };
812
+ }
813
+ if (!routineHasWindows(key)) {
814
+ const report = {
815
+ status: "skipped",
816
+ fetched: 0,
817
+ posted: 0,
818
+ duplicates: 0,
819
+ errors: [],
820
+ skipped: true,
821
+ };
822
+ const block = renderFetchReportBlock(report, { routine: key, agentDay });
823
+ return { report, block };
824
+ }
825
+ let planContext;
826
+ try {
827
+ planContext = this.buildFanOutPlanContext(parentEvent, key, agentDay);
828
+ }
829
+ catch (err) {
830
+ return this.fail(key, agentDay, parentEvent, "plan-assembly-failed", err);
831
+ }
832
+ // The acquisition plan can resolve to zero `<fetch>` rows when every
833
+ // integration the routine touches is disabled / cross-backend-bound
834
+ // on a connector-less integration / etc. `splitAcquisitionPlanByIntegration`
835
+ // drops integrations with no rows, so an empty `subPlans` exactly
836
+ // means the routine has nothing to fetch. Treat that as a skip —
837
+ // we don't pay the cold-start to confirm the agent has nothing
838
+ // to do.
839
+ if (planContext.subPlans.length === 0) {
840
+ const report = {
841
+ status: "skipped",
842
+ fetched: 0,
843
+ posted: 0,
844
+ duplicates: 0,
845
+ errors: [],
846
+ skipped: true,
847
+ fetcherCorrelationId: planContext.placeholder.correlationId,
848
+ };
849
+ const block = renderFetchReportBlock(report, { routine: key, agentDay });
850
+ logger.debug({
851
+ routine: key,
852
+ correlationId: planContext.placeholder.correlationId,
853
+ parentCorrelationId: parentEvent.correlationId,
854
+ }, "Routine fetch-window pre-pass skipped — acquisition plan empty");
855
+ return { report, block };
856
+ }
857
+ try {
858
+ return await this.runFanOut({
859
+ key,
860
+ agentDay,
861
+ parentEvent,
862
+ subPlans: planContext.subPlans,
863
+ integrationsSnapshot: planContext.integrationsSnapshot,
864
+ accounts: planContext.accounts,
865
+ timestamps: planContext.timestamps,
866
+ });
867
+ }
868
+ catch (err) {
869
+ return this.fail(key, agentDay, parentEvent, "fan-out-failed", err, {
870
+ fetcherCorrelationId: planContext.placeholder.correlationId,
871
+ });
872
+ }
873
+ }
874
+ buildFanOutPlanContext(parentEvent, key, agentDay) {
875
+ // Resolve binding off a placeholder event so we know the session
876
+ // backend before splitting the plan (the partial body's mode markers
877
+ // and the per-row delegated-same / delegated-cross resolution both
878
+ // depend on the resolved backend). The placeholder borrows the
879
+ // parent correlationId so the empty-plan and plan-assembly-failed
880
+ // short-circuit reports carry a stable correlation id; fan-out
881
+ // sub-sessions get fresh ids per attempt.
882
+ const placeholder = {
883
+ ...createEvent({
884
+ type: FETCH_WINDOW_EVENT_TYPE,
885
+ source: parentEvent.source,
886
+ priority: EventPriority.NORMAL,
887
+ correlationId: parentEvent.correlationId,
888
+ }),
889
+ routine: "fetch_window",
890
+ };
891
+ const preBinding = this.agentRouter.resolveBinding(placeholder, {
892
+ processKey: FETCH_WINDOW_PROCESS_KEY,
893
+ });
894
+ const integrationsSnapshot = readIntegrations(this.db);
895
+ const accounts = this.collectAccounts(integrationsSnapshot);
896
+ const timestamps = buildAcquisitionTimestamps(new Date(), this.config.timezone || undefined, this.config.dayBoundaryHour);
897
+ const planInput = {
898
+ routine: key,
899
+ agentDay,
900
+ integrations: integrationsSnapshot,
901
+ sessionBackend: preBinding.main.backendId,
902
+ accounts,
903
+ timestamps,
904
+ };
905
+ return {
906
+ key,
907
+ agentDay,
908
+ placeholder,
909
+ integrationsSnapshot,
910
+ subPlans: splitAcquisitionPlanByIntegration(planInput),
911
+ accounts,
912
+ timestamps,
913
+ };
914
+ }
915
+ /**
916
+ * §5 BackendQuotaError row + §4.4 retryEscalationTier — re-derive the
917
+ * per-integration sub-plan against the CURRENT attempt's binding so a
918
+ * cross-backend swap (escalation tier flips main backend, or
919
+ * `prePassRetryEscalationTier` swaps the per-attempt tier) keeps the
920
+ * plan's `<fetch mode="…">` attribute aligned with the partial body's
921
+ * remaining mode-branch (the partial is filtered for the new backend
922
+ * via `renderPartialForFanOut`). Returns null only when the
923
+ * integration becomes unreachable on the new backend (e.g. native
924
+ * mode bound to A but the attempt re-resolves to B); the runner
925
+ * passes the call through to record the original sub-plan and let
926
+ * the agent emit `no-surface` errors organically.
927
+ *
928
+ * Pure: snapshot inputs (`integrationsSnapshot`, `accounts`,
929
+ * `timestamps`) are frozen at run() entry; only `sessionBackend`
930
+ * varies across attempts. Re-derivation is cheap —
931
+ * `splitAcquisitionPlanByIntegration` walks `ROUTINE_WINDOWS[routine]`
932
+ * (a small constant) without I/O.
933
+ */
934
+ rebuildSubPlanForBackend(integrationKey, routineKey, agentDay, sessionBackend, integrationsSnapshot, accounts, timestamps) {
935
+ const subPlans = splitAcquisitionPlanByIntegration({
936
+ routine: routineKey,
937
+ agentDay,
938
+ integrations: integrationsSnapshot,
939
+ sessionBackend,
940
+ accounts,
941
+ timestamps,
942
+ });
943
+ return subPlans.find((p) => p.integrationKey === integrationKey) ?? null;
944
+ }
945
+ buildRetryPolicy() {
946
+ return {
947
+ maxAttempts: clampInt(this.config.prePassMaxAttemptsPerIntegration, 1, 5, 3),
948
+ backoffMs: Array.isArray(this.config.prePassBackoffMs)
949
+ ? this.config.prePassBackoffMs
950
+ : [1000, 2000, 4000],
951
+ perIntegrationBudgetUsd: nonNegativeNumber(this.config.prePassMaxBudgetUsdPerIntegration, 0.6),
952
+ retryOnPartial: this.config.prePassRetryOnPartial !== false,
953
+ };
954
+ }
955
+ fanOutConcurrency() {
956
+ const configured = this.config.prePassFanOutConcurrency;
957
+ if (configured === null || configured === undefined)
958
+ return null;
959
+ // Cap at the integration roster size — fan-out spawns at most one
960
+ // sub-session per IntegrationKey, so a higher concurrency cannot
961
+ // help and a hardcoded literal would silently restrict whenever a
962
+ // new integration descriptor is added to `INTEGRATION_KEYS`.
963
+ return clampInt(configured, 1, INTEGRATION_KEYS.length, INTEGRATION_KEYS.length);
964
+ }
965
+ retryEscalationTier() {
966
+ const configured = this.config.prePassRetryEscalationTier;
967
+ return configured === "lite" || configured === "medium" || configured === "high"
968
+ ? configured
969
+ : null;
970
+ }
971
+ async runFanOut(input) {
972
+ const policy = this.buildRetryPolicy();
973
+ const globalBudget = new FanOutBudgetGuard(nonNegativeNumber(this.config.prePassMaxBudgetUsdPerRoutine, 1.5));
974
+ const tasks = input.subPlans.map((subPlan) => async () => this.runOneIntegrationWithRetry({
975
+ ...input,
976
+ subPlan,
977
+ policy,
978
+ globalBudget,
979
+ }));
980
+ const subReports = await runWithConcurrency(tasks, this.fanOutConcurrency());
981
+ const merged = mergeSubReports(subReports, input.key, input.agentDay);
982
+ // §7.1 example shape — `integrations: [{key, status, attempts, fetched,
983
+ // posted, duplicates, durationMs, costUsd, finalError?}]` plus an
984
+ // `aggregate` headline. Reuse the helpers so the SSE
985
+ // `prepass_completed` payload (in `run()` below) and this log line
986
+ // never disagree on the per-integration numbers.
987
+ const integrations = subReports.map(summarizeIntegrationReport);
988
+ logger.info({
989
+ routine: input.key,
990
+ parentCorrelationId: input.parentEvent.correlationId,
991
+ integrations,
992
+ aggregate: {
993
+ ...summarizeFetchReport(merged.report),
994
+ // Use the live `globalBudget.spent` counter here so the daemon
995
+ // log line is the canonical readout for "what the global cap
996
+ // saw" — the §7.2 SSE payload mirrors `summarizeFetchReport`'s
997
+ // sum-over-attempts. Today the two converge (every committed
998
+ // cost is also reflected in an attempt's `costUsd`), but tying
999
+ // the log line to the guard means a future divergence (e.g. a
1000
+ // commit path that bypasses the per-attempt record) surfaces
1001
+ // here instead of going silent.
1002
+ costUsd: globalBudget.spent,
1003
+ },
1004
+ }, "Routine fetch-window fan-out completed");
1005
+ return merged;
1006
+ }
1007
+ async runOneIntegrationWithRetry(input) {
1008
+ const attempts = [];
1009
+ // Per-integration budget cap is enforced at TWO complementary layers,
1010
+ // BOTH driven by `policy.perIntegrationBudgetUsd`:
1011
+ // (1) `integrationBudget` (this guard) — HARD pre-attempt reservation
1012
+ // against the binding's `max_budget_usd` envelope. Trips before
1013
+ // the next SDK call when the upper bound on the next attempt
1014
+ // would exceed the cap. Surfaces as `{type:"budget-cap"}`.
1015
+ // (2) `defaultRetryDecision`'s cumulative-cost branch — SOFT
1016
+ // post-attempt check against actual `costUsd` summed across
1017
+ // priorAttempts. Surfaces as `decision.reason="per-integration-budget-cap"`.
1018
+ // Layer (1) is the pessimistic guard (envelope ≥ actual); layer (2)
1019
+ // catches the case where individual attempts cost more than expected.
1020
+ // Either fires depending on the cost shape — both are intentional.
1021
+ const integrationBudget = new FanOutBudgetGuard(input.policy.perIntegrationBudgetUsd);
1022
+ const retryOn = input.policy.retryOn ?? defaultRetryDecision;
1023
+ const escalationTier = this.retryEscalationTier();
1024
+ let retriesExhausted = false;
1025
+ for (let attempt = 1; attempt <= input.policy.maxAttempts; attempt++) {
1026
+ const startedAt = new Date().toISOString();
1027
+ const fetcherEvent = this.createFanOutFetcherEvent(input.parentEvent, input.key, input.subPlan, attempt, input.policy.maxAttempts);
1028
+ const requestedTier = attempt > 1 && escalationTier !== null ? escalationTier : undefined;
1029
+ // Symmetry guarantee: every iteration emits exactly one
1030
+ // `prepass_subsession_started` BEFORE any work, and exactly one
1031
+ // `prepass_subsession_completed` after the attempt's outcome is
1032
+ // recorded — including the binding-resolve-failed and budget-cap
1033
+ // short-circuits, which previously were invisible to the dashboard.
1034
+ this.emitSubSessionStarted(input, fetcherEvent.correlationId, attempt);
1035
+ let binding;
1036
+ try {
1037
+ binding = this.agentRouter.resolveBinding(fetcherEvent, {
1038
+ processKey: FETCH_WINDOW_PROCESS_KEY,
1039
+ ...(requestedTier ? { requestedTier } : {}),
1040
+ });
1041
+ }
1042
+ catch (err) {
1043
+ const record = this.failedAttemptRecord(attempt, fetcherEvent.correlationId, startedAt, "binding-resolve-failed", err);
1044
+ attempts.push(record);
1045
+ const decision = retryOn(record, attempt, input.policy, attempts.slice(0, -1));
1046
+ this.logFanOutFailure(input, fetcherEvent, record, decision, {
1047
+ failureKind: "binding-resolve-failed",
1048
+ err,
1049
+ startedAt,
1050
+ });
1051
+ this.emitSubSessionCompleted(input, fetcherEvent.correlationId, attempt, record, decision);
1052
+ if (!decision.retry) {
1053
+ retriesExhausted = this.didExhaustRetries(record, decision, input.policy);
1054
+ break;
1055
+ }
1056
+ await sleep(this.backoffForAttempt(input.policy, attempt));
1057
+ continue;
1058
+ }
1059
+ // §5 BackendQuotaError row + §4.4 retryEscalationTier — the
1060
+ // pre-pass plan was originally rendered against
1061
+ // `preBinding.main.backendId` (set in `buildFanOutPlanContext`).
1062
+ // If THIS attempt's binding picks a different backend (escalation
1063
+ // tier flipped main, or the resolver picked a different default
1064
+ // for a higher tier), re-derive the per-integration sub-plan
1065
+ // against the current backend so the plan's `<fetch mode="…">`
1066
+ // attributes match the partial body's remaining mode-branch
1067
+ // (`renderPartialForFanOut` filters the partial against the
1068
+ // CURRENT backend; the plan must follow). The recompute uses the
1069
+ // frozen accounts + timestamps from `buildFanOutPlanContext` so
1070
+ // the windows don't drift mid-routine.
1071
+ const livePlan = this.rebuildSubPlanForBackend(input.subPlan.integrationKey, input.key, input.agentDay, binding.main.backendId, input.integrationsSnapshot, input.accounts, input.timestamps);
1072
+ // Native-mode integrations bound to a specific backend can become
1073
+ // unreachable after a cross-backend binding swap (the new backend
1074
+ // has no native connector for this integration). When that
1075
+ // happens, `livePlan` is null — fall back to the original
1076
+ // sub-plan so the agent still iterates the rows and surfaces
1077
+ // `no-surface` errors organically. The retry policy will then
1078
+ // either escalate or short-circuit per the existing matrix.
1079
+ if (livePlan && livePlan.block !== input.subPlan.block) {
1080
+ // Mutate the per-attempt fetcher event so ContextBuilder injects
1081
+ // the freshly-rendered plan block instead of the stale one.
1082
+ // Cheap — the event is a one-shot per attempt.
1083
+ fetcherEvent.data.acquisitionPlanBlock
1084
+ = livePlan.block;
1085
+ }
1086
+ const estimateUsd = binding.main.maxBudgetUsd;
1087
+ const globalReservation = input.globalBudget.reserve(estimateUsd);
1088
+ if (!globalReservation.ok) {
1089
+ const record = this.budgetCapAttemptRecord(attempt, fetcherEvent.correlationId, startedAt, "global-budget-cap", globalReservation.remaining);
1090
+ attempts.push(record);
1091
+ const decision = {
1092
+ retry: false,
1093
+ reason: "global-budget-cap",
1094
+ };
1095
+ this.logFanOutFailure(input, fetcherEvent, record, decision, {
1096
+ failureKind: "global-budget-cap",
1097
+ binding: binding.main,
1098
+ startedAt,
1099
+ });
1100
+ this.emitSubSessionCompleted(input, fetcherEvent.correlationId, attempt, record, decision);
1101
+ retriesExhausted = true;
1102
+ break;
1103
+ }
1104
+ const integrationReservation = integrationBudget.reserve(estimateUsd);
1105
+ if (!integrationReservation.ok) {
1106
+ input.globalBudget.commit(globalReservation, 0);
1107
+ const record = this.budgetCapAttemptRecord(attempt, fetcherEvent.correlationId, startedAt, "budget-cap", integrationReservation.remaining);
1108
+ attempts.push(record);
1109
+ const decision = {
1110
+ retry: false,
1111
+ reason: "budget-cap",
1112
+ };
1113
+ this.logFanOutFailure(input, fetcherEvent, record, decision, {
1114
+ failureKind: "budget-cap",
1115
+ binding: binding.main,
1116
+ startedAt,
1117
+ });
1118
+ this.emitSubSessionCompleted(input, fetcherEvent.correlationId, attempt, record, decision);
1119
+ retriesExhausted = true;
1120
+ break;
1121
+ }
1122
+ let context;
1123
+ try {
1124
+ context = await this.contextBuilder.build(fetcherEvent);
1125
+ }
1126
+ catch (err) {
1127
+ input.globalBudget.commit(globalReservation, 0);
1128
+ integrationBudget.commit(integrationReservation, 0);
1129
+ const record = this.failedAttemptRecord(attempt, fetcherEvent.correlationId, startedAt, "context-build-failed", err);
1130
+ attempts.push(record);
1131
+ const decision = retryOn(record, attempt, input.policy, attempts.slice(0, -1));
1132
+ this.logFanOutFailure(input, fetcherEvent, record, decision, {
1133
+ failureKind: "context-build-failed",
1134
+ err,
1135
+ binding: binding.main,
1136
+ startedAt,
1137
+ });
1138
+ this.emitSubSessionCompleted(input, fetcherEvent.correlationId, attempt, record, decision);
1139
+ if (!decision.retry) {
1140
+ retriesExhausted = this.didExhaustRetries(record, decision, input.policy);
1141
+ break;
1142
+ }
1143
+ await sleep(this.backoffForAttempt(input.policy, attempt));
1144
+ continue;
1145
+ }
1146
+ let result = null;
1147
+ let executeErr = undefined;
1148
+ let record;
1149
+ try {
1150
+ const priorAttemptHintBlock = buildPriorAttemptHintBlock(attempts, input.subPlan.integrationKey);
1151
+ // PRE_PASS_FAN_OUT_DESIGN.md §4.2 — every sub-session sees the
1152
+ // `routine.fetch_window` task-flow body with `{integration_partial}`
1153
+ // substituted for the one partial its integrationKey owns. The
1154
+ // integrations snapshot fed to both the partial's mode filter
1155
+ // and the composed allowed-tools list is sliced to a single key
1156
+ // so the sub-session cannot see — or call MCP tools for — any
1157
+ // other integration's surface (defense-in-depth on top of the
1158
+ // prompt isolation).
1159
+ const slicedIntegrations = this.sliceIntegrationSnapshot(input.integrationsSnapshot, input.subPlan.integrationKey);
1160
+ const partialFilename = getIntegrationDescriptor(input.subPlan.integrationKey).prePassPartial;
1161
+ if (!partialFilename) {
1162
+ throw new Error(`Integration "${input.subPlan.integrationKey}" has no prePassPartial descriptor field — cannot dispatch fan-out sub-session`);
1163
+ }
1164
+ const reassemblePrompt = (bid) => {
1165
+ const assembled = this.prompt.assemble(FETCH_WINDOW_EVENT_TYPE, FETCH_WINDOW_PROCESS_KEY, bid);
1166
+ // Re-render the partial against the resolved backend each
1167
+ // time the SDK reassembles (e.g. on quota-driven fallback).
1168
+ // Mode markers inside the partial depend on the chosen
1169
+ // backend, so a cross-backend fallback must regenerate the
1170
+ // body to match the new MCP surface — matches the failure
1171
+ // mode catalogue's BackendQuotaError row.
1172
+ const partialBody = renderPartialForFanOut(partialFilename, slicedIntegrations, bid);
1173
+ const filled = assembled.replaceAll(FETCH_WINDOW_INTEGRATION_PARTIAL_PLACEHOLDER, partialBody);
1174
+ return priorAttemptHintBlock
1175
+ ? `${priorAttemptHintBlock}\n\n${filled}`
1176
+ : filled;
1177
+ };
1178
+ const prompt = reassemblePrompt(binding.main.backendId);
1179
+ const allowedToolsOverride = composePrePassAllowedTools(this.config.apiPort, slicedIntegrations, binding.main.backendId);
1180
+ result = await this.agentRouter.execute({
1181
+ prompt,
1182
+ context,
1183
+ event: fetcherEvent,
1184
+ processKey: FETCH_WINDOW_PROCESS_KEY,
1185
+ preResolvedBinding: binding,
1186
+ reassemblePrompt,
1187
+ allowedToolsOverride,
1188
+ });
1189
+ input.globalBudget.commit(globalReservation, result.costUsd);
1190
+ integrationBudget.commit(integrationReservation, result.costUsd);
1191
+ record = this.attemptRecordFromResult(attempt, fetcherEvent, startedAt, result);
1192
+ }
1193
+ catch (err) {
1194
+ input.globalBudget.commit(globalReservation, 0);
1195
+ integrationBudget.commit(integrationReservation, 0);
1196
+ executeErr = err;
1197
+ record = this.failedAttemptRecord(attempt, fetcherEvent.correlationId, startedAt, "agent-execute-failed", err);
1198
+ }
1199
+ attempts.push(record);
1200
+ const decision = retryOn(record, attempt, input.policy, attempts.slice(0, -1));
1201
+ if (result) {
1202
+ this.logFanOutAttempt(input, fetcherEvent, result, record, decision, binding.main.backendId);
1203
+ }
1204
+ else {
1205
+ // §7.1 — when the SDK actually invoked a backend session and
1206
+ // that session threw (timeout, BackendQuotaError, transport
1207
+ // failure, …) the audit feed must reflect it. Successful
1208
+ // attempts log via `logFanOutAttempt` with the AgentResult; the
1209
+ // throw path has no result, so we route through
1210
+ // `logFanOutFailure` which carries the same `detail.prePass`
1211
+ // payload the metrics aggregator filters on (preserving the
1212
+ // failure on `/metrics/pre-pass` alongside the other four
1213
+ // pre-execute failure modes).
1214
+ this.logFanOutFailure(input, fetcherEvent, record, decision, {
1215
+ failureKind: "agent-execute-failed",
1216
+ err: executeErr,
1217
+ binding: binding.main,
1218
+ startedAt,
1219
+ });
1220
+ }
1221
+ this.emitSubSessionCompleted(input, fetcherEvent.correlationId, attempt, record, decision);
1222
+ if (!decision.retry) {
1223
+ retriesExhausted = this.didExhaustRetries(record, decision, input.policy);
1224
+ break;
1225
+ }
1226
+ await sleep(this.backoffForAttempt(input.policy, attempt));
1227
+ }
1228
+ if (attempts.length === 0) {
1229
+ const now = new Date().toISOString();
1230
+ attempts.push({
1231
+ attempt: 0,
1232
+ status: "skipped",
1233
+ fetched: 0,
1234
+ posted: 0,
1235
+ duplicates: 0,
1236
+ errors: [],
1237
+ fetcherCorrelationId: input.parentEvent.correlationId,
1238
+ startedAt: now,
1239
+ endedAt: now,
1240
+ costUsd: 0,
1241
+ numTurns: 0,
1242
+ });
1243
+ }
1244
+ const final = attempts[attempts.length - 1];
1245
+ const allErrors = attempts.flatMap((att) => att.errors);
1246
+ return {
1247
+ ...final,
1248
+ errors: allErrors.length > 0 ? allErrors : final.errors,
1249
+ integrationKey: input.subPlan.integrationKey,
1250
+ attempts,
1251
+ retriesExhausted,
1252
+ };
1253
+ }
1254
+ /**
1255
+ * Emit `prepass_subsession_started` for an attempt that is about to run.
1256
+ * Called at the TOP of every loop iteration so the started/completed
1257
+ * pair is symmetric across all paths — including binding-resolve-failed
1258
+ * and budget-cap short-circuits, which previously emitted neither.
1259
+ */
1260
+ emitSubSessionStarted(input, fetcherCorrelationId, attempt) {
1261
+ this.broadcastPrepassProgress("prepass_subsession_started", input.parentEvent, {
1262
+ routine: input.key,
1263
+ integrationKey: input.subPlan.integrationKey,
1264
+ attempt,
1265
+ maxAttempts: input.policy.maxAttempts,
1266
+ fetcherCorrelationId,
1267
+ parentCorrelationId: input.parentEvent.correlationId,
1268
+ });
1269
+ }
1270
+ /**
1271
+ * Emit `prepass_subsession_completed` for an attempt that has just
1272
+ * recorded its outcome. Mirror of `emitSubSessionStarted` — invoked
1273
+ * once per iteration after every code path that pushes a record
1274
+ * (success, parse error, agent throw, binding-resolve-failed,
1275
+ * global-budget-cap, per-integration-budget-cap).
1276
+ */
1277
+ emitSubSessionCompleted(input, fetcherCorrelationId, attempt, record, decision) {
1278
+ this.broadcastPrepassProgress("prepass_subsession_completed", input.parentEvent, {
1279
+ routine: input.key,
1280
+ integrationKey: input.subPlan.integrationKey,
1281
+ attempt,
1282
+ maxAttempts: input.policy.maxAttempts,
1283
+ fetcherCorrelationId,
1284
+ parentCorrelationId: input.parentEvent.correlationId,
1285
+ status: record.status,
1286
+ fetched: record.fetched,
1287
+ posted: record.posted,
1288
+ duplicates: record.duplicates,
1289
+ willRetry: decision.retry,
1290
+ retryReason: decision.reason,
1291
+ });
1292
+ }
1293
+ createFanOutFetcherEvent(parentEvent, key, subPlan, attempt, maxAttempts) {
1294
+ return {
1295
+ ...createEvent({
1296
+ type: FETCH_WINDOW_EVENT_TYPE,
1297
+ source: parentEvent.source,
1298
+ priority: EventPriority.NORMAL,
1299
+ }),
1300
+ routine: "fetch_window",
1301
+ data: {
1302
+ acquisitionPlanBlock: subPlan.block,
1303
+ parentRoutine: key,
1304
+ parentCorrelationId: parentEvent.correlationId,
1305
+ prePassFanOut: {
1306
+ integrationKey: subPlan.integrationKey,
1307
+ attempt,
1308
+ maxAttempts,
1309
+ fetchRowCount: subPlan.fetchRowCount,
1310
+ rowsHaveAccount: subPlan.rowsHaveAccount,
1311
+ },
1312
+ },
1313
+ };
1314
+ }
1315
+ sliceIntegrationSnapshot(integrations, key) {
1316
+ const state = integrations[key];
1317
+ return state ? { [key]: state } : {};
1318
+ }
1319
+ attemptRecordFromResult(attempt, fetcherEvent, startedAt, result) {
1320
+ const parsed = parseFetchWindowOutput(result.output);
1321
+ const endedAt = new Date().toISOString();
1322
+ if ("parseError" in parsed) {
1323
+ return {
1324
+ attempt,
1325
+ status: "failed",
1326
+ fetched: 0,
1327
+ posted: 0,
1328
+ duplicates: 0,
1329
+ errors: [
1330
+ {
1331
+ type: "pre-pass-parse-failed",
1332
+ reason: parsed.parseError,
1333
+ attempt,
1334
+ },
1335
+ ],
1336
+ parseError: parsed.parseError,
1337
+ fetcherCorrelationId: fetcherEvent.correlationId,
1338
+ startedAt,
1339
+ endedAt,
1340
+ costUsd: result.costUsd,
1341
+ numTurns: result.numTurns,
1342
+ };
1343
+ }
1344
+ return {
1345
+ attempt,
1346
+ status: parsed.status,
1347
+ fetched: parsed.fetched,
1348
+ posted: parsed.posted,
1349
+ duplicates: parsed.duplicates,
1350
+ errors: parsed.errors.map((err) => ({ ...err, attempt })),
1351
+ fetcherCorrelationId: fetcherEvent.correlationId,
1352
+ startedAt,
1353
+ endedAt,
1354
+ costUsd: result.costUsd,
1355
+ numTurns: result.numTurns,
1356
+ };
1357
+ }
1358
+ failedAttemptRecord(attempt, fetcherCorrelationId, startedAt, kind, err) {
1359
+ const message = err instanceof Error ? err.message : String(err);
1360
+ const endedAt = new Date().toISOString();
1361
+ return {
1362
+ attempt,
1363
+ status: "failed",
1364
+ fetched: 0,
1365
+ posted: 0,
1366
+ duplicates: 0,
1367
+ errors: [{ type: "pre-pass-failed", kind, message, attempt }],
1368
+ fetcherCorrelationId,
1369
+ startedAt,
1370
+ endedAt,
1371
+ costUsd: 0,
1372
+ numTurns: 0,
1373
+ };
1374
+ }
1375
+ budgetCapAttemptRecord(attempt, fetcherCorrelationId, startedAt, type, remaining) {
1376
+ const endedAt = new Date().toISOString();
1377
+ return {
1378
+ attempt,
1379
+ status: "failed",
1380
+ fetched: 0,
1381
+ posted: 0,
1382
+ duplicates: 0,
1383
+ errors: [{ type, remaining, attempt }],
1384
+ fetcherCorrelationId,
1385
+ startedAt,
1386
+ endedAt,
1387
+ costUsd: 0,
1388
+ numTurns: 0,
1389
+ };
1390
+ }
1391
+ didExhaustRetries(record, decision, policy) {
1392
+ if (record.status === "success" || record.status === "skipped")
1393
+ return false;
1394
+ // FanOutBudgetGuard.reserve() failures surface as explicit error rows
1395
+ // — those are the parallel-reservation cap trips covered by §4.7.
1396
+ if (record.errors.some((err) => err.type === "budget-cap" || err.type === "global-budget-cap")) {
1397
+ return true;
1398
+ }
1399
+ // §4.3 contract: "exhausted maxAttempts (or a budget/global cap tripped)".
1400
+ // The per-integration cumulative-cost cap branch in defaultRetryDecision
1401
+ // returns reason=BUDGET_CAP without leaving a budget-cap error row on
1402
+ // the attempt — without this clause the sub-report would carry
1403
+ // retriesExhausted=false even though the loop terminated on a cap.
1404
+ if (decision.reason === RETRY_REASONS.MAX_ATTEMPTS
1405
+ || decision.reason === RETRY_REASONS.BUDGET_CAP) {
1406
+ return true;
1407
+ }
1408
+ return record.attempt >= policy.maxAttempts;
1409
+ }
1410
+ backoffForAttempt(policy, attempt) {
1411
+ if (attempt >= policy.maxAttempts)
1412
+ return 0;
1413
+ const configured = policy.backoffMs[attempt - 1];
1414
+ if (typeof configured === "number")
1415
+ return Math.max(0, configured);
1416
+ const last = policy.backoffMs[policy.backoffMs.length - 1];
1417
+ return typeof last === "number" ? Math.max(0, last) : 0;
1418
+ }
1419
+ /**
1420
+ * Unified audit-row companion for every fan-out failure mode —
1421
+ * binding-resolve-failed, global-budget-cap, budget-cap (per-integration),
1422
+ * context-build-failed, and agent-execute-failed. Routes through
1423
+ * `audit.logError` (writes `result='failed'`) with a `prePass` payload
1424
+ * so `MetricsCollector.collectPrePassMetrics` can see every failure
1425
+ * mode without a parallel `result='success'` row. Before this helper
1426
+ * existed, the four pre-execute branches wrote nothing at all and the
1427
+ * agent-execute path wrote a `failureKind`-only row that the aggregator
1428
+ * silently skipped (it filters on `detail.prePass` being a non-null
1429
+ * object). Cost / tokens are intentionally NOT supplied — pre-execute
1430
+ * paths have zero cost, the agent-execute throw path has no usable
1431
+ * AgentResult, so any figure here would be a guess; the aggregator
1432
+ * coalesces missing `cost_usd` to 0.
1433
+ */
1434
+ logFanOutFailure(input, fetcherEvent, record, decision, options) {
1435
+ try {
1436
+ const message = options.err instanceof Error
1437
+ ? options.err.message
1438
+ : options.err !== undefined
1439
+ ? String(options.err)
1440
+ : options.failureKind;
1441
+ const error = new Error(message);
1442
+ const startMs = Date.parse(options.startedAt);
1443
+ const durationMs = Number.isFinite(startMs)
1444
+ ? Math.max(0, Date.now() - startMs)
1445
+ : undefined;
1446
+ this.audit.logError(fetcherEvent, error, "autonomous", {
1447
+ ...(durationMs !== undefined ? { durationMs } : {}),
1448
+ ...(options.binding ? { backendId: options.binding.backendId } : {}),
1449
+ ...(options.binding ? { modelId: options.binding.modelId } : {}),
1450
+ failureKind: options.failureKind,
1451
+ prePass: {
1452
+ parentCorrelationId: input.parentEvent.correlationId,
1453
+ parentRoutine: input.key,
1454
+ integrationKey: input.subPlan.integrationKey,
1455
+ attempt: record.attempt,
1456
+ maxAttempts: input.policy.maxAttempts,
1457
+ retriedFromAttempt: record.attempt > 1 ? record.attempt - 1 : null,
1458
+ status: record.status,
1459
+ fetched: record.fetched,
1460
+ posted: record.posted,
1461
+ duplicates: record.duplicates,
1462
+ errors: record.errors,
1463
+ willRetry: decision.retry,
1464
+ retryReason: decision.reason,
1465
+ ...(options.binding ? { requestedBackend: options.binding.backendId } : {}),
1466
+ },
1467
+ });
1468
+ }
1469
+ catch (logErr) {
1470
+ logger.warn({
1471
+ err: logErr,
1472
+ routine: input.key,
1473
+ integrationKey: input.subPlan.integrationKey,
1474
+ failureKind: options.failureKind,
1475
+ correlationId: fetcherEvent.correlationId,
1476
+ }, "Failed to log routine.fetch_window fan-out failure audit row");
1477
+ }
1478
+ }
1479
+ logFanOutAttempt(input, fetcherEvent, result, record, decision, requestedBackend) {
1480
+ // §5 BackendQuotaError mitigation — set `fallbackTriggered` when the
1481
+ // backend that actually executed differs from the binding the runner
1482
+ // asked for. The audit row's `backend` column carries the ACTUAL
1483
+ // backend (set from `result.backendId` above); `requestedBackend`
1484
+ // surfaces what the runner intended, so the operator can spot
1485
+ // recurring fallbacks by grepping `fallbackTriggered: true` rows
1486
+ // without reconstructing the binding state from the daemon log.
1487
+ const fallbackTriggered = result.backendId !== undefined && result.backendId !== requestedBackend;
1488
+ try {
1489
+ this.audit.logAction({
1490
+ event: fetcherEvent,
1491
+ model: result.model,
1492
+ costUsd: result.costUsd,
1493
+ usage: result.usage,
1494
+ modelUsage: result.modelUsage,
1495
+ durationMs: result.durationMs,
1496
+ numTurns: result.numTurns,
1497
+ trigger: "autonomous",
1498
+ ...(result.backendId ? { backend: result.backendId } : {}),
1499
+ ...(result.costSource ? { costSource: result.costSource } : {}),
1500
+ contextUpdated: result.contextUpdated,
1501
+ ...(typeof result.advisorCallCount === "number"
1502
+ ? { advisorCallCount: result.advisorCallCount }
1503
+ : {}),
1504
+ prePass: {
1505
+ parentCorrelationId: input.parentEvent.correlationId,
1506
+ // §7.3 metric aggregation — every fan-out audit row carries
1507
+ // the parent routine key so `/metrics/pre-pass` can group by
1508
+ // routine without joining back to the parent's row.
1509
+ parentRoutine: input.key,
1510
+ integrationKey: input.subPlan.integrationKey,
1511
+ attempt: record.attempt,
1512
+ maxAttempts: input.policy.maxAttempts,
1513
+ // §7.1 example surfaces `retriedFromAttempt`. `null` for the
1514
+ // first attempt in a sub-session's chain; otherwise the prior
1515
+ // attempt index. Derivable but cheaper than a cross-row join.
1516
+ retriedFromAttempt: record.attempt > 1 ? record.attempt - 1 : null,
1517
+ status: record.status,
1518
+ fetched: record.fetched,
1519
+ posted: record.posted,
1520
+ duplicates: record.duplicates,
1521
+ errors: record.errors,
1522
+ willRetry: decision.retry,
1523
+ retryReason: decision.reason,
1524
+ ...(fallbackTriggered ? { fallbackTriggered: true } : {}),
1525
+ requestedBackend,
1526
+ },
1527
+ });
1528
+ }
1529
+ catch (err) {
1530
+ logger.warn({
1531
+ err,
1532
+ routine: input.key,
1533
+ integrationKey: input.subPlan.integrationKey,
1534
+ correlationId: fetcherEvent.correlationId,
1535
+ }, "Failed to log routine.fetch_window fan-out agent_actions row");
1536
+ }
1537
+ }
1538
+ /**
1539
+ * Helper for the failure paths — renders a `<fetch_report status="failed">`
1540
+ * block and logs the underlying error. Never throws so the caller can
1541
+ * always continue with the parent routine dispatch.
1542
+ */
1543
+ fail(routine, agentDay, parentEvent, kind, err, extra = {}) {
1544
+ const message = err instanceof Error ? err.message : String(err);
1545
+ const report = {
1546
+ status: "failed",
1547
+ fetched: 0,
1548
+ posted: 0,
1549
+ duplicates: 0,
1550
+ errors: [{ type: "pre-pass-failed", kind, message }],
1551
+ skipped: false,
1552
+ failureReason: `${kind}: ${message}`,
1553
+ ...(extra.fetcherCorrelationId
1554
+ ? { fetcherCorrelationId: extra.fetcherCorrelationId }
1555
+ : {}),
1556
+ };
1557
+ const block = renderFetchReportBlock(report, { routine, agentDay });
1558
+ logger.warn({
1559
+ routine,
1560
+ kind,
1561
+ err,
1562
+ parentCorrelationId: parentEvent.correlationId,
1563
+ }, "Routine fetch-window pre-pass failed — parent routine will see <fetch_report status='failed'>");
1564
+ return { report, block };
1565
+ }
1566
+ /**
1567
+ * Translate the mail registry's active-account list into the
1568
+ * `AcquisitionAccount[]` shape `buildAcquisitionPlan` expects. Only
1569
+ * accounts whose integration is currently non-disabled survive — a
1570
+ * disabled gmail integration with five accounts produces zero rows,
1571
+ * matching the partial's `<!-- mode:disabled:gmail -->` no-op.
1572
+ */
1573
+ collectAccounts(integrations) {
1574
+ const rows = [];
1575
+ for (const account of this.getActiveMailAccounts()) {
1576
+ const integrationKey = mailAccountIntegrationKey(account);
1577
+ if (integrationKey === null)
1578
+ continue;
1579
+ const state = integrations[integrationKey];
1580
+ if (!state || state.mode === "disabled")
1581
+ continue;
1582
+ rows.push({
1583
+ integration: integrationKey,
1584
+ accountId: account.id,
1585
+ label: account.email,
1586
+ });
1587
+ }
1588
+ return rows;
1589
+ }
1590
+ }
1591
+ //# sourceMappingURL=routine-fetch-window-runner.js.map