@saluzi/saluzi-edu 0.4.5 → 0.4.6
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/dist/cli.js +53 -53
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/control-watchdog.test.ts +129 -0
- package/packages/remote-control-server/src/__tests__/work-dispatch.test.ts +44 -0
- package/packages/remote-control-server/src/__tests__/ws-handler.test.ts +185 -1
- package/packages/remote-control-server/src/services/disconnect-monitor.ts +5 -0
- package/packages/remote-control-server/src/services/transport.ts +89 -0
- package/packages/remote-control-server/src/services/work-dispatch.ts +56 -1
- package/packages/remote-control-server/src/transport/ws-handler.ts +85 -20
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
2
|
+
import { mockConfigModule } from './helpers/mock-config'
|
|
3
|
+
|
|
4
|
+
// Mock config
|
|
5
|
+
mock.module('../config', () => mockConfigModule({ pollTimeout: 1 }))
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
storeReset,
|
|
9
|
+
storeCreateEnvironment,
|
|
10
|
+
storeCreateSession,
|
|
11
|
+
storeGetPendingWorkItem,
|
|
12
|
+
} from '../store'
|
|
13
|
+
import {
|
|
14
|
+
getEventBus,
|
|
15
|
+
removeEventBus,
|
|
16
|
+
getAllEventBuses,
|
|
17
|
+
} from '../transport/event-bus'
|
|
18
|
+
import {
|
|
19
|
+
publishSessionEvent,
|
|
20
|
+
sweepUnansweredControlRequests,
|
|
21
|
+
} from '../services/transport'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Control-request response watchdog(services/transport.ts)。
|
|
25
|
+
*
|
|
26
|
+
* Web→worker control_request 是程序化 RPC(健康 worker 亚秒响应)。
|
|
27
|
+
* pending 超过 90s 未回 control_response = worker 写路径楔死(响应排在
|
|
28
|
+
* 停滞的 uploader 后面永远到不了服务端,WS 却照常读入)— sweep 触发
|
|
29
|
+
* 与 ws-handler 不活跃关闭相同的恢复:重派发 work item → worker 重建
|
|
30
|
+
* 全新 transport。
|
|
31
|
+
*/
|
|
32
|
+
describe('control-request response watchdog', () => {
|
|
33
|
+
let envId: string
|
|
34
|
+
let sessionId: string
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
storeReset()
|
|
38
|
+
for (const [key] of getAllEventBuses()) {
|
|
39
|
+
removeEventBus(key)
|
|
40
|
+
}
|
|
41
|
+
const env = storeCreateEnvironment({ secret: 's' })
|
|
42
|
+
envId = env.id
|
|
43
|
+
const session = storeCreateSession({ environmentId: envId })
|
|
44
|
+
sessionId = session.id
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('unanswered control_request past timeout fires recovery redispatch', async () => {
|
|
48
|
+
publishSessionEvent(
|
|
49
|
+
sessionId,
|
|
50
|
+
'control_request',
|
|
51
|
+
{ request_id: 'r1', request: { subtype: 'get_context_usage' } },
|
|
52
|
+
'outbound',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// 未超时 — 不触发
|
|
56
|
+
sweepUnansweredControlRequests(Date.now() + 10_000)
|
|
57
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
58
|
+
|
|
59
|
+
// 超过 90s 阈值 — 触发恢复性重派发
|
|
60
|
+
sweepUnansweredControlRequests(Date.now() + 100_000)
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 0)) // redispatch 异步落库
|
|
62
|
+
expect(storeGetPendingWorkItem(envId)?.sessionId).toBe(sessionId)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('inbound control_response settles the pending request', async () => {
|
|
66
|
+
publishSessionEvent(
|
|
67
|
+
sessionId,
|
|
68
|
+
'control_request',
|
|
69
|
+
{ request_id: 'r2', request: { subtype: 'get_context_usage' } },
|
|
70
|
+
'outbound',
|
|
71
|
+
)
|
|
72
|
+
publishSessionEvent(
|
|
73
|
+
sessionId,
|
|
74
|
+
'control_response',
|
|
75
|
+
{ response: { subtype: 'success', request_id: 'r2' } },
|
|
76
|
+
'inbound',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
sweepUnansweredControlRequests(Date.now() + 100_000)
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
81
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('worker→web control_requests are not tracked (permission dialogs wait for humans)', async () => {
|
|
85
|
+
// 方向为 inbound 的 control_request(worker 发起的 can_use_tool 权限
|
|
86
|
+
// 询问)不进 pending 表 — 那些等待人类响应,分钟级是正常的。
|
|
87
|
+
publishSessionEvent(
|
|
88
|
+
sessionId,
|
|
89
|
+
'control_request',
|
|
90
|
+
{
|
|
91
|
+
request_id: 'r3',
|
|
92
|
+
request: { subtype: 'can_use_tool', tool_name: 'Bash' },
|
|
93
|
+
},
|
|
94
|
+
'inbound',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
sweepUnansweredControlRequests(Date.now() + 100_000)
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
99
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('recovery fires once per episode and stale entries are reaped', async () => {
|
|
103
|
+
publishSessionEvent(
|
|
104
|
+
sessionId,
|
|
105
|
+
'control_request',
|
|
106
|
+
{ request_id: 'r4', request: { subtype: 'interrupt' } },
|
|
107
|
+
'outbound',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// 第一次超时 sweep — 触发;再 sweep 一次(仍未响应)— hasOpenItem
|
|
111
|
+
// 守卫阻止堆叠,只保留一个 work item。
|
|
112
|
+
sweepUnansweredControlRequests(Date.now() + 100_000)
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
114
|
+
sweepUnansweredControlRequests(Date.now() + 120_000)
|
|
115
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
116
|
+
const items = (await import('../store'))
|
|
117
|
+
.storeListWorkItems()
|
|
118
|
+
.filter(item => item.sessionId === sessionId && item.state === 'pending')
|
|
119
|
+
expect(items).toHaveLength(1)
|
|
120
|
+
|
|
121
|
+
// 远超 reap 阈值(90s×4)— pending 表条目被清理(worker 彻底消失
|
|
122
|
+
// 的场景下保证 Map 有界)。已创建的 work item 不受影响。
|
|
123
|
+
sweepUnansweredControlRequests(Date.now() + 500_000)
|
|
124
|
+
const itemsAfterReap = (await import('../store'))
|
|
125
|
+
.storeListWorkItems()
|
|
126
|
+
.filter(item => item.sessionId === sessionId && item.state === 'pending')
|
|
127
|
+
expect(itemsAfterReap).toHaveLength(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
stopWork,
|
|
21
21
|
heartbeatWork,
|
|
22
22
|
reconnectWorkForEnvironment,
|
|
23
|
+
redispatchWorkForSession,
|
|
23
24
|
} from '../services/work-dispatch'
|
|
24
25
|
|
|
25
26
|
describe('Work Dispatch', () => {
|
|
@@ -195,4 +196,47 @@ describe('Work Dispatch', () => {
|
|
|
195
196
|
expect(workIds).toHaveLength(0)
|
|
196
197
|
})
|
|
197
198
|
})
|
|
199
|
+
|
|
200
|
+
describe('redispatchWorkForSession', () => {
|
|
201
|
+
test('creates a pending work item for a live session regardless of status', async () => {
|
|
202
|
+
// 楔死 worker 的会话通常卡在 running(turn-end result 被吞)—
|
|
203
|
+
// 重新派发不能像 reconnectWorkForEnvironment 那样按 idle 过滤。
|
|
204
|
+
const { storeUpdateSession } = await import('../store')
|
|
205
|
+
storeUpdateSession(sessionId, { status: 'running' })
|
|
206
|
+
await redispatchWorkForSession(sessionId)
|
|
207
|
+
const item = storeGetPendingWorkItem(envId)
|
|
208
|
+
expect(item).toBeDefined()
|
|
209
|
+
expect(item?.sessionId).toBe(sessionId)
|
|
210
|
+
expect(item?.state).toBe('pending')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('skips when an open (pending/dispatched) item already exists', async () => {
|
|
214
|
+
await createWorkItem(envId, sessionId)
|
|
215
|
+
await redispatchWorkForSession(sessionId)
|
|
216
|
+
const items = (await import('../store'))
|
|
217
|
+
.storeListWorkItems()
|
|
218
|
+
.filter(item => item.sessionId === sessionId)
|
|
219
|
+
expect(items).toHaveLength(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('skips closed (archived) sessions', async () => {
|
|
223
|
+
const { storeUpdateSession } = await import('../store')
|
|
224
|
+
storeUpdateSession(sessionId, { status: 'archived' })
|
|
225
|
+
await redispatchWorkForSession(sessionId)
|
|
226
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('skips sessions with no environment binding', async () => {
|
|
230
|
+
const orphan = storeCreateSession({})
|
|
231
|
+
await redispatchWorkForSession(orphan.id)
|
|
232
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('swallows errors for inactive environments', async () => {
|
|
236
|
+
storeUpdateEnvironment(envId, { status: 'disconnected' })
|
|
237
|
+
// 不抛错 — 断连 env 由 disconnect monitor 处理
|
|
238
|
+
await redispatchWorkForSession(sessionId)
|
|
239
|
+
expect(storeGetPendingWorkItem(envId)).toBeFalsy()
|
|
240
|
+
})
|
|
241
|
+
})
|
|
198
242
|
})
|
|
@@ -4,7 +4,11 @@ import { mockConfigModule } from './helpers/mock-config'
|
|
|
4
4
|
// Mock config before imports
|
|
5
5
|
mock.module('../config', () => mockConfigModule())
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
storeReset,
|
|
9
|
+
storeCreateEnvironment,
|
|
10
|
+
storeCreateSession,
|
|
11
|
+
} from '../store'
|
|
8
12
|
import {
|
|
9
13
|
getEventBus,
|
|
10
14
|
removeEventBus,
|
|
@@ -17,6 +21,7 @@ import {
|
|
|
17
21
|
handleWebSocketClose,
|
|
18
22
|
closeAllConnections,
|
|
19
23
|
touchClientActivity,
|
|
24
|
+
closeInactiveClient,
|
|
20
25
|
} from '../transport/ws-handler'
|
|
21
26
|
|
|
22
27
|
// Minimal WSContext mock
|
|
@@ -451,6 +456,55 @@ describe('ws-handler', () => {
|
|
|
451
456
|
expect(msg.isSynthetic).toBe(true)
|
|
452
457
|
})
|
|
453
458
|
|
|
459
|
+
test('replay skips point-in-time control traffic (interrupt, permission RPCs)', () => {
|
|
460
|
+
// Regression: the replay used to forward EVERY retained event,
|
|
461
|
+
// including one-shot RPCs. A replayed `interrupt` control_request was
|
|
462
|
+
// re-executed by the bridge — aborting whatever turn was running
|
|
463
|
+
// AFTER the reconnect.
|
|
464
|
+
const bus = getEventBus('replay_ctrl')
|
|
465
|
+
bus.publish({
|
|
466
|
+
id: 'e1',
|
|
467
|
+
sessionId: 'replay_ctrl',
|
|
468
|
+
type: 'user',
|
|
469
|
+
payload: { content: 'task prompt' },
|
|
470
|
+
direction: 'outbound',
|
|
471
|
+
})
|
|
472
|
+
bus.publish({
|
|
473
|
+
id: 'e2',
|
|
474
|
+
sessionId: 'replay_ctrl',
|
|
475
|
+
type: 'control_request',
|
|
476
|
+
payload: { request_id: 'r1', request: { subtype: 'interrupt' } },
|
|
477
|
+
direction: 'outbound',
|
|
478
|
+
})
|
|
479
|
+
bus.publish({
|
|
480
|
+
id: 'e3',
|
|
481
|
+
sessionId: 'replay_ctrl',
|
|
482
|
+
type: 'permission_response',
|
|
483
|
+
payload: { request_id: 'r2', approved: true },
|
|
484
|
+
direction: 'outbound',
|
|
485
|
+
})
|
|
486
|
+
bus.publish({
|
|
487
|
+
id: 'e4',
|
|
488
|
+
sessionId: 'replay_ctrl',
|
|
489
|
+
type: 'assistant',
|
|
490
|
+
payload: { content: 'done' },
|
|
491
|
+
direction: 'inbound',
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
const ws = createMockWs()
|
|
495
|
+
handleWebSocketOpen(ws, 'replay_ctrl')
|
|
496
|
+
|
|
497
|
+
const sent = ws.getSentData()
|
|
498
|
+
const types = sent.map(
|
|
499
|
+
(line: string) => (JSON.parse(line) as { type: string }).type,
|
|
500
|
+
)
|
|
501
|
+
expect(types).toContain('user')
|
|
502
|
+
expect(types).toContain('assistant')
|
|
503
|
+
expect(types).not.toContain('control_request')
|
|
504
|
+
expect(types).not.toContain('permission_response')
|
|
505
|
+
expect(types).not.toContain('interrupt')
|
|
506
|
+
})
|
|
507
|
+
|
|
454
508
|
test('replaces existing connection for same session', () => {
|
|
455
509
|
const ws1 = createMockWs()
|
|
456
510
|
const ws2 = createMockWs()
|
|
@@ -546,6 +600,136 @@ describe('ws-handler', () => {
|
|
|
546
600
|
})
|
|
547
601
|
})
|
|
548
602
|
|
|
603
|
+
describe('closeInactiveClient', () => {
|
|
604
|
+
// Regression: the original keepalive interval read a const closure
|
|
605
|
+
// variable captured at open time — touchClientActivity mutated a
|
|
606
|
+
// different copy on the cleanup entry, so EVERY connection was closed
|
|
607
|
+
// exactly CLIENT_ACTIVITY_TIMEOUT after open ("client inactive" 误报,
|
|
608
|
+
// observed as duration=300s closes on actively-POSTing workers).
|
|
609
|
+
test('client activity via touchClientActivity resets the inactivity clock', () => {
|
|
610
|
+
const closed: Array<{ code?: number; reason?: string }> = []
|
|
611
|
+
const ws = {
|
|
612
|
+
readyState: 1,
|
|
613
|
+
send: () => {},
|
|
614
|
+
close: (code?: number, reason?: string) =>
|
|
615
|
+
closed.push({ code, reason }),
|
|
616
|
+
} as any
|
|
617
|
+
handleWebSocketOpen(ws, 'inact1')
|
|
618
|
+
|
|
619
|
+
// Connection opened at T0; the worker POSTs (touch) at T0+10min.
|
|
620
|
+
// Measured 100s after the touch → within the 300s budget → NOT closed.
|
|
621
|
+
const touchedAt = Date.now() + 600_000
|
|
622
|
+
const originalNow = Date.now
|
|
623
|
+
Date.now = () => touchedAt
|
|
624
|
+
try {
|
|
625
|
+
touchClientActivity('inact1')
|
|
626
|
+
} finally {
|
|
627
|
+
Date.now = originalNow
|
|
628
|
+
}
|
|
629
|
+
expect(closeInactiveClient('inact1', touchedAt + 100_000)).toBe(false)
|
|
630
|
+
expect(closed).toHaveLength(0)
|
|
631
|
+
|
|
632
|
+
handleWebSocketClose(ws, 'inact1', 1000, 'done')
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
test('closes a truly silent client and queues a recovery work item', async () => {
|
|
636
|
+
const env = storeCreateEnvironment({ secret: 's' })
|
|
637
|
+
const session = storeCreateSession({ environmentId: env.id })
|
|
638
|
+
|
|
639
|
+
const closed: Array<{ code?: number; reason?: string }> = []
|
|
640
|
+
const ws = {
|
|
641
|
+
readyState: 1,
|
|
642
|
+
send: () => {},
|
|
643
|
+
close: (code?: number, reason?: string) =>
|
|
644
|
+
closed.push({ code, reason }),
|
|
645
|
+
} as any
|
|
646
|
+
handleWebSocketOpen(ws, session.id)
|
|
647
|
+
|
|
648
|
+
// Opened at ~now; nothing touches for 400s → closed as dead.
|
|
649
|
+
const openedAt = Date.now()
|
|
650
|
+
expect(closeInactiveClient(session.id, openedAt + 400_000)).toBe(true)
|
|
651
|
+
expect(closed).toHaveLength(1)
|
|
652
|
+
expect(closed[0]?.code).toBe(1000)
|
|
653
|
+
|
|
654
|
+
// Redispatch is fire-and-forget — let the microtask settle.
|
|
655
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
656
|
+
const { storeGetPendingWorkItem } = await import('../store')
|
|
657
|
+
expect(storeGetPendingWorkItem(env.id)?.sessionId).toBe(session.id)
|
|
658
|
+
|
|
659
|
+
handleWebSocketClose(ws, session.id, 1000, 'done')
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
test('does not close within the inactivity budget', () => {
|
|
663
|
+
const ws = createMockWs()
|
|
664
|
+
handleWebSocketOpen(ws, 'inact3')
|
|
665
|
+
expect(closeInactiveClient('inact3', Date.now() + 100_000)).toBe(false)
|
|
666
|
+
handleWebSocketClose(ws, 'inact3', 1000, 'done')
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('is a no-op for unknown sessions and dead sockets', () => {
|
|
670
|
+
expect(closeInactiveClient('never-opened')).toBe(false)
|
|
671
|
+
const ws = createMockWs(3) // CLOSED
|
|
672
|
+
handleWebSocketOpen(ws, 'inact4')
|
|
673
|
+
expect(closeInactiveClient('inact4', Date.now() + 400_000)).toBe(false)
|
|
674
|
+
handleWebSocketClose(ws, 'inact4', 1000, 'done')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// HybridTransport workers send WS-level keep_alive frames (direct
|
|
678
|
+
// socket writes) even when their POST write path is dead — counting
|
|
679
|
+
// them as activity would make the inactivity close unreachable for
|
|
680
|
+
// exactly the zombie state it exists to detect.
|
|
681
|
+
test('WS keep_alive frames do NOT refresh the inactivity clock', () => {
|
|
682
|
+
const closed: Array<{ code?: number; reason?: string }> = []
|
|
683
|
+
const ws = {
|
|
684
|
+
readyState: 1,
|
|
685
|
+
send: () => {},
|
|
686
|
+
close: (code?: number, reason?: string) =>
|
|
687
|
+
closed.push({ code, reason }),
|
|
688
|
+
} as any
|
|
689
|
+
handleWebSocketOpen(ws, 'ka1')
|
|
690
|
+
|
|
691
|
+
// keep_alive 帧到达 +250s(预算内)— 不刷新时钟
|
|
692
|
+
const at250 = Date.now() + 250_000
|
|
693
|
+
const originalNow = Date.now
|
|
694
|
+
Date.now = () => at250
|
|
695
|
+
try {
|
|
696
|
+
handleWebSocketMessage(ws, 'ka1', '{"type":"keep_alive"}\n')
|
|
697
|
+
} finally {
|
|
698
|
+
Date.now = originalNow
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// +350s(自打开起超预算)→ 尽管 keepalive 一直在发,仍然关闭
|
|
702
|
+
expect(closeInactiveClient('ka1', Date.now() + 350_000)).toBe(true)
|
|
703
|
+
expect(closed).toHaveLength(1)
|
|
704
|
+
|
|
705
|
+
handleWebSocketClose(ws, 'ka1', 1000, 'done')
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
test('real WS messages still refresh the inactivity clock', () => {
|
|
709
|
+
const ws = createMockWs()
|
|
710
|
+
handleWebSocketOpen(ws, 'ka2')
|
|
711
|
+
|
|
712
|
+
// 真实消息(非 keep_alive)到达 +250s — 刷新时钟
|
|
713
|
+
const at250 = Date.now() + 250_000
|
|
714
|
+
const originalNow = Date.now
|
|
715
|
+
Date.now = () => at250
|
|
716
|
+
try {
|
|
717
|
+
handleWebSocketMessage(
|
|
718
|
+
ws,
|
|
719
|
+
'ka2',
|
|
720
|
+
'{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u-ka"}\n',
|
|
721
|
+
)
|
|
722
|
+
} finally {
|
|
723
|
+
Date.now = originalNow
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// +350s 距打开超预算,但距上次活动(+250s)仅 100s → 不关闭
|
|
727
|
+
expect(closeInactiveClient('ka2', at250 + 100_000)).toBe(false)
|
|
728
|
+
|
|
729
|
+
handleWebSocketClose(ws, 'ka2', 1000, 'done')
|
|
730
|
+
})
|
|
731
|
+
})
|
|
732
|
+
|
|
549
733
|
describe('handleWebSocketClose', () => {
|
|
550
734
|
test('cleans up on close', () => {
|
|
551
735
|
const ws = createMockWs()
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
storeMarkAcpAgentOffline,
|
|
7
7
|
} from '../store'
|
|
8
8
|
import { config } from '../config'
|
|
9
|
+
import { sweepUnansweredControlRequests } from './transport'
|
|
9
10
|
|
|
10
11
|
/** ACP env 离线后留给 acp-link 重连复用的宽限时间。超过后才将 env 置为
|
|
11
12
|
* disconnected。会话不再自动归档——由用户手动删除。 */
|
|
@@ -57,5 +58,9 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|
|
57
58
|
export function startDisconnectMonitor(): ReturnType<typeof setInterval> {
|
|
58
59
|
return setInterval(() => {
|
|
59
60
|
runDisconnectMonitorSweep()
|
|
61
|
+
// Control-request response watchdog — detects a wedged worker write
|
|
62
|
+
// path (WS healthy, POST path dead) from unanswered RPCs and fires the
|
|
63
|
+
// recovery work re-dispatch. See services/transport.ts.
|
|
64
|
+
sweepUnansweredControlRequests()
|
|
60
65
|
}, 60_000) // Check every minute
|
|
61
66
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { getEventBus } from '../transport/event-bus'
|
|
3
3
|
import { persistSessionEvent } from './event-persistence'
|
|
4
|
+
import { log } from '../logger'
|
|
5
|
+
import { redispatchWorkForSession } from './work-dispatch'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Extract plain text from various message payload formats.
|
|
@@ -207,6 +209,85 @@ export function normalizePayload(
|
|
|
207
209
|
* carries the full content). */
|
|
208
210
|
const TRANSIENT_EVENT_TYPES = new Set(['stream_event', 'partial_assistant'])
|
|
209
211
|
|
|
212
|
+
// ─── Control-request response watchdog ─────────────────────────────────────
|
|
213
|
+
//
|
|
214
|
+
// Web→worker control_requests (get_context_usage / interrupt / set_model / …)
|
|
215
|
+
// are programmatic RPCs that a healthy worker answers in well under a
|
|
216
|
+
// second (every subtype branch of handleServerControlRequest writes a
|
|
217
|
+
// response). A pending request older than the threshold therefore means the
|
|
218
|
+
// worker's write path is wedged — responses are queued behind a stalled
|
|
219
|
+
// uploader and never reach the server, while its WS keeps reading (the
|
|
220
|
+
// zombie state observed as "session stuck running, new messages get no
|
|
221
|
+
// response"). The sweep fires the same recovery the ws-handler inactivity
|
|
222
|
+
// close uses: a fresh work item → worker poll → brand-new transport.
|
|
223
|
+
|
|
224
|
+
interface PendingControlRequest {
|
|
225
|
+
sessionId: string
|
|
226
|
+
subtype: string
|
|
227
|
+
sentAt: number
|
|
228
|
+
/** Recovery already dispatched for this wedge episode. */
|
|
229
|
+
recoveryFired: boolean
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pendingControlRequests = new Map<string, PendingControlRequest>()
|
|
233
|
+
|
|
234
|
+
/** Web→worker control_request has 90s to be answered before recovery fires.
|
|
235
|
+
* Generous vs. the web UI's own 8s control round-trip timeout — only a
|
|
236
|
+
* genuinely dead write path should trip it. */
|
|
237
|
+
const CONTROL_RESPONSE_TIMEOUT_MS = 90_000
|
|
238
|
+
/** Reap unanswerable entries (worker gone mid-RPC) so the map stays bounded. */
|
|
239
|
+
const PENDING_REAP_MS = CONTROL_RESPONSE_TIMEOUT_MS * 4
|
|
240
|
+
|
|
241
|
+
function recordOutboundControlRequest(
|
|
242
|
+
sessionId: string,
|
|
243
|
+
payload: unknown,
|
|
244
|
+
): void {
|
|
245
|
+
const p = payload as {
|
|
246
|
+
request_id?: unknown
|
|
247
|
+
request?: { subtype?: unknown }
|
|
248
|
+
} | null
|
|
249
|
+
if (!p || typeof p.request_id !== 'string') return
|
|
250
|
+
pendingControlRequests.set(p.request_id, {
|
|
251
|
+
sessionId,
|
|
252
|
+
subtype:
|
|
253
|
+
typeof p.request?.subtype === 'string' ? p.request.subtype : '(unknown)',
|
|
254
|
+
sentAt: Date.now(),
|
|
255
|
+
recoveryFired: false,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function settleControlResponse(payload: unknown): void {
|
|
260
|
+
const resp = (payload as { response?: { request_id?: unknown } } | null)
|
|
261
|
+
?.response
|
|
262
|
+
if (resp && typeof resp.request_id === 'string') {
|
|
263
|
+
pendingControlRequests.delete(resp.request_id)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Sweep pending web→worker control requests. For requests unanswered past
|
|
269
|
+
* the timeout, log the wedge and fire a recovery work re-dispatch (once
|
|
270
|
+
* per episode — redispatchWorkForSession itself also no-ops when an open
|
|
271
|
+
* item already exists). Called from the disconnect monitor's 60s interval.
|
|
272
|
+
*/
|
|
273
|
+
export function sweepUnansweredControlRequests(now = Date.now()): void {
|
|
274
|
+
for (const [requestId, pending] of pendingControlRequests) {
|
|
275
|
+
const ageMs = now - pending.sentAt
|
|
276
|
+
if (ageMs <= CONTROL_RESPONSE_TIMEOUT_MS) continue
|
|
277
|
+
if (!pending.recoveryFired) {
|
|
278
|
+
log(
|
|
279
|
+
`[RCS] control_request ${pending.subtype} request_id=${requestId} unanswered for ${Math.round(ageMs / 1000)}s on session=${pending.sessionId}` +
|
|
280
|
+
' — worker write path may be wedged, redispatching work',
|
|
281
|
+
)
|
|
282
|
+
pending.recoveryFired = true
|
|
283
|
+
void redispatchWorkForSession(pending.sessionId)
|
|
284
|
+
}
|
|
285
|
+
if (ageMs > PENDING_REAP_MS) {
|
|
286
|
+
pendingControlRequests.delete(requestId)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
210
291
|
/** Publish an event to a session's bus and persist it durably. */
|
|
211
292
|
export function publishSessionEvent(
|
|
212
293
|
sessionId: string,
|
|
@@ -232,6 +313,14 @@ export function publishSessionEvent(
|
|
|
232
313
|
const normalized = normalizePayload(type, payload)
|
|
233
314
|
const transient = TRANSIENT_EVENT_TYPES.has(type)
|
|
234
315
|
|
|
316
|
+
// Control-response watchdog bookkeeping (see above). The raw payload is
|
|
317
|
+
// used — normalizePayload preserves request_id/response for these types.
|
|
318
|
+
if (direction === 'outbound' && type === 'control_request') {
|
|
319
|
+
recordOutboundControlRequest(sessionId, payload)
|
|
320
|
+
} else if (direction === 'inbound' && type === 'control_response') {
|
|
321
|
+
settleControlResponse(payload)
|
|
322
|
+
}
|
|
323
|
+
|
|
235
324
|
const event = bus.publish(
|
|
236
325
|
{
|
|
237
326
|
id: eventId,
|
|
@@ -7,10 +7,11 @@ import {
|
|
|
7
7
|
storeListSessionsByEnvironment,
|
|
8
8
|
storeGetEnvironment,
|
|
9
9
|
storeListWorkItems,
|
|
10
|
+
storeGetSession,
|
|
10
11
|
} from '../store'
|
|
11
12
|
import { config } from '../config'
|
|
12
13
|
import { getBaseUrl } from '../config'
|
|
13
|
-
import { touchSession } from './session'
|
|
14
|
+
import { touchSession, isSessionClosedStatus } from './session'
|
|
14
15
|
import { touchEnvironmentActivity } from './environment'
|
|
15
16
|
import type { WorkResponse } from '../types/api'
|
|
16
17
|
|
|
@@ -171,3 +172,57 @@ export function reconnectWorkForEnvironment(envId: string) {
|
|
|
171
172
|
const promises = activeSessions.map(s => createWorkItem(envId, s.id))
|
|
172
173
|
return Promise.all(promises)
|
|
173
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Server-initiated recovery dispatch for a single session.
|
|
178
|
+
*
|
|
179
|
+
* Fired when the session's ingress WS is closed for client inactivity
|
|
180
|
+
* (ws-handler): with HybridTransport the inactivity clock only advances via
|
|
181
|
+
* HTTP POST ingress (all writes go through POST — the WS carries no
|
|
182
|
+
* app-level client→server frames), so 300s of POST silence with a live WS
|
|
183
|
+
* means the worker's event uploader is wedged (e.g. one hung POST stalled
|
|
184
|
+
* the serialized drain loop). The worker's WS auto-reconnects within
|
|
185
|
+
* seconds — from the server's side everything looks healthy again, so
|
|
186
|
+
* WITHOUT this re-dispatch the zombie transport is never replaced: the
|
|
187
|
+
* at-capacity poll (10 min interval) returns no work and the worker's
|
|
188
|
+
* write path stays dead forever (observed: session stuck 'running', all
|
|
189
|
+
* worker events and control_responses silently swallowed).
|
|
190
|
+
*
|
|
191
|
+
* A fresh work item makes the worker's next poll fire onWorkReceived →
|
|
192
|
+
* brand-new transport (fresh WS + fresh uploader) → recovery. Deliberately
|
|
193
|
+
* NOT filtered by session status — a wedged worker's session is typically
|
|
194
|
+
* stuck in 'running' (the turn-end result event was eaten), which is
|
|
195
|
+
* exactly the case that needs the rebuild.
|
|
196
|
+
*/
|
|
197
|
+
export async function redispatchWorkForSession(
|
|
198
|
+
sessionId: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const session = storeGetSession(sessionId)
|
|
201
|
+
if (!session) return
|
|
202
|
+
if (isSessionClosedStatus(session.status)) return
|
|
203
|
+
const envId = session.environmentId
|
|
204
|
+
if (!envId) return
|
|
205
|
+
|
|
206
|
+
// Don't stack items: if the env already has a pending or in-flight
|
|
207
|
+
// dispatch for this cycle, the recovery is already queued.
|
|
208
|
+
const hasOpenItem = storeListWorkItems().some(
|
|
209
|
+
item =>
|
|
210
|
+
item.environmentId === envId &&
|
|
211
|
+
item.sessionId === sessionId &&
|
|
212
|
+
(item.state === 'pending' || item.state === 'dispatched'),
|
|
213
|
+
)
|
|
214
|
+
if (hasOpenItem) return
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await createWorkItem(envId, sessionId)
|
|
218
|
+
log(
|
|
219
|
+
`[RCS] Redispatched work item after WS inactivity close: session=${sessionId} env=${envId}`,
|
|
220
|
+
)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Env inactive (worker gone — disconnect monitor will handle it) or
|
|
223
|
+
// race with deletion. Not an error worth surfacing.
|
|
224
|
+
log(
|
|
225
|
+
`[RCS] Redispatch after inactivity close skipped (session=${sessionId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|