@saluzi/saluzi-edu 0.4.5 → 0.4.7
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 +55 -55
- package/dist/guide/guide-data.json +175 -175
- 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__/helpers/mock-config.ts +2 -0
- package/packages/remote-control-server/src/__tests__/stuck-turn-watchdog.test.ts +453 -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/config.ts +9 -0
- package/packages/remote-control-server/src/services/disconnect-monitor.ts +10 -0
- package/packages/remote-control-server/src/services/stuck-turn-watchdog.ts +144 -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
|
@@ -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()
|
|
@@ -39,6 +39,14 @@ export interface RcsConfig {
|
|
|
39
39
|
* (session_keepalive_interval_v2_ms, default 120s) plus jitter, otherwise
|
|
40
40
|
* healthy idle sessions get torn down between keepalives. Default 300s. */
|
|
41
41
|
wsClientInactivityTimeout: number
|
|
42
|
+
/** Stuck-turn watchdog (seconds). A session whose status is `running` with
|
|
43
|
+
* no inbound worker events (and no outbound user message) for this long is
|
|
44
|
+
* flipped back to `idle` + gets a recovery work re-dispatch — the turn-end
|
|
45
|
+
* result event was lost in transit (worker-side drop) and nothing else
|
|
46
|
+
* would ever release the Web UI input. Generous vs. long silent tools:
|
|
47
|
+
* a false flip only unlocks the input early (messages queue in the REPL);
|
|
48
|
+
* the next worker event re-derives `running`. 0 disables. Default 900s. */
|
|
49
|
+
stuckTurnTimeout: number
|
|
42
50
|
/** Whether artifact content (GET /v1/artifacts/:id/content) is served
|
|
43
51
|
* without authentication. Default true — the unguessable artifact id is
|
|
44
52
|
* the secret, same trust model as the upstream cloud-artifacts service.
|
|
@@ -84,6 +92,7 @@ export const config: RcsConfig = {
|
|
|
84
92
|
process.env.RCS_WS_CLIENT_INACTIVITY_TIMEOUT || '300',
|
|
85
93
|
10,
|
|
86
94
|
),
|
|
95
|
+
stuckTurnTimeout: parseInt(process.env.RCS_STUCK_TURN_TIMEOUT || '900', 10),
|
|
87
96
|
artifactsPublicAccess: process.env.RCS_ARTIFACTS_PUBLIC_ACCESS !== 'false',
|
|
88
97
|
}
|
|
89
98
|
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
storeMarkAcpAgentOffline,
|
|
7
7
|
} from '../store'
|
|
8
8
|
import { config } from '../config'
|
|
9
|
+
import { sweepUnansweredControlRequests } from './transport'
|
|
10
|
+
import { sweepStuckTurnSessions } from './stuck-turn-watchdog'
|
|
9
11
|
|
|
10
12
|
/** ACP env 离线后留给 acp-link 重连复用的宽限时间。超过后才将 env 置为
|
|
11
13
|
* disconnected。会话不再自动归档——由用户手动删除。 */
|
|
@@ -57,5 +59,13 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|
|
57
59
|
export function startDisconnectMonitor(): ReturnType<typeof setInterval> {
|
|
58
60
|
return setInterval(() => {
|
|
59
61
|
runDisconnectMonitorSweep()
|
|
62
|
+
// Control-request response watchdog — detects a wedged worker write
|
|
63
|
+
// path (WS healthy, POST path dead) from unanswered RPCs and fires the
|
|
64
|
+
// recovery work re-dispatch. See services/transport.ts.
|
|
65
|
+
sweepUnansweredControlRequests()
|
|
66
|
+
// Stuck-turn watchdog — heals sessions whose turn-end result event was
|
|
67
|
+
// lost in transit (status stuck `running`, Web UI input locked forever).
|
|
68
|
+
// See services/stuck-turn-watchdog.ts.
|
|
69
|
+
sweepStuckTurnSessions()
|
|
60
70
|
}, 60_000) // Check every minute
|
|
61
71
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { log } from '../logger'
|
|
2
|
+
import { storeListSessions } from '../store'
|
|
3
|
+
import { config } from '../config'
|
|
4
|
+
import type { SessionEvent } from '../transport/event-bus'
|
|
5
|
+
import { getEventBus } from '../transport/event-bus'
|
|
6
|
+
import { updateSessionStatus } from './session'
|
|
7
|
+
import { redispatchWorkForSession } from './work-dispatch'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stuck-turn watchdog — the server-side self-heal for sessions whose
|
|
11
|
+
* turn-end signal was lost in transit.
|
|
12
|
+
*
|
|
13
|
+
* A session's status only returns from `running` to `idle` when the worker's
|
|
14
|
+
* `result`/`result_success` event arrives (ws-handler.ts ingestBridgeMessage)
|
|
15
|
+
* or the user interrupts. The worker sends that result fire-and-forget —
|
|
16
|
+
* when the write is dropped (transport-swap window, uploader batch drop
|
|
17
|
+
* after maxConsecutiveFailures, close-grace expiry), it is never re-sent and
|
|
18
|
+
* the session stays `running` forever. The Web UI locks the input (black
|
|
19
|
+
* stop square) and every new message lands in a queue that never drains.
|
|
20
|
+
*
|
|
21
|
+
* Nothing else detects this state:
|
|
22
|
+
* - the worker's write-path watchdog only fires when POSTs stop completing
|
|
23
|
+
* entirely (a transient drop followed by healthy POSTs is invisible);
|
|
24
|
+
* - the WS inactivity clock is kept fresh by keepalive POSTs;
|
|
25
|
+
* - the control-request watchdog only tracks RPCs the server itself sent.
|
|
26
|
+
*
|
|
27
|
+
* The sweep flips a `running` session with no turn signal for
|
|
28
|
+
* `config.stuckTurnTimeout` back to `idle` (updateSessionStatus publishes
|
|
29
|
+
* `session_status`, unlocking live SSE viewers immediately — a genuinely
|
|
30
|
+
* still-running turn re-derives `running` on its next event) and fires the
|
|
31
|
+
* recovery work re-dispatch (zombie-transport rebuild, same as the WS
|
|
32
|
+
* inactivity close).
|
|
33
|
+
*
|
|
34
|
+
* `requires_action` is deliberately out of scope: a permission ask that
|
|
35
|
+
* arrived is visible and actionable in the Web UI — the user is the one
|
|
36
|
+
* blocking, not a lost signal.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Inbound event types that count as turn progress — an ALLOWLIST, so future
|
|
41
|
+
* chatter types default to not feeding the signal clock:
|
|
42
|
+
* - `user` (worker-mirrored tool results), `assistant`, `result`/
|
|
43
|
+
* `result_success`, `error` — the conversation itself;
|
|
44
|
+
* - `system` — subagent task events (task_started/task_progress/…).
|
|
45
|
+
* Exception: subtype `init` is re-sent on every transport (re)connect —
|
|
46
|
+
* transport liveness, not turn progress.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately NOT counted:
|
|
49
|
+
* - `control_request`/`control_response` — RPC traffic. The Web UI polls
|
|
50
|
+
* get_context_usage every 60s while the page is open, so the worker's
|
|
51
|
+
* control_response answers flow constantly even for a wedged session —
|
|
52
|
+
* counting them would make the watchdog permanently blind exactly when
|
|
53
|
+
* the user is watching the stuck session.
|
|
54
|
+
* - `models_state`, `system/init` — connect-time snapshots.
|
|
55
|
+
* - `task_state` — background task-list churn; a background task running
|
|
56
|
+
* outside any turn must not starve the watchdog.
|
|
57
|
+
* - `session_status` — server-synthesized (including this watchdog's own
|
|
58
|
+
* idle flip; counting it would make the sweep single-shot).
|
|
59
|
+
*/
|
|
60
|
+
const SIGNAL_EVENT_TYPES = new Set([
|
|
61
|
+
'user',
|
|
62
|
+
'assistant',
|
|
63
|
+
'partial_assistant',
|
|
64
|
+
'result',
|
|
65
|
+
'result_success',
|
|
66
|
+
'error',
|
|
67
|
+
'system',
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
function isInboundSignal(event: SessionEvent): boolean {
|
|
71
|
+
if (event.direction !== 'inbound') return false
|
|
72
|
+
if (!SIGNAL_EVENT_TYPES.has(event.type)) return false
|
|
73
|
+
if (event.type === 'system') {
|
|
74
|
+
// system/init is a connect-time snapshot, re-sent on every
|
|
75
|
+
// (re)connect — not turn progress.
|
|
76
|
+
const subtype = (event.payload as Record<string, unknown> | null)?.subtype
|
|
77
|
+
if (subtype === 'init') return false
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wall-clock of the latest event on the bus that proves the session's turn
|
|
84
|
+
* is (or was) progressing: any inbound worker conversation event (assistant,
|
|
85
|
+
* tool-result user, result, subagent task events), or the outbound user
|
|
86
|
+
* message that started the turn. Returns 0 when the bus carries no signal
|
|
87
|
+
* at all (fresh bus after a server restart — callers fall back to the
|
|
88
|
+
* persisted session record).
|
|
89
|
+
*/
|
|
90
|
+
export function lastTurnSignalAt(events: SessionEvent[]): number {
|
|
91
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
92
|
+
const event = events[i]
|
|
93
|
+
if (isInboundSignal(event)) {
|
|
94
|
+
return event.createdAt
|
|
95
|
+
}
|
|
96
|
+
if (event.direction === 'outbound' && event.type === 'user') {
|
|
97
|
+
return event.createdAt
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sweep all sessions for stuck turns. Returns the number of sessions healed.
|
|
105
|
+
* `now` is injectable for tests.
|
|
106
|
+
*/
|
|
107
|
+
export function sweepStuckTurnSessions(now: number = Date.now()): number {
|
|
108
|
+
const thresholdMs = config.stuckTurnTimeout * 1000
|
|
109
|
+
if (!(thresholdMs > 0)) return 0
|
|
110
|
+
|
|
111
|
+
let swept = 0
|
|
112
|
+
for (const session of storeListSessions()) {
|
|
113
|
+
// Only `running` locks the input with no recovery path; see module docs
|
|
114
|
+
// for why requires_action (visible, actionable card) and archived
|
|
115
|
+
// sessions are excluded.
|
|
116
|
+
if (session.status !== 'running') continue
|
|
117
|
+
// ACP sessions carry their turn traffic on the per-channel-group ACP
|
|
118
|
+
// bus (getAcpEventBus), not the session bus this sweep scans — healing
|
|
119
|
+
// them from the wrong bus would be a false positive.
|
|
120
|
+
if (session.source === 'acp') continue
|
|
121
|
+
|
|
122
|
+
const bus = getEventBus(session.id)
|
|
123
|
+
const lastSignal = lastTurnSignalAt(bus.getEventsSince(0))
|
|
124
|
+
// Empty bus (server restart) or a signal older than the session record
|
|
125
|
+
// (persisted legacy) — fall back to the persisted creation time so
|
|
126
|
+
// restart-legacy stuck sessions still heal after the same grace window.
|
|
127
|
+
const reference = Math.max(lastSignal, session.createdAt.getTime())
|
|
128
|
+
if (now - reference <= thresholdMs) continue
|
|
129
|
+
|
|
130
|
+
const silenceSec = Math.round((now - reference) / 1000)
|
|
131
|
+
log(
|
|
132
|
+
`[RCS] Stuck-turn watchdog: session=${session.id} running with no turn signal for ${silenceSec}s` +
|
|
133
|
+
' — flipping to idle (turn-end result was lost) and redispatching work',
|
|
134
|
+
)
|
|
135
|
+
// Recovery re-dispatch first: when the worker's transport is actually
|
|
136
|
+
// dead this rebuilds it (fresh uploader → the write path recovers).
|
|
137
|
+
// No-op for healthy transports with an unchanged token.
|
|
138
|
+
void redispatchWorkForSession(session.id)
|
|
139
|
+
// Publishes `session_status` → live SSE viewers unlock the input.
|
|
140
|
+
updateSessionStatus(session.id, 'idle')
|
|
141
|
+
swept++
|
|
142
|
+
}
|
|
143
|
+
return swept
|
|
144
|
+
}
|
|
@@ -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
|
+
}
|