@agent-native/core 0.144.2 → 0.144.3

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 (70) hide show
  1. package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -4
  2. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +7 -3
  3. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +13 -3
  4. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +64 -23
  5. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +69 -46
  6. package/corpus/templates/calendar/app/hooks/use-view-preferences.ts +35 -6
  7. package/corpus/templates/calendar/app/hooks/use-zoom-auth.ts +24 -3
  8. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +45 -6
  9. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +38 -5
  10. package/corpus/templates/clips/desktop/src/app.tsx +146 -45
  11. package/corpus/templates/clips/server/jobs/brain-export.ts +13 -6
  12. package/corpus/templates/clips/server/jobs/builder-media-compression.ts +10 -3
  13. package/corpus/templates/clips/server/jobs/meeting-reminders.ts +10 -3
  14. package/corpus/templates/clips/server/jobs/poll-calendars.ts +23 -21
  15. package/corpus/templates/clips/server/jobs/stale-meeting-sweeper.ts +10 -3
  16. package/corpus/templates/clips/server/jobs/transactional-emails.ts +10 -3
  17. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +1 -0
  18. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +15 -2
  19. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +82 -39
  20. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +31 -9
  21. package/corpus/templates/mail/server/plugins/mail-jobs.ts +36 -17
  22. package/dist/client/AssistantChat.js +73 -43
  23. package/dist/client/MultiTabAssistantChat.d.ts +3 -1
  24. package/dist/client/MultiTabAssistantChat.js +79 -92
  25. package/dist/client/browser-session-bridge.js +97 -54
  26. package/dist/client/db-admin/useAgentSync.js +34 -43
  27. package/dist/client/dynamic-suggestions.d.ts +3 -0
  28. package/dist/client/dynamic-suggestions.js +54 -52
  29. package/dist/client/hooks/index.d.ts +1 -0
  30. package/dist/client/hooks/index.js +1 -0
  31. package/dist/client/i18n.d.ts +7 -0
  32. package/dist/client/i18n.js +12 -3
  33. package/dist/client/integrations/useIntegrationStatus.d.ts +1 -1
  34. package/dist/client/integrations/useIntegrationStatus.js +9 -22
  35. package/dist/client/notifications/NotificationsBell.js +14 -9
  36. package/dist/client/progress/RunsTray.js +6 -5
  37. package/dist/client/settings/useBuilderStatus.js +95 -73
  38. package/dist/client/transcription/BuilderTranscriptionCta.js +27 -31
  39. package/dist/client/use-agent-engine-configured.js +21 -3
  40. package/dist/client/use-pausing-interval.d.ts +6 -0
  41. package/dist/client/use-pausing-interval.js +6 -0
  42. package/dist/client/use-poll-loop.d.ts +39 -0
  43. package/dist/client/use-poll-loop.js +73 -0
  44. package/dist/client/use-run-stuck-detection.js +41 -13
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/integrations/google-docs-poller.js +26 -7
  47. package/dist/integrations/pending-tasks-retry-job.js +17 -23
  48. package/dist/integrations/plugin.js +16 -15
  49. package/dist/integrations/remote-push-delivery-job.js +16 -25
  50. package/dist/integrations/remote-retry-job.js +19 -23
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/oauth-tokens/google-refresh.js +12 -19
  53. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  54. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  55. package/dist/server/agent-chat-plugin.js +26 -29
  56. package/dist/server/index.d.ts +1 -0
  57. package/dist/server/index.js +1 -0
  58. package/dist/server/interval-job.d.ts +25 -0
  59. package/dist/server/interval-job.js +19 -0
  60. package/dist/server/realtime-token.d.ts +1 -1
  61. package/dist/shared/index.d.ts +1 -0
  62. package/dist/shared/index.js +1 -0
  63. package/dist/shared/poll-engine.d.ts +49 -0
  64. package/dist/shared/poll-engine.js +148 -0
  65. package/dist/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  66. package/dist/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
  67. package/dist/tracking/providers.js +10 -1
  68. package/package.json +4 -3
  69. package/src/templates/default/.agents/skills/internationalization/SKILL.md +5 -0
  70. package/src/templates/workspace-core/.agents/skills/internationalization/SKILL.md +5 -0
