@swarmclawai/swarmclaw 0.9.0 → 0.9.2
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 +1 -1
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +3 -2
- package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
- package/src/lib/server/connectors/whatsapp.test.ts +24 -0
- package/src/lib/server/connectors/whatsapp.ts +18 -2
- package/src/lib/server/runtime/heartbeat-wake.ts +10 -1
- package/src/lib/server/runtime/session-run-manager.test.ts +131 -0
- package/src/lib/server/runtime/session-run-manager.ts +105 -0
- package/src/lib/server/sandbox/session-runtime.test.ts +4 -3
- package/src/lib/server/storage.ts +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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": {
|
|
@@ -82,11 +82,12 @@ describe('shouldPersistInboundUserMessage', () => {
|
|
|
82
82
|
assert.equal(shouldPersistInboundUserMessage(false, 'connector'), true)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
it('returns true for internal eval messages', () => {
|
|
85
|
+
it('returns true for internal eval and subagent messages', () => {
|
|
86
86
|
assert.equal(shouldPersistInboundUserMessage(true, 'eval'), true)
|
|
87
|
+
assert.equal(shouldPersistInboundUserMessage(true, 'subagent'), true)
|
|
87
88
|
})
|
|
88
89
|
|
|
89
|
-
it('returns false for internal
|
|
90
|
+
it('returns false for other internal messages', () => {
|
|
90
91
|
assert.equal(shouldPersistInboundUserMessage(true, 'heartbeat'), false)
|
|
91
92
|
assert.equal(shouldPersistInboundUserMessage(true, 'chat'), false)
|
|
92
93
|
assert.equal(shouldPersistInboundUserMessage(true, 'daemon'), false)
|
|
@@ -40,7 +40,7 @@ export function shouldAutoRouteHeartbeatAlerts(config?: {
|
|
|
40
40
|
|
|
41
41
|
export function shouldPersistInboundUserMessage(internal: boolean, source: string): boolean {
|
|
42
42
|
if (!internal) return true
|
|
43
|
-
return source === 'eval'
|
|
43
|
+
return source === 'eval' || source === 'subagent'
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function escapeRegExp(value: string): string {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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>()
|