specrails-core 3.3.0 → 3.4.0

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.
Files changed (66) hide show
  1. package/README.md +77 -63
  2. package/VERSION +1 -1
  3. package/bin/doctor.sh +1 -1
  4. package/bin/perf-check.sh +21 -0
  5. package/bin/specrails-core.js +7 -4
  6. package/commands/doctor.md +1 -1
  7. package/commands/setup.md +72 -79
  8. package/docs/README.md +3 -2
  9. package/docs/agents.md +15 -15
  10. package/docs/changelog.md +20 -20
  11. package/docs/concepts.md +3 -3
  12. package/docs/customization.md +18 -5
  13. package/docs/deployment.md +24 -9
  14. package/docs/getting-started.md +20 -27
  15. package/docs/installation.md +81 -72
  16. package/docs/local-tickets.md +14 -41
  17. package/docs/migration-guide.md +9 -10
  18. package/docs/playbook-parallel-dev.md +8 -8
  19. package/docs/playbook-product-discovery.md +1 -1
  20. package/docs/plugin-architecture.md +137 -0
  21. package/docs/research/codex-compatibility-analysis.md +11 -11
  22. package/docs/research/mcp-feasibility-analysis.md +5 -5
  23. package/docs/testing/test-matrix-codex.md +1 -2
  24. package/docs/user-docs/cli-reference.md +57 -69
  25. package/docs/user-docs/codex-vs-claude-code.md +7 -8
  26. package/docs/user-docs/faq.md +32 -26
  27. package/docs/user-docs/getting-started-codex.md +7 -7
  28. package/docs/user-docs/installation.md +50 -40
  29. package/docs/user-docs/quick-start.md +21 -27
  30. package/docs/workflows.md +62 -74
  31. package/install.sh +3 -3
  32. package/package.json +1 -1
  33. package/templates/agents/sr-merge-resolver.md +1 -1
  34. package/templates/claude-md/CLAUDE-quickstart.md +2 -2
  35. package/templates/commands/{sr → specrails}/batch-implement.md +18 -18
  36. package/templates/commands/{sr → specrails}/implement.md +8 -8
  37. package/templates/commands/{sr → specrails}/memory-inspect.md +3 -3
  38. package/templates/commands/{sr → specrails}/merge-resolve.md +1 -1
  39. package/templates/commands/{sr → specrails}/opsx-diff.md +1 -1
  40. package/templates/commands/{sr → specrails}/product-backlog.md +7 -7
  41. package/templates/commands/{sr → specrails}/propose-spec.md +1 -1
  42. package/templates/commands/{sr → specrails}/refactor-recommender.md +1 -1
  43. package/templates/commands/{sr → specrails}/retry.md +13 -13
  44. package/templates/commands/{sr → specrails}/team-debug.md +5 -5
  45. package/templates/commands/{sr → specrails}/team-review.md +4 -4
  46. package/templates/commands/{sr → specrails}/telemetry.md +2 -2
  47. package/templates/commands/{sr → specrails}/update-product-driven-backlog.md +2 -2
  48. package/templates/commands/{sr → specrails}/vpc-drift.md +4 -4
  49. package/templates/commands/{sr → specrails}/why.md +5 -5
  50. package/templates/commands/test.md +2 -2
  51. package/templates/skills/sr-batch-implement/SKILL.md +18 -18
  52. package/templates/skills/sr-implement/SKILL.md +8 -8
  53. package/templates/skills/sr-product-backlog/SKILL.md +7 -7
  54. package/templates/skills/sr-refactor-recommender/SKILL.md +1 -1
  55. package/templates/skills/sr-update-backlog/SKILL.md +2 -2
  56. package/templates/skills/sr-why/SKILL.md +5 -5
  57. package/update.sh +2 -3
  58. package/docs/api-reference.md +0 -266
  59. package/integration-contract.json +0 -45
  60. package/templates/local-tickets-schema.json +0 -7
  61. package/templates/skills/sr-health-check/SKILL.md +0 -531
  62. package/templates/web-manager/package-lock.json +0 -3740
  63. package/templates/web-manager/server/queue-manager.test.ts +0 -607
  64. package/templates/web-manager/server/queue-manager.ts +0 -565
  65. /package/templates/commands/{sr → specrails}/compat-check.md +0 -0
  66. /package/templates/commands/{sr → specrails}/health-check.md +0 -0
