@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -12,7 +12,9 @@ import { log } from './logger'
12
12
  import { WORKSPACE_DIR } from './data-dir'
13
13
  import { drainSystemEvents } from './system-events'
14
14
  import { buildIdentityContinuityContext } from './identity-continuity'
15
+ import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
15
16
  import { ensureAgentThreadSession } from './agent-thread-session'
17
+ import { isAgentDisabled } from './agent-availability'
16
18
 
17
19
  const HEARTBEAT_TICK_MS = 5_000
18
20
 
@@ -133,7 +135,7 @@ interface HeartbeatFileSession {
133
135
 
134
136
  const DEFAULT_HEARTBEAT_PROMPT = 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.'
135
137
 
136
- function readHeartbeatFile(session: HeartbeatFileSession): string {
138
+ export function readHeartbeatFile(session: HeartbeatFileSession): string {
137
139
  try {
138
140
  const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
139
141
  if (fs.existsSync(filePath)) {
@@ -195,7 +197,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
195
197
  return true
196
198
  }
197
199
 
198
- function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
200
+ export function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
199
201
  if (!agent) return fallbackPrompt
200
202
 
201
203
  const identityContext = buildIdentityContext(session, agent)
@@ -284,7 +286,7 @@ function resolveNum(obj: Record<string, any>, key: string, current: number): num
284
286
  return current
285
287
  }
286
288
 
287
- function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
289
+ export function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
288
290
  // Global defaults — 30 min interval (was 120s)
289
291
  let intervalSec = resolveInterval(settings, DEFAULT_HEARTBEAT_INTERVAL_SEC)
290
292
  const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
@@ -365,11 +367,11 @@ async function tickHeartbeats() {
365
367
 
366
368
  const agents = loadAgents()
367
369
  for (const agent of Object.values(agents) as any[]) {
368
- if (!agent?.id || agent.heartbeatEnabled !== true) continue
370
+ if (!agent?.id || agent.heartbeatEnabled !== true || isAgentDisabled(agent)) continue
369
371
  ensureAgentThreadSession(String(agent.id))
370
372
  }
371
373
  const sessions = loadSessions()
372
- const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
374
+ const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true && !isAgentDisabled(a))
373
375
 
374
376
  // Prune tracked sessions that no longer exist or have heartbeat disabled
375
377
  for (const trackedId of state.lastBySession.keys()) {
@@ -390,6 +392,7 @@ async function tickHeartbeats() {
390
392
 
391
393
  // Check if this session or its agent has explicit heartbeat opt-in
392
394
  const agent = session.agentId ? agents[session.agentId] : null
395
+ if (isAgentDisabled(agent)) continue
393
396
  const explicitOptIn = session.heartbeatEnabled === true || (agent && agent.heartbeatEnabled === true)
394
397
 
395
398
  // If global loopMode is bounded, only allow sessions with explicit opt-in
@@ -427,14 +430,20 @@ async function tickHeartbeats() {
427
430
 
428
431
  const rawHeartbeatFileContent = readHeartbeatFile(session)
429
432
  const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
430
- const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
433
+ const hasExplicitGoal = !!(agent?.heartbeatGoal || agent?.heartbeatNextAction)
434
+ const hasAgentContext = !!(agent?.description || agent?.systemPrompt || agent?.soul)
431
435
  const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
432
- // Skip heartbeat only if there's truly nothing to drive it:
433
- // no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
434
- if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
435
- continue
436
+ const hasUserMessages = lastUserMessageAt(session) > 0
437
+ // Skip heartbeat if there's nothing to drive it. An agent description alone
438
+ // is not enough the session needs at least one user message or an explicit
439
+ // heartbeat goal/HEARTBEAT.md content. This prevents noise on unused sessions.
440
+ if (!hasExplicitGoal && !heartbeatFileContent && !hasCustomPrompt) {
441
+ if (!hasAgentContext || !hasUserMessages) continue
436
442
  }
437
- const heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
443
+ const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
444
+ const heartbeatMessage = isMainSession(session)
445
+ ? buildMainLoopHeartbeatPrompt(session, baseHeartbeatMessage)
446
+ : baseHeartbeatMessage
438
447
 
439
448
  const enqueue = enqueueSessionRun({
440
449
  sessionId: session.id,
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+
4
+ import {
5
+ buildHeartbeatWakePrompt,
6
+ buildWakeTriggerContext,
7
+ hasPendingHeartbeatWake,
8
+ mergeHeartbeatWakeRequest,
9
+ requestHeartbeatNow,
10
+ resetHeartbeatWakeStateForTests,
11
+ snapshotPendingHeartbeatWakesForTests,
12
+ } from './heartbeat-wake'
13
+
14
+ describe('heartbeat-wake helpers', () => {
15
+ afterEach(() => {
16
+ resetHeartbeatWakeStateForTests()
17
+ })
18
+
19
+ it('retains distinct wake events per target and keeps the latest requested timestamp', () => {
20
+ const first = mergeHeartbeatWakeRequest(undefined, {
21
+ agentId: 'ops',
22
+ reason: 'connector-message',
23
+ source: 'connector:slack',
24
+ resumeMessage: 'Slack says the deploy is red.',
25
+ requestedAt: 100,
26
+ })
27
+ const merged = mergeHeartbeatWakeRequest(first, {
28
+ agentId: 'ops',
29
+ reason: 'schedule',
30
+ source: 'schedule:nightly',
31
+ resumeMessage: 'Nightly check-in fired.',
32
+ requestedAt: 250,
33
+ })
34
+
35
+ assert.equal(merged.agentId, 'ops')
36
+ assert.equal(merged.requestedAt, 250)
37
+ assert.equal(merged.events.length, 2)
38
+ assert.deepEqual(merged.events.map((event) => event.reason), ['connector-message', 'schedule'])
39
+ })
40
+
41
+ it('deduplicates identical events but preserves differently sourced triggers', () => {
42
+ let wake = mergeHeartbeatWakeRequest(undefined, {
43
+ sessionId: 's1',
44
+ reason: 'schedule',
45
+ source: 'schedule:nightly',
46
+ requestedAt: 1,
47
+ })
48
+ wake = mergeHeartbeatWakeRequest(wake, {
49
+ sessionId: 's1',
50
+ reason: 'schedule',
51
+ source: 'schedule:nightly',
52
+ requestedAt: 2,
53
+ })
54
+ wake = mergeHeartbeatWakeRequest(wake, {
55
+ sessionId: 's1',
56
+ reason: 'schedule',
57
+ source: 'schedule:hourly',
58
+ requestedAt: 3,
59
+ })
60
+
61
+ assert.equal(wake.events.length, 2)
62
+ assert.deepEqual(wake.events.map((event) => event.source), ['schedule:hourly', 'schedule:nightly'])
63
+ })
64
+
65
+ it('builds a structured trigger context for event-driven wakes', () => {
66
+ const wake = mergeHeartbeatWakeRequest(undefined, {
67
+ sessionId: 'sess-1',
68
+ reason: 'connector-message',
69
+ source: 'connector:slack',
70
+ resumeMessage: 'Slack says deploy is still red.',
71
+ detail: 'Text: prod deploy is still failing health checks',
72
+ requestedAt: 10,
73
+ priority: 90,
74
+ })
75
+ const triggerContext = buildWakeTriggerContext(wake.events, '2026-03-08T15:30:00.000Z')
76
+ const prompt = buildHeartbeatWakePrompt({
77
+ wake,
78
+ basePrompt: 'BASE_PROMPT',
79
+ nowIso: '2026-03-08T15:30:00.000Z',
80
+ })
81
+
82
+ assert.match(triggerContext, /## Wake Trigger Context/)
83
+ assert.match(triggerContext, /reason=connector-message \| source=connector:slack \| priority=90/)
84
+ assert.match(triggerContext, /Resume: Slack says deploy is still red\./)
85
+ assert.match(triggerContext, /Detail: Text: prod deploy is still failing health checks/)
86
+ assert.match(prompt, /^BASE_PROMPT/m)
87
+ assert.match(prompt, /Reply HEARTBEAT_OK only if every trigger above is already handled/)
88
+ })
89
+
90
+ it('tracks pending wake state while coalesced wakes are queued', () => {
91
+ requestHeartbeatNow({
92
+ sessionId: 'sess-2',
93
+ reason: 'watch_job',
94
+ source: 'watch:http',
95
+ resumeMessage: 'Check the changed API response.',
96
+ })
97
+ requestHeartbeatNow({
98
+ sessionId: 'sess-2',
99
+ reason: 'connector-message',
100
+ source: 'connector:slack',
101
+ resumeMessage: 'Slack asks for an update.',
102
+ })
103
+
104
+ assert.equal(hasPendingHeartbeatWake(), true)
105
+ const wakes = snapshotPendingHeartbeatWakesForTests()
106
+ assert.equal(wakes.length, 1)
107
+ assert.deepEqual(
108
+ [...wakes[0].events.map((event) => event.reason)].sort(),
109
+ ['connector-message', 'watch_job'],
110
+ )
111
+ })
112
+ })
@@ -1,84 +1,338 @@
1
1
  /**
2
2
  * On-demand heartbeat wake — triggers an immediate heartbeat for an agent/session.
3
- * Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
3
+ * Requests are debounced with a short coalesce window, retain distinct trigger
4
+ * events per target, and retry when the session lane is already busy.
4
5
  */
5
6
 
6
7
  import { ensureAgentThreadSession } from './agent-thread-session'
8
+ import {
9
+ buildAgentHeartbeatPrompt,
10
+ heartbeatConfigForSession,
11
+ isHeartbeatContentEffectivelyEmpty,
12
+ readHeartbeatFile,
13
+ } from './heartbeat-service'
14
+ import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
7
15
  import { loadSessions, loadAgents, loadSettings } from './storage'
8
- import { enqueueSessionRun } from './session-run-manager'
16
+ import { enqueueSessionRun, getSessionExecutionState } from './session-run-manager'
9
17
  import { log } from './logger'
18
+ import { isAgentDisabled } from './agent-availability'
10
19
 
11
- interface WakeRequest {
20
+ export interface WakeRequestInput {
21
+ eventId?: string
12
22
  agentId?: string
13
23
  sessionId?: string
14
24
  reason?: string
25
+ source?: string
26
+ resumeMessage?: string
27
+ detail?: string
28
+ requestedAt?: number
29
+ occurredAt?: number
30
+ priority?: number
31
+ retryCount?: number
32
+ }
33
+
34
+ export interface WakeEvent {
35
+ eventId?: string
36
+ reason: string
37
+ source?: string
38
+ resumeMessage?: string
39
+ detail?: string
40
+ occurredAt: number
41
+ priority: number
42
+ }
43
+
44
+ export interface WakeRequest {
45
+ agentId?: string
46
+ sessionId?: string
47
+ requestedAt: number
48
+ retryCount: number
49
+ events: WakeEvent[]
15
50
  }
16
51
 
17
52
  const COALESCE_MS = 250
53
+ const RETRY_MS = 1_000
54
+ const MAX_WAKE_EVENTS = 6
55
+ const MAX_RESUME_CHARS = 280
56
+ const MAX_DETAIL_CHARS = 800
57
+ type WakeTimerKind = 'normal' | 'retry'
18
58
 
19
59
  const globalKey = '__swarmclaw_heartbeat_wake__' as const
20
60
  const globalScope = globalThis as typeof globalThis & {
21
- [globalKey]?: { pending: Map<string, WakeRequest>; timer: ReturnType<typeof setTimeout> | null }
61
+ [globalKey]?: {
62
+ pending: Map<string, WakeRequest>
63
+ timer: ReturnType<typeof setTimeout> | null
64
+ timerDueAt: number | null
65
+ timerKind: WakeTimerKind | null
66
+ }
22
67
  }
23
68
  const state = globalScope[globalKey] ?? (globalScope[globalKey] = {
24
69
  pending: new Map(),
25
70
  timer: null,
71
+ timerDueAt: null,
72
+ timerKind: null,
26
73
  })
27
74
 
75
+ function trimText(value: unknown, maxChars: number): string | undefined {
76
+ if (typeof value !== 'string') return undefined
77
+ const normalized = value.replace(/\s+/g, ' ').trim()
78
+ return normalized ? normalized.slice(0, maxChars) : undefined
79
+ }
80
+
81
+ function normalizeWakeReason(reason?: string): string {
82
+ return trimText(reason, 80) || 'on-demand'
83
+ }
84
+
85
+ function normalizeWakeTarget(value?: string): string | undefined {
86
+ return trimText(value, 160)
87
+ }
88
+
89
+ function normalizeOccurredAt(value?: number): number {
90
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : Date.now()
91
+ }
92
+
93
+ function reasonPriority(reason: string): number {
94
+ const normalized = reason.toLowerCase()
95
+ if (/(approval|connector-message|webhook|watch_job|scheduled_wake|task-completed)/.test(normalized)) return 90
96
+ if (/(schedule)/.test(normalized)) return 70
97
+ if (/(comparison|manual|on-demand)/.test(normalized)) return 50
98
+ return 40
99
+ }
100
+
101
+ function normalizeWakeEvent(input: WakeRequestInput): WakeEvent {
102
+ const reason = normalizeWakeReason(input.reason)
103
+ const explicitPriority = typeof input.priority === 'number' && Number.isFinite(input.priority)
104
+ ? Math.trunc(input.priority)
105
+ : reasonPriority(reason)
106
+ return {
107
+ ...(trimText(input.eventId, 160) ? { eventId: trimText(input.eventId, 160) } : {}),
108
+ reason,
109
+ ...(trimText(input.source, 120) ? { source: trimText(input.source, 120) } : {}),
110
+ ...(trimText(input.resumeMessage, MAX_RESUME_CHARS) ? { resumeMessage: trimText(input.resumeMessage, MAX_RESUME_CHARS) } : {}),
111
+ ...(trimText(input.detail, MAX_DETAIL_CHARS) ? { detail: trimText(input.detail, MAX_DETAIL_CHARS) } : {}),
112
+ occurredAt: normalizeOccurredAt(input.occurredAt ?? input.requestedAt),
113
+ priority: Math.max(0, Math.min(100, explicitPriority)),
114
+ }
115
+ }
116
+
117
+ function uniqueWakeEvents(existing: WakeEvent[], incoming: WakeEvent): WakeEvent[] {
118
+ const merged = [...existing]
119
+ const matchIndex = merged.findIndex((candidate) => {
120
+ if (candidate.eventId && incoming.eventId) return candidate.eventId === incoming.eventId
121
+ return candidate.reason === incoming.reason
122
+ && candidate.source === incoming.source
123
+ && candidate.resumeMessage === incoming.resumeMessage
124
+ && candidate.detail === incoming.detail
125
+ })
126
+
127
+ if (matchIndex >= 0) {
128
+ const previous = merged[matchIndex]
129
+ merged[matchIndex] = {
130
+ ...previous,
131
+ ...incoming,
132
+ priority: Math.max(previous.priority, incoming.priority),
133
+ occurredAt: Math.max(previous.occurredAt, incoming.occurredAt),
134
+ resumeMessage: incoming.resumeMessage || previous.resumeMessage,
135
+ detail: incoming.detail || previous.detail,
136
+ }
137
+ } else {
138
+ merged.push(incoming)
139
+ }
140
+
141
+ merged.sort((left, right) => {
142
+ if (right.priority !== left.priority) return right.priority - left.priority
143
+ return right.occurredAt - left.occurredAt
144
+ })
145
+ return merged.slice(0, MAX_WAKE_EVENTS)
146
+ }
147
+
148
+ function wakeTargetKey(input: { agentId?: string; sessionId?: string }): string {
149
+ return `${normalizeWakeTarget(input.agentId) || ''}::${normalizeWakeTarget(input.sessionId) || ''}`
150
+ }
151
+
152
+ export function mergeHeartbeatWakeRequest(
153
+ existing: WakeRequest | undefined,
154
+ next: WakeRequestInput,
155
+ ): WakeRequest {
156
+ const agentId = normalizeWakeTarget(next.agentId) || existing?.agentId
157
+ const sessionId = normalizeWakeTarget(next.sessionId) || existing?.sessionId
158
+ const requestedAt = Math.max(existing?.requestedAt || 0, normalizeOccurredAt(next.requestedAt))
159
+ const retryCount = Math.max(existing?.retryCount || 0, typeof next.retryCount === 'number' ? Math.trunc(next.retryCount) : 0)
160
+ const events = uniqueWakeEvents(existing?.events || [], normalizeWakeEvent(next))
161
+ return {
162
+ ...(agentId ? { agentId } : {}),
163
+ ...(sessionId ? { sessionId } : {}),
164
+ requestedAt,
165
+ retryCount,
166
+ events,
167
+ }
168
+ }
169
+
170
+ function queuePendingWake(next: WakeRequestInput): void {
171
+ const key = wakeTargetKey(next)
172
+ const existing = state.pending.get(key)
173
+ state.pending.set(key, mergeHeartbeatWakeRequest(existing, next))
174
+ }
175
+
176
+ function queuePendingWakeRequest(next: WakeRequest): void {
177
+ const key = wakeTargetKey(next)
178
+ let merged = state.pending.get(key)
179
+ for (const event of next.events) {
180
+ merged = mergeHeartbeatWakeRequest(merged, {
181
+ agentId: next.agentId,
182
+ sessionId: next.sessionId,
183
+ requestedAt: next.requestedAt,
184
+ retryCount: next.retryCount,
185
+ eventId: event.eventId,
186
+ reason: event.reason,
187
+ source: event.source,
188
+ resumeMessage: event.resumeMessage,
189
+ detail: event.detail,
190
+ occurredAt: event.occurredAt,
191
+ priority: event.priority,
192
+ })
193
+ }
194
+ if (merged) state.pending.set(key, merged)
195
+ }
196
+
197
+ function scheduleFlush(delayMs: number, kind: WakeTimerKind = 'normal'): void {
198
+ const delay = Math.max(0, Number.isFinite(delayMs) ? Math.trunc(delayMs) : COALESCE_MS)
199
+ const dueAt = Date.now() + delay
200
+ if (state.timer) {
201
+ if (state.timerKind === 'retry' && kind !== 'normal') return
202
+ if (typeof state.timerDueAt === 'number' && state.timerDueAt <= dueAt) return
203
+ clearTimeout(state.timer)
204
+ state.timer = null
205
+ state.timerDueAt = null
206
+ state.timerKind = null
207
+ }
208
+
209
+ state.timerDueAt = dueAt
210
+ state.timerKind = kind
211
+ state.timer = setTimeout(() => {
212
+ flushWakes()
213
+ }, delay)
214
+ }
215
+
216
+ export function buildWakeTriggerContext(events: WakeEvent[], nowIso?: string): string {
217
+ const lines = [
218
+ '## Wake Trigger Context',
219
+ `Triggered at: ${nowIso || new Date().toISOString()}`,
220
+ 'These new events caused this immediate wake. Prioritize them over generic background polling and avoid repeating already-completed work.',
221
+ 'If the base heartbeat instructions require an exact file change or exact acknowledgment phrase, follow that exactly and do not add extra commentary.',
222
+ ]
223
+ for (const event of events.slice(0, MAX_WAKE_EVENTS)) {
224
+ const tags = [
225
+ `reason=${event.reason}`,
226
+ event.source ? `source=${event.source}` : '',
227
+ `priority=${event.priority}`,
228
+ `at=${new Date(event.occurredAt).toISOString()}`,
229
+ ].filter(Boolean).join(' | ')
230
+ lines.push(`- ${tags}`)
231
+ if (event.resumeMessage) lines.push(` Resume: ${event.resumeMessage}`)
232
+ if (event.detail) lines.push(` Detail: ${event.detail}`)
233
+ }
234
+ lines.push('Reply HEARTBEAT_OK only if every trigger above is already handled or truly needs no action.')
235
+ return lines.join('\n')
236
+ }
237
+
238
+ export function buildHeartbeatWakePrompt(input: {
239
+ wake: WakeRequest
240
+ basePrompt?: string
241
+ nowIso?: string
242
+ }): string {
243
+ const triggerContext = buildWakeTriggerContext(input.wake.events, input.nowIso)
244
+ if (input.basePrompt?.trim()) {
245
+ return [
246
+ input.basePrompt.trim(),
247
+ '',
248
+ triggerContext,
249
+ ].join('\n')
250
+ }
251
+ return [
252
+ 'AGENT_HEARTBEAT_WAKE',
253
+ `Time: ${input.nowIso || new Date().toISOString()}`,
254
+ triggerContext,
255
+ 'Take the highest-value next step now, or reply HEARTBEAT_OK if nothing needs attention.',
256
+ ].join('\n')
257
+ }
258
+
259
+ function resolveWakeSessionId(
260
+ wake: WakeRequest,
261
+ sessions: Record<string, Record<string, unknown>>,
262
+ ): string | undefined {
263
+ if (wake.sessionId) return wake.sessionId
264
+ if (!wake.agentId) return undefined
265
+
266
+ let bestSession: { id: string; lastActiveAt: number } | null = null
267
+ for (const session of Object.values(sessions)) {
268
+ if (session.agentId !== wake.agentId) continue
269
+ const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
270
+ if (!bestSession || lastActive > bestSession.lastActiveAt) {
271
+ bestSession = { id: String(session.id), lastActiveAt: lastActive }
272
+ }
273
+ }
274
+ if (bestSession?.id) return bestSession.id
275
+ return ensureAgentThreadSession(wake.agentId)?.id
276
+ }
277
+
28
278
  function flushWakes(): void {
29
279
  state.timer = null
30
- const wakes = new Map(state.pending)
280
+ state.timerDueAt = null
281
+ state.timerKind = null
282
+ const wakes = [...state.pending.values()]
31
283
  state.pending.clear()
32
284
 
285
+ if (!wakes.length) return
286
+
33
287
  const agents = loadAgents()
34
288
  const settings = loadSettings()
289
+ const sessions = loadSessions() as Record<string, Record<string, unknown>>
290
+ let delayedForRetry = false
35
291
 
36
- for (const [_key, wake] of wakes) {
292
+ for (const wake of wakes) {
37
293
  try {
38
- let sessionId = wake.sessionId
39
-
40
- // If only agentId provided, find the agent's most recently active session
41
- if (!sessionId && wake.agentId) {
42
- const sessions = loadSessions()
43
- let bestSession: { id: string; lastActiveAt: number } | null = null
44
- for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
45
- if (s.agentId !== wake.agentId) continue
46
- const lastActive = typeof s.lastActiveAt === 'number' ? s.lastActiveAt : 0
47
- if (!bestSession || lastActive > bestSession.lastActiveAt) {
48
- bestSession = { id: s.id as string, lastActiveAt: lastActive }
49
- }
50
- }
51
- sessionId = bestSession?.id
52
- if (!sessionId) {
53
- sessionId = ensureAgentThreadSession(wake.agentId)?.id
54
- }
55
- }
56
-
294
+ const sessionId = resolveWakeSessionId(wake, sessions)
57
295
  if (!sessionId) continue
58
296
 
59
- const session = loadSessions()[sessionId] as Record<string, unknown> | undefined
297
+ const session = (sessions[sessionId] || loadSessions()[sessionId]) as Record<string, unknown> | undefined
60
298
  if (!session) continue
61
299
 
300
+ const execution = getSessionExecutionState(sessionId)
301
+ if (execution.hasRunning || execution.hasQueued) {
302
+ queuePendingWakeRequest({
303
+ ...wake,
304
+ sessionId,
305
+ retryCount: wake.retryCount + 1,
306
+ })
307
+ delayedForRetry = true
308
+ log.info('heartbeat-wake', `Wake delayed for busy session ${sessionId}`, {
309
+ running: execution.hasRunning,
310
+ queued: execution.queueLength,
311
+ })
312
+ continue
313
+ }
314
+
62
315
  const agentId = (session.agentId || wake.agentId) as string | undefined
63
- const agent = agentId ? agents[agentId] : null
64
-
65
- // Build a minimal heartbeat prompt for the wake
66
- const reason = wake.reason || 'on-demand'
67
- const prompt = [
68
- 'AGENT_HEARTBEAT_WAKE',
69
- `Time: ${new Date().toISOString()}`,
70
- agent ? `Agent: ${(agent as Record<string, unknown>).name}` : '',
71
- `Wake reason: ${reason}`,
72
- '',
73
- 'An event has occurred that may require your attention.',
74
- 'Review and take appropriate action, or reply HEARTBEAT_OK if nothing is needed.',
75
- ].filter(Boolean).join('\n')
76
-
77
- // Resolve heartbeat model from agent/settings
78
- const heartbeatModel =
79
- (agent as Record<string, unknown> | null)?.heartbeatModel as string | undefined
80
- || settings.heartbeatModel as string | undefined
81
- || undefined
316
+ const agent = agentId ? agents[agentId] as Record<string, unknown> | null : null
317
+ if (isAgentDisabled(agent)) continue
318
+
319
+ const cfg = heartbeatConfigForSession(session, settings, agents)
320
+ if (!cfg.enabled) {
321
+ log.info('heartbeat-wake', `Wake skipped for session ${sessionId}: heartbeat disabled`, {
322
+ agentId,
323
+ })
324
+ continue
325
+ }
326
+ const rawHeartbeatFileContent = readHeartbeatFile(session)
327
+ const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
328
+ const baseHeartbeatPrompt = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
329
+ const promptCore = isMainSession(session)
330
+ ? buildMainLoopHeartbeatPrompt(session, baseHeartbeatPrompt)
331
+ : baseHeartbeatPrompt
332
+ const prompt = buildHeartbeatWakePrompt({
333
+ wake,
334
+ basePrompt: promptCore,
335
+ })
82
336
 
83
337
  enqueueSessionRun({
84
338
  sessionId,
@@ -87,28 +341,55 @@ function flushWakes(): void {
87
341
  source: 'heartbeat-wake',
88
342
  mode: 'collect',
89
343
  dedupeKey: `heartbeat-wake:${sessionId}`,
90
- modelOverride: heartbeatModel,
344
+ modelOverride: cfg.model || undefined,
91
345
  heartbeatConfig: {
92
- ackMaxChars: 300,
93
- showOk: false,
94
- showAlerts: true,
95
- target: null,
346
+ ackMaxChars: cfg.ackMaxChars,
347
+ showOk: cfg.showOk,
348
+ showAlerts: cfg.showAlerts,
349
+ target: cfg.target,
96
350
  },
97
351
  })
98
352
 
99
- log.info('heartbeat-wake', `Wake fired for session ${sessionId} (reason: ${reason})`)
353
+ log.info('heartbeat-wake', `Wake fired for session ${sessionId}`, {
354
+ reasons: wake.events.map((event: WakeEvent) => event.reason),
355
+ retryCount: wake.retryCount,
356
+ })
100
357
  } catch (err: unknown) {
358
+ queuePendingWakeRequest({
359
+ ...wake,
360
+ retryCount: wake.retryCount + 1,
361
+ })
362
+ delayedForRetry = true
101
363
  log.warn('heartbeat-wake', `Wake failed: ${err instanceof Error ? err.message : String(err)}`)
102
364
  }
103
365
  }
366
+
367
+ if (delayedForRetry && state.pending.size > 0) {
368
+ scheduleFlush(RETRY_MS, 'retry')
369
+ }
104
370
  }
105
371
 
106
372
  /** Queue a heartbeat wake. Multiple rapid calls are coalesced into a single flush. */
107
- export function requestHeartbeatNow(opts: WakeRequest): void {
108
- const key = opts.agentId || opts.sessionId || 'unknown'
109
- state.pending.set(key, opts)
373
+ export function requestHeartbeatNow(opts: WakeRequestInput): void {
374
+ queuePendingWake(opts)
375
+ scheduleFlush(COALESCE_MS, 'normal')
376
+ }
110
377
 
111
- if (!state.timer) {
112
- state.timer = setTimeout(flushWakes, COALESCE_MS)
113
- }
378
+ export function resetHeartbeatWakeStateForTests(): void {
379
+ if (state.timer) clearTimeout(state.timer)
380
+ state.timer = null
381
+ state.timerDueAt = null
382
+ state.timerKind = null
383
+ state.pending.clear()
384
+ }
385
+
386
+ export function hasPendingHeartbeatWake(): boolean {
387
+ return state.pending.size > 0 || Boolean(state.timer)
388
+ }
389
+
390
+ export function snapshotPendingHeartbeatWakesForTests(): WakeRequest[] {
391
+ return [...state.pending.values()].map((wake) => ({
392
+ ...wake,
393
+ events: wake.events.map((event: WakeEvent) => ({ ...event })),
394
+ }))
114
395
  }