@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.
- package/README.md +12 -14
- package/next.config.ts +13 -2
- package/package.json +4 -2
- package/src/app/api/agents/[id]/thread/route.ts +9 -0
- package/src/app/api/agents/route.ts +4 -0
- package/src/app/api/agents/thread-route.test.ts +133 -0
- package/src/app/api/approvals/route.test.ts +148 -0
- package/src/app/api/canvas/[sessionId]/route.ts +3 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
- package/src/app/api/chats/[id]/devserver/route.ts +48 -7
- package/src/app/api/chats/[id]/messages/route.ts +42 -18
- package/src/app/api/chats/[id]/route.ts +1 -1
- package/src/app/api/chats/[id]/stop/route.ts +5 -4
- package/src/app/api/chats/route.ts +23 -2
- package/src/app/api/clawhub/install/route.ts +28 -8
- package/src/app/api/connectors/[id]/route.ts +46 -3
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/route.test.ts +165 -0
- package/src/app/api/gateways/[id]/health/route.ts +27 -12
- package/src/app/api/gateways/[id]/route.ts +2 -0
- package/src/app/api/gateways/health-route.test.ts +135 -0
- package/src/app/api/gateways/route.ts +2 -0
- package/src/app/api/mcp-servers/route.test.ts +130 -0
- package/src/app/api/openclaw/deploy/route.ts +38 -5
- package/src/app/api/plugins/install/route.ts +46 -6
- package/src/app/api/plugins/marketplace/route.ts +48 -15
- package/src/app/api/preview-server/route.ts +26 -11
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/schedules/[id]/run/route.ts +4 -0
- package/src/app/api/schedules/route.test.ts +86 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/app/api/setup/check-provider/route.test.ts +19 -0
- package/src/app/api/setup/check-provider/route.ts +40 -10
- package/src/app/api/skills/[id]/route.ts +12 -0
- package/src/app/api/skills/import/route.ts +14 -12
- package/src/app/api/skills/route.ts +13 -1
- package/src/app/api/tasks/[id]/route.ts +10 -1
- package/src/app/api/tasks/import/github/route.test.ts +65 -0
- package/src/app/api/tasks/import/github/route.ts +337 -0
- package/src/app/api/wallets/[id]/approve/route.ts +17 -3
- package/src/app/api/wallets/[id]/route.ts +79 -33
- package/src/app/api/wallets/[id]/send/route.ts +19 -33
- package/src/app/api/wallets/route.ts +78 -61
- package/src/app/api/webhooks/[id]/route.ts +33 -6
- package/src/app/api/webhooks/route.test.ts +272 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-card.tsx +9 -2
- package/src/components/agents/agent-chat-list.tsx +18 -2
- package/src/components/agents/agent-list.tsx +1 -0
- package/src/components/agents/agent-sheet.tsx +257 -38
- package/src/components/agents/inspector-panel.tsx +41 -0
- package/src/components/canvas/canvas-panel.tsx +236 -65
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-card.tsx +36 -13
- package/src/components/chat/chat-header.tsx +48 -16
- package/src/components/chat/chat-list.tsx +28 -4
- package/src/components/chat/checkpoint-timeline.tsx +50 -34
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/chat/message-bubble.tsx +208 -145
- package/src/components/chat/message-list.tsx +48 -19
- package/src/components/chatrooms/chatroom-message.tsx +2 -2
- package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
- package/src/components/connectors/connector-health.tsx +1 -1
- package/src/components/connectors/connector-list.tsx +7 -2
- package/src/components/connectors/connector-sheet.tsx +337 -148
- package/src/components/gateways/gateway-sheet.tsx +2 -2
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
- package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
- package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
- package/src/components/plugins/plugin-list.tsx +45 -9
- package/src/components/plugins/plugin-sheet.tsx +55 -7
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/providers/provider-list.tsx +2 -1
- package/src/components/providers/provider-sheet.tsx +21 -2
- package/src/components/schedules/schedule-card.tsx +25 -1
- package/src/components/schedules/schedule-sheet.tsx +44 -2
- package/src/components/secrets/secret-sheet.tsx +21 -2
- package/src/components/shared/agent-switch-dialog.tsx +12 -1
- package/src/components/shared/bottom-sheet.tsx +13 -3
- package/src/components/shared/command-palette.tsx +8 -1
- package/src/components/shared/confirm-dialog.tsx +19 -4
- package/src/components/shared/connector-platform-icon.test.ts +28 -0
- package/src/components/shared/connector-platform-icon.tsx +39 -6
- package/src/components/shared/settings/plugin-manager.tsx +29 -6
- package/src/components/shared/settings/section-capability-policy.tsx +45 -3
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/skills/skill-list.tsx +25 -0
- package/src/components/skills/skill-sheet.tsx +84 -12
- package/src/components/tasks/approvals-panel.tsx +289 -34
- package/src/components/tasks/task-board.tsx +410 -25
- package/src/components/tasks/task-card.tsx +66 -8
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
- package/src/components/wallets/wallet-panel.tsx +435 -90
- package/src/components/wallets/wallet-section.tsx +198 -48
- package/src/components/webhooks/webhook-sheet.tsx +22 -2
- package/src/lib/approval-display.ts +20 -0
- package/src/lib/canvas-content.ts +198 -0
- package/src/lib/chat-artifact-summary.ts +165 -0
- package/src/lib/chat-display.test.ts +91 -0
- package/src/lib/chat-display.ts +58 -0
- package/src/lib/chat-streaming-state.test.ts +47 -1
- package/src/lib/chat-streaming-state.ts +42 -0
- package/src/lib/ollama-model.ts +10 -0
- package/src/lib/openclaw-endpoint.test.ts +8 -0
- package/src/lib/openclaw-endpoint.ts +6 -1
- package/src/lib/plugin-install-cors.ts +46 -0
- package/src/lib/plugin-sources.test.ts +43 -0
- package/src/lib/plugin-sources.ts +77 -0
- package/src/lib/providers/ollama.ts +16 -6
- package/src/lib/providers/openclaw.test.ts +54 -0
- package/src/lib/providers/openclaw.ts +127 -11
- package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
- package/src/lib/schedule-dedupe.test.ts +66 -1
- package/src/lib/schedule-dedupe.ts +169 -12
- package/src/lib/schedule-origin.test.ts +20 -0
- package/src/lib/schedule-origin.ts +15 -0
- package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
- package/src/lib/server/agent-availability.ts +16 -0
- package/src/lib/server/agent-runtime-config.ts +12 -4
- package/src/lib/server/agent-thread-session.test.ts +51 -0
- package/src/lib/server/agent-thread-session.ts +7 -0
- package/src/lib/server/approval-match.ts +205 -0
- package/src/lib/server/approvals-auto-approve.test.ts +538 -1
- package/src/lib/server/approvals.ts +214 -1
- package/src/lib/server/assistant-control.test.ts +29 -0
- package/src/lib/server/assistant-control.ts +23 -0
- package/src/lib/server/build-llm.test.ts +79 -0
- package/src/lib/server/build-llm.ts +14 -4
- package/src/lib/server/canvas-content.test.ts +32 -0
- package/src/lib/server/canvas-content.ts +6 -0
- package/src/lib/server/capability-router.test.ts +33 -0
- package/src/lib/server/capability-router.ts +80 -19
- package/src/lib/server/chat-execution-advanced.test.ts +651 -0
- package/src/lib/server/chat-execution-disabled.test.ts +94 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
- package/src/lib/server/chat-execution.ts +378 -73
- package/src/lib/server/clawhub-client.test.ts +14 -8
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.test.ts +1147 -0
- package/src/lib/server/connectors/manager.ts +461 -137
- package/src/lib/server/connectors/pairing.ts +26 -5
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.test.ts +134 -0
- package/src/lib/server/connectors/whatsapp.ts +271 -47
- package/src/lib/server/context-manager.ts +6 -1
- package/src/lib/server/daemon-state.ts +84 -47
- package/src/lib/server/data-dir.test.ts +37 -0
- package/src/lib/server/data-dir.ts +20 -1
- package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
- package/src/lib/server/devserver-launch.test.ts +60 -0
- package/src/lib/server/devserver-launch.ts +85 -0
- package/src/lib/server/elevenlabs.test.ts +247 -1
- package/src/lib/server/elevenlabs.ts +147 -43
- package/src/lib/server/ethereum.ts +590 -0
- package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
- package/src/lib/server/eval/agent-regression.test.ts +18 -1
- package/src/lib/server/eval/agent-regression.ts +383 -11
- package/src/lib/server/evm-swap.ts +475 -0
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
- package/src/lib/server/heartbeat-service.ts +20 -11
- package/src/lib/server/heartbeat-wake.test.ts +112 -0
- package/src/lib/server/heartbeat-wake.ts +338 -57
- package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/mcp-client.test.ts +16 -0
- package/src/lib/server/mcp-client.ts +25 -0
- package/src/lib/server/memory-integration.test.ts +719 -0
- package/src/lib/server/memory-policy.test.ts +43 -0
- package/src/lib/server/memory-policy.ts +132 -0
- package/src/lib/server/memory-tiers.test.ts +60 -0
- package/src/lib/server/memory-tiers.ts +16 -0
- package/src/lib/server/ollama-runtime.ts +58 -0
- package/src/lib/server/openclaw-deploy.test.ts +109 -1
- package/src/lib/server/openclaw-deploy.ts +557 -81
- package/src/lib/server/openclaw-gateway.test.ts +131 -0
- package/src/lib/server/openclaw-gateway.ts +10 -4
- package/src/lib/server/openclaw-health.test.ts +35 -0
- package/src/lib/server/openclaw-health.ts +215 -47
- package/src/lib/server/orchestrator-lg.ts +3 -2
- package/src/lib/server/orchestrator.ts +2 -0
- package/src/lib/server/plugins-advanced.test.ts +351 -0
- package/src/lib/server/plugins.ts +211 -6
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-advanced.test.ts +528 -0
- package/src/lib/server/queue-followups.test.ts +409 -2
- package/src/lib/server/queue-reconcile.test.ts +128 -0
- package/src/lib/server/queue.ts +527 -68
- package/src/lib/server/scheduler.ts +29 -1
- package/src/lib/server/session-note.test.ts +36 -0
- package/src/lib/server/session-note.ts +42 -0
- package/src/lib/server/session-run-manager.ts +83 -4
- package/src/lib/server/session-tools/canvas.ts +14 -12
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.test.ts +138 -0
- package/src/lib/server/session-tools/connector.ts +366 -54
- package/src/lib/server/session-tools/context.ts +17 -3
- package/src/lib/server/session-tools/crud.ts +484 -84
- package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +102 -10
- package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
- package/src/lib/server/session-tools/discovery.ts +80 -12
- package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
- package/src/lib/server/session-tools/file.ts +43 -4
- package/src/lib/server/session-tools/human-loop.ts +35 -5
- package/src/lib/server/session-tools/index.ts +44 -9
- package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
- package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
- package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.test.ts +93 -0
- package/src/lib/server/session-tools/memory.ts +554 -75
- package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/plugin-creator.ts +57 -1
- package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
- package/src/lib/server/session-tools/schedule.ts +6 -1
- package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
- package/src/lib/server/session-tools/shell.ts +22 -3
- package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
- package/src/lib/server/session-tools/wallet.ts +1374 -139
- package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
- package/src/lib/server/session-tools/web.ts +621 -70
- package/src/lib/server/skill-discovery.ts +128 -0
- package/src/lib/server/skill-eligibility.test.ts +84 -0
- package/src/lib/server/skill-eligibility.ts +95 -0
- package/src/lib/server/skill-prompt-budget.test.ts +102 -0
- package/src/lib/server/skill-prompt-budget.ts +125 -0
- package/src/lib/server/skills-normalize.test.ts +54 -0
- package/src/lib/server/skills-normalize.ts +372 -26
- package/src/lib/server/solana.ts +214 -29
- package/src/lib/server/storage.ts +65 -36
- package/src/lib/server/stream-agent-chat.test.ts +437 -2
- package/src/lib/server/stream-agent-chat.ts +957 -79
- package/src/lib/server/system-events.ts +1 -1
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-loop-detection.test.ts +105 -0
- package/src/lib/server/tool-loop-detection.ts +260 -0
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +271 -0
- package/src/lib/server/wallet-execution.test.ts +198 -0
- package/src/lib/server/wallet-portfolio.test.ts +98 -0
- package/src/lib/server/wallet-portfolio.ts +724 -0
- package/src/lib/server/wallet-service.test.ts +57 -0
- package/src/lib/server/wallet-service.ts +213 -0
- package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
- package/src/lib/server/watch-jobs.ts +17 -2
- package/src/lib/server/workspace-context.ts +111 -0
- package/src/lib/skill-save-payload.test.ts +39 -0
- package/src/lib/skill-save-payload.ts +37 -0
- package/src/lib/tasks.ts +28 -0
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/tool-event-summary.test.ts +30 -0
- package/src/lib/tool-event-summary.ts +37 -0
- package/src/lib/validation/schemas.ts +1 -0
- package/src/lib/wallet-transactions.test.ts +75 -0
- package/src/lib/wallet-transactions.ts +43 -0
- package/src/lib/wallet.test.ts +17 -0
- package/src/lib/wallet.ts +183 -0
- package/src/proxy.test.ts +31 -0
- package/src/proxy.ts +34 -2
- package/src/stores/use-chat-store.ts +15 -1
- 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
|
|
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
|
-
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
|
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]?: {
|
|
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
|
-
|
|
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
|
|
292
|
+
for (const wake of wakes) {
|
|
37
293
|
try {
|
|
38
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
'
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
344
|
+
modelOverride: cfg.model || undefined,
|
|
91
345
|
heartbeatConfig: {
|
|
92
|
-
ackMaxChars:
|
|
93
|
-
showOk:
|
|
94
|
-
showAlerts:
|
|
95
|
-
target:
|
|
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}
|
|
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:
|
|
108
|
-
|
|
109
|
-
|
|
373
|
+
export function requestHeartbeatNow(opts: WakeRequestInput): void {
|
|
374
|
+
queuePendingWake(opts)
|
|
375
|
+
scheduleFlush(COALESCE_MS, 'normal')
|
|
376
|
+
}
|
|
110
377
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
}
|