@swarmclawai/swarmclaw 0.7.8 → 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 -15
- 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 +22 -2
- package/src/app/api/clawhub/install/route.ts +28 -8
- package/src/app/api/connectors/[id]/route.ts +26 -1
- 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/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/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 +73 -24
- package/src/components/agents/inspector-panel.tsx +41 -0
- package/src/components/canvas/canvas-panel.tsx +236 -65
- package/src/components/chat/chat-card.tsx +36 -13
- package/src/components/chat/chat-header.tsx +44 -16
- package/src/components/chat/chat-list.tsx +28 -4
- package/src/components/chat/checkpoint-timeline.tsx +50 -34
- 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/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/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 +7 -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 +191 -95
- package/src/components/tasks/task-board.tsx +273 -2
- package/src/components/tasks/task-card.tsx +38 -9
- 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 +11 -0
- package/src/lib/server/capability-router.ts +26 -1
- 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 +353 -72
- package/src/lib/server/clawhub-client.test.ts +14 -8
- package/src/lib/server/connectors/manager.test.ts +1147 -0
- package/src/lib/server/connectors/manager.ts +362 -63
- 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 +1 -1
- 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 +189 -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 +15 -10
- 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/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 +2 -2
- package/src/lib/server/plugins-advanced.test.ts +351 -0
- package/src/lib/server/plugins.ts +205 -5
- package/src/lib/server/queue-advanced.test.ts +528 -0
- package/src/lib/server/queue-followups.test.ts +262 -0
- package/src/lib/server/queue-reconcile.test.ts +128 -0
- package/src/lib/server/queue.ts +293 -61
- 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 +52 -4
- package/src/lib/server/session-tools/canvas.ts +14 -12
- package/src/lib/server/session-tools/connector.test.ts +138 -0
- package/src/lib/server/session-tools/connector.ts +348 -61
- package/src/lib/server/session-tools/context.ts +12 -3
- package/src/lib/server/session-tools/crud.ts +221 -10
- package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
- package/src/lib/server/session-tools/delegate.ts +64 -8
- 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/memory.test.ts +93 -0
- package/src/lib/server/session-tools/memory.ts +546 -79
- package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
- 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 +162 -1
- package/src/lib/server/session-tools/web.ts +468 -64
- 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 +419 -9
- package/src/lib/server/stream-agent-chat.ts +887 -83
- package/src/lib/server/system-events.ts +1 -1
- package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
- 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.ts +4 -2
- 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-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 +210 -14
|
@@ -14,6 +14,7 @@ import { drainSystemEvents } from './system-events'
|
|
|
14
14
|
import { buildIdentityContinuityContext } from './identity-continuity'
|
|
15
15
|
import { buildMainLoopHeartbeatPrompt, isMainSession } from './main-agent-loop'
|
|
16
16
|
import { ensureAgentThreadSession } from './agent-thread-session'
|
|
17
|
+
import { isAgentDisabled } from './agent-availability'
|
|
17
18
|
|
|
18
19
|
const HEARTBEAT_TICK_MS = 5_000
|
|
19
20
|
|
|
@@ -134,7 +135,7 @@ interface HeartbeatFileSession {
|
|
|
134
135
|
|
|
135
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.'
|
|
136
137
|
|
|
137
|
-
function readHeartbeatFile(session: HeartbeatFileSession): string {
|
|
138
|
+
export function readHeartbeatFile(session: HeartbeatFileSession): string {
|
|
138
139
|
try {
|
|
139
140
|
const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
|
|
140
141
|
if (fs.existsSync(filePath)) {
|
|
@@ -196,7 +197,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
|
|
196
197
|
return true
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
|
|
200
|
+
export function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
|
|
200
201
|
if (!agent) return fallbackPrompt
|
|
201
202
|
|
|
202
203
|
const identityContext = buildIdentityContext(session, agent)
|
|
@@ -285,7 +286,7 @@ function resolveNum(obj: Record<string, any>, key: string, current: number): num
|
|
|
285
286
|
return current
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
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 {
|
|
289
290
|
// Global defaults — 30 min interval (was 120s)
|
|
290
291
|
let intervalSec = resolveInterval(settings, DEFAULT_HEARTBEAT_INTERVAL_SEC)
|
|
291
292
|
const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
|
|
@@ -366,11 +367,11 @@ async function tickHeartbeats() {
|
|
|
366
367
|
|
|
367
368
|
const agents = loadAgents()
|
|
368
369
|
for (const agent of Object.values(agents) as any[]) {
|
|
369
|
-
if (!agent?.id || agent.heartbeatEnabled !== true) continue
|
|
370
|
+
if (!agent?.id || agent.heartbeatEnabled !== true || isAgentDisabled(agent)) continue
|
|
370
371
|
ensureAgentThreadSession(String(agent.id))
|
|
371
372
|
}
|
|
372
373
|
const sessions = loadSessions()
|
|
373
|
-
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))
|
|
374
375
|
|
|
375
376
|
// Prune tracked sessions that no longer exist or have heartbeat disabled
|
|
376
377
|
for (const trackedId of state.lastBySession.keys()) {
|
|
@@ -391,6 +392,7 @@ async function tickHeartbeats() {
|
|
|
391
392
|
|
|
392
393
|
// Check if this session or its agent has explicit heartbeat opt-in
|
|
393
394
|
const agent = session.agentId ? agents[session.agentId] : null
|
|
395
|
+
if (isAgentDisabled(agent)) continue
|
|
394
396
|
const explicitOptIn = session.heartbeatEnabled === true || (agent && agent.heartbeatEnabled === true)
|
|
395
397
|
|
|
396
398
|
// If global loopMode is bounded, only allow sessions with explicit opt-in
|
|
@@ -428,12 +430,15 @@ async function tickHeartbeats() {
|
|
|
428
430
|
|
|
429
431
|
const rawHeartbeatFileContent = readHeartbeatFile(session)
|
|
430
432
|
const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
|
|
431
|
-
const
|
|
433
|
+
const hasExplicitGoal = !!(agent?.heartbeatGoal || agent?.heartbeatNextAction)
|
|
434
|
+
const hasAgentContext = !!(agent?.description || agent?.systemPrompt || agent?.soul)
|
|
432
435
|
const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
437
442
|
}
|
|
438
443
|
const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
|
|
439
444
|
const heartbeatMessage = isMainSession(session)
|
|
@@ -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
|
}
|