@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.
@@ -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 { storeReset } from '../store'
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
+ }