@saluzi/saluzi-edu 0.4.0 → 0.4.1

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.0",
3
+ "version": "0.4.1",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -158,7 +158,7 @@ describe('Session Attach wait mode', () => {
158
158
  expect(res.status).toBe(403)
159
159
  const body = (await res.json()) as { error: { type: string } }
160
160
  expect(body.error.type).toBe('forbidden')
161
- // updatePollTime runs only AFTER the gate — a 403 must not keep a dead
161
+ // touchEnvironmentActivity runs only AFTER the gate — a 403 must not keep a dead
162
162
  // env looking alive.
163
163
  expect(storeGetEnvironment(env.id)?.lastPollAt?.getTime()).toBe(
164
164
  stale.getTime(),
@@ -4,7 +4,11 @@ import { mockConfigModule } from './helpers/mock-config'
4
4
  // Mock config before importing modules
5
5
  mock.module('../config', () => mockConfigModule())
6
6
 
7
- import { storeReset, storeCreateEnvironment } from '../store'
7
+ import {
8
+ storeReset,
9
+ storeCreateEnvironment,
10
+ storeUpdateEnvironment,
11
+ } from '../store'
8
12
  import {
9
13
  createSession,
10
14
  createCodeSession,
@@ -22,7 +26,7 @@ import {
22
26
  registerEnvironment,
23
27
  deregisterEnvironment,
24
28
  getEnvironment,
25
- updatePollTime,
29
+ touchEnvironmentActivity,
26
30
  listActiveEnvironments,
27
31
  listActiveEnvironmentsResponse,
28
32
  listActiveEnvironmentsByUsername,
@@ -230,14 +234,38 @@ describe('Environment Service', () => {
230
234
  })
231
235
  })
232
236
 
233
- describe('updatePollTime', () => {
234
- test('updates lastPollAt', () => {
237
+ describe('touchEnvironmentActivity', () => {
238
+ test('updates lastPollAt for an active env', () => {
235
239
  const result = registerEnvironment({})
236
- const before = getEnvironment(result.environment_id)?.lastPollAt
237
- // Small delay to ensure time difference
238
- updatePollTime(result.environment_id)
240
+ const stale = new Date(Date.now() - 60_000)
241
+ storeUpdateEnvironment(result.environment_id, { lastPollAt: stale })
242
+ touchEnvironmentActivity(result.environment_id)
239
243
  const after = getEnvironment(result.environment_id)?.lastPollAt
240
- expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime())
244
+ expect(after!.getTime()).toBeGreaterThan(stale.getTime())
245
+ })
246
+
247
+ test('reactivates a disconnected env', () => {
248
+ const result = registerEnvironment({})
249
+ storeUpdateEnvironment(result.environment_id, { status: 'disconnected' })
250
+ touchEnvironmentActivity(result.environment_id)
251
+ const env = getEnvironment(result.environment_id)
252
+ expect(env?.status).toBe('active')
253
+ expect(env?.lastPollAt).toBeTruthy()
254
+ })
255
+
256
+ test('leaves a deregistered env untouched (terminal state)', () => {
257
+ const result = registerEnvironment({})
258
+ const stale = new Date(Date.now() - 60_000)
259
+ storeUpdateEnvironment(result.environment_id, { lastPollAt: stale })
260
+ deregisterEnvironment(result.environment_id)
261
+ touchEnvironmentActivity(result.environment_id)
262
+ const env = getEnvironment(result.environment_id)
263
+ expect(env?.status).toBe('deregistered')
264
+ expect(env?.lastPollAt?.getTime()).toBe(stale.getTime())
265
+ })
266
+
267
+ test('no-ops for a non-existent env', () => {
268
+ expect(() => touchEnvironmentActivity('env_no')).not.toThrow()
241
269
  })
242
270
  })
243
271
 
@@ -10,6 +10,8 @@ import {
10
10
  storeCreateSession,
11
11
  storeGetWorkItem,
12
12
  storeGetPendingWorkItem,
13
+ storeGetEnvironment,
14
+ storeUpdateEnvironment,
13
15
  } from '../store'
14
16
  import {
15
17
  createWorkItem,
@@ -129,6 +131,42 @@ describe('Work Dispatch', () => {
129
131
  const result = heartbeatWork('work_no')
130
132
  expect(result.state).toBe('acked')
131
133
  })
134
+
135
+ test('refreshes env liveness — at-capacity workers poll for work only every 10 min', async () => {
136
+ const workId = await createWorkItem(envId, sessionId)
137
+ ackWork(workId)
138
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
139
+ storeUpdateEnvironment(envId, { lastPollAt: stale })
140
+ heartbeatWork(workId)
141
+ const lastPollAt = storeGetEnvironment(envId)?.lastPollAt
142
+ expect(lastPollAt!.getTime()).toBeGreaterThan(stale.getTime())
143
+ })
144
+
145
+ test('reactivates a monitor-flipped (disconnected) env', async () => {
146
+ const workId = await createWorkItem(envId, sessionId)
147
+ ackWork(workId)
148
+ storeUpdateEnvironment(envId, { status: 'disconnected' })
149
+ heartbeatWork(workId)
150
+ const env = storeGetEnvironment(envId)
151
+ expect(env?.status).toBe('active')
152
+ expect(env?.lastPollAt).toBeTruthy()
153
+ })
154
+
155
+ test('does not refresh env liveness for a stopped (completed) work item', async () => {
156
+ // 410 path: a zombie heartbeat after a Web-UI session deletion must
157
+ // not resurrect the env or refresh its liveness.
158
+ const workId = await createWorkItem(envId, sessionId)
159
+ stopWork(workId)
160
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
161
+ storeUpdateEnvironment(envId, {
162
+ status: 'disconnected',
163
+ lastPollAt: stale,
164
+ })
165
+ heartbeatWork(workId)
166
+ const env = storeGetEnvironment(envId)
167
+ expect(env?.status).toBe('disconnected')
168
+ expect(env?.lastPollAt?.getTime()).toBe(stale.getTime())
169
+ })
132
170
  })
133
171
 
134
172
  describe('reconnectWorkForEnvironment', () => {
@@ -17,7 +17,7 @@ import {
17
17
  } from '../../auth/middleware'
18
18
  import { getEventBus } from '../../transport/event-bus'
19
19
  import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store'
20
- import { updatePollTime } from '../../services/environment'
20
+ import { touchEnvironmentActivity } from '../../services/environment'
21
21
 
22
22
  const app = new Hono()
23
23
 
@@ -120,11 +120,12 @@ app.post(
120
120
  touchSession(sessionId)
121
121
  // V2 path doesn't go through /v1/environments/:id/work/poll, so
122
122
  // disconnect-monitor never sees lastPollAt updates. Refresh the parent
123
- // environment's poll time here — otherwise the env gets marked
123
+ // environment's liveness here — otherwise the env gets marked
124
124
  // disconnected and disappears from /web/environments even though the
125
- // worker is actively heartbeating.
125
+ // worker is actively heartbeating. touchEnvironmentActivity (not a raw
126
+ // lastPollAt write) so a monitor-flipped env is also reactivated.
126
127
  if (session.environment_id) {
127
- updatePollTime(session.environment_id)
128
+ touchEnvironmentActivity(session.environment_id)
128
129
  }
129
130
  return c.json({ status: 'ok', last_heartbeat_at: now.toISOString() }, 200)
130
131
  },
@@ -142,9 +143,9 @@ app.post('/:id/worker/register', acceptCliHeaders, apiKeyAuth, async c => {
142
143
  }
143
144
 
144
145
  const epoch = incrementEpoch(sessionId)
145
- // Same lastPollAt rationale as heartbeat — V2 path skips v1 poll.
146
+ // Same liveness rationale as heartbeat — V2 path skips v1 poll.
146
147
  if (session.environment_id) {
147
- updatePollTime(session.environment_id)
148
+ touchEnvironmentActivity(session.environment_id)
148
149
  }
149
150
  return c.json({ worker_epoch: epoch }, 200)
150
151
  })
@@ -28,8 +28,11 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
28
28
  continue
29
29
  }
30
30
  if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
31
+ // lastPollAt is written by touchEnvironmentActivity — any worker
32
+ // traffic counts (poll, assignment, session-create, work heartbeat),
33
+ // not just literal polls.
31
34
  log(
32
- `[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
35
+ `[RCS] Environment ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
33
36
  )
34
37
  storeUpdateEnvironment(env.id, { status: 'disconnected' })
35
38
  // 会话不自动归档:perpetual worker 重启时复用 envId 重新激活 env,
@@ -273,16 +273,13 @@ export function getEnvironment(envId: string) {
273
273
  return storeGetEnvironment(envId)
274
274
  }
275
275
 
276
- export function updatePollTime(envId: string) {
277
- storeUpdateEnvironment(envId, { lastPollAt: new Date() })
278
- }
279
-
280
276
  /**
281
277
  * Worker-traffic liveness signal: refresh lastPollAt and, when the disconnect
282
278
  * monitor had flipped the env to 'disconnected', reactivate it.
283
279
  *
284
280
  * The flip is based on a stale heartbeat — a worker actively hitting the env
285
- * (work poll, assignment poll, session-creation retry during claim wait)
281
+ * (work poll, assignment poll, session-creation retry during claim wait,
282
+ * work-item heartbeat while at capacity, v2 worker register/heartbeat)
286
283
  * proves it is alive. Without reactivation the CLI polls a zombie env
287
284
  * forever: the work-poll route never 404s, so the client's env-lost recovery
288
285
  * never fires, and any session created on the flipped env gets no work item
@@ -11,6 +11,7 @@ import {
11
11
  import { config } from '../config'
12
12
  import { getBaseUrl } from '../config'
13
13
  import { touchSession } from './session'
14
+ import { touchEnvironmentActivity } from './environment'
14
15
  import type { WorkResponse } from '../types/api'
15
16
 
16
17
  /** Encode work secret as base64 JSON (no JWT — just API key as token) */
@@ -140,6 +141,19 @@ export function heartbeatWork(workId: string): {
140
141
  if (item?.sessionId) {
141
142
  touchSession(item.sessionId)
142
143
  }
144
+ // Worker liveness for the parent env: an at-capacity worker (e.g. the
145
+ // `slz rcs one` repl worker, capacity=1) stops polling for work for the
146
+ // whole duration of a session (at-capacity poll interval is 10 min) and
147
+ // sends only this heartbeat (~60s). Without counting it as env activity,
148
+ // the disconnect monitor flips every busy env to 'disconnected' once
149
+ // RCS_DISCONNECT_TIMEOUT (default 300s) passes since the dispatch poll.
150
+ // Use the item's own environmentId — the route's :id param is not
151
+ // validated against it. Completed (stopped → 410) items must NOT refresh
152
+ // liveness: a zombie heartbeat after a Web-UI session deletion must not
153
+ // resurrect the env.
154
+ if (item && item.state !== 'completed') {
155
+ touchEnvironmentActivity(item.environmentId)
156
+ }
143
157
  const now = new Date()
144
158
  return {
145
159
  lease_extended: true,