@agent-native/core 0.131.7 → 0.132.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +123 -0
  3. package/corpus/core/docs/content/durable-background-runs.mdx +15 -6
  4. package/corpus/core/docs/design/durable-agent-runs.md +15 -11
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/activity.ts +127 -10
  7. package/corpus/core/src/a2a/index.ts +3 -0
  8. package/corpus/core/src/agent/durable-background.ts +38 -28
  9. package/corpus/core/src/agent/harness/background.ts +12 -2
  10. package/corpus/core/src/agent/production-agent.ts +18 -15
  11. package/corpus/core/src/agent/run-store.ts +45 -3
  12. package/corpus/core/src/application-state/handlers.ts +45 -0
  13. package/corpus/core/src/application-state/index.ts +3 -0
  14. package/corpus/core/src/application-state/store.ts +38 -34
  15. package/corpus/core/src/audit/redact.ts +104 -18
  16. package/corpus/core/src/cli/create.ts +115 -16
  17. package/corpus/core/src/client/AgentPanel.tsx +2 -7
  18. package/corpus/core/src/client/AssistantChat.tsx +10 -2
  19. package/corpus/core/src/client/MultiTabAssistantChat.tsx +3 -0
  20. package/corpus/core/src/client/RunStuckBanner.tsx +29 -6
  21. package/corpus/core/src/client/agent-chat.ts +4 -9
  22. package/corpus/core/src/client/application-state.ts +115 -12
  23. package/corpus/core/src/client/dynamic-suggestions.ts +4 -12
  24. package/corpus/core/src/client/frame-protocol.ts +5 -1
  25. package/corpus/core/src/client/guided-questions.tsx +12 -23
  26. package/corpus/core/src/db/client.ts +148 -28
  27. package/corpus/core/src/db/create-get-db.ts +39 -6
  28. package/corpus/core/src/db/runtime-diagnostics.ts +41 -6
  29. package/corpus/core/src/deploy/build.ts +26 -33
  30. package/corpus/core/src/feature-flags/store.ts +10 -5
  31. package/corpus/core/src/integrations/a2a-continuations-store.ts +11 -0
  32. package/corpus/core/src/integrations/config-store.ts +13 -0
  33. package/corpus/core/src/integrations/google-docs-poller.ts +26 -2
  34. package/corpus/core/src/mcp-client/routes.ts +33 -2
  35. package/corpus/core/src/onboarding/plugin.ts +31 -25
  36. package/corpus/core/src/org/accept-pending.ts +2 -0
  37. package/corpus/core/src/org/auto-join-domain.ts +2 -0
  38. package/corpus/core/src/org/context.ts +35 -10
  39. package/corpus/core/src/org/handlers.ts +6 -0
  40. package/corpus/core/src/org/request-org-cache.ts +71 -0
  41. package/corpus/core/src/resources/store.ts +10 -9
  42. package/corpus/core/src/scripts/runner.ts +6 -3
  43. package/corpus/core/src/secrets/storage.ts +97 -5
  44. package/corpus/core/src/server/action-change.ts +33 -0
  45. package/corpus/core/src/server/action-routes.ts +11 -9
  46. package/corpus/core/src/server/agent-teams.ts +10 -2
  47. package/corpus/core/src/server/better-auth-instance.ts +39 -12
  48. package/corpus/core/src/server/core-routes-plugin.ts +19 -2
  49. package/corpus/core/src/server/credential-provider.ts +57 -14
  50. package/corpus/core/src/server/http-response-telemetry.ts +1 -0
  51. package/corpus/core/src/server/poll.ts +95 -57
  52. package/corpus/core/src/server/voice-providers-status.ts +19 -4
  53. package/corpus/core/src/workspace-files/tool.ts +18 -11
  54. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -0
  55. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1 -1
  56. package/corpus/templates/analytics/actions/ensure-native-v2-dashboards.ts +299 -0
  57. package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +34 -0
  58. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +82 -0
  59. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +2 -2
  60. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +40 -34
  61. package/corpus/templates/analytics/app/hooks/use-user-pref.ts +3 -3
  62. package/corpus/templates/analytics/app/i18n-data.ts +40 -0
  63. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +18 -10
  64. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +14 -0
  65. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +1 -0
  66. package/corpus/templates/analytics/app/routes/chart.tsx +3 -0
  67. package/corpus/templates/analytics/changelog/2026-07-30-analytics-dashboards-now-expose-native-funnel-heatmap-callou.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-30-analytics-now-ships-reusable-native-v2-dashboard-manifests-w.md +6 -0
  69. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +81 -1
  70. package/corpus/templates/analytics/server/lib/native-v2-dashboards.ts +457 -0
  71. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +73 -2
  72. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  73. package/corpus/templates/analytics/shared/dashboard-funnel.ts +83 -0
  74. package/corpus/templates/clips/actions/generate-workflow.ts +6 -4
  75. package/corpus/templates/clips/actions/reconcile-workflow-generation.ts +257 -0
  76. package/corpus/templates/clips/app/hooks/use-auto-title.ts +166 -4
  77. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -1
  78. package/corpus/templates/clips/changelog/2026-07-27-stopped-agent-runs-no-longer-leave-generated-workflow-cards-.md +6 -0
  79. package/corpus/templates/clips/changelog/2026-07-30-workflow-generation-now-stops-retrying-after-repeated-failur.md +6 -0
  80. package/corpus/templates/clips/shared/workflow.ts +5 -0
  81. package/corpus/templates/content/changelog/2026-07-30-long-running-delegated-requests-now-continue-reliably.md +6 -0
  82. package/corpus/templates/content/netlify.toml +3 -0
  83. package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
  84. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +2 -1
  85. package/corpus/templates/slides/actions/patch-deck.ts +25 -1
  86. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +17 -4
  87. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +9 -1
  88. package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
  89. package/corpus/templates/slides/app/context/DeckContext.tsx +11 -3
  90. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +39 -11
  91. package/corpus/templates/slides/app/pages/Index.tsx +2 -2
  92. package/corpus/templates/slides/changelog/2026-07-30-slide-previews-keep-their-aspect-ratio-isolate-embedded-styl.md +6 -0
  93. package/corpus/templates/slides/changelog/2026-07-30-slides-no-longer-lose-decks-while-organization-access-is-loa.md +6 -0
  94. package/corpus/toolkit/CHANGELOG.md +8 -0
  95. package/corpus/toolkit/package.json +1 -1
  96. package/corpus/toolkit/src/composer/runtime-adapters.tsx +16 -11
  97. package/dist/a2a/activity.d.ts +22 -4
  98. package/dist/a2a/activity.d.ts.map +1 -1
  99. package/dist/a2a/activity.js +97 -11
  100. package/dist/a2a/activity.js.map +1 -1
  101. package/dist/a2a/index.d.ts +1 -1
  102. package/dist/a2a/index.d.ts.map +1 -1
  103. package/dist/a2a/index.js +1 -1
  104. package/dist/a2a/index.js.map +1 -1
  105. package/dist/agent/durable-background.d.ts +13 -8
  106. package/dist/agent/durable-background.d.ts.map +1 -1
  107. package/dist/agent/durable-background.js +36 -28
  108. package/dist/agent/durable-background.js.map +1 -1
  109. package/dist/agent/harness/background.d.ts.map +1 -1
  110. package/dist/agent/harness/background.js +12 -2
  111. package/dist/agent/harness/background.js.map +1 -1
  112. package/dist/agent/production-agent.d.ts.map +1 -1
  113. package/dist/agent/production-agent.js +18 -15
  114. package/dist/agent/production-agent.js.map +1 -1
  115. package/dist/agent/run-store.d.ts +2 -0
  116. package/dist/agent/run-store.d.ts.map +1 -1
  117. package/dist/agent/run-store.js +44 -3
  118. package/dist/agent/run-store.js.map +1 -1
  119. package/dist/application-state/handlers.d.ts +15 -0
  120. package/dist/application-state/handlers.d.ts.map +1 -1
  121. package/dist/application-state/handlers.js +42 -2
  122. package/dist/application-state/handlers.js.map +1 -1
  123. package/dist/application-state/index.d.ts +2 -2
  124. package/dist/application-state/index.d.ts.map +1 -1
  125. package/dist/application-state/index.js +2 -2
  126. package/dist/application-state/index.js.map +1 -1
  127. package/dist/application-state/store.d.ts +11 -0
  128. package/dist/application-state/store.d.ts.map +1 -1
  129. package/dist/application-state/store.js +37 -40
  130. package/dist/application-state/store.js.map +1 -1
  131. package/dist/audit/redact.d.ts +25 -2
  132. package/dist/audit/redact.d.ts.map +1 -1
  133. package/dist/audit/redact.js +85 -21
  134. package/dist/audit/redact.js.map +1 -1
  135. package/dist/cli/create.d.ts +8 -1
  136. package/dist/cli/create.d.ts.map +1 -1
  137. package/dist/cli/create.js +98 -16
  138. package/dist/cli/create.js.map +1 -1
  139. package/dist/client/AgentPanel.d.ts.map +1 -1
  140. package/dist/client/AgentPanel.js +2 -7
  141. package/dist/client/AgentPanel.js.map +1 -1
  142. package/dist/client/AssistantChat.d.ts.map +1 -1
  143. package/dist/client/AssistantChat.js +10 -2
  144. package/dist/client/AssistantChat.js.map +1 -1
  145. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  146. package/dist/client/MultiTabAssistantChat.js +1 -1
  147. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  148. package/dist/client/RunStuckBanner.d.ts +11 -1
  149. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  150. package/dist/client/RunStuckBanner.js +20 -7
  151. package/dist/client/RunStuckBanner.js.map +1 -1
  152. package/dist/client/agent-chat.d.ts.map +1 -1
  153. package/dist/client/agent-chat.js +4 -6
  154. package/dist/client/agent-chat.js.map +1 -1
  155. package/dist/client/application-state.d.ts +11 -0
  156. package/dist/client/application-state.d.ts.map +1 -1
  157. package/dist/client/application-state.js +87 -9
  158. package/dist/client/application-state.js.map +1 -1
  159. package/dist/client/dynamic-suggestions.d.ts.map +1 -1
  160. package/dist/client/dynamic-suggestions.js +4 -13
  161. package/dist/client/dynamic-suggestions.js.map +1 -1
  162. package/dist/client/frame-protocol.d.ts +1 -0
  163. package/dist/client/frame-protocol.d.ts.map +1 -1
  164. package/dist/client/frame-protocol.js.map +1 -1
  165. package/dist/client/guided-questions.d.ts.map +1 -1
  166. package/dist/client/guided-questions.js +6 -22
  167. package/dist/client/guided-questions.js.map +1 -1
  168. package/dist/collab/awareness.d.ts +2 -2
  169. package/dist/collab/awareness.d.ts.map +1 -1
  170. package/dist/collab/routes.d.ts +1 -1
  171. package/dist/db/client.d.ts +57 -10
  172. package/dist/db/client.d.ts.map +1 -1
  173. package/dist/db/client.js +122 -31
  174. package/dist/db/client.js.map +1 -1
  175. package/dist/db/create-get-db.d.ts.map +1 -1
  176. package/dist/db/create-get-db.js +31 -7
  177. package/dist/db/create-get-db.js.map +1 -1
  178. package/dist/db/runtime-diagnostics.d.ts +1 -0
  179. package/dist/db/runtime-diagnostics.d.ts.map +1 -1
  180. package/dist/db/runtime-diagnostics.js +31 -6
  181. package/dist/db/runtime-diagnostics.js.map +1 -1
  182. package/dist/deploy/build.d.ts +5 -20
  183. package/dist/deploy/build.d.ts.map +1 -1
  184. package/dist/deploy/build.js +18 -30
  185. package/dist/deploy/build.js.map +1 -1
  186. package/dist/feature-flags/store.d.ts.map +1 -1
  187. package/dist/feature-flags/store.js +10 -5
  188. package/dist/feature-flags/store.js.map +1 -1
  189. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  190. package/dist/integrations/a2a-continuations-store.js +12 -0
  191. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  192. package/dist/integrations/config-store.d.ts +1 -0
  193. package/dist/integrations/config-store.d.ts.map +1 -1
  194. package/dist/integrations/config-store.js +11 -0
  195. package/dist/integrations/config-store.js.map +1 -1
  196. package/dist/integrations/google-docs-poller.d.ts.map +1 -1
  197. package/dist/integrations/google-docs-poller.js +19 -2
  198. package/dist/integrations/google-docs-poller.js.map +1 -1
  199. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  200. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  201. package/dist/mcp-client/routes.d.ts.map +1 -1
  202. package/dist/mcp-client/routes.js +30 -2
  203. package/dist/mcp-client/routes.js.map +1 -1
  204. package/dist/notifications/routes.d.ts +6 -6
  205. package/dist/onboarding/plugin.d.ts.map +1 -1
  206. package/dist/onboarding/plugin.js +12 -8
  207. package/dist/onboarding/plugin.js.map +1 -1
  208. package/dist/org/accept-pending.d.ts.map +1 -1
  209. package/dist/org/accept-pending.js +2 -0
  210. package/dist/org/accept-pending.js.map +1 -1
  211. package/dist/org/auto-join-domain.d.ts.map +1 -1
  212. package/dist/org/auto-join-domain.js +2 -0
  213. package/dist/org/auto-join-domain.js.map +1 -1
  214. package/dist/org/context.d.ts.map +1 -1
  215. package/dist/org/context.js +29 -9
  216. package/dist/org/context.js.map +1 -1
  217. package/dist/org/handlers.d.ts.map +1 -1
  218. package/dist/org/handlers.js +5 -0
  219. package/dist/org/handlers.js.map +1 -1
  220. package/dist/org/request-org-cache.d.ts +16 -0
  221. package/dist/org/request-org-cache.d.ts.map +1 -0
  222. package/dist/org/request-org-cache.js +62 -0
  223. package/dist/org/request-org-cache.js.map +1 -0
  224. package/dist/progress/routes.d.ts +1 -1
  225. package/dist/resources/handlers.d.ts +1 -1
  226. package/dist/resources/store.d.ts.map +1 -1
  227. package/dist/resources/store.js +11 -9
  228. package/dist/resources/store.js.map +1 -1
  229. package/dist/scripts/runner.d.ts.map +1 -1
  230. package/dist/scripts/runner.js +3 -3
  231. package/dist/scripts/runner.js.map +1 -1
  232. package/dist/secrets/storage.d.ts.map +1 -1
  233. package/dist/secrets/storage.js +82 -5
  234. package/dist/secrets/storage.js.map +1 -1
  235. package/dist/server/action-change.d.ts +20 -0
  236. package/dist/server/action-change.d.ts.map +1 -1
  237. package/dist/server/action-change.js +29 -0
  238. package/dist/server/action-change.js.map +1 -1
  239. package/dist/server/action-routes.d.ts.map +1 -1
  240. package/dist/server/action-routes.js +7 -8
  241. package/dist/server/action-routes.js.map +1 -1
  242. package/dist/server/agent-teams.d.ts.map +1 -1
  243. package/dist/server/agent-teams.js +10 -2
  244. package/dist/server/agent-teams.js.map +1 -1
  245. package/dist/server/better-auth-instance.d.ts.map +1 -1
  246. package/dist/server/better-auth-instance.js +31 -14
  247. package/dist/server/better-auth-instance.js.map +1 -1
  248. package/dist/server/core-routes-plugin.d.ts +1 -0
  249. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  250. package/dist/server/core-routes-plugin.js +18 -4
  251. package/dist/server/core-routes-plugin.js.map +1 -1
  252. package/dist/server/credential-provider.d.ts +16 -0
  253. package/dist/server/credential-provider.d.ts.map +1 -1
  254. package/dist/server/credential-provider.js +49 -13
  255. package/dist/server/credential-provider.js.map +1 -1
  256. package/dist/server/http-response-telemetry.d.ts.map +1 -1
  257. package/dist/server/http-response-telemetry.js +1 -0
  258. package/dist/server/http-response-telemetry.js.map +1 -1
  259. package/dist/server/poll.d.ts +6 -0
  260. package/dist/server/poll.d.ts.map +1 -1
  261. package/dist/server/poll.js +84 -47
  262. package/dist/server/poll.js.map +1 -1
  263. package/dist/server/realtime-token.d.ts +1 -1
  264. package/dist/server/voice-providers-status.d.ts.map +1 -1
  265. package/dist/server/voice-providers-status.js +12 -3
  266. package/dist/server/voice-providers-status.js.map +1 -1
  267. package/dist/workspace-files/tool.d.ts.map +1 -1
  268. package/dist/workspace-files/tool.js +18 -11
  269. package/dist/workspace-files/tool.js.map +1 -1
  270. package/docs/content/durable-background-runs.mdx +15 -6
  271. package/docs/design/durable-agent-runs.md +15 -11
  272. package/package.json +3 -3
  273. package/src/a2a/activity.ts +127 -10
  274. package/src/a2a/index.ts +3 -0
  275. package/src/agent/durable-background.ts +38 -28
  276. package/src/agent/harness/background.ts +12 -2
  277. package/src/agent/production-agent.ts +18 -15
  278. package/src/agent/run-store.ts +45 -3
  279. package/src/application-state/handlers.ts +45 -0
  280. package/src/application-state/index.ts +3 -0
  281. package/src/application-state/store.ts +38 -34
  282. package/src/audit/redact.ts +104 -18
  283. package/src/cli/create.ts +115 -16
  284. package/src/client/AgentPanel.tsx +2 -7
  285. package/src/client/AssistantChat.tsx +10 -2
  286. package/src/client/MultiTabAssistantChat.tsx +3 -0
  287. package/src/client/RunStuckBanner.tsx +29 -6
  288. package/src/client/agent-chat.ts +4 -9
  289. package/src/client/application-state.ts +115 -12
  290. package/src/client/dynamic-suggestions.ts +4 -12
  291. package/src/client/frame-protocol.ts +5 -1
  292. package/src/client/guided-questions.tsx +12 -23
  293. package/src/db/client.ts +148 -28
  294. package/src/db/create-get-db.ts +39 -6
  295. package/src/db/runtime-diagnostics.ts +41 -6
  296. package/src/deploy/build.ts +26 -33
  297. package/src/feature-flags/store.ts +10 -5
  298. package/src/integrations/a2a-continuations-store.ts +11 -0
  299. package/src/integrations/config-store.ts +13 -0
  300. package/src/integrations/google-docs-poller.ts +26 -2
  301. package/src/mcp-client/routes.ts +33 -2
  302. package/src/onboarding/plugin.ts +31 -25
  303. package/src/org/accept-pending.ts +2 -0
  304. package/src/org/auto-join-domain.ts +2 -0
  305. package/src/org/context.ts +35 -10
  306. package/src/org/handlers.ts +6 -0
  307. package/src/org/request-org-cache.ts +71 -0
  308. package/src/resources/store.ts +10 -9
  309. package/src/scripts/runner.ts +6 -3
  310. package/src/secrets/storage.ts +97 -5
  311. package/src/server/action-change.ts +33 -0
  312. package/src/server/action-routes.ts +11 -9
  313. package/src/server/agent-teams.ts +10 -2
  314. package/src/server/better-auth-instance.ts +39 -12
  315. package/src/server/core-routes-plugin.ts +19 -2
  316. package/src/server/credential-provider.ts +57 -14
  317. package/src/server/http-response-telemetry.ts +1 -0
  318. package/src/server/poll.ts +95 -57
  319. package/src/server/voice-providers-status.ts +19 -4
  320. package/src/workspace-files/tool.ts +18 -11
