@swarmclawai/swarmclaw 0.7.6 → 0.7.8
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 +19 -10
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +16 -0
- package/src/app/api/agents/route.ts +2 -0
- package/src/app/api/chats/[id]/route.ts +21 -1
- package/src/app/api/chats/route.ts +13 -1
- package/src/app/api/connectors/[id]/route.ts +20 -2
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
- package/src/app/api/external-agents/[id]/route.ts +38 -6
- package/src/app/api/external-agents/route.ts +17 -1
- package/src/app/api/gateways/[id]/health/route.ts +8 -0
- package/src/app/api/gateways/[id]/route.ts +53 -1
- package/src/app/api/gateways/route.ts +53 -0
- package/src/app/api/openclaw/deploy/route.ts +139 -0
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- 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/cli/index.js +40 -0
- package/src/cli/index.test.js +68 -0
- package/src/cli/spec.js +60 -0
- package/src/components/agents/agent-sheet.tsx +281 -33
- package/src/components/auth/setup-wizard.tsx +75 -2
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-header.tsx +4 -0
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/gateways/gateway-sheet.tsx +140 -8
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
- 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 +221 -17
- package/src/components/shared/settings/section-capability-policy.tsx +38 -0
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/tasks/approvals-panel.tsx +177 -18
- package/src/components/tasks/task-board.tsx +137 -23
- package/src/components/tasks/task-card.tsx +29 -0
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/lib/server/agent-runtime-config.ts +142 -7
- package/src/lib/server/agent-thread-session.ts +9 -1
- package/src/lib/server/capability-router.test.ts +22 -0
- package/src/lib/server/capability-router.ts +54 -18
- package/src/lib/server/chat-execution.ts +33 -3
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.ts +99 -74
- package/src/lib/server/daemon-state.ts +83 -46
- package/src/lib/server/elevenlabs.test.ts +59 -1
- package/src/lib/server/heartbeat-service.ts +5 -1
- 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/openclaw-deploy.test.ts +8 -0
- package/src/lib/server/openclaw-deploy.ts +679 -19
- package/src/lib/server/orchestrator-lg.ts +1 -0
- package/src/lib/server/orchestrator.ts +11 -0
- package/src/lib/server/plugins.ts +6 -1
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-followups.test.ts +147 -2
- package/src/lib/server/queue.ts +278 -8
- package/src/lib/server/session-run-manager.ts +31 -0
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.ts +26 -1
- package/src/lib/server/session-tools/context.ts +5 -0
- package/src/lib/server/session-tools/crud.ts +265 -76
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +38 -2
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.ts +14 -2
- 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/web-inputs.test.ts +17 -0
- package/src/lib/server/session-tools/web.ts +153 -6
- package/src/lib/server/stream-agent-chat.test.ts +27 -2
- package/src/lib/server/stream-agent-chat.ts +104 -30
- package/src/lib/server/tool-aliases.ts +2 -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-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +269 -0
- package/src/lib/setup-defaults.ts +2 -2
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/validation/schemas.ts +9 -0
- package/src/types/index.ts +104 -0
|
@@ -332,6 +332,7 @@ export interface ConnectorReconnectState {
|
|
|
332
332
|
nextRetryAt: number
|
|
333
333
|
backoffMs: number
|
|
334
334
|
error: string
|
|
335
|
+
exhausted: boolean
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
|
|
@@ -342,6 +343,55 @@ const RECONNECT_INITIAL_BACKOFF_MS = 1_000
|
|
|
342
343
|
const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
|
|
343
344
|
const RECONNECT_MAX_ATTEMPTS = 10
|
|
344
345
|
|
|
346
|
+
interface ConnectorReconnectPolicy {
|
|
347
|
+
initialBackoffMs?: number
|
|
348
|
+
maxBackoffMs?: number
|
|
349
|
+
maxAttempts?: number
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function createConnectorReconnectState(
|
|
353
|
+
init: Partial<ConnectorReconnectState> = {},
|
|
354
|
+
policy: ConnectorReconnectPolicy = {},
|
|
355
|
+
): ConnectorReconnectState {
|
|
356
|
+
return {
|
|
357
|
+
attempts: init.attempts ?? 0,
|
|
358
|
+
lastAttemptAt: init.lastAttemptAt ?? 0,
|
|
359
|
+
nextRetryAt: init.nextRetryAt ?? 0,
|
|
360
|
+
backoffMs: init.backoffMs ?? policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS,
|
|
361
|
+
error: init.error ?? '',
|
|
362
|
+
exhausted: init.exhausted ?? false,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function advanceConnectorReconnectState(
|
|
367
|
+
previous: ConnectorReconnectState,
|
|
368
|
+
error: string,
|
|
369
|
+
now = Date.now(),
|
|
370
|
+
policy: ConnectorReconnectPolicy = {},
|
|
371
|
+
): ConnectorReconnectState {
|
|
372
|
+
const initialBackoffMs = policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS
|
|
373
|
+
const maxBackoffMs = policy.maxBackoffMs ?? RECONNECT_MAX_BACKOFF_MS
|
|
374
|
+
const maxAttempts = policy.maxAttempts ?? RECONNECT_MAX_ATTEMPTS
|
|
375
|
+
const attempts = previous.attempts + 1
|
|
376
|
+
const backoffMs = Math.min(maxBackoffMs, initialBackoffMs * (2 ** Math.max(0, attempts - 1)))
|
|
377
|
+
return {
|
|
378
|
+
attempts,
|
|
379
|
+
lastAttemptAt: now,
|
|
380
|
+
nextRetryAt: now + backoffMs,
|
|
381
|
+
backoffMs,
|
|
382
|
+
error,
|
|
383
|
+
exhausted: attempts >= maxAttempts,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function clearReconnectState(connectorId: string): void {
|
|
388
|
+
reconnectState.delete(connectorId)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function setReconnectState(connectorId: string, state: ConnectorReconnectState): void {
|
|
392
|
+
reconnectState.set(connectorId, state)
|
|
393
|
+
}
|
|
394
|
+
|
|
345
395
|
/** Record a health event for a connector (persisted to connector_health collection) */
|
|
346
396
|
function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType, message?: string): void {
|
|
347
397
|
const id = genId()
|
|
@@ -2008,33 +2058,43 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
2008
2058
|
const connector = connectors[connectorId] as Connector | undefined
|
|
2009
2059
|
if (!connector) throw new Error('Connector not found')
|
|
2010
2060
|
|
|
2011
|
-
//
|
|
2012
|
-
|
|
2013
|
-
if (connector.
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
}
|
|
2020
|
-
// Also check config for inline token (some platforms)
|
|
2021
|
-
if (!botToken && connector.config.botToken) {
|
|
2022
|
-
botToken = connector.config.botToken
|
|
2023
|
-
}
|
|
2024
|
-
if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
|
|
2025
|
-
botToken = connector.config.password
|
|
2061
|
+
// Starting a connector expresses durable intent: keep it enabled across
|
|
2062
|
+
// transient failures so daemon recovery and server restarts can retry it.
|
|
2063
|
+
if (connector.isEnabled !== true) {
|
|
2064
|
+
connector.isEnabled = true
|
|
2065
|
+
connector.updatedAt = Date.now()
|
|
2066
|
+
connectors[connectorId] = connector
|
|
2067
|
+
saveConnectors(connectors)
|
|
2068
|
+
notify('connectors')
|
|
2026
2069
|
}
|
|
2027
2070
|
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2071
|
+
try {
|
|
2072
|
+
// Resolve bot token from credential
|
|
2073
|
+
let botToken = ''
|
|
2074
|
+
if (connector.credentialId) {
|
|
2075
|
+
const creds = loadCredentials()
|
|
2076
|
+
const cred = creds[connector.credentialId]
|
|
2077
|
+
if (cred?.encryptedKey) {
|
|
2078
|
+
try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
// Also check config for inline token (some platforms)
|
|
2082
|
+
if (!botToken && connector.config.botToken) {
|
|
2083
|
+
botToken = connector.config.botToken
|
|
2084
|
+
}
|
|
2085
|
+
if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
|
|
2086
|
+
botToken = connector.config.password
|
|
2087
|
+
}
|
|
2031
2088
|
|
|
2032
|
-
|
|
2089
|
+
if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
|
|
2090
|
+
throw new Error('No bot token configured')
|
|
2091
|
+
}
|
|
2033
2092
|
|
|
2034
|
-
|
|
2035
|
-
|
|
2093
|
+
const platform = await getPlatform(connector.platform)
|
|
2094
|
+
|
|
2095
|
+
// Bump generation counter so stale events from previous instances are ignored
|
|
2096
|
+
generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
|
|
2036
2097
|
|
|
2037
|
-
try {
|
|
2038
2098
|
const instance = await platform.start(
|
|
2039
2099
|
connector,
|
|
2040
2100
|
botToken,
|
|
@@ -2049,6 +2109,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
2049
2109
|
connector.updatedAt = Date.now()
|
|
2050
2110
|
connectors[connectorId] = connector
|
|
2051
2111
|
saveConnectors(connectors)
|
|
2112
|
+
clearReconnectState(connectorId)
|
|
2052
2113
|
notify('connectors')
|
|
2053
2114
|
|
|
2054
2115
|
console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
|
|
@@ -2056,7 +2117,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
2056
2117
|
} catch (err: unknown) {
|
|
2057
2118
|
const errMsg = err instanceof Error ? err.message : String(err)
|
|
2058
2119
|
connector.status = 'error'
|
|
2059
|
-
connector.isEnabled =
|
|
2120
|
+
connector.isEnabled = true
|
|
2060
2121
|
connector.lastError = errMsg
|
|
2061
2122
|
connector.updatedAt = Date.now()
|
|
2062
2123
|
connectors[connectorId] = connector
|
|
@@ -2074,6 +2135,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
|
|
|
2074
2135
|
await instance.stop()
|
|
2075
2136
|
running.delete(connectorId)
|
|
2076
2137
|
}
|
|
2138
|
+
clearReconnectState(connectorId)
|
|
2077
2139
|
|
|
2078
2140
|
for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
|
|
2079
2141
|
if (entry.connector.id !== connectorId) continue
|
|
@@ -2141,6 +2203,7 @@ export async function repairConnector(connectorId: string): Promise<void> {
|
|
|
2141
2203
|
await instance.stop()
|
|
2142
2204
|
running.delete(connectorId)
|
|
2143
2205
|
}
|
|
2206
|
+
clearReconnectState(connectorId)
|
|
2144
2207
|
|
|
2145
2208
|
// Clear auth directory
|
|
2146
2209
|
const { clearAuthDir } = await import('./whatsapp')
|
|
@@ -2609,7 +2672,7 @@ export async function checkConnectorHealth(): Promise<void> {
|
|
|
2609
2672
|
// Connector is healthy — clear any reconnect state
|
|
2610
2673
|
if (reconnectState.has(id)) {
|
|
2611
2674
|
console.log(`[connector-health] Connector "${instance.connector.name}" recovered`)
|
|
2612
|
-
|
|
2675
|
+
clearReconnectState(id)
|
|
2613
2676
|
}
|
|
2614
2677
|
continue
|
|
2615
2678
|
}
|
|
@@ -2627,68 +2690,30 @@ export async function checkConnectorHealth(): Promise<void> {
|
|
|
2627
2690
|
|
|
2628
2691
|
// If the connector is not enabled, don't attempt reconnect
|
|
2629
2692
|
if (!connector.isEnabled) {
|
|
2630
|
-
|
|
2693
|
+
clearReconnectState(id)
|
|
2631
2694
|
continue
|
|
2632
2695
|
}
|
|
2633
2696
|
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
// Check if we've exceeded max attempts
|
|
2644
|
-
if (state.attempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
2645
|
-
console.warn(`[connector-health] Connector "${connector.name}" exceeded ${RECONNECT_MAX_ATTEMPTS} reconnect attempts — marking as error`)
|
|
2646
|
-
connector.status = 'error'
|
|
2647
|
-
connector.lastError = `Auto-reconnect gave up after ${RECONNECT_MAX_ATTEMPTS} attempts: ${state.error}`
|
|
2648
|
-
connector.updatedAt = Date.now()
|
|
2649
|
-
connectors[id] = connector
|
|
2650
|
-
connectorsDirty = true
|
|
2651
|
-
reconnectState.delete(id)
|
|
2652
|
-
notify('connectors')
|
|
2653
|
-
continue
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
const now = Date.now()
|
|
2657
|
-
|
|
2658
|
-
// Check if enough time has passed for the next retry
|
|
2659
|
-
if (now < state.nextRetryAt) {
|
|
2660
|
-
// Not yet time to retry — keep state and skip
|
|
2661
|
-
continue
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
state.attempts += 1
|
|
2665
|
-
state.lastAttemptAt = now
|
|
2666
|
-
reconnectState.set(id, state)
|
|
2667
|
-
|
|
2668
|
-
try {
|
|
2669
|
-
console.log(`[connector-health] Reconnecting "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS})`)
|
|
2670
|
-
await startConnector(id)
|
|
2671
|
-
// Success — clear reconnect state
|
|
2672
|
-
reconnectState.delete(id)
|
|
2673
|
-
console.log(`[connector-health] Connector "${connector.name}" reconnected successfully`)
|
|
2674
|
-
recordHealthEvent(id, 'reconnected', `Connector "${connector.name}" reconnected after ${state.attempts} attempt(s)`)
|
|
2675
|
-
} catch (err: unknown) {
|
|
2676
|
-
const errorMsg = err instanceof Error ? err.message : String(err)
|
|
2677
|
-
state.error = errorMsg
|
|
2678
|
-
state.backoffMs = Math.min(RECONNECT_MAX_BACKOFF_MS, RECONNECT_INITIAL_BACKOFF_MS * (2 ** state.attempts))
|
|
2679
|
-
state.nextRetryAt = now + state.backoffMs
|
|
2680
|
-
reconnectState.set(id, state)
|
|
2681
|
-
console.warn(`[connector-health] Reconnect failed for "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS}): ${errorMsg}. Next retry at ${new Date(state.nextRetryAt).toISOString()}`)
|
|
2697
|
+
connector.status = 'error'
|
|
2698
|
+
connector.lastError = connector.lastError || 'Connection lost'
|
|
2699
|
+
connector.updatedAt = Date.now()
|
|
2700
|
+
connectors[id] = connector
|
|
2701
|
+
connectorsDirty = true
|
|
2702
|
+
if (!reconnectState.has(id)) {
|
|
2703
|
+
setReconnectState(id, createConnectorReconnectState({
|
|
2704
|
+
error: connector.lastError || 'Connection lost',
|
|
2705
|
+
}))
|
|
2682
2706
|
}
|
|
2683
2707
|
}
|
|
2684
2708
|
|
|
2685
2709
|
if (connectorsDirty) {
|
|
2686
2710
|
saveConnectors(connectors)
|
|
2711
|
+
notify('connectors')
|
|
2687
2712
|
}
|
|
2688
2713
|
|
|
2689
2714
|
// Purge reconnect state for connectors that no longer exist
|
|
2690
2715
|
for (const id of reconnectState.keys()) {
|
|
2691
|
-
if (!connectors[id]
|
|
2716
|
+
if (!connectors[id] || connectors[id]?.isEnabled !== true || running.has(id)) clearReconnectState(id)
|
|
2692
2717
|
}
|
|
2693
2718
|
}
|
|
2694
2719
|
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
startConnector,
|
|
12
12
|
getConnectorStatus,
|
|
13
13
|
checkConnectorHealth,
|
|
14
|
+
createConnectorReconnectState,
|
|
15
|
+
advanceConnectorReconnectState,
|
|
16
|
+
clearReconnectState,
|
|
17
|
+
getAllReconnectStates,
|
|
18
|
+
getReconnectState,
|
|
19
|
+
setReconnectState,
|
|
14
20
|
} from './connectors/manager'
|
|
15
21
|
import { startHeartbeatService, stopHeartbeatService, getHeartbeatServiceStatus } from './heartbeat-service'
|
|
16
22
|
import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGateway } from './openclaw-gateway'
|
|
@@ -34,6 +40,7 @@ const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
|
|
|
34
40
|
const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
|
|
35
41
|
const BROWSER_MAX_AGE = 10 * 60 * 1000 // 10 minutes idle = orphaned
|
|
36
42
|
const HEALTH_CHECK_INTERVAL = 120_000 // 2 minutes
|
|
43
|
+
const CONNECTOR_HEALTH_CHECK_INTERVAL = 5_000 // 5 seconds
|
|
37
44
|
const MEMORY_CONSOLIDATION_INTERVAL = 6 * 3600_000 // 6 hours
|
|
38
45
|
const MEMORY_CONSOLIDATION_INITIAL_DELAY = 60_000 // 1 minute after daemon start
|
|
39
46
|
const STALE_MULTIPLIER = 4 // session is stale after N × heartbeat interval
|
|
@@ -87,12 +94,12 @@ const ds: {
|
|
|
87
94
|
queueIntervalId: ReturnType<typeof setInterval> | null
|
|
88
95
|
browserSweepId: ReturnType<typeof setInterval> | null
|
|
89
96
|
healthIntervalId: ReturnType<typeof setInterval> | null
|
|
97
|
+
connectorHealthIntervalId: ReturnType<typeof setInterval> | null
|
|
90
98
|
memoryConsolidationTimeoutId: ReturnType<typeof setTimeout> | null
|
|
91
99
|
memoryConsolidationIntervalId: ReturnType<typeof setInterval> | null
|
|
92
100
|
evalSchedulerIntervalId: ReturnType<typeof setInterval> | null
|
|
93
101
|
/** Session IDs we've already alerted as stale (alert-once semantics). */
|
|
94
102
|
staleSessionIds: Set<string>
|
|
95
|
-
connectorRestartState: Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>
|
|
96
103
|
/** OpenClaw gateway agent IDs currently considered down. */
|
|
97
104
|
openclawDownAgentIds: Set<string>
|
|
98
105
|
/** Per-agent auto-repair state for OpenClaw gateways. */
|
|
@@ -107,11 +114,11 @@ const ds: {
|
|
|
107
114
|
queueIntervalId: null,
|
|
108
115
|
browserSweepId: null,
|
|
109
116
|
healthIntervalId: null,
|
|
117
|
+
connectorHealthIntervalId: null,
|
|
110
118
|
memoryConsolidationTimeoutId: null,
|
|
111
119
|
memoryConsolidationIntervalId: null,
|
|
112
120
|
evalSchedulerIntervalId: null,
|
|
113
121
|
staleSessionIds: new Set<string>(),
|
|
114
|
-
connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>(),
|
|
115
122
|
openclawDownAgentIds: new Set<string>(),
|
|
116
123
|
openclawRepairState: new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>(),
|
|
117
124
|
lastIntegrityCheckAt: null,
|
|
@@ -123,7 +130,6 @@ const ds: {
|
|
|
123
130
|
|
|
124
131
|
// Backfill fields for hot-reloaded daemon state objects from older code versions.
|
|
125
132
|
if (!ds.staleSessionIds) ds.staleSessionIds = new Set<string>()
|
|
126
|
-
if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>()
|
|
127
133
|
if (!ds.openclawDownAgentIds) ds.openclawDownAgentIds = new Set<string>()
|
|
128
134
|
if (!ds.openclawRepairState) ds.openclawRepairState = new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>()
|
|
129
135
|
if (ds.lastIntegrityCheckAt === undefined) ds.lastIntegrityCheckAt = null
|
|
@@ -132,6 +138,7 @@ if (ds.lastIntegrityDriftCount === undefined) ds.lastIntegrityDriftCount = 0
|
|
|
132
138
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
139
|
if ((ds as any).issueLastAlertAt) delete (ds as any).issueLastAlertAt
|
|
134
140
|
if (ds.healthIntervalId === undefined) ds.healthIntervalId = null
|
|
141
|
+
if (ds.connectorHealthIntervalId === undefined) ds.connectorHealthIntervalId = null
|
|
135
142
|
if (ds.manualStopRequested === undefined) ds.manualStopRequested = false
|
|
136
143
|
if (ds.memoryConsolidationTimeoutId === undefined) ds.memoryConsolidationTimeoutId = null
|
|
137
144
|
if (ds.memoryConsolidationIntervalId === undefined) ds.memoryConsolidationIntervalId = null
|
|
@@ -156,6 +163,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
|
|
|
156
163
|
startQueueProcessor()
|
|
157
164
|
startBrowserSweep()
|
|
158
165
|
startHealthMonitor()
|
|
166
|
+
startConnectorHealthMonitor()
|
|
159
167
|
startHeartbeatService()
|
|
160
168
|
startMemoryConsolidation()
|
|
161
169
|
startEvalScheduler()
|
|
@@ -173,6 +181,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
|
|
|
173
181
|
startQueueProcessor()
|
|
174
182
|
startBrowserSweep()
|
|
175
183
|
startHealthMonitor()
|
|
184
|
+
startConnectorHealthMonitor()
|
|
176
185
|
startHeartbeatService()
|
|
177
186
|
startMemoryConsolidation()
|
|
178
187
|
startEvalScheduler()
|
|
@@ -201,6 +210,7 @@ export function stopDaemon(options?: { source?: string; manualStop?: boolean })
|
|
|
201
210
|
stopQueueProcessor()
|
|
202
211
|
stopBrowserSweep()
|
|
203
212
|
stopHealthMonitor()
|
|
213
|
+
stopConnectorHealthMonitor()
|
|
204
214
|
stopHeartbeatService()
|
|
205
215
|
stopMemoryConsolidation()
|
|
206
216
|
stopEvalScheduler()
|
|
@@ -278,7 +288,8 @@ async function sendHealthAlert(text: string) {
|
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
async function runConnectorHealthChecks(now: number) {
|
|
281
|
-
// First,
|
|
291
|
+
// First, collapse dead runtime instances into persisted error state so the
|
|
292
|
+
// daemon can own the restart cadence and backoff policy.
|
|
282
293
|
try {
|
|
283
294
|
await checkConnectorHealth()
|
|
284
295
|
} catch (err: unknown) {
|
|
@@ -289,48 +300,30 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
289
300
|
for (const connector of Object.values(connectors) as Record<string, unknown>[]) {
|
|
290
301
|
if (!connector?.id || typeof connector.id !== 'string') continue
|
|
291
302
|
if (connector.isEnabled !== true) {
|
|
292
|
-
|
|
303
|
+
clearReconnectState(connector.id)
|
|
293
304
|
continue
|
|
294
305
|
}
|
|
295
306
|
|
|
296
307
|
const runtimeStatus = getConnectorStatus(connector.id)
|
|
297
308
|
if (runtimeStatus === 'running') {
|
|
298
|
-
|
|
309
|
+
clearReconnectState(connector.id)
|
|
299
310
|
continue
|
|
300
311
|
}
|
|
301
312
|
|
|
302
|
-
const current =
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
connector.status = 'error'
|
|
310
|
-
connector.lastError = `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures`
|
|
311
|
-
connector.updatedAt = Date.now()
|
|
312
|
-
connectors[connector.id] = connector
|
|
313
|
-
saveConnectors(connectors)
|
|
314
|
-
ds.connectorRestartState.delete(connector.id)
|
|
315
|
-
createNotification({
|
|
316
|
-
type: 'error',
|
|
317
|
-
title: `Connector "${connector.name}" failed`,
|
|
318
|
-
message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures.`,
|
|
319
|
-
dedupKey: `connector-gave-up:${connector.id}`,
|
|
320
|
-
entityType: 'connector',
|
|
321
|
-
entityId: connector.id,
|
|
322
|
-
})
|
|
313
|
+
const current = getReconnectState(connector.id)
|
|
314
|
+
?? createConnectorReconnectState(
|
|
315
|
+
{ error: typeof connector.lastError === 'string' ? connector.lastError : '' },
|
|
316
|
+
{ initialBackoffMs: CONNECTOR_RESTART_BASE_MS },
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if (current.exhausted) {
|
|
323
320
|
continue
|
|
324
321
|
}
|
|
325
322
|
|
|
326
|
-
|
|
327
|
-
CONNECTOR_RESTART_MAX_MS,
|
|
328
|
-
CONNECTOR_RESTART_BASE_MS * (2 ** Math.min(6, current.failCount)),
|
|
329
|
-
)
|
|
330
|
-
if ((now - current.lastAttemptAt) < backoffMs) continue
|
|
323
|
+
if (current.nextRetryAt > now) continue
|
|
331
324
|
|
|
332
325
|
// Notify on first detection of a down connector
|
|
333
|
-
if (current.
|
|
326
|
+
if (current.attempts === 0) {
|
|
334
327
|
createNotification({
|
|
335
328
|
type: 'warning',
|
|
336
329
|
title: `Connector "${connector.name}" is down`,
|
|
@@ -341,24 +334,43 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
341
334
|
})
|
|
342
335
|
}
|
|
343
336
|
|
|
344
|
-
current.lastAttemptAt = now
|
|
345
|
-
ds.connectorRestartState.set(connector.id, current)
|
|
346
337
|
try {
|
|
347
338
|
await startConnector(connector.id)
|
|
348
|
-
|
|
339
|
+
clearReconnectState(connector.id)
|
|
349
340
|
await sendHealthAlert(`Connector "${connector.name}" (${connector.platform}) was down and has been auto-restarted.`)
|
|
350
341
|
} catch (err: unknown) {
|
|
351
|
-
current.failCount += 1
|
|
352
|
-
current.wakeAttempts += 1
|
|
353
|
-
ds.connectorRestartState.set(connector.id, current)
|
|
354
342
|
const message = err instanceof Error ? err.message : String(err)
|
|
355
|
-
|
|
343
|
+
const next = advanceConnectorReconnectState(current, message, now, {
|
|
344
|
+
initialBackoffMs: CONNECTOR_RESTART_BASE_MS,
|
|
345
|
+
maxBackoffMs: CONNECTOR_RESTART_MAX_MS,
|
|
346
|
+
maxAttempts: MAX_WAKE_ATTEMPTS,
|
|
347
|
+
})
|
|
348
|
+
setReconnectState(connector.id, next)
|
|
349
|
+
if (next.exhausted) {
|
|
350
|
+
console.warn(`[health] Connector "${connector.name}" exceeded ${MAX_WAKE_ATTEMPTS} auto-restart attempts — giving up until the server restarts or the user retries manually`)
|
|
351
|
+
connector.status = 'error'
|
|
352
|
+
connector.lastError = `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} attempts: ${message}`
|
|
353
|
+
connector.updatedAt = Date.now()
|
|
354
|
+
connectors[connector.id] = connector
|
|
355
|
+
saveConnectors(connectors)
|
|
356
|
+
notify('connectors')
|
|
357
|
+
createNotification({
|
|
358
|
+
type: 'error',
|
|
359
|
+
title: `Connector "${connector.name}" failed`,
|
|
360
|
+
message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} attempts.`,
|
|
361
|
+
dedupKey: `connector-gave-up:${connector.id}`,
|
|
362
|
+
entityType: 'connector',
|
|
363
|
+
entityId: connector.id,
|
|
364
|
+
})
|
|
365
|
+
} else {
|
|
366
|
+
console.warn(`[health] Connector auto-restart failed for ${connector.name} (attempt ${next.attempts}/${MAX_WAKE_ATTEMPTS}): ${message}`)
|
|
367
|
+
}
|
|
356
368
|
}
|
|
357
369
|
}
|
|
358
370
|
|
|
359
371
|
// Purge restart state for connectors that no longer exist in storage
|
|
360
|
-
for (const id of
|
|
361
|
-
if (!connectors[id])
|
|
372
|
+
for (const id of Object.keys(getAllReconnectStates())) {
|
|
373
|
+
if (!connectors[id] || connectors[id]?.isEnabled !== true) clearReconnectState(id)
|
|
362
374
|
}
|
|
363
375
|
}
|
|
364
376
|
|
|
@@ -776,8 +788,6 @@ async function runHealthChecks() {
|
|
|
776
788
|
|
|
777
789
|
if (sessionsDirty) saveSessions(sessions)
|
|
778
790
|
|
|
779
|
-
await runConnectorHealthChecks(now)
|
|
780
|
-
|
|
781
791
|
// Provider reachability checks
|
|
782
792
|
try {
|
|
783
793
|
await runProviderHealthChecks()
|
|
@@ -851,6 +861,26 @@ function stopHealthMonitor() {
|
|
|
851
861
|
}
|
|
852
862
|
}
|
|
853
863
|
|
|
864
|
+
function startConnectorHealthMonitor() {
|
|
865
|
+
if (ds.connectorHealthIntervalId) return
|
|
866
|
+
|
|
867
|
+
const tick = () => {
|
|
868
|
+
runConnectorHealthChecks(Date.now()).catch((err) => {
|
|
869
|
+
console.error('[daemon] Connector health tick failed:', err instanceof Error ? err.message : String(err))
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
tick()
|
|
874
|
+
ds.connectorHealthIntervalId = setInterval(tick, CONNECTOR_HEALTH_CHECK_INTERVAL)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function stopConnectorHealthMonitor() {
|
|
878
|
+
if (ds.connectorHealthIntervalId) {
|
|
879
|
+
clearInterval(ds.connectorHealthIntervalId)
|
|
880
|
+
ds.connectorHealthIntervalId = null
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
854
884
|
function runConsolidationTick() {
|
|
855
885
|
import('./memory-consolidation').then(({ runDailyConsolidation }) =>
|
|
856
886
|
runDailyConsolidation().then((stats) => {
|
|
@@ -951,12 +981,16 @@ function stopEvalScheduler() {
|
|
|
951
981
|
}
|
|
952
982
|
|
|
953
983
|
export async function runDaemonHealthCheckNow() {
|
|
954
|
-
await
|
|
984
|
+
await Promise.all([
|
|
985
|
+
runHealthChecks(),
|
|
986
|
+
runConnectorHealthChecks(Date.now()),
|
|
987
|
+
])
|
|
955
988
|
}
|
|
956
989
|
|
|
957
990
|
export function getDaemonStatus() {
|
|
958
991
|
const queue = loadQueue()
|
|
959
992
|
const schedules = loadSchedules()
|
|
993
|
+
const reconnectStates = Object.values(getAllReconnectStates())
|
|
960
994
|
|
|
961
995
|
// Find next scheduled task
|
|
962
996
|
let nextScheduled: number | null = null
|
|
@@ -985,9 +1019,12 @@ export function getDaemonStatus() {
|
|
|
985
1019
|
heartbeat: getHeartbeatServiceStatus(),
|
|
986
1020
|
health: {
|
|
987
1021
|
monitorActive: !!ds.healthIntervalId,
|
|
1022
|
+
connectorMonitorActive: !!ds.connectorHealthIntervalId,
|
|
988
1023
|
staleSessions: ds.staleSessionIds.size,
|
|
989
|
-
connectorsInBackoff:
|
|
1024
|
+
connectorsInBackoff: reconnectStates.filter((state) => !state.exhausted).length,
|
|
1025
|
+
connectorsExhausted: reconnectStates.filter((state) => state.exhausted).length,
|
|
990
1026
|
checkIntervalSec: Math.trunc(HEALTH_CHECK_INTERVAL / 1000),
|
|
1027
|
+
connectorCheckIntervalSec: Math.trunc(CONNECTOR_HEALTH_CHECK_INTERVAL / 1000),
|
|
991
1028
|
integrity: {
|
|
992
1029
|
enabled: loadSettings().integrityMonitorEnabled !== false,
|
|
993
1030
|
lastCheckedAt: ds.lastIntegrityCheckAt,
|
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
import { describe, it } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { requestElevenLabsMp3Stream, synthesizeElevenLabsMp3 } from './elevenlabs'
|
|
3
|
+
import { requestElevenLabsMp3Stream, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from './elevenlabs'
|
|
4
|
+
import { loadSettings, saveSettings } from './storage'
|
|
4
5
|
|
|
5
6
|
describe('elevenlabs helpers', () => {
|
|
7
|
+
it('prefers agent override first, then settings default, then env fallback', () => {
|
|
8
|
+
const originalSettings = loadSettings()
|
|
9
|
+
const originalKey = process.env.ELEVENLABS_API_KEY
|
|
10
|
+
const originalVoice = process.env.ELEVENLABS_VOICE
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
saveSettings({
|
|
14
|
+
...originalSettings,
|
|
15
|
+
elevenLabsApiKey: 'settings-key',
|
|
16
|
+
elevenLabsVoiceId: 'settings-voice',
|
|
17
|
+
})
|
|
18
|
+
process.env.ELEVENLABS_API_KEY = 'env-key'
|
|
19
|
+
process.env.ELEVENLABS_VOICE = 'env-voice'
|
|
20
|
+
|
|
21
|
+
assert.deepEqual(resolveElevenLabsConfig('agent-voice'), {
|
|
22
|
+
apiKey: 'settings-key',
|
|
23
|
+
voiceId: 'agent-voice',
|
|
24
|
+
})
|
|
25
|
+
assert.deepEqual(resolveElevenLabsConfig(null), {
|
|
26
|
+
apiKey: 'settings-key',
|
|
27
|
+
voiceId: 'settings-voice',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
saveSettings({
|
|
31
|
+
...originalSettings,
|
|
32
|
+
elevenLabsApiKey: 'settings-key',
|
|
33
|
+
elevenLabsVoiceId: null,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(resolveElevenLabsConfig(undefined), {
|
|
37
|
+
apiKey: 'settings-key',
|
|
38
|
+
voiceId: 'env-voice',
|
|
39
|
+
})
|
|
40
|
+
} finally {
|
|
41
|
+
saveSettings(originalSettings)
|
|
42
|
+
if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
|
|
43
|
+
else process.env.ELEVENLABS_API_KEY = originalKey
|
|
44
|
+
if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
|
|
45
|
+
else process.env.ELEVENLABS_VOICE = originalVoice
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
6
49
|
it('synthesizeElevenLabsMp3 posts TTS request and returns audio bytes', async () => {
|
|
7
50
|
const originalFetch = global.fetch
|
|
51
|
+
const originalSettings = loadSettings()
|
|
8
52
|
const originalKey = process.env.ELEVENLABS_API_KEY
|
|
9
53
|
const originalVoice = process.env.ELEVENLABS_VOICE
|
|
54
|
+
saveSettings({
|
|
55
|
+
...originalSettings,
|
|
56
|
+
elevenLabsApiKey: null,
|
|
57
|
+
elevenLabsVoiceId: null,
|
|
58
|
+
})
|
|
10
59
|
process.env.ELEVENLABS_API_KEY = 'test-key'
|
|
11
60
|
process.env.ELEVENLABS_VOICE = 'voice-123'
|
|
12
61
|
|
|
@@ -25,6 +74,7 @@ describe('elevenlabs helpers', () => {
|
|
|
25
74
|
assert.equal(out.toString('utf8'), 'abc')
|
|
26
75
|
} finally {
|
|
27
76
|
global.fetch = originalFetch
|
|
77
|
+
saveSettings(originalSettings)
|
|
28
78
|
if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
|
|
29
79
|
else process.env.ELEVENLABS_API_KEY = originalKey
|
|
30
80
|
if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
|
|
@@ -34,14 +84,21 @@ describe('elevenlabs helpers', () => {
|
|
|
34
84
|
|
|
35
85
|
it('requestElevenLabsMp3Stream calls streaming endpoint', async () => {
|
|
36
86
|
const originalFetch = global.fetch
|
|
87
|
+
const originalSettings = loadSettings()
|
|
37
88
|
const originalKey = process.env.ELEVENLABS_API_KEY
|
|
38
89
|
const originalVoice = process.env.ELEVENLABS_VOICE
|
|
90
|
+
saveSettings({
|
|
91
|
+
...originalSettings,
|
|
92
|
+
elevenLabsApiKey: null,
|
|
93
|
+
elevenLabsVoiceId: null,
|
|
94
|
+
})
|
|
39
95
|
process.env.ELEVENLABS_API_KEY = 'test-key'
|
|
40
96
|
process.env.ELEVENLABS_VOICE = 'voice-xyz'
|
|
41
97
|
|
|
42
98
|
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
43
99
|
assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-xyz/stream')
|
|
44
100
|
assert.equal(init?.method, 'POST')
|
|
101
|
+
assert.equal((init?.headers as Record<string, string>)['xi-api-key'], 'test-key')
|
|
45
102
|
return new Response('stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
|
|
46
103
|
}) as typeof fetch
|
|
47
104
|
|
|
@@ -51,6 +108,7 @@ describe('elevenlabs helpers', () => {
|
|
|
51
108
|
assert.equal(await res.text(), 'stream')
|
|
52
109
|
} finally {
|
|
53
110
|
global.fetch = originalFetch
|
|
111
|
+
saveSettings(originalSettings)
|
|
54
112
|
if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
|
|
55
113
|
else process.env.ELEVENLABS_API_KEY = originalKey
|
|
56
114
|
if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
|
|
@@ -12,6 +12,7 @@ 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'
|
|
16
17
|
|
|
17
18
|
const HEARTBEAT_TICK_MS = 5_000
|
|
@@ -434,7 +435,10 @@ async function tickHeartbeats() {
|
|
|
434
435
|
if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
|
|
435
436
|
continue
|
|
436
437
|
}
|
|
437
|
-
const
|
|
438
|
+
const baseHeartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
|
|
439
|
+
const heartbeatMessage = isMainSession(session)
|
|
440
|
+
? buildMainLoopHeartbeatPrompt(session, baseHeartbeatMessage)
|
|
441
|
+
: baseHeartbeatMessage
|
|
438
442
|
|
|
439
443
|
const enqueue = enqueueSessionRun({
|
|
440
444
|
sessionId: session.id,
|