@@ -427,6 +427,24 @@ function serverUrlForPendingUpload(
427
427
  // already-connected user to the setup flow.
428
428
  type VideoStorageProbe = "configured" | "missing" | "unknown";
429
429
 
430
+ // Poll cadence for the caller's re-check loop is 5s; bound each probe request
431
+ // well above that so a hung request can't wedge the poll's in-flight guard.
432
+ const VIDEO_STORAGE_PROBE_TIMEOUT_MS = Math.max(10_000, 5000 * 4);
433
+
434
+ async function fetchWithAbortTimeout(
435
+ url: string,
436
+ init: RequestInit,
437
+ timeoutMs: number,
438
+ ): Promise<Response> {
439
+ const controller = new AbortController();
440
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
441
+ try {
442
+ return await fetch(url, { ...init, signal: controller.signal });
443
+ } finally {
444
+ clearTimeout(timer);
445
+ }
446
+ }
447
+
430
448
  async function hasConfiguredVideoStorage(
431
449
  serverUrl: string,
432
450
  ): Promise<VideoStorageProbe> {
@@ -437,12 +455,13 @@ async function hasConfiguredVideoStorage(
437
455
  let sawDefinitiveAnswer = false;
438
456
 
439
457
  try {
440
- const uploadStatus = await fetch(
458
+ const uploadStatus = await fetchWithAbortTimeout(
441
459
  `${base}/_agent-native/file-upload/status`,
442
460
  {
443
461
  credentials: "include",
444
462
  cache: "no-store",
445
463
  },
464
+ VIDEO_STORAGE_PROBE_TIMEOUT_MS,
446
465
  );
447
466
  if (uploadStatus.ok) {
448
467
  const body = (await uploadStatus.json().catch(() => null)) as {
@@ -458,10 +477,14 @@ async function hasConfiguredVideoStorage(
458
477
  }
459
478
 
460
479
  try {
461
- const builderStatus = await fetch(`${base}/_agent-native/builder/status`, {
462
- credentials: "include",
463
- cache: "no-store",
464
- });
480
+ const builderStatus = await fetchWithAbortTimeout(
481
+ `${base}/_agent-native/builder/status`,
482
+ {
483
+ credentials: "include",
484
+ cache: "no-store",
485
+ },
486
+ VIDEO_STORAGE_PROBE_TIMEOUT_MS,
487
+ );
465
488
  if (builderStatus.ok) {
466
489
  const body = (await builderStatus.json().catch(() => null)) as {
467
490
  configured?: boolean;
@@ -1143,10 +1166,23 @@ export function App() {
1143
1166
  return;
1144
1167
  }
1145
1168
 
1146
- const interval = window.setInterval(() => {
1147
- void refreshVideoStorageStatus();
1148
- }, 5000);
1149
- return () => window.clearInterval(interval);
1169
+ let inFlight = false;
1170
+ const tick = () => {
1171
+ if (document.hidden || inFlight) return;
1172
+ inFlight = true;
1173
+ void refreshVideoStorageStatus().finally(() => {
1174
+ inFlight = false;
1175
+ });
1176
+ };
1177
+ const interval = window.setInterval(tick, 5000);
1178
+ const onVisibilityChange = () => {
1179
+ if (!document.hidden) tick();
1180
+ };
1181
+ document.addEventListener("visibilitychange", onVisibilityChange);
1182
+ return () => {
1183
+ window.clearInterval(interval);
1184
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1185
+ };
1150
1186
  }, [
1151
1187
  authStatus,
1152
1188
  localRecordingMode,
@@ -1630,22 +1666,45 @@ export function App() {
1630
1666
  return;
1631
1667
  }
1632
1668
  let cancelled = false;
1669
+ let inFlight = false;
1633
1670
  const poll = async () => {
1634
- const result = await callClipsAction<{
1635
- requests?: RewindExtensionRequest[];
1636
- }>("list-rewind-extension-requests", {}, { method: "GET" }).catch(
1637
- () => null,
1671
+ if (document.hidden || inFlight) return;
1672
+ inFlight = true;
1673
+ const controller = new AbortController();
1674
+ const abortTimer = setTimeout(
1675
+ () => controller.abort(),
1676
+ Math.max(10_000, 3_000 * 4),
1638
1677
  );
1639
- if (cancelled) return;
1640
- for (const request of result?.requests ?? []) {
1641
- void processRewindExtension(request);
1678
+ try {
1679
+ const result = await callClipsAction<{
1680
+ requests?: RewindExtensionRequest[];
1681
+ }>(
1682
+ "list-rewind-extension-requests",
1683
+ {},
1684
+ { method: "GET", signal: controller.signal },
1685
+ )
1686
+ // coercion-ok: nothing to process this sweep either way; the next
1687
+ // tick re-reads the pending requests.
1688
+ .catch(() => null);
1689
+ if (cancelled) return;
1690
+ for (const request of result?.requests ?? []) {
1691
+ void processRewindExtension(request);
1692
+ }
1693
+ } finally {
1694
+ clearTimeout(abortTimer);
1695
+ inFlight = false;
1642
1696
  }
1643
1697
  };
1644
1698
  void poll();
1645
1699
  const timer = window.setInterval(() => void poll(), 3_000);
1700
+ const onVisibilityChange = () => {
1701
+ if (!document.hidden) void poll();
1702
+ };
1703
+ document.addEventListener("visibilitychange", onVisibilityChange);
1646
1704
  return () => {
1647
1705
  cancelled = true;
1648
1706
  window.clearInterval(timer);
1707
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1649
1708
  };
1650
1709
  }, [
1651
1710
  authStatus,
@@ -1702,41 +1761,63 @@ export function App() {
1702
1761
  useEffect(() => {
1703
1762
  if (featureConfig?.screenMemory?.enabled !== true) return;
1704
1763
  let cancelled = false;
1764
+ let inFlight = false;
1705
1765
  const sweep = async () => {
1706
- const due = await invoke<DueRewindAgentHandoff[]>(
1707
- "screen_memory_due_agent_handoffs",
1708
- ).catch(() => []);
1709
- if (cancelled) return;
1710
- for (const item of due) {
1711
- try {
1712
- const cleanup = await callClipsAction<{
1713
- deleted: boolean;
1714
- reason: string;
1715
- }>("delete-agent-recording-if-unpromoted", {
1716
- id: item.recordingId,
1717
- });
1718
- if (cleanup.deleted) {
1719
- await invoke("screen_memory_mark_agent_handoff_deleted", {
1720
- requestId: item.requestId,
1721
- });
1722
- } else if (cleanup.reason === "promoted") {
1723
- await invoke("screen_memory_cancel_agent_handoff_cleanup", {
1724
- requestId: item.requestId,
1725
- });
1766
+ if (document.hidden || inFlight) return;
1767
+ inFlight = true;
1768
+ try {
1769
+ const due = await invoke<DueRewindAgentHandoff[]>(
1770
+ "screen_memory_due_agent_handoffs",
1771
+ )
1772
+ // coercion-ok: handoffs stay due in the local store, so an empty
1773
+ // sweep and a failed one both retry on the next tick.
1774
+ .catch(() => []);
1775
+ if (cancelled) return;
1776
+ for (const item of due) {
1777
+ try {
1778
+ const controller = new AbortController();
1779
+ const abortTimer = setTimeout(
1780
+ () => controller.abort(),
1781
+ Math.max(10_000, 60_000 * 4),
1782
+ );
1783
+ const cleanup = await callClipsAction<{
1784
+ deleted: boolean;
1785
+ reason: string;
1786
+ }>(
1787
+ "delete-agent-recording-if-unpromoted",
1788
+ { id: item.recordingId },
1789
+ { signal: controller.signal },
1790
+ ).finally(() => clearTimeout(abortTimer));
1791
+ if (cleanup.deleted) {
1792
+ await invoke("screen_memory_mark_agent_handoff_deleted", {
1793
+ requestId: item.requestId,
1794
+ });
1795
+ } else if (cleanup.reason === "promoted") {
1796
+ await invoke("screen_memory_cancel_agent_handoff_cleanup", {
1797
+ requestId: item.requestId,
1798
+ });
1799
+ }
1800
+ } catch (error) {
1801
+ console.warn(
1802
+ "[clips-tray] agent-created Clip cleanup failed:",
1803
+ error,
1804
+ );
1726
1805
  }
1727
- } catch (error) {
1728
- console.warn(
1729
- "[clips-tray] agent-created Clip cleanup failed:",
1730
- error,
1731
- );
1732
1806
  }
1807
+ } finally {
1808
+ inFlight = false;
1733
1809
  }
1734
1810
  };
1735
1811
  void sweep();
1736
1812
  const timer = window.setInterval(() => void sweep(), 60_000);
1813
+ const onVisibilityChange = () => {
1814
+ if (!document.hidden) void sweep();
1815
+ };
1816
+ document.addEventListener("visibilitychange", onVisibilityChange);
1737
1817
  return () => {
1738
1818
  cancelled = true;
1739
1819
  window.clearInterval(timer);
1820
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1740
1821
  };
1741
1822
  }, [callClipsAction, featureConfig?.screenMemory?.enabled]);
1742
1823
 
@@ -1967,11 +2048,18 @@ export function App() {
1967
2048
  if (signInInflightRef.current) return;
1968
2049
  signInInflightRef.current = true;
1969
2050
 
2051
+ let tickInFlight = false;
2052
+ let onVisibilityChange: (() => void) | null = null;
2053
+
1970
2054
  function stopPolling() {
1971
2055
  if (pollIntervalRef.current !== null) {
1972
2056
  clearInterval(pollIntervalRef.current);
1973
2057
  pollIntervalRef.current = null;
1974
2058
  }
2059
+ if (onVisibilityChange) {
2060
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2061
+ onVisibilityChange = null;
2062
+ }
1975
2063
  }
1976
2064
 
1977
2065
  function finishWithError(message: string) {
@@ -2000,11 +2088,16 @@ export function App() {
2000
2088
  // Poll the exchange endpoint for the session token.
2001
2089
  const start = Date.now();
2002
2090
  const TIMEOUT_MS = 180_000; // 3 minutes
2003
- pollIntervalRef.current = setInterval(async () => {
2091
+ const POLL_ABORT_MS = Math.max(10_000, 1500 * 4);
2092
+ const tick = async () => {
2093
+ if (document.hidden || tickInFlight) return;
2094
+ tickInFlight = true;
2095
+ const controller = new AbortController();
2096
+ const abortTimer = setTimeout(() => controller.abort(), POLL_ABORT_MS);
2004
2097
  try {
2005
2098
  const xr = await fetch(
2006
2099
  `${base}/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
2007
- { credentials: "include" },
2100
+ { credentials: "include", signal: controller.signal },
2008
2101
  );
2009
2102
  if (!xr.ok) {
2010
2103
  if (Date.now() - start > TIMEOUT_MS) {
@@ -2030,7 +2123,7 @@ export function App() {
2030
2123
  // bearer token above is the reliable desktop auth path.
2031
2124
  await fetch(
2032
2125
  `${base}/_agent-native/auth/session?_session=${xd.token}`,
2033
- { credentials: "include" },
2126
+ { credentials: "include", signal: controller.signal },
2034
2127
  );
2035
2128
  signInInflightRef.current = false;
2036
2129
  setSignInPending(false);
@@ -2047,8 +2140,16 @@ export function App() {
2047
2140
  if (Date.now() - start > TIMEOUT_MS) {
2048
2141
  finishWithError("Google sign-in timed out. Please try again.");
2049
2142
  }
2143
+ } finally {
2144
+ clearTimeout(abortTimer);
2145
+ tickInFlight = false;
2050
2146
  }
2051
- }, 1500);
2147
+ };
2148
+ pollIntervalRef.current = setInterval(() => void tick(), 1500);
2149
+ onVisibilityChange = () => {
2150
+ if (!document.hidden) void tick();
2151
+ };
2152
+ document.addEventListener("visibilitychange", onVisibilityChange);
2052
2153
  } catch (err) {
2053
2154
  console.error("[clips-tray] signInExternal failed:", err);
2054
2155
  signInInflightRef.current = false;
@@ -18,6 +18,7 @@ const SWEEP_LIMIT = 4;
18
18
  const DISCOVERY_LIMIT = 20;
19
19
  const BACKFILL_DAYS = 7;
20
20
  let skippingLogged = false;
21
+ let running = false;
21
22
 
22
23
  async function seedRecentBrainExports(): Promise<void> {
23
24
  const now = new Date();
@@ -173,12 +174,18 @@ export default function registerBrainExportJob(): void {
173
174
  return;
174
175
  }
175
176
  setInterval(() => {
176
- reapExpiredUploads().catch((error) =>
177
- console.error("[uploads] lease reaper interval failed:", error),
178
- );
179
- runBrainExportSweepOnce().catch((error) =>
180
- console.error("[brain-export] interval failed:", error),
181
- );
177
+ if (running) return;
178
+ running = true;
179
+ Promise.allSettled([
180
+ reapExpiredUploads().catch((error) =>
181
+ console.error("[uploads] lease reaper interval failed:", error),
182
+ ),
183
+ runBrainExportSweepOnce().catch((error) =>
184
+ console.error("[brain-export] interval failed:", error),
185
+ ),
186
+ ]).finally(() => {
187
+ running = false;
188
+ });
182
189
  }, SWEEP_INTERVAL_MS);
183
190
  console.log(
184
191
  `[brain-export] Recurring recovery sweep every ${SWEEP_INTERVAL_MS / 1000}s.`,
@@ -13,6 +13,7 @@ import { runBuilderMediaCompressionSweepOnce } from "../lib/builder-media-compre
13
13
 
14
14
  const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
15
15
  let skippingLogged = false;
16
+ let running = false;
16
17
 
17
18
  export { runBuilderMediaCompressionSweepOnce };
18
19
 
@@ -31,9 +32,15 @@ export default function registerBuilderMediaCompressionJob(): void {
31
32
  }
32
33
 
33
34
  setInterval(() => {
34
- runBuilderMediaCompressionSweepOnce().catch((err) =>
35
- console.error("[builder-media-compression] interval failed:", err),
36
- );
35
+ if (running) return;
36
+ running = true;
37
+ runBuilderMediaCompressionSweepOnce()
38
+ .catch((err) =>
39
+ console.error("[builder-media-compression] interval failed:", err),
40
+ )
41
+ .finally(() => {
42
+ running = false;
43
+ });
37
44
  }, SWEEP_INTERVAL_MS);
38
45
  console.log(
39
46
  `[builder-media-compression] Recurring compression sweep every ${SWEEP_INTERVAL_MS / 1000}s.`,
@@ -29,6 +29,7 @@ import { getDb, schema } from "../db/index.js";
29
29
  const REMINDER_INTERVAL_MS = 60 * 1000;
30
30
  const REMINDER_WINDOW_MS = 5 * 60 * 1000;
31
31
  let skippingLogged = false;
32
+ let running = false;
32
33
 
33
34
  const meetingReminderSchema = z.object({
34
35
  meetingId: z.string(),
@@ -114,9 +115,15 @@ export default function registerMeetingRemindersJob(): void {
114
115
  return;
115
116
  }
116
117
  setInterval(() => {
117
- runMeetingRemindersOnce().catch((err) =>
118
- console.error("[meeting-reminders] interval failed:", err),
119
- );
118
+ if (running) return;
119
+ running = true;
120
+ runMeetingRemindersOnce()
121
+ .catch((err) =>
122
+ console.error("[meeting-reminders] interval failed:", err),
123
+ )
124
+ .finally(() => {
125
+ running = false;
126
+ });
120
127
  }, REMINDER_INTERVAL_MS);
121
128
  console.log(
122
129
  `[meeting-reminders] Recurring meeting reminders every ${REMINDER_INTERVAL_MS / 1000}s.`,
@@ -2,15 +2,16 @@
2
2
  * poll-calendars — recurring job (every 5 min).
3
3
  *
4
4
  * Sweeps every connected calendar_accounts row and refreshes its events.
5
- * Runs as a Nitro plugin via `setInterval` (matches the mail-jobs pattern
6
- * — works on every host that keeps a long-lived process; on serverless
7
- * the deployment plugin should call `runPollCalendarsOnce` from a
8
- * scheduled function instead).
5
+ * Runs as a Nitro plugin via `startIntervalJob` (matches the mail-jobs
6
+ * pattern — works on every host that keeps a long-lived process; on
7
+ * serverless the deployment plugin should call `runPollCalendarsOnce` from
8
+ * a scheduled function instead).
9
9
  *
10
10
  * Token refresh + per-account error capture happens inside the
11
11
  * `sync-calendars` action; this job is just a thin scheduler.
12
12
  */
13
13
 
14
+ import { startIntervalJob } from "@agent-native/core/server/interval-job";
14
15
  import { runWithRequestContext } from "@agent-native/core/server/request-context";
15
16
 
16
17
  import syncCalendars from "../../actions/sync-calendars.js";
@@ -21,24 +22,21 @@ let skippingLogged = false;
21
22
  /**
22
23
  * Run a single poll pass. Exported so deployment-specific schedulers
23
24
  * (e.g. Netlify Scheduled Functions) can invoke it directly without
24
- * relying on a long-lived process.
25
+ * relying on a long-lived process. Throws on failure so a scheduled
26
+ * invocation records a failed run rather than a silent empty one.
25
27
  */
26
28
  export async function runPollCalendarsOnce(): Promise<void> {
27
29
  // No request context — this job runs as the system. The sync-calendars
28
30
  // action accepts `allAccounts: true` to skip the per-user accessFilter.
29
31
  await runWithRequestContext({}, async () => {
30
- try {
31
- const result = await syncCalendars.run(
32
- { allAccounts: true } as any,
33
- {} as any,
32
+ const result = await syncCalendars.run(
33
+ { allAccounts: true } as any,
34
+ {} as any,
35
+ );
36
+ if (result?.synced) {
37
+ console.log(
38
+ `[poll-calendars] synced ${result.synced} accounts, ${result.events ?? 0} events, ${result.meetings ?? 0} meetings`,
34
39
  );
35
- if (result?.synced) {
36
- console.log(
37
- `[poll-calendars] synced ${result.synced} accounts, ${result.events ?? 0} events, ${result.meetings ?? 0} meetings`,
38
- );
39
- }
40
- } catch (err: any) {
41
- console.error(`[poll-calendars] sync failed:`, err?.message ?? err);
42
40
  }
43
41
  });
44
42
  }
@@ -62,11 +60,15 @@ export default function registerPollCalendarsJob(): void {
62
60
  }
63
61
  return;
64
62
  }
65
- setInterval(() => {
66
- runPollCalendarsOnce().catch((err) =>
67
- console.error("[poll-calendars] interval failed:", err),
68
- );
69
- }, POLL_INTERVAL_MS);
63
+ // sync-calendars calls Google's Calendar API with no fetch-level timeout of
64
+ // its own, so the overlap guard and the hung-sweep timeout both come from
65
+ // startIntervalJob rather than a module-level `running` flag here.
66
+ startIntervalJob(() => runPollCalendarsOnce(), {
67
+ intervalMs: POLL_INTERVAL_MS,
68
+ leading: false,
69
+ onError: (err: any) =>
70
+ console.error("[poll-calendars] sync failed:", err?.message ?? err),
71
+ });
70
72
  console.log(
71
73
  `[poll-calendars] Recurring calendar sync every ${POLL_INTERVAL_MS / 1000}s.`,
72
74
  );
@@ -46,6 +46,7 @@ const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 60 min of zero transcript activity
46
46
  // a slow Gemini call. Mirrors finalize-meeting.ts's force-takeover window.
47
47
  const PENDING_STALE_MS = 2 * 60 * 1000; // 2 min
48
48
  let skippingLogged = false;
49
+ let running = false;
49
50
 
50
51
  /**
51
52
  * Close out a single stranded-live meeting row: stamp actualEnd, set
@@ -284,9 +285,15 @@ export default function registerStaleMeetingSweeperJob(): void {
284
285
  return;
285
286
  }
286
287
  setInterval(() => {
287
- runStaleMeetingSweepOnce().catch((err) =>
288
- console.error("[stale-meeting-sweeper] interval failed:", err),
289
- );
288
+ if (running) return;
289
+ running = true;
290
+ runStaleMeetingSweepOnce()
291
+ .catch((err) =>
292
+ console.error("[stale-meeting-sweeper] interval failed:", err),
293
+ )
294
+ .finally(() => {
295
+ running = false;
296
+ });
290
297
  }, SWEEP_INTERVAL_MS);
291
298
  console.log(
292
299
  `[stale-meeting-sweeper] Recurring stale-meeting reconciliation every ${SWEEP_INTERVAL_MS / 1000}s.`,
@@ -49,6 +49,7 @@ const SENDING_LEASE_MS = 2 * 60 * 1000;
49
49
  const MAX_ATTEMPTS = 3;
50
50
  const RETRY_BASE_DELAY_MS = 60_000;
51
51
  let skippingLogged = false;
52
+ let running = false;
52
53
 
53
54
  type DirectShare = {
54
55
  id: string;
@@ -1304,9 +1305,15 @@ export default function registerTransactionalEmailsJob(): void {
1304
1305
  return;
1305
1306
  }
1306
1307
  setInterval(() => {
1307
- runTransactionalEmailsOnce().catch((error) =>
1308
- console.error("[transactional-emails] interval failed:", error),
1309
- );
1308
+ if (running) return;
1309
+ running = true;
1310
+ runTransactionalEmailsOnce()
1311
+ .catch((error) =>
1312
+ console.error("[transactional-emails] interval failed:", error),
1313
+ )
1314
+ .finally(() => {
1315
+ running = false;
1316
+ });
1310
1317
  }, JOB_INTERVAL_MS);
1311
1318
  console.log(
1312
1319
  `[transactional-emails] Recurring reconciliation and delivery every ${JOB_INTERVAL_MS / 1000}s.`,
@@ -167,6 +167,7 @@ export function NotionButton() {
167
167
 
168
168
  // Poll for connection
169
169
  pollRef.current = setInterval(async () => {
170
+ if (document.hidden) return;
170
171
  const result = await refetch();
171
172
  if (result.data?.connected) {
172
173
  clearInterval(pollRef.current);
@@ -83,11 +83,24 @@ export function FusionAppBanner({
83
83
  // ── Building: poll sync-fusion-app on mount + every ~8s while building ────
84
84
  const syncMutateRef = useRef(syncMutation.mutate);
85
85
  syncMutateRef.current = syncMutation.mutate;
86
+ const syncInFlightRef = useRef(false);
86
87
  useEffect(() => {
87
88
  if (status !== "building") return;
88
- syncMutateRef.current({ designId } as any);
89
+ // The leading sync goes through the same guard as the interval ticks: a
90
+ // container check still pending at the first tick would otherwise let a
91
+ // second status sync start and race the provisioning/screen-upsert work.
92
+ const runSync = (): void => {
93
+ if (syncInFlightRef.current) return;
94
+ syncInFlightRef.current = true;
95
+ syncMutateRef.current(
96
+ { designId } as any,
97
+ { onSettled: () => (syncInFlightRef.current = false) } as any,
98
+ );
99
+ };
100
+ runSync();
89
101
  const interval = window.setInterval(() => {
90
- syncMutateRef.current({ designId } as any);
102
+ if (document.hidden) return;
103
+ runSync();
91
104
  }, SYNC_POLL_INTERVAL_MS);
92
105
  return () => window.clearInterval(interval);
93
106
  }, [designId, status]);