@swarmclawai/swarmclaw 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -8,6 +8,7 @@ import {
8
8
  normalizeWhatsAppAudioForSend,
9
9
  normalizeWhatsAppIdentifier,
10
10
  resolveWhatsAppAllowedIdentifiers,
11
+ sendWhatsAppTypingPresence,
11
12
  } from './whatsapp'
12
13
  import { normalizeE164, normalizeWhatsappTarget } from './response-media'
13
14
 
@@ -145,6 +146,29 @@ test('isWhatsAppSocketAlive keeps QR and active sessions marked live', () => {
145
146
  }), true)
146
147
  })
147
148
 
149
+ test('sendWhatsAppTypingPresence sends composing updates for the target jid', async () => {
150
+ const calls: Array<{ state: string; jid: string }> = []
151
+ await sendWhatsAppTypingPresence({
152
+ socket: {
153
+ sendPresenceUpdate: async (state, jid) => {
154
+ calls.push({ state, jid })
155
+ },
156
+ },
157
+ channelId: '15550001111@s.whatsapp.net',
158
+ })
159
+
160
+ assert.deepEqual(calls, [
161
+ { state: 'composing', jid: '15550001111@s.whatsapp.net' },
162
+ ])
163
+ })
164
+
165
+ test('sendWhatsAppTypingPresence ignores empty targets and missing sockets', async () => {
166
+ await sendWhatsAppTypingPresence({
167
+ socket: null,
168
+ channelId: ' ',
169
+ })
170
+ })
171
+
148
172
  test('normalizeWhatsAppAudioForSend transcodes mp3 voice notes to Android-safe opus/ogg', () => {
149
173
  let transcodeCalls = 0
150
174
  const converted = normalizeWhatsAppAudioForSend({
@@ -41,6 +41,10 @@ type WhatsAppSocketState = {
41
41
  } | null
42
42
  } | null
43
43
 
44
+ type WhatsAppPresenceSocket = {
45
+ sendPresenceUpdate?: (state: 'composing' | 'paused', jid: string) => Promise<unknown>
46
+ } | null
47
+
44
48
  export function buildWhatsAppTextPayloads(text: string): Array<{ text: string; linkPreview: null }> {
45
49
  const chunks = text.length <= WHATSAPP_SINGLE_MESSAGE_MAX
46
50
  ? [text]
@@ -68,6 +72,17 @@ export function isWhatsAppSocketAlive(params: {
68
72
  return params.connectionState == null
69
73
  }
70
74
 
75
+ export async function sendWhatsAppTypingPresence(params: {
76
+ socket: WhatsAppPresenceSocket
77
+ channelId: string
78
+ }): Promise<void> {
79
+ const channelId = String(params.channelId || '').trim()
80
+ if (!channelId) return
81
+ const sendPresenceUpdate = params.socket?.sendPresenceUpdate
82
+ if (typeof sendPresenceUpdate !== 'function') return
83
+ await sendPresenceUpdate('composing', channelId)
84
+ }
85
+
71
86
  function normalizeMimeType(mimeType?: string): string {
72
87
  return String(mimeType || '').toLowerCase().split(';')[0].trim()
73
88
  }
@@ -438,6 +453,9 @@ const whatsapp: PlatformConnector = {
438
453
  }
439
454
  return { messageId: lastMessageId }
440
455
  },
456
+ async sendTyping(channelId) {
457
+ await sendWhatsAppTypingPresence({ socket: sock as WhatsAppPresenceSocket, channelId })
458
+ },
441
459
  async stop() {
442
460
  stopped = true
443
461
  connectionState = 'close'
@@ -652,9 +670,7 @@ const whatsapp: PlatformConnector = {
652
670
  console.log(`[whatsapp] Message from ${inbound.senderName} (${jid}): ${inbound.text.slice(0, 80)}`)
653
671
 
654
672
  try {
655
- await sock!.sendPresenceUpdate('composing', jid)
656
673
  const reply = await resolveConnectorIngressReply(onMessage, inbound)
657
- await sock!.sendPresenceUpdate('paused', jid)
658
674
  if (!reply) continue
659
675
 
660
676
  const sent = await instance.sendMessage?.(jid, reply.visibleText)
@@ -17,6 +17,7 @@ import {
17
17
  enqueueSessionRun,
18
18
  getSessionExecutionState,
19
19
  hasActiveNonHeartbeatSessionLease,
20
+ repairSessionRunQueue,
20
21
  } from '@/lib/server/runtime/session-run-manager'
21
22
  import { log } from '@/lib/server/logger'
22
23
  import { isAgentDisabled } from '@/lib/server/agents/agent-availability'
@@ -301,8 +302,16 @@ function flushWakes(): void {
301
302
  const session = (sessions[sessionId] || loadSessions()[sessionId]) as Record<string, unknown> | undefined
302
303
  if (!session) continue
303
304
 
304
- const execution = getSessionExecutionState(sessionId)
305
+ let execution = getSessionExecutionState(sessionId)
305
306
  const sharedNonHeartbeatBusy = hasActiveNonHeartbeatSessionLease(sessionId)
307
+ if (execution.hasQueued && !execution.hasRunning && !sharedNonHeartbeatBusy) {
308
+ const repair = repairSessionRunQueue(sessionId, {
309
+ reason: 'Recovered stale queued run before heartbeat wake',
310
+ })
311
+ if (repair.recoveredQueuedRuns > 0 || repair.kickedExecutionKeys > 0) {
312
+ execution = getSessionExecutionState(sessionId)
313
+ }
314
+ }
306
315
  if (execution.hasRunning || execution.hasQueued || sharedNonHeartbeatBusy) {
307
316
  queuePendingWakeRequest({
308
317
  ...wake,
@@ -32,6 +32,29 @@ type RuntimeState = {
32
32
  activityLeaseRenewTimers?: Map<string, ReturnType<typeof setInterval>>
33
33
  }
34
34
 
35
+ type ManualQueueEntry = {
36
+ executionKey: string
37
+ run: {
38
+ id: string
39
+ sessionId: string
40
+ source: string
41
+ internal: boolean
42
+ mode: 'followup' | 'steer' | 'collect'
43
+ status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
44
+ messagePreview: string
45
+ queuedAt: number
46
+ startedAt?: number
47
+ endedAt?: number
48
+ error?: string
49
+ }
50
+ message: string
51
+ onEvents: Array<(event: unknown) => void>
52
+ signalController: AbortController
53
+ resolve: (value: unknown) => void
54
+ reject: (error: Error) => void
55
+ promise: Promise<unknown>
56
+ }
57
+
35
58
  /** Pending promises from fire-and-forget drain calls. We suppress their
36
59
  * rejections and await them in afterEach so node:test doesn't see
37
60
  * "asynchronous activity after the test ended" warnings. */
@@ -54,6 +77,54 @@ function resetState() {
54
77
  }
55
78
  }
56
79
 
80
+ function getRuntimeState(): RuntimeState {
81
+ return (globalThis as Record<string, unknown>)[globalKey] as RuntimeState
82
+ }
83
+
84
+ function makeManualQueuedEntry(input: {
85
+ sessionId: string
86
+ runId: string
87
+ message: string
88
+ source?: string
89
+ internal?: boolean
90
+ queuedAt?: number
91
+ }): { entry: ManualQueueEntry; promise: Promise<unknown> } {
92
+ let resolve!: (value: unknown) => void
93
+ let reject!: (error: Error) => void
94
+ const promise = new Promise<unknown>((res, rej) => {
95
+ resolve = res
96
+ reject = rej
97
+ })
98
+ const entry: ManualQueueEntry = {
99
+ executionKey: `session:${input.sessionId}`,
100
+ run: {
101
+ id: input.runId,
102
+ sessionId: input.sessionId,
103
+ source: input.source || 'chat',
104
+ internal: input.internal === true,
105
+ mode: input.internal ? 'collect' : 'followup',
106
+ status: 'queued',
107
+ messagePreview: input.message,
108
+ queuedAt: input.queuedAt ?? Date.now(),
109
+ },
110
+ message: input.message,
111
+ onEvents: [],
112
+ signalController: new AbortController(),
113
+ resolve,
114
+ reject,
115
+ promise,
116
+ }
117
+ return { entry, promise }
118
+ }
119
+
120
+ function insertManualQueuedEntry(entry: ManualQueueEntry, promise: Promise<unknown>) {
121
+ const state = getRuntimeState()
122
+ state.queueByExecution.set(entry.executionKey, [entry as unknown])
123
+ state.runs.set(entry.run.id, entry.run)
124
+ state.recentRunIds.push(entry.run.id)
125
+ state.promises.set(entry.run.id, promise)
126
+ }
127
+
57
128
  /** Wrapper around enqueueSessionRun that captures the run promise to
58
129
  * prevent async-after-test warnings from node:test. */
59
130
  function enqueue(input: Parameters<typeof mgr.enqueueSessionRun>[0]) {
@@ -663,6 +734,66 @@ describe('session-run-manager', () => {
663
734
  assert.ok(finished)
664
735
  assert.notEqual(finished.status, 'queued')
665
736
  })
737
+
738
+ it('re-kicks a recent queued entry when the execution lane is idle', async () => {
739
+ seedSession('sess-rekick')
740
+ const runId = 'manual-rekick'
741
+ const { entry, promise } = makeManualQueuedEntry({
742
+ sessionId: 'sess-rekick',
743
+ runId,
744
+ message: 'recover me',
745
+ })
746
+ insertManualQueuedEntry(entry, promise)
747
+
748
+ const repair = mgr.repairSessionRunQueue('sess-rekick')
749
+ assert.equal(repair.recoveredQueuedRuns, 0)
750
+ assert.equal(repair.kickedExecutionKeys, 1)
751
+
752
+ await promise.catch(() => {})
753
+
754
+ const run = mgr.getRunById(runId)
755
+ assert.ok(run)
756
+ assert.notEqual(run.status, 'queued')
757
+ })
758
+
759
+ it('recovers stale queued runs before a fresh enqueue can get wedged behind them', async () => {
760
+ seedSession('sess-stale-recover')
761
+ const staleRunId = 'manual-stale'
762
+ const { entry, promise } = makeManualQueuedEntry({
763
+ sessionId: 'sess-stale-recover',
764
+ runId: staleRunId,
765
+ message: 'ghost queued run',
766
+ queuedAt: Date.now() - 60_000,
767
+ })
768
+ insertManualQueuedEntry(entry, promise)
769
+
770
+ const fresh = enqueue({
771
+ sessionId: 'sess-stale-recover',
772
+ message: 'fresh message',
773
+ })
774
+
775
+ const staleResult = await promise
776
+ assert.deepEqual(staleResult, {
777
+ runId: staleRunId,
778
+ sessionId: 'sess-stale-recover',
779
+ text: '',
780
+ persisted: false,
781
+ toolEvents: [],
782
+ error: 'Recovered stale queued run before enqueue',
783
+ })
784
+
785
+ const staleRun = mgr.getRunById(staleRunId)
786
+ assert.ok(staleRun)
787
+ assert.equal(staleRun.status, 'failed')
788
+
789
+ const execution = mgr.getSessionExecutionState('sess-stale-recover')
790
+ assert.ok(execution.queueLength <= 1, `expected stale run to be cleared, got queueLength=${execution.queueLength}`)
791
+
792
+ await fresh.promise.catch(() => {})
793
+ const freshRun = mgr.getRunById(fresh.runId)
794
+ assert.ok(freshRun)
795
+ assert.notEqual(freshRun.status, 'queued')
796
+ })
666
797
  })
667
798
 
668
799
  describe('cancelAllHeartbeatRuns', () => {
@@ -74,6 +74,7 @@ const COLLECT_COALESCE_WINDOW_MS = 1500
74
74
  const SHARED_ACTIVITY_LEASE_TTL_MS = 15_000
75
75
  const SHARED_ACTIVITY_LEASE_RENEW_MS = 5_000
76
76
  const HEARTBEAT_BUSY_RETRY_MS = 1_000
77
+ const STALE_QUEUED_RUN_MS = 15_000
77
78
  const SHARED_ACTIVITY_LEASE_OWNER = `session-run:${process.pid}:${genId(6)}`
78
79
  const state: RuntimeState = hmrSingleton<RuntimeState>('__swarmclaw_session_run_manager__', () => ({
79
80
  runningByExecution: new Map<string, QueueEntry>(),
@@ -212,6 +213,13 @@ function clearDeferredDrain(executionKey: string): void {
212
213
  state.deferredDrainTimers.delete(executionKey)
213
214
  }
214
215
 
216
+ function deleteQueueEntry(queue: QueueEntry[], target: QueueEntry): boolean {
217
+ const idx = queue.indexOf(target)
218
+ if (idx === -1) return false
219
+ queue.splice(idx, 1)
220
+ return true
221
+ }
222
+
215
223
  function scheduleDeferredDrain(executionKey: string, delayMs = HEARTBEAT_BUSY_RETRY_MS): void {
216
224
  if (state.deferredDrainTimers.has(executionKey)) return
217
225
  const timer = setTimeout(() => {
@@ -249,6 +257,99 @@ function reconcileSessionActivityLease(sessionId: string): void {
249
257
  else stopSessionActivityLease(sessionId)
250
258
  }
251
259
 
260
+ function resolveRecoveredQueuedEntry(entry: QueueEntry, reason: string): void {
261
+ if (entry.run.status === 'completed' || entry.run.status === 'failed' || entry.run.status === 'cancelled') {
262
+ entry.run.endedAt = entry.run.endedAt || now()
263
+ } else {
264
+ entry.run.status = 'failed'
265
+ entry.run.endedAt = now()
266
+ }
267
+ entry.run.error = reason
268
+ emitToSubscribers(entry, { t: 'err', text: reason })
269
+ emitRunMeta(entry, 'failed', {
270
+ error: reason,
271
+ recovered: true,
272
+ })
273
+ entry.resolve({
274
+ runId: entry.run.id,
275
+ sessionId: entry.run.sessionId,
276
+ text: '',
277
+ persisted: false,
278
+ toolEvents: [],
279
+ error: reason,
280
+ })
281
+ }
282
+
283
+ export function repairSessionRunQueue(
284
+ sessionId: string,
285
+ opts?: {
286
+ executionKey?: string
287
+ maxQueuedAgeMs?: number
288
+ reason?: string
289
+ },
290
+ ): {
291
+ kickedExecutionKeys: number
292
+ recoveredQueuedRuns: number
293
+ } {
294
+ const maxQueuedAgeMs = Math.max(1_000, opts?.maxQueuedAgeMs ?? STALE_QUEUED_RUN_MS)
295
+ const reason = opts?.reason || 'Recovered stale queued run'
296
+ const targetExecutionKey = typeof opts?.executionKey === 'string' && opts.executionKey.trim()
297
+ ? opts.executionKey.trim()
298
+ : null
299
+ const queuedNow = now()
300
+ let kickedExecutionKeys = 0
301
+ let recoveredQueuedRuns = 0
302
+
303
+ for (const [executionKey, queue] of state.queueByExecution.entries()) {
304
+ if (targetExecutionKey && executionKey !== targetExecutionKey) continue
305
+ if (!queue.length) {
306
+ clearDeferredDrain(executionKey)
307
+ state.queueByExecution.delete(executionKey)
308
+ continue
309
+ }
310
+ if (state.runningByExecution.has(executionKey)) continue
311
+
312
+ const matching = queue.filter((entry) => entry.run.sessionId === sessionId)
313
+ if (!matching.length) continue
314
+
315
+ for (const entry of [...matching]) {
316
+ const missingPromise = !state.promises.has(entry.run.id)
317
+ const previousStatus = entry.run.status
318
+ const nonQueued = previousStatus !== 'queued'
319
+ const ageMs = Math.max(0, queuedNow - (entry.run.queuedAt || 0))
320
+ const stale = nonQueued || missingPromise || ageMs >= maxQueuedAgeMs
321
+ if (!stale) continue
322
+ if (!deleteQueueEntry(queue, entry)) continue
323
+ clearDeferredDrain(executionKey)
324
+ resolveRecoveredQueuedEntry(entry, reason)
325
+ recoveredQueuedRuns += 1
326
+ log.warn('session-run', `Recovered stale queued run ${entry.run.id}`, {
327
+ sessionId: entry.run.sessionId,
328
+ executionKey,
329
+ source: entry.run.source,
330
+ ageMs,
331
+ missingPromise,
332
+ previousStatus,
333
+ })
334
+ }
335
+
336
+ if (!queue.length) {
337
+ clearDeferredDrain(executionKey)
338
+ state.queueByExecution.delete(executionKey)
339
+ continue
340
+ }
341
+
342
+ if (queue.some((entry) => entry.run.sessionId === sessionId)) {
343
+ clearDeferredDrain(executionKey)
344
+ kickedExecutionKeys += 1
345
+ void drainExecution(executionKey)
346
+ }
347
+ }
348
+
349
+ if (recoveredQueuedRuns > 0) reconcileSessionActivityLease(sessionId)
350
+ return { kickedExecutionKeys, recoveredQueuedRuns }
351
+ }
352
+
252
353
  function cancelPendingForSession(sessionId: string, reason: string): number {
253
354
  let cancelled = 0
254
355
  for (const [key, queue] of state.queueByExecution.entries()) {
@@ -510,6 +611,10 @@ export function enqueueSessionRun(input: EnqueueSessionRunInput): EnqueueSession
510
611
  const executionKey = typeof input.executionGroupKey === 'string' && input.executionGroupKey.trim()
511
612
  ? input.executionGroupKey.trim()
512
613
  : executionKeyForSession(input.sessionId)
614
+ repairSessionRunQueue(input.sessionId, {
615
+ executionKey,
616
+ reason: 'Recovered stale queued run before enqueue',
617
+ })
513
618
  const runtime = loadRuntimeSettings()
514
619
  const defaultMaxRuntimeMs = runtime.ongoingLoopMaxRuntimeMs ?? (10 * 60_000)
515
620
  const sessionData = loadSession(input.sessionId) as SessionToolConfig | null
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { resolveSandboxRuntimeStatus, resolveSandboxWorkdir } from '@/lib/server/sandbox/session-runtime'
4
+ import type { Session } from '@/types'
4
5
 
5
6
  test('resolveSandboxRuntimeStatus defaults enabled sandboxes to all sessions', () => {
6
7
  const status = resolveSandboxRuntimeStatus({
@@ -9,7 +10,7 @@ test('resolveSandboxRuntimeStatus defaults enabled sandboxes to all sessions', (
9
10
  id: 'session-1',
10
11
  agentId: 'agent-1',
11
12
  parentSessionId: 'parent-1',
12
- } as any,
13
+ } as Session,
13
14
  })
14
15
 
15
16
  assert.equal(status.mode, 'all')
@@ -25,7 +26,7 @@ test('resolveSandboxRuntimeStatus skips the main session in non-main mode', () =
25
26
  id: 'main-session',
26
27
  agentId: 'agent-1',
27
28
  heartbeatEnabled: true,
28
- } as any,
29
+ } as Session,
29
30
  })
30
31
 
31
32
  assert.equal(status.mode, 'non-main')
@@ -40,7 +41,7 @@ test('resolveSandboxRuntimeStatus sandboxes child sessions in non-main mode', ()
40
41
  id: 'child-session',
41
42
  agentId: 'agent-1',
42
43
  parentSessionId: 'main-session',
43
- } as any,
44
+ } as Session,
44
45
  })
45
46
 
46
47
  assert.equal(status.sandboxed, true)
@@ -244,17 +244,18 @@ function getCollectionRawCache(table: string): LRUMap<string, string> {
244
244
  }
245
245
 
246
246
  function loadCollectionWithNormalizationState(table: string): {
247
- result: Record<string, any>
247
+ result: Record<string, StoredObject>
248
248
  normalizedCount: number
249
249
  } {
250
250
  const endPerf = perf.start('storage', 'loadCollection', { table })
251
251
  const raw = getCollectionRawCache(table)
252
- const result: Record<string, any> = {}
252
+ const result: Record<string, StoredObject> = {}
253
253
  let normalizedCount = 0
254
254
  for (const [id, data] of raw.entries()) {
255
255
  try {
256
256
  const normalized = normalizeStoredRecord(table, JSON.parse(data))
257
- result[id] = normalized
257
+ if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) continue
258
+ result[id] = normalized as StoredObject
258
259
  if (JSON.stringify(normalized) !== data) normalizedCount += 1
259
260
  } catch {
260
261
  // Ignore malformed records instead of crashing list endpoints.
@@ -301,11 +302,11 @@ function normalizeStoredRecord(table: string, value: unknown): unknown {
301
302
  return session
302
303
  }
303
304
 
304
- function loadCollection(table: string): Record<string, any> {
305
+ function loadCollection(table: string): Record<string, StoredObject> {
305
306
  return loadCollectionWithNormalizationState(table).result
306
307
  }
307
308
 
308
- function saveCollection(table: string, data: Record<string, any>) {
309
+ function saveCollection(table: string, data: Record<string, unknown>) {
309
310
  const endPerf = perf.start('storage', 'saveCollection', { table })
310
311
  const current = getCollectionRawCache(table)
311
312
  const next = new Map<string, string>()