@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 { 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()
@@ -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
+ }