@@ -1,607 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
- import { EventEmitter } from 'events'
3
- import { Readable } from 'stream'
4
-
5
- // Mock child_process and uuid before importing queue-manager
6
- vi.mock('child_process', () => ({
7
- spawn: vi.fn(),
8
- execSync: vi.fn(),
9
- }))
10
-
11
- vi.mock('uuid', () => ({
12
- v4: vi.fn(() => 'test-uuid-1111'),
13
- }))
14
-
15
- vi.mock('tree-kill', () => ({
16
- default: vi.fn(),
17
- }))
18
-
19
- // Mock hooks to avoid side effects in tests
20
- vi.mock('./hooks', () => ({
21
- resetPhases: vi.fn(),
22
- }))
23
-
24
- import { spawn as mockSpawn, execSync as mockExecSync } from 'child_process'
25
- import treeKill from 'tree-kill'
26
- import { v4 as mockUuidV4 } from 'uuid'
27
- import { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError } from './queue-manager'
28
- import type { WsMessage } from './types'
29
-
30
- function createMockChildProcess() {
31
- const child = new EventEmitter() as any
32
- child.stdout = new Readable({ read() {} })
33
- child.stderr = new Readable({ read() {} })
34
- child.pid = 12345
35
- return child
36
- }
37
-
38
- describe('QueueManager', () => {
39
- let qm: QueueManager
40
- let broadcast: ReturnType<typeof vi.fn>
41
-
42
- beforeEach(() => {
43
- vi.resetAllMocks()
44
- broadcast = vi.fn()
45
- qm = new QueueManager(broadcast)
46
- })
47
-
48
- afterEach(() => {
49
- vi.restoreAllMocks()
50
- })
51
-
52
- // ─── enqueue ──────────────────────────────────────────────────────────────
53
-
54
- describe('enqueue', () => {
55
- it('returns a job with status queued when a process is already running', () => {
56
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
57
- const child1 = createMockChildProcess()
58
- const child2 = createMockChildProcess()
59
- vi.mocked(mockSpawn)
60
- .mockReturnValueOnce(child1 as any)
61
- .mockReturnValueOnce(child2 as any)
62
- vi.mocked(mockUuidV4)
63
- .mockReturnValueOnce('job-1' as any)
64
- .mockReturnValueOnce('job-2' as any)
65
-
66
- qm.enqueue('/implement #1')
67
- const secondJob = qm.enqueue('/implement #2')
68
-
69
- expect(secondJob.status).toBe('queued')
70
- expect(secondJob.queuePosition).toBe(1)
71
- })
72
-
73
- it('returns a job with status running when queue is empty (auto-drains)', () => {
74
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
75
- const child = createMockChildProcess()
76
- vi.mocked(mockSpawn).mockReturnValue(child as any)
77
-
78
- const job = qm.enqueue('/implement #1')
79
-
80
- expect(job.status).toBe('running')
81
- })
82
-
83
- it('throws ClaudeNotFoundError when claude is not on PATH', () => {
84
- vi.mocked(mockExecSync).mockImplementation(() => {
85
- throw new Error('not found')
86
- })
87
-
88
- expect(() => qm.enqueue('/implement #1')).toThrow(ClaudeNotFoundError)
89
- })
90
-
91
- it('broadcasts queue state after enqueue', () => {
92
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
93
- const child = createMockChildProcess()
94
- vi.mocked(mockSpawn).mockReturnValue(child as any)
95
-
96
- qm.enqueue('/implement #1')
97
-
98
- const queueBroadcasts = broadcast.mock.calls.filter(
99
- (args: unknown[]) => (args[0] as WsMessage).type === 'queue'
100
- )
101
- expect(queueBroadcasts.length).toBeGreaterThanOrEqual(1)
102
- })
103
- })
104
-
105
- // ─── cancel ───────────────────────────────────────────────────────────────
106
-
107
- describe('cancel', () => {
108
- it('on a queued job: removes from queue and broadcasts queue state', () => {
109
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
110
- const child = createMockChildProcess()
111
- vi.mocked(mockSpawn).mockReturnValue(child as any)
112
- vi.mocked(mockUuidV4)
113
- .mockReturnValueOnce('job-running' as any)
114
- .mockReturnValueOnce('job-queued' as any)
115
-
116
- qm.enqueue('/implement #1')
117
- qm.enqueue('/implement #2')
118
-
119
- broadcast.mockClear()
120
-
121
- const result = qm.cancel('job-queued')
122
-
123
- expect(result).toBe('canceled')
124
- const jobs = qm.getJobs()
125
- const canceledJob = jobs.find((j) => j.id === 'job-queued')
126
- expect(canceledJob?.status).toBe('canceled')
127
-
128
- const queueBroadcast = broadcast.mock.calls.find(
129
- (args: unknown[]) => (args[0] as WsMessage).type === 'queue'
130
- )
131
- expect(queueBroadcast).toBeDefined()
132
- })
133
-
134
- it('on a running job: calls treeKill with SIGTERM and returns canceling', () => {
135
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
136
- const child = createMockChildProcess()
137
- vi.mocked(mockSpawn).mockReturnValue(child as any)
138
- vi.mocked(mockUuidV4).mockReturnValue('job-running' as any)
139
-
140
- qm.enqueue('/implement #1')
141
-
142
- const result = qm.cancel('job-running')
143
-
144
- expect(result).toBe('canceling')
145
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
146
- })
147
-
148
- it('on a non-existent ID: throws JobNotFoundError', () => {
149
- expect(() => qm.cancel('no-such-id')).toThrow(JobNotFoundError)
150
- })
151
-
152
- it('on a completed job: throws JobAlreadyTerminalError', async () => {
153
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
154
- const child = createMockChildProcess()
155
- vi.mocked(mockSpawn).mockReturnValue(child as any)
156
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
157
-
158
- qm.enqueue('/implement #1')
159
- child.emit('close', 0)
160
-
161
- // Let close handler run
162
- await new Promise((r) => setTimeout(r, 10))
163
-
164
- expect(() => qm.cancel('job-1')).toThrow(JobAlreadyTerminalError)
165
- })
166
- })
167
-
168
- // ─── pause / resume ───────────────────────────────────────────────────────
169
-
170
- describe('pause', () => {
171
- it('prevents _drainQueue from starting the next job', () => {
172
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
173
- const child = createMockChildProcess()
174
- vi.mocked(mockSpawn).mockReturnValue(child as any)
175
- vi.mocked(mockUuidV4)
176
- .mockReturnValueOnce('job-1' as any)
177
- .mockReturnValueOnce('job-2' as any)
178
-
179
- qm.pause()
180
- qm.enqueue('/implement #1')
181
- qm.enqueue('/implement #2')
182
-
183
- // spawn should not have been called because queue is paused
184
- expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
185
- })
186
- })
187
-
188
- describe('resume', () => {
189
- it('calls _drainQueue and starts the next job if one is queued', () => {
190
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
191
- const child = createMockChildProcess()
192
- vi.mocked(mockSpawn).mockReturnValue(child as any)
193
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
194
-
195
- qm.pause()
196
- qm.enqueue('/implement #1')
197
- expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
198
-
199
- qm.resume()
200
- expect(vi.mocked(mockSpawn)).toHaveBeenCalledOnce()
201
-
202
- const jobs = qm.getJobs()
203
- expect(jobs[0].status).toBe('running')
204
- })
205
- })
206
-
207
- // ─── reorder ──────────────────────────────────────────────────────────────
208
-
209
- describe('reorder', () => {
210
- it('reorders the queue array', () => {
211
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
212
- const child = createMockChildProcess()
213
- vi.mocked(mockSpawn).mockReturnValue(child as any)
214
- vi.mocked(mockUuidV4)
215
- .mockReturnValueOnce('job-running' as any)
216
- .mockReturnValueOnce('job-a' as any)
217
- .mockReturnValueOnce('job-b' as any)
218
-
219
- qm.enqueue('/implement #1')
220
- qm.enqueue('/implement #2')
221
- qm.enqueue('/implement #3')
222
-
223
- qm.reorder(['job-b', 'job-a'])
224
-
225
- const jobs = qm.getJobs()
226
- const jobB = jobs.find((j) => j.id === 'job-b')
227
- const jobA = jobs.find((j) => j.id === 'job-a')
228
- expect(jobB?.queuePosition).toBe(1)
229
- expect(jobA?.queuePosition).toBe(2)
230
- })
231
-
232
- it('throws when jobIds do not match the queued set', () => {
233
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
234
- const child = createMockChildProcess()
235
- vi.mocked(mockSpawn).mockReturnValue(child as any)
236
- vi.mocked(mockUuidV4)
237
- .mockReturnValueOnce('job-running' as any)
238
- .mockReturnValueOnce('job-a' as any)
239
-
240
- qm.enqueue('/implement #1')
241
- qm.enqueue('/implement #2')
242
-
243
- // Provide wrong ID
244
- expect(() => qm.reorder(['job-a', 'wrong-id'])).toThrow()
245
- })
246
- })
247
-
248
- // ─── job transitions ──────────────────────────────────────────────────────
249
-
250
- describe('job status transitions', () => {
251
- it('job transitions to completed when process exits with code 0', async () => {
252
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
253
- const child = createMockChildProcess()
254
- vi.mocked(mockSpawn).mockReturnValue(child as any)
255
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
256
-
257
- qm.enqueue('/implement #1')
258
- child.emit('close', 0)
259
-
260
- await new Promise((r) => setTimeout(r, 10))
261
-
262
- const jobs = qm.getJobs()
263
- expect(jobs[0].status).toBe('completed')
264
- expect(jobs[0].exitCode).toBe(0)
265
- })
266
-
267
- it('job transitions to failed when process exits with non-zero code', async () => {
268
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
269
- const child = createMockChildProcess()
270
- vi.mocked(mockSpawn).mockReturnValue(child as any)
271
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
272
-
273
- qm.enqueue('/implement #1')
274
- child.emit('close', 1)
275
-
276
- await new Promise((r) => setTimeout(r, 10))
277
-
278
- const jobs = qm.getJobs()
279
- expect(jobs[0].status).toBe('failed')
280
- expect(jobs[0].exitCode).toBe(1)
281
- })
282
- })
283
-
284
- // ─── getLogBuffer ─────────────────────────────────────────────────────────
285
-
286
- describe('getLogBuffer', () => {
287
- it('returns log lines accumulated during job execution', async () => {
288
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
289
- const child = createMockChildProcess()
290
- vi.mocked(mockSpawn).mockReturnValue(child as any)
291
-
292
- qm.enqueue('/implement #1')
293
-
294
- child.stdout.push('hello from stdout\n')
295
- child.stdout.push(null)
296
-
297
- await new Promise((r) => setTimeout(r, 50))
298
-
299
- const buf = qm.getLogBuffer()
300
- const line = buf.find((l) => l.line === 'hello from stdout')
301
- expect(line).toBeDefined()
302
- expect(line?.source).toBe('stdout')
303
- })
304
-
305
- it('returns a copy, not a reference', () => {
306
- const buf = qm.getLogBuffer()
307
- buf.push({} as any)
308
- expect(qm.getLogBuffer()).toEqual([])
309
- })
310
- })
311
-
312
- // ─── sequential queue drain ───────────────────────────────────────────────
313
-
314
- describe('sequential queue drain', () => {
315
- it('second job starts when first jobs process emits close', async () => {
316
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
317
- const child1 = createMockChildProcess()
318
- const child2 = createMockChildProcess()
319
- vi.mocked(mockSpawn)
320
- .mockReturnValueOnce(child1 as any)
321
- .mockReturnValueOnce(child2 as any)
322
- vi.mocked(mockUuidV4)
323
- .mockReturnValueOnce('job-1' as any)
324
- .mockReturnValueOnce('job-2' as any)
325
-
326
- qm.enqueue('/implement #1')
327
- qm.enqueue('/implement #2')
328
-
329
- expect(qm.getActiveJobId()).toBe('job-1')
330
-
331
- child1.emit('close', 0)
332
-
333
- await new Promise((r) => setTimeout(r, 10))
334
-
335
- expect(qm.getActiveJobId()).toBe('job-2')
336
-
337
- const jobs = qm.getJobs()
338
- expect(jobs.find((j) => j.id === 'job-2')?.status).toBe('running')
339
- })
340
- })
341
-
342
- // ─── kill timer ───────────────────────────────────────────────────────────
343
-
344
- describe('kill timer', () => {
345
- it('fires SIGKILL after 5s if process does not exit', async () => {
346
- vi.useFakeTimers()
347
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
348
- const child = createMockChildProcess()
349
- vi.mocked(mockSpawn).mockReturnValue(child as any)
350
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
351
-
352
- qm.enqueue('/implement #1')
353
- qm.cancel('job-1')
354
-
355
- // Advance past 5s timeout
356
- vi.advanceTimersByTime(5100)
357
-
358
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
359
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGKILL')
360
-
361
- vi.clearAllTimers()
362
- vi.useRealTimers()
363
- })
364
- })
365
-
366
- // ─── getActiveJobId / isPaused ────────────────────────────────────────────
367
-
368
- describe('getActiveJobId', () => {
369
- it('returns null when no job is running', () => {
370
- expect(qm.getActiveJobId()).toBeNull()
371
- })
372
-
373
- it('returns the running job id after enqueue', () => {
374
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
375
- const child = createMockChildProcess()
376
- vi.mocked(mockSpawn).mockReturnValue(child as any)
377
- vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
378
-
379
- qm.enqueue('/implement #1')
380
-
381
- expect(qm.getActiveJobId()).toBe('job-1')
382
- })
383
- })
384
-
385
- describe('isPaused', () => {
386
- it('returns false by default', () => {
387
- expect(qm.isPaused()).toBe(false)
388
- })
389
-
390
- it('returns true after pause()', () => {
391
- qm.pause()
392
- expect(qm.isPaused()).toBe(true)
393
- })
394
-
395
- it('returns false after resume()', () => {
396
- qm.pause()
397
- qm.resume()
398
- expect(qm.isPaused()).toBe(false)
399
- })
400
- })
401
-
402
- // ─── zombie detection ─────────────────────────────────────────────────────
403
-
404
- describe('zombie detection', () => {
405
- it('auto-terminates a job with no output after the configured timeout', () => {
406
- vi.useFakeTimers()
407
- vi.mocked(treeKill).mockClear()
408
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
409
- const child = createMockChildProcess()
410
- vi.mocked(mockSpawn).mockReturnValue(child as any)
411
- vi.mocked(mockUuidV4).mockReturnValue('job-zombie' as any)
412
-
413
- const qmZombie = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 30_000 })
414
- qmZombie.enqueue('/implement #1')
415
-
416
- // Advance past the 30s zombie timeout
417
- vi.advanceTimersByTime(30_100)
418
-
419
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
420
-
421
- vi.clearAllTimers()
422
- vi.useRealTimers()
423
- })
424
-
425
- it('resets the zombie timer on each output line', async () => {
426
- vi.useFakeTimers()
427
- vi.mocked(treeKill).mockClear()
428
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
429
- const child = createMockChildProcess()
430
- vi.mocked(mockSpawn).mockReturnValue(child as any)
431
- vi.mocked(mockUuidV4).mockReturnValue('job-active' as any)
432
-
433
- const qmActive = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 30_000 })
434
- qmActive.enqueue('/implement #1')
435
-
436
- // Advance 25s without any output — timer is still counting (fires at 30s)
437
- vi.advanceTimersByTime(25_000)
438
-
439
- // Push output — the 'data' event is emitted via process.nextTick by Node.js streams.
440
- // Awaiting a nextTick-based promise flushes the nextTick queue, causing the 'data'
441
- // event to fire and reset the zombie timer before we advance time further.
442
- child.stdout.push('still alive\n')
443
- await new Promise<void>(resolve => process.nextTick(resolve))
444
-
445
- // Advance another 25s — timer was reset at ~25s (fires at ~55s), so at t=50s it has NOT fired
446
- vi.advanceTimersByTime(25_000)
447
-
448
- expect(vi.mocked(treeKill)).not.toHaveBeenCalled()
449
-
450
- vi.clearAllTimers()
451
- vi.useRealTimers()
452
- })
453
-
454
- it('clears the zombie timer when the job exits normally', async () => {
455
- vi.useFakeTimers()
456
- vi.mocked(treeKill).mockClear()
457
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
458
- const child = createMockChildProcess()
459
- vi.mocked(mockSpawn).mockReturnValue(child as any)
460
- vi.mocked(mockUuidV4).mockReturnValue('job-clean' as any)
461
-
462
- const qmClean = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 30_000 })
463
- qmClean.enqueue('/implement #1')
464
-
465
- // Job exits normally before timeout
466
- child.emit('close', 0)
467
-
468
- // Advance past timeout — timer should have been cleared, no SIGTERM
469
- vi.advanceTimersByTime(40_000)
470
-
471
- expect(vi.mocked(treeKill)).not.toHaveBeenCalled()
472
-
473
- vi.clearAllTimers()
474
- vi.useRealTimers()
475
- })
476
-
477
- it('clears the zombie timer when the job is cancelled', () => {
478
- vi.useFakeTimers()
479
- vi.mocked(treeKill).mockClear()
480
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
481
- const child = createMockChildProcess()
482
- vi.mocked(mockSpawn).mockReturnValue(child as any)
483
- vi.mocked(mockUuidV4).mockReturnValue('job-cancel' as any)
484
-
485
- const qmCancel = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 30_000 })
486
- qmCancel.enqueue('/implement #1')
487
-
488
- // Cancel explicitly before zombie timeout fires
489
- vi.mocked(treeKill).mockClear()
490
- qmCancel.cancel('job-cancel')
491
-
492
- // The cancel itself sends SIGTERM
493
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
494
-
495
- // Advance well past the zombie timeout — kill timer (5s) will fire SIGKILL,
496
- // but the zombie timer (30s) should have been cleared by cancel
497
- vi.advanceTimersByTime(40_000)
498
-
499
- // Only SIGTERM (from cancel) and SIGKILL (from kill timer) — no additional SIGTERM from zombie
500
- const sigtermCalls = vi.mocked(treeKill).mock.calls.filter((c) => c[1] === 'SIGTERM')
501
- expect(sigtermCalls.length).toBe(1)
502
-
503
- vi.clearAllTimers()
504
- vi.useRealTimers()
505
- })
506
-
507
- it('does not auto-terminate when zombieTimeoutMs is 0', () => {
508
- vi.useFakeTimers()
509
- vi.mocked(treeKill).mockClear()
510
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
511
- const child = createMockChildProcess()
512
- vi.mocked(mockSpawn).mockReturnValue(child as any)
513
- vi.mocked(mockUuidV4).mockReturnValue('job-no-zombie' as any)
514
-
515
- const qmNoZombie = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 0 })
516
- qmNoZombie.enqueue('/implement #1')
517
-
518
- // Advance far past any threshold
519
- vi.advanceTimersByTime(600_000)
520
-
521
- expect(vi.mocked(treeKill)).not.toHaveBeenCalled()
522
-
523
- vi.clearAllTimers()
524
- vi.useRealTimers()
525
- })
526
-
527
- it('emits a zombie-detection log line to stderr when triggered', () => {
528
- vi.useFakeTimers()
529
- vi.mocked(treeKill).mockClear()
530
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
531
- const child = createMockChildProcess()
532
- vi.mocked(mockSpawn).mockReturnValue(child as any)
533
- vi.mocked(mockUuidV4).mockReturnValue('job-log' as any)
534
-
535
- const broadcasts: WsMessage[] = []
536
- const qmLog = new QueueManager((msg) => broadcasts.push(msg), undefined, { zombieTimeoutMs: 10_000 })
537
- qmLog.enqueue('/implement #1')
538
-
539
- vi.advanceTimersByTime(10_100)
540
-
541
- const logMessages = broadcasts.filter(
542
- (m): m is import('./types').LogMessage =>
543
- m.type === 'log' && 'line' in m && (m as any).line.includes('zombie-detection')
544
- )
545
- expect(logMessages.length).toBeGreaterThanOrEqual(1)
546
- expect(logMessages[0].source).toBe('stderr')
547
- expect(logMessages[0].processId).toBe('job-log')
548
-
549
- vi.clearAllTimers()
550
- vi.useRealTimers()
551
- })
552
-
553
- it('reads WM_ZOMBIE_TIMEOUT_MS from the environment', () => {
554
- vi.useFakeTimers()
555
- vi.mocked(treeKill).mockClear()
556
- process.env.WM_ZOMBIE_TIMEOUT_MS = '5000'
557
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
558
- const child = createMockChildProcess()
559
- vi.mocked(mockSpawn).mockReturnValue(child as any)
560
- vi.mocked(mockUuidV4).mockReturnValue('job-env' as any)
561
-
562
- const qmEnv = new QueueManager(broadcast)
563
- qmEnv.enqueue('/implement #1')
564
-
565
- vi.advanceTimersByTime(5_100)
566
-
567
- expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
568
-
569
- delete process.env.WM_ZOMBIE_TIMEOUT_MS
570
- vi.clearAllTimers()
571
- vi.useRealTimers()
572
- })
573
-
574
- it('drains the queue after zombie cleanup', async () => {
575
- vi.useFakeTimers()
576
- vi.mocked(treeKill).mockClear()
577
- vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
578
- const child1 = createMockChildProcess()
579
- const child2 = createMockChildProcess()
580
- vi.mocked(mockSpawn)
581
- .mockReturnValueOnce(child1 as any)
582
- .mockReturnValueOnce(child2 as any)
583
- vi.mocked(mockUuidV4)
584
- .mockReturnValueOnce('job-zombie-drain' as any)
585
- .mockReturnValueOnce('job-next' as any)
586
-
587
- const qmDrain = new QueueManager(broadcast, undefined, { zombieTimeoutMs: 10_000 })
588
- qmDrain.enqueue('/implement #1')
589
- qmDrain.enqueue('/implement #2')
590
-
591
- expect(qmDrain.getActiveJobId()).toBe('job-zombie-drain')
592
-
593
- // Trigger zombie detection
594
- vi.advanceTimersByTime(10_100)
595
- // Simulate the process exiting after SIGTERM
596
- child1.emit('close', null)
597
-
598
- // Let async handlers settle
599
- await Promise.resolve()
600
-
601
- expect(qmDrain.getActiveJobId()).toBe('job-next')
602
-
603
- vi.clearAllTimers()
604
- vi.useRealTimers()
605
- })
606
- })
607
- })