package/src/db/client.ts CHANGED
@@ -954,19 +954,19 @@ export function isServerlessRuntime(): boolean {
954
954
  }
955
955
 
956
956
  /**
957
- * postgres.js pool options tuned per runtime. A serverless instance handles
958
- * one request at a time, so a single connection is enough. Keeping the
959
- * foreground pool at 1 is important because each instance can also open an
960
- * app and Better Auth pool; a max of 2 on each pool still exhausted Neon
961
- * under a burst of warm instances. idle_timeout is shortened on
957
+ * postgres.js pool options tuned per runtime. idle_timeout is shortened on
962
958
  * serverless so a thawed-but-idle instance releases its connections quickly.
963
- * Long-lived Node servers keep the normal pool for throughput.
959
+ * Long-lived Node servers keep the larger pool for throughput.
960
+ *
961
+ * The cap assumes {@link sharedDbPool}: one pool per URL for the whole
962
+ * process, not one per consumer. See {@link neonPoolMax} for why the cap must
963
+ * leave room for concurrency.
964
964
  */
965
965
  export function pgPoolOptions(url: string): Record<string, unknown> {
966
966
  const serverless = isServerlessRuntime();
967
967
  return {
968
968
  onnotice: () => {},
969
- max: serverless ? 1 : 10,
969
+ max: serverless ? 4 : 20,
970
970
  idle_timeout: serverless ? 20 : 240,
971
971
  max_lifetime: 60 * 30,
972
972
  connect_timeout: 10,
@@ -977,13 +977,21 @@ export function pgPoolOptions(url: string): Record<string, unknown> {
977
977
  }
978
978
 
979
979
  /**
980
- * Connection cap for the @neondatabase/serverless `Pool`. Same instance
981
- * accumulation risk as postgres.js — one connection is enough for a
982
- * foreground serverless invocation and keeps the aggregate app/framework/auth
983
- * budget bounded.
980
+ * Connection cap for the @neondatabase/serverless `Pool`.
981
+ *
982
+ * TRAP: this number is the ceiling on how many statements one request can have
983
+ * in flight at once. It used to be 1 on serverless, which made every
984
+ * `Promise.all([...reads])` in a request serialize behind the single slot —
985
+ * against a remote endpoint that is ~83ms of pure wait per query, so ten
986
+ * "concurrent" reads took ~880ms instead of ~86ms. The cap was 1 only because
987
+ * every consumer built its OWN pool (the DbExec singleton, Better Auth, and one
988
+ * per `createGetDb()` schema module), so the real per-instance connection count
989
+ * was that number multiplied by ~6. {@link sharedDbPool} collapses those into
990
+ * one pool per URL, so the same aggregate budget buys in-request concurrency.
991
+ * Keep pools shared if you raise this.
984
992
  */
985
993
  export function neonPoolMax(): number {
986
- if (!isServerlessRuntime()) return 10;
994
+ if (!isServerlessRuntime()) return 20;
987
995
  // The durable background-function worker is a SINGLE process per run (unlike
988
996
  // the many warm request-instances the foreground serverless has), so it can
989
997
  // safely hold a larger pool without risking Neon's connection cap. The agent's
@@ -996,7 +1004,7 @@ export function neonPoolMax(): number {
996
1004
  // burst; keep the foreground serverless pool tiny to avoid "Max client
997
1005
  // connections reached" across many warm instances.
998
1006
  if (isBackgroundFunctionPoolContext()) return 8;
999
- return 1;
1007
+ return 4;
1000
1008
  }
1001
1009
 
1002
1010
  /**
@@ -1110,6 +1118,113 @@ export function attachNeonPoolErrorLogger(
1110
1118
  });
1111
1119
  }
1112
1120
 
1121
+ // ---------------------------------------------------------------------------
1122
+ // Shared connection pools
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ interface ClosablePool {
1126
+ end(): Promise<unknown>;
1127
+ }
1128
+
1129
+ const _sharedDbPools = new Map<string, ClosablePool>();
1130
+ const _sharedDbPoolCloseHooks = new Set<() => void>();
1131
+ const _sharedDbPoolReplacementHooks = new Map<string, Set<() => void>>();
1132
+
1133
+ /**
1134
+ * One connection pool per (driver, URL) for the whole process.
1135
+ *
1136
+ * Opening a connection to a remote database costs a full TCP + TLS + auth
1137
+ * round-trip chain (~300-800ms measured against Neon in-region), and it used
1138
+ * to be paid once per CONSUMER: the `getDbExec` singleton, Better Auth, and one
1139
+ * more for every `createGetDb()` schema module (four in core alone, plus each
1140
+ * app's). Their `max` values also multiplied against the provider's connection
1141
+ * cap, which forced each pool down to a size too small to run a request's reads
1142
+ * concurrently.
1143
+ *
1144
+ * TRAP: only consumers that live as long as the process may share. Anything
1145
+ * handed a `close()` — `createDbExec()`, the migration exec on the direct
1146
+ * endpoint — must keep a private pool, or closing it takes the whole process's
1147
+ * database access down with it.
1148
+ */
1149
+ export function sharedDbPool<T extends ClosablePool>(
1150
+ driver: string,
1151
+ url: string,
1152
+ create: () => T,
1153
+ ): T {
1154
+ const key = `${driver}${url}`;
1155
+ const existing = _sharedDbPools.get(key);
1156
+ if (existing) return existing as T;
1157
+ const created = create();
1158
+ _sharedDbPools.set(key, created);
1159
+ return created;
1160
+ }
1161
+
1162
+ /**
1163
+ * Swap the pool registered for a key, for the postgres.js recycle path that
1164
+ * replaces a pool whose query timed out. Without this the registry would keep
1165
+ * handing out the discarded pool.
1166
+ */
1167
+ export function replaceSharedDbPool<T extends ClosablePool>(
1168
+ driver: string,
1169
+ url: string,
1170
+ previous: T,
1171
+ next: T,
1172
+ ): void {
1173
+ const key = `${driver}${url}`;
1174
+ if (_sharedDbPools.get(key) !== previous) return;
1175
+ _sharedDbPools.set(key, next);
1176
+ for (const hook of _sharedDbPoolReplacementHooks.get(key) ?? []) {
1177
+ try {
1178
+ hook();
1179
+ } catch {
1180
+ // A consumer's reset must not block the replacement from being used.
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Run `hook` when one shared pool is replaced, so derived consumers rebuild
1187
+ * their handles instead of continuing to use the timed-out pool.
1188
+ */
1189
+ export function onSharedDbPoolReplaced(
1190
+ driver: string,
1191
+ url: string,
1192
+ hook: () => void,
1193
+ ): void {
1194
+ const key = `${driver}${String.fromCharCode(0)}${url}`;
1195
+ const hooks = _sharedDbPoolReplacementHooks.get(key) ?? new Set();
1196
+ hooks.add(hook);
1197
+ _sharedDbPoolReplacementHooks.set(key, hooks);
1198
+ }
1199
+
1200
+ /**
1201
+ * Run `hook` when the shared pools are closed, so consumers holding a derived
1202
+ * handle (a Drizzle instance bound to the pool, the Better Auth adapter) drop
1203
+ * it instead of issuing queries on a closed pool.
1204
+ */
1205
+ export function onSharedDbPoolsClosed(hook: () => void): void {
1206
+ _sharedDbPoolCloseHooks.add(hook);
1207
+ }
1208
+
1209
+ export async function closeSharedDbPools(): Promise<void> {
1210
+ const pools = [..._sharedDbPools.values()];
1211
+ _sharedDbPools.clear();
1212
+ for (const hook of _sharedDbPoolCloseHooks) {
1213
+ try {
1214
+ hook();
1215
+ } catch {
1216
+ // A consumer's reset must not block the rest from being released.
1217
+ }
1218
+ }
1219
+ await Promise.all(
1220
+ pools.map((pool) =>
1221
+ Promise.resolve(pool.end()).catch(() => {
1222
+ // Already closed (process exiting, provider hung up) — nothing to do.
1223
+ }),
1224
+ ),
1225
+ );
1226
+ }
1227
+
1113
1228
  function disposePostgresPoolEventually(
1114
1229
  pool: { end: () => Promise<unknown> },
1115
1230
  label: string,
@@ -1130,8 +1245,6 @@ function disposePostgresPoolEventually(
1130
1245
  // ---------------------------------------------------------------------------
1131
1246
 
1132
1247
  let _exec: DbExec | undefined;
1133
- let _pgPool: any;
1134
- let _neonPool: any;
1135
1248
  let _sqlite: any;
1136
1249
  let _initPromise: Promise<void> | undefined;
1137
1250
 
@@ -1231,9 +1344,14 @@ async function createDbExecInternal(
1231
1344
  // connections, so its direct executions use stateless Neon HTTP instead.
1232
1345
  // The foreground and transaction surface keep the WebSocket pool.
1233
1346
  const bgHttp = isBackgroundFunctionPoolContext();
1234
- const pool = new Pool({ connectionString: url, max: neonPoolMax() });
1347
+ const makePool = () =>
1348
+ new Pool({ connectionString: url, max: neonPoolMax() });
1349
+ // The singleton exec shares the process pool; `createDbExec()` callers own
1350
+ // a `close()` and so must not be handed it.
1351
+ const pool = trackSingletonResources
1352
+ ? sharedDbPool("neon", url, makePool)
1353
+ : makePool();
1235
1354
  attachNeonPoolErrorLogger(pool);
1236
- if (trackSingletonResources) _neonPool = pool;
1237
1355
  const httpSql = bgHttp ? neon(url, { fullResults: true }) : null;
1238
1356
  async function queryNeonClient(
1239
1357
  client: any,
@@ -1461,6 +1579,7 @@ async function createDbExecInternal(
1461
1579
  }, 1);
1462
1580
  },
1463
1581
  async close() {
1582
+ if (trackSingletonResources) return closeSharedDbPools();
1464
1583
  await pool.end();
1465
1584
  },
1466
1585
  };
@@ -1566,12 +1685,17 @@ async function createDbExecInternal(
1566
1685
  // server-side timeout, avoiding ECONNRESET when the server hangs up.
1567
1686
  const createPool = () => postgres(url, pgPoolOptions(url));
1568
1687
  type PostgresPool = ReturnType<typeof createPool>;
1569
- let pool = createPool();
1570
- if (trackSingletonResources) _pgPool = pool;
1688
+ // Same rule as the Neon path: the singleton exec shares the process pool,
1689
+ // `createDbExec()` callers own a `close()` and get a private one.
1690
+ let pool = trackSingletonResources
1691
+ ? sharedDbPool("postgres-js", url, createPool)
1692
+ : createPool();
1571
1693
  const recyclePool = (timedOutPool: PostgresPool) => {
1572
1694
  if (pool === timedOutPool) {
1573
1695
  pool = createPool();
1574
- if (trackSingletonResources) _pgPool = pool;
1696
+ if (trackSingletonResources) {
1697
+ replaceSharedDbPool("postgres-js", url, timedOutPool, pool);
1698
+ }
1575
1699
  }
1576
1700
  disposePostgresPoolEventually(timedOutPool, "timed-out pooled query");
1577
1701
  };
@@ -1626,6 +1750,7 @@ async function createDbExecInternal(
1626
1750
  return result as T;
1627
1751
  },
1628
1752
  async close() {
1753
+ if (trackSingletonResources) return closeSharedDbPools();
1629
1754
  await pool.end();
1630
1755
  },
1631
1756
  };
@@ -1884,14 +2009,9 @@ export function getDbExec(): DbExec {
1884
2009
 
1885
2010
  /** Close the database connection (for scripts that need cleanup). */
1886
2011
  export async function closeDbExec(): Promise<void> {
1887
- if (_pgPool) {
1888
- await _pgPool.end();
1889
- _pgPool = undefined;
1890
- }
1891
- if (_neonPool) {
1892
- await _neonPool.end();
1893
- _neonPool = undefined;
1894
- }
2012
+ // Both Postgres pools live in the shared registry, which also notifies the
2013
+ // Drizzle / Better Auth consumers bound to them.
2014
+ await closeSharedDbPools();
1895
2015
  if (_sqlite) {
1896
2016
  _sqlite.close();
1897
2017
  _sqlite = undefined;
@@ -21,6 +21,9 @@ import {
21
21
  withDbTimeout,
22
22
  retryOnConnectionError,
23
23
  dbOpTimeoutMs,
24
+ sharedDbPool,
25
+ onSharedDbPoolsClosed,
26
+ onSharedDbPoolReplaced,
24
27
  } from "./client.js";
25
28
 
26
29
  // Lazy driver loaders — cached promises so dynamic import only runs once.
@@ -444,6 +447,27 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
444
447
  let _db: any;
445
448
  let _dbReady: Promise<any> | undefined;
446
449
 
450
+ // The Drizzle instance is bound to a shared pool, so a `closeDbExec()` (test
451
+ // teardown, script cleanup) must invalidate it rather than leave this store
452
+ // issuing queries on a closed pool. Registered lazily from the pooled
453
+ // branches only — `createGetDb` is called at module scope by every store, and
454
+ // core's specs widely mock `db/client.js`.
455
+ let _closeHookRegistered = false;
456
+ function resetOnPoolClose(driver?: string, url?: string): void {
457
+ if (_closeHookRegistered) return;
458
+ _closeHookRegistered = true;
459
+ onSharedDbPoolsClosed(() => {
460
+ _db = undefined;
461
+ _dbReady = undefined;
462
+ });
463
+ if (driver && url) {
464
+ onSharedDbPoolReplaced(driver, url, () => {
465
+ _db = undefined;
466
+ _dbReady = undefined;
467
+ });
468
+ }
469
+ }
470
+
447
471
  function startInit(): Promise<any> {
448
472
  if (_dbReady) return _dbReady;
449
473
 
@@ -473,10 +497,15 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
473
497
  if (dialect === "postgres") {
474
498
  if (isNeonUrl(url)) {
475
499
  _dbReady = getNeonServerlessDrizzle().then(({ drizzle, Pool }) => {
476
- const rawPool = new Pool({
477
- connectionString: url,
478
- max: neonPoolMax(),
479
- });
500
+ // Shared with the DbExec singleton, Better Auth, and every other
501
+ // `createGetDb` store: one connect per process instead of one per
502
+ // schema module. See `sharedDbPool` in client.ts.
503
+ resetOnPoolClose("neon", url);
504
+ const rawPool = sharedDbPool(
505
+ "neon",
506
+ url,
507
+ () => new Pool({ connectionString: url, max: neonPoolMax() }),
508
+ );
480
509
  attachNeonPoolErrorLogger(rawPool);
481
510
  // Wrap the pool with the resilience layer so Drizzle queries get the
482
511
  // same withDbTimeout + retryOnConnectionError protection as the raw
@@ -489,8 +518,12 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
489
518
  _dbReady = getPgDrizzle().then(({ drizzle, postgres }) => {
490
519
  // pgPoolOptions caps the pool to a small size on serverless so
491
520
  // concurrent frozen instances don't exhaust Neon/Postgres'
492
- // connection limit ("Max client connections reached").
493
- const client = postgres(url, pgPoolOptions(url));
521
+ // connection limit ("Max client connections reached"). Shared across
522
+ // consumers see `sharedDbPool` in client.ts.
523
+ resetOnPoolClose("postgres-js", url);
524
+ const client = sharedDbPool("postgres-js", url, () =>
525
+ postgres(url, pgPoolOptions(url)),
526
+ );
494
527
  _db = drizzle(buildResilientPostgresJsClient(client), { schema });
495
528
  });
496
529
  }
@@ -51,7 +51,7 @@ export interface DatabaseSchemaHealthResult {
51
51
  error?: string;
52
52
  }
53
53
 
54
- const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
54
+ export const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
55
55
  {
56
56
  table: "agent_runs",
57
57
  columns: [
@@ -253,6 +253,24 @@ async function tableColumns(
253
253
  : sqliteTableColumns(exec, table);
254
254
  }
255
255
 
256
+ /**
257
+ * Memo for the default probe, which several client surfaces re-run on every
258
+ * page load at one `information_schema` round trip per required table.
259
+ *
260
+ * Only a clean, completed result is memoized: a probe reporting a missing table
261
+ * or an unreadable database must never be served from cache, or the repair that
262
+ * follows stays invisible for the whole window. Schema changes are additive and
263
+ * applied by migrations, so a clean answer cannot silently go bad — but the
264
+ * window is still kept to seconds, short enough that anything a deploy changes
265
+ * shows up on the next page load rather than being pinned. Callers that
266
+ * override `exec`, `dialect`, or `required` bypass it entirely.
267
+ */
268
+ const SCHEMA_HEALTH_MEMO_MS = 5_000;
269
+ let schemaHealthMemo: {
270
+ at: number;
271
+ result: DatabaseSchemaHealthResult;
272
+ } | null = null;
273
+
256
274
  export async function runDatabaseSchemaHealthCheck(
257
275
  options: {
258
276
  exec?: DbExec;
@@ -260,6 +278,15 @@ export async function runDatabaseSchemaHealthCheck(
260
278
  required?: RequiredSchemaTable[];
261
279
  } = {},
262
280
  ): Promise<DatabaseSchemaHealthResult> {
281
+ const memoizable = !options.exec && !options.dialect && !options.required;
282
+ if (
283
+ memoizable &&
284
+ schemaHealthMemo &&
285
+ Date.now() - schemaHealthMemo.at < SCHEMA_HEALTH_MEMO_MS
286
+ ) {
287
+ return schemaHealthMemo.result;
288
+ }
289
+
263
290
  const dialect = options.dialect ?? getDialect();
264
291
  const exec = options.exec ?? getDbExec();
265
292
  const required = options.required ?? DEFAULT_REQUIRED_SCHEMA;
@@ -267,18 +294,24 @@ export async function runDatabaseSchemaHealthCheck(
267
294
  const missingColumns: Array<{ table: string; column: string }> = [];
268
295
 
269
296
  try {
270
- for (const requirement of required) {
271
- const columns = await tableColumns(exec, dialect, requirement.table);
297
+ // Independent probes: serially they cost one network round trip each.
298
+ const found = await Promise.all(
299
+ required.map((requirement) =>
300
+ tableColumns(exec, dialect, requirement.table),
301
+ ),
302
+ );
303
+ required.forEach((requirement, index) => {
304
+ const columns = found[index];
272
305
  if (!columns) {
273
306
  missingTables.push(requirement.table);
274
- continue;
307
+ return;
275
308
  }
276
309
  for (const column of requirement.columns) {
277
310
  if (!columns.has(column)) {
278
311
  missingColumns.push({ table: requirement.table, column });
279
312
  }
280
313
  }
281
- }
314
+ });
282
315
  } catch (err) {
283
316
  return {
284
317
  ok: false,
@@ -290,13 +323,15 @@ export async function runDatabaseSchemaHealthCheck(
290
323
  };
291
324
  }
292
325
 
293
- return {
326
+ const result: DatabaseSchemaHealthResult = {
294
327
  ok: missingTables.length === 0 && missingColumns.length === 0,
295
328
  checked: true,
296
329
  dialect,
297
330
  missingTables,
298
331
  missingColumns,
299
332
  };
333
+ if (memoizable && result.ok) schemaHealthMemo = { at: Date.now(), result };
334
+ return result;
300
335
  }
301
336
 
302
337
  export function formatRuntimeDebugFingerprint(
@@ -30,7 +30,7 @@ import {
30
30
  AGENT_BACKGROUND_PROCESSOR_ROUTE,
31
31
  AGENT_BACKGROUND_PROCESSOR_ROUTE_FIELD,
32
32
  AGENT_CHAT_PROCESS_RUN_PATH,
33
- isDurableBackgroundFlagEnabled,
33
+ isDurableBackgroundFlagExplicitlyDisabled,
34
34
  } from "../agent/durable-background.js";
35
35
  import {
36
36
  INTEGRATION_RECOVERY_RUNTIME_MARKER,
@@ -2576,29 +2576,14 @@ export function findInstalledResvgPackages(
2576
2576
 
2577
2577
  /**
2578
2578
  * Deploy-time gate for emitting the second `-background` Netlify function.
2579
- * Reads the same env flag the runtime gate uses
2580
- * (`AGENT_CHAT_DURABLE_BACKGROUND`).
2581
- *
2582
- * DEFAULT-OFF (opt-in). It IS the runtime gate's flag parse
2583
- * (`isDurableBackgroundFlagEnabled`), so the two can no longer drift:
2584
- * unset/empty/unknown means DISABLED; an app opts IN
2585
- * only with an explicit truthy value (`true`/`1`/`yes`/`on`). A premature
2586
- * fleet-wide default-on caused real-user incidents (2026-06-24) before the
2587
- * async worker path was proven, so durable is opt-in until verified live. This
2588
- * gate is what emits the 15-min `-background` function so the `_process-run`
2589
- * dispatch lands on it (async 202 → the worker runs with the real 15-min budget
2590
- * → its ~13-min soft-timeout fits). The deploy gate and runtime gate MUST agree:
2591
- * if the deploy emitted no `-background` function but the runtime still routed
2592
- * the worker into the ~13-min timeout regime, the worker would overshoot the
2593
- * ~60s synchronous wall and re-dispatch in a loop. (The runtime now also guards
2594
- * the ~13-min budget on the real function name via `isInBackgroundFunctionRuntime`,
2595
- * so a missing emit does not loop.) A missing emit is NOT benign, though: the
2596
- * app then runs every turn on the ~60s synchronous wall for the life of the
2597
- * deploy, so an opted-in build that cannot emit the function FAILS rather than
2598
- * warning.
2579
+ * Netlify deploys are default-on; an explicit falsy
2580
+ * `AGENT_CHAT_DURABLE_BACKGROUND` value opts out. This is called only from the
2581
+ * Netlify preset, so non-Netlify builds remain unaffected. The gate and runtime
2582
+ * default must agree: otherwise the runtime could target a worker that the
2583
+ * deploy did not emit.
2599
2584
  */
2600
2585
  export function isDurableBackgroundDeployEnabled(): boolean {
2601
- return isDurableBackgroundFlagEnabled();
2586
+ return !isDurableBackgroundFlagExplicitlyDisabled();
2602
2587
  }
2603
2588
 
2604
2589
  /**
@@ -2649,9 +2634,15 @@ export function emitSingleTemplateNetlifyKeepWarmFunction(
2649
2634
  // dispatch (18.4s observed to reach the agent loop). A POST with no runId is
2650
2635
  // rejected by the `_process-run` route before any DB work, so this only keeps
2651
2636
  // the container alive.
2652
- const backgroundWarmPath = isDurableBackgroundEmitRequired()
2653
- ? JSON.stringify(AGENT_BACKGROUND_FUNCTION_URL_PATH)
2654
- : "null";
2637
+ const backgroundEntryPath = path.join(
2638
+ internalDir,
2639
+ AGENT_BACKGROUND_FUNCTION_NAME,
2640
+ `${AGENT_BACKGROUND_FUNCTION_NAME}.mjs`,
2641
+ );
2642
+ const backgroundWarmPath =
2643
+ isDurableBackgroundEmitRequired() && fs.existsSync(backgroundEntryPath)
2644
+ ? JSON.stringify(AGENT_BACKGROUND_FUNCTION_URL_PATH)
2645
+ : "null";
2655
2646
  const entry = `const HEALTH_PATH = "/_agent-native/health";
2656
2647
  const BACKGROUND_WARM_PATH = ${backgroundWarmPath};
2657
2648
  const REQUEST_TIMEOUT_MS = 25_000;
@@ -4148,14 +4139,12 @@ export default bundle;
4148
4139
  }
4149
4140
 
4150
4141
  if (preset === "netlify") {
4151
- emitSingleTemplateNetlifyKeepWarmFunction(cwd);
4152
-
4153
- // Durable background agent runs (default-OFF / opt-in; enable with a truthy
4154
- // AGENT_CHAT_DURABLE_BACKGROUND). Additive ONLY: emits a SECOND Netlify
4155
- // function whose name ends in `-background` re-exporting the same handler
4156
- // bundle, so the chat `_process-run` POST lands on Netlify's async (15-min)
4157
- // function. When not opted in this is a no-op and the single-function
4158
- // deploy is byte-for-byte unchanged.
4142
+ // Durable background agent runs are default-on for Netlify; a falsy
4143
+ // AGENT_CHAT_DURABLE_BACKGROUND value opts out. Additive ONLY: emits a
4144
+ // SECOND Netlify function whose name ends in `-background` re-exporting the
4145
+ // same handler bundle, so the chat `_process-run` POST lands on Netlify's
4146
+ // async (15-min) function. When opted out this is a no-op and the
4147
+ // single-function deploy is byte-for-byte unchanged.
4159
4148
  // NOT wrapped in try/catch: this block only runs when the runtime depends
4160
4149
  // on the function, and a swallowed failure ships an app that loses the
4161
4150
  // background budget for the life of the deploy with nothing in the log.
@@ -4163,6 +4152,10 @@ export default bundle;
4163
4152
  emitSingleTemplateNetlifyBackgroundFunction(cwd);
4164
4153
  }
4165
4154
 
4155
+ // Emit keep-warm after the background artifact so it only pings a function
4156
+ // that this build actually produced.
4157
+ emitSingleTemplateNetlifyKeepWarmFunction(cwd);
4158
+
4166
4159
  if (isIntegrationDurableDispatchDeployEnabled()) {
4167
4160
  try {
4168
4161
  emitSingleTemplateNetlifyIntegrationRecoveryFunction(cwd);
@@ -89,11 +89,16 @@ export async function getFeatureFlagRules(
89
89
  if (!getFeatureFlagDefinition(key)) return defaultFeatureFlagRules();
90
90
  // An organization-specific rule overrides the global rule. The fallback is
91
91
  // what makes global exact-org targeting meaningful for callers in an org.
92
- const stored = scope.orgId?.trim()
93
- ? ((await getOrgSetting(scope.orgId, settingKey(key))) ??
94
- (await getSetting(settingKey(key))))
95
- : await getSetting(settingKey(key));
96
- return normalizeFeatureFlagRules(stored);
92
+ // Most flags have no org override, so `??` made the common path two serial
93
+ // round trips; both settings rows are independent, so read them together.
94
+ const orgId = scope.orgId?.trim();
95
+ if (!orgId)
96
+ return normalizeFeatureFlagRules(await getSetting(settingKey(key)));
97
+ const [orgStored, globalStored] = await Promise.all([
98
+ getOrgSetting(orgId, settingKey(key)),
99
+ getSetting(settingKey(key)),
100
+ ]);
101
+ return normalizeFeatureFlagRules(orgStored ?? globalStored);
97
102
  }
98
103
 
99
104
  /**
@@ -777,6 +777,17 @@ export async function recoverDueA2AContinuationIds(
777
777
  const receiptFilter = confirmedDeliveryOnly
778
778
  ? " AND terminal_delivery_confirmed_at IS NOT NULL"
779
779
  : "";
780
+ // The two lease resets below and the due SELECT all only ever touch rows in
781
+ // these three statuses, so one probe short-circuits all three. Without it the
782
+ // 60s retry job pays two blind UPDATE round trips per app forever on a queue
783
+ // that has been empty since boot.
784
+ const live = await client.execute({
785
+ sql: `SELECT id FROM integration_a2a_continuations
786
+ WHERE status IN ('pending', 'processing', 'delivering')${taskFilter}${receiptFilter}
787
+ LIMIT 1`,
788
+ args: [...taskArgs],
789
+ });
790
+ if ((live.rows?.length ?? 0) === 0) return [];
780
791
  // If a processor dies after a provider receipt, retry history-only custody
781
792
  // as soon as its short follow-up deadline passes. A pre-receipt delivery
782
793
  // claim retains the longer stale cutoff before an at-least-once resend.
@@ -33,6 +33,17 @@ async function ensureTable(): Promise<void> {
33
33
  return _initPromise;
34
34
  }
35
35
 
36
+ /**
37
+ * Bumped on every in-process config write. Pollers that back off while their
38
+ * integration is disabled watch this so enabling one takes effect on the next
39
+ * tick instead of at the end of their backoff window.
40
+ */
41
+ let _configWriteEpoch = 0;
42
+
43
+ export function integrationConfigWriteEpoch(): number {
44
+ return _configWriteEpoch;
45
+ }
46
+
36
47
  export interface IntegrationConfig {
37
48
  platform: string;
38
49
  configKey: string;
@@ -88,6 +99,7 @@ export async function saveIntegrationConfig(
88
99
  Date.now(),
89
100
  ],
90
101
  });
102
+ _configWriteEpoch += 1;
91
103
  }
92
104
 
93
105
  /**
@@ -103,6 +115,7 @@ export async function deleteIntegrationConfig(
103
115
  sql: `DELETE FROM integration_configs WHERE platform = ? AND config_key = ?`,
104
116
  args: [platform, configKey],
105
117
  });
118
+ _configWriteEpoch += 1;
106
119
  }
107
120
 
108
121
  /**
@@ -27,7 +27,11 @@ import {
27
27
  listChanges,
28
28
  listDocComments,
29
29
  } from "./adapters/google-docs.js";
30
- import { getIntegrationConfig, saveIntegrationConfig } from "./config-store.js";
30
+ import {
31
+ getIntegrationConfig,
32
+ integrationConfigWriteEpoch,
33
+ saveIntegrationConfig,
34
+ } from "./config-store.js";
31
35
  import { getThreadMapping, saveThreadMapping } from "./thread-mapping-store.js";
32
36
  import type { IncomingMessage } from "./types.js";
33
37
 
@@ -634,14 +638,34 @@ export async function startGoogleDocsPoller(
634
638
  }
635
639
  }
636
640
 
641
+ /** Backoff for the config probe while google-docs is disabled. */
642
+ const DISABLED_CONFIG_RECHECK_MS = 5 * 60 * 1000;
643
+
637
644
  function startPollLoop(
638
645
  options: GoogleDocsPollerOptions,
639
646
  intervalMs: number,
640
647
  ): void {
648
+ let disabledUntil = 0;
649
+ let disabledAtConfigWriteEpoch = -1;
650
+
641
651
  async function poll() {
642
652
  try {
653
+ // The loop starts even when google-docs was never configured (so it picks
654
+ // up a later enable), which used to mean one `integration_configs` read
655
+ // every 30s per app forever. While disabled, re-read only every
656
+ // DISABLED_CONFIG_RECHECK_MS — or immediately after any in-process config
657
+ // write, so enabling the integration still takes effect on the next tick.
658
+ const epoch = integrationConfigWriteEpoch();
659
+ if (Date.now() < disabledUntil && epoch === disabledAtConfigWriteEpoch) {
660
+ return;
661
+ }
643
662
  const config = await getIntegrationConfig(PLATFORM);
644
- if (!config?.configData?.enabled) return;
663
+ if (!config?.configData?.enabled) {
664
+ disabledUntil = Date.now() + DISABLED_CONFIG_RECHECK_MS;
665
+ disabledAtConfigWriteEpoch = epoch;
666
+ return;
667
+ }
668
+ disabledUntil = 0;
645
669
  await processChanges(options);
646
670
  } catch (err) {
647
671
  // Unwrap ErrorEvent (Neon WS driver emits these on network failure) so logs show the real cause