@worca/ui 0.21.0 → 0.23.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.
@@ -16,7 +16,7 @@
16
16
  * the discrepancy with `du -sh`.
17
17
  */
18
18
 
19
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import * as fsp from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { Router } from 'express';
@@ -192,11 +192,95 @@ function _readWorktreeStatus(worktreePath) {
192
192
  return null;
193
193
  }
194
194
 
195
+ /**
196
+ * Run a pre-validated cleanup batch in the background. Each task stamps
197
+ * `cleanup_state: 'cleaning'`, calls `removeWorktree` (which deletes the
198
+ * registry entry on success), and on failure stamps `cleanup_error` while
199
+ * clearing `cleanup_state` so the UI can render the error and let the user
200
+ * retry. Concurrency is bounded by `CLEANUP_CONCURRENCY`.
201
+ */
202
+ async function _runCleanupBatch(worcaDir, accepted) {
203
+ const tasks = accepted.map(({ run_id, reg }) => ({
204
+ run_id,
205
+ fn: async () => {
206
+ _patchRegistry(worcaDir, run_id, { cleanup_state: 'cleaning' });
207
+ try {
208
+ await removeWorktree(worcaDir, run_id, { skipPrune: true });
209
+ if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
210
+ return { run_id, ok: true };
211
+ } catch (err) {
212
+ _patchRegistry(worcaDir, run_id, {
213
+ cleanup_state: undefined,
214
+ cleanup_error: err?.message || String(err),
215
+ });
216
+ return { run_id, ok: false, error: err?.message || String(err) };
217
+ }
218
+ },
219
+ }));
220
+
221
+ try {
222
+ await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
223
+ } catch {
224
+ /* per-task failures already persisted into the registry */
225
+ }
226
+
227
+ try {
228
+ await pruneWorktrees(worcaDir);
229
+ } catch {
230
+ /* non-fatal */
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Atomically patch fields on a pipelines.d/<run>.json entry.
236
+ * Set a field to `undefined` to delete it. Returns `false` if the file is
237
+ * gone (the worktree was already cleaned up) or unreadable.
238
+ *
239
+ * Note: write is not strictly atomic — for a single-writer-per-id model
240
+ * (the cleanup background task owns its registry entry for the lifetime
241
+ * of the cleanup), read-modify-write is fine. A multi-writer scenario
242
+ * would need rename-into-place; we don't have that here.
243
+ */
244
+ function _patchRegistry(worcaDir, runId, patch) {
245
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
246
+ if (!existsSync(regFile)) return false;
247
+ let reg;
248
+ try {
249
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
250
+ } catch {
251
+ return false;
252
+ }
253
+ for (const [k, v] of Object.entries(patch)) {
254
+ if (v === undefined) delete reg[k];
255
+ else reg[k] = v;
256
+ }
257
+ try {
258
+ writeFileSync(regFile, JSON.stringify(reg, null, 2), 'utf8');
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ function _isPidAlive(pid) {
266
+ if (!pid || typeof pid !== 'number') return false;
267
+ try {
268
+ process.kill(pid, 0);
269
+ return true;
270
+ } catch (err) {
271
+ if (err.code === 'EPERM') return true;
272
+ return false;
273
+ }
274
+ }
275
+
195
276
  async function _listWorktrees(worcaDir) {
196
277
  const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
197
278
  if (!existsSync(pipelinesDir)) return [];
198
279
 
199
- const entries = [];
280
+ // Phase 1: cheap synchronous metadata (registry parse, status read).
281
+ // Phase 2: disk walks in parallel — without this, 13 worktrees serialize
282
+ // ~3s of awaits even when most results would have been disk-cache hits.
283
+ const metas = [];
200
284
  for (const file of readdirSync(pipelinesDir)) {
201
285
  if (!file.endsWith('.json')) continue;
202
286
 
@@ -211,13 +295,35 @@ async function _listWorktrees(worcaDir) {
211
295
  const worktreePath = reg.worktree_path;
212
296
  const worktreeExists = existsSync(worktreePath);
213
297
 
214
- // Prefer actual status.json; fall back to registry field
215
298
  let status = reg.status || 'unknown';
216
299
  if (worktreeExists) {
217
300
  const actual = _readWorktreeStatus(worktreePath);
218
301
  if (actual) status = actual;
219
302
  }
220
303
 
304
+ // Stale-registry reconciliation: a child can die before ever writing
305
+ // status.json (e.g. fleet halt right after dispatch, preflight crash,
306
+ // SIGKILL). In that case the worktree exists but .worca/runs/ doesn't,
307
+ // _readWorktreeStatus returns null, and we'd fall back to reg.status
308
+ // which may still say "running" with a dead pid. Treat that as
309
+ // "interrupted" and patch the registry so this only happens once.
310
+ //
311
+ // Only reconcile when reg.pid is present — a missing pid means the
312
+ // entry is either from a non-standard registration path (e.g. test
313
+ // fixtures) or pre-dates the pid-on-registration contract, so we
314
+ // can't make liveness claims about it.
315
+ if (
316
+ status === 'running' &&
317
+ typeof reg.pid === 'number' &&
318
+ !_isPidAlive(reg.pid)
319
+ ) {
320
+ status = 'interrupted';
321
+ _patchRegistry(worcaDir, reg.run_id, {
322
+ status: 'interrupted',
323
+ interrupted_reason: 'stale_pid',
324
+ });
325
+ }
326
+
221
327
  let ageSeconds = 0;
222
328
  if (reg.started_at) {
223
329
  const started = new Date(reg.started_at).getTime();
@@ -226,32 +332,44 @@ async function _listWorktrees(worcaDir) {
226
332
  }
227
333
  }
228
334
 
229
- let diskInfo = { bytes: 0, truncated: false };
230
- if (worktreeExists) {
231
- diskInfo = await _getDiskBytes(worktreePath);
232
- }
233
-
234
- entries.push({
235
- run_id: reg.run_id || '',
236
- title: reg.title || '',
237
- branch: reg.branch || '',
238
- worktree_path: worktreePath,
239
- disk_bytes: diskInfo.bytes,
240
- truncated: diskInfo.truncated,
241
- age_seconds: ageSeconds,
242
- // started_at lets the client sort with the same sortByStartDesc helper
243
- // used by run-list, keeping ordering consistent across views.
244
- started_at: reg.started_at || null,
335
+ metas.push({
336
+ reg,
337
+ worktreePath,
338
+ worktreeExists,
245
339
  status,
246
- removable: status !== 'running',
247
- fleet_id: reg.fleet_id || null,
248
- workspace_id: reg.workspace_id || null,
249
- group_type: reg.group_type || null,
250
- group_status: null, // populated by W-040 / W-047
251
- resumable: RESUMABLE_STATUSES.has(status),
340
+ ageSeconds,
341
+ cleanup_state: reg.cleanup_state || null,
342
+ cleanup_error: reg.cleanup_error || null,
252
343
  });
253
344
  }
254
- return entries;
345
+
346
+ const disks = await Promise.all(
347
+ metas.map((m) =>
348
+ m.worktreeExists
349
+ ? _getDiskBytes(m.worktreePath)
350
+ : Promise.resolve({ bytes: 0, truncated: false }),
351
+ ),
352
+ );
353
+
354
+ return metas.map((m, i) => ({
355
+ run_id: m.reg.run_id || '',
356
+ title: m.reg.title || '',
357
+ branch: m.reg.branch || '',
358
+ worktree_path: m.worktreePath,
359
+ disk_bytes: disks[i].bytes,
360
+ truncated: disks[i].truncated,
361
+ age_seconds: m.ageSeconds,
362
+ started_at: m.reg.started_at || null,
363
+ status: m.status,
364
+ removable: m.status !== 'running',
365
+ fleet_id: m.reg.fleet_id || null,
366
+ workspace_id: m.reg.workspace_id || null,
367
+ group_type: m.reg.group_type || null,
368
+ group_status: null,
369
+ resumable: RESUMABLE_STATUSES.has(m.status),
370
+ cleanup_state: m.cleanup_state,
371
+ cleanup_error: m.cleanup_error,
372
+ }));
255
373
  }
256
374
 
257
375
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -367,13 +485,19 @@ export function createWorktreesRouter() {
367
485
 
368
486
  // POST /worktrees/cleanup
369
487
  //
370
- // Batch worktree removal. Always responds with HTTP 200 and a JSON body of
371
- // shape `{ ok, results, failed_count }`, where `ok` is the AND of per-id
372
- // outcomes and `failed_count` is the number of entries with `ok: false`.
373
- // Per-entry errors carry a `code` field (`running`, `resumable_or_grouped`)
374
- // when actionable. Clients must inspect `results[]` a single bad id never
375
- // aborts the batch, and partial failures are not signalled via HTTP status.
376
- router.post('/cleanup', async (req, res) => {
488
+ // Batch worktree removal — async. Synchronously validates each id and
489
+ // stamps `cleanup_state: 'pending'` on the registry entries that pass
490
+ // pre-flight checks, then returns 202. The actual removal happens in
491
+ // the background with bounded concurrency. Clients poll GET /worktrees
492
+ // and observe `cleanup_state` per entry; on success the entry vanishes,
493
+ // on failure `cleanup_error` is set and `cleanup_state` is cleared.
494
+ //
495
+ // Response shape `{ ok, accepted, rejected }` where `rejected[]` carries
496
+ // entries that failed pre-flight (running, resumable without force, etc).
497
+ // A single bad id never blocks the rest of the batch; this stays
498
+ // compatible with the legacy synchronous shape's promise that partial
499
+ // failures are not signalled via HTTP status.
500
+ router.post('/cleanup', (req, res) => {
377
501
  const worcaDir = req.project?.worcaDir;
378
502
  if (!worcaDir) {
379
503
  return res
@@ -395,86 +519,82 @@ export function createWorktreesRouter() {
395
519
  }
396
520
  }
397
521
 
398
- const tasks = run_ids.map((run_id) => ({
399
- run_id,
400
- fn: async () => {
401
- const regFile = join(
402
- worcaDir,
403
- 'multi',
404
- 'pipelines.d',
405
- `${run_id}.json`,
406
- );
407
- if (!existsSync(regFile)) {
408
- return {
409
- run_id,
410
- ok: false,
411
- error: `Worktree "${run_id}" not found`,
412
- };
413
- }
414
-
415
- let reg;
416
- try {
417
- reg = JSON.parse(readFileSync(regFile, 'utf8'));
418
- } catch {
419
- return {
420
- run_id,
421
- ok: false,
422
- error: 'Failed to read registry entry',
423
- };
424
- }
425
-
426
- let status = reg.status || 'unknown';
427
- if (reg.worktree_path && existsSync(reg.worktree_path)) {
428
- const actual = _readWorktreeStatus(reg.worktree_path);
429
- if (actual) status = actual;
430
- }
522
+ // Pre-flight: read each registry entry, decide pending vs reject. We do
523
+ // this synchronously so the HTTP response can carry the rejection list
524
+ // clients shouldn't have to poll to learn that a 'running' worktree
525
+ // was refused.
526
+ const accepted = [];
527
+ const rejected = [];
528
+ for (const run_id of run_ids) {
529
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
530
+ if (!existsSync(regFile)) {
531
+ rejected.push({
532
+ run_id,
533
+ ok: false,
534
+ error: `Worktree "${run_id}" not found`,
535
+ });
536
+ continue;
537
+ }
538
+ let reg;
539
+ try {
540
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
541
+ } catch {
542
+ rejected.push({
543
+ run_id,
544
+ ok: false,
545
+ error: 'Failed to read registry entry',
546
+ });
547
+ continue;
548
+ }
431
549
 
432
- if (status === 'running') {
433
- return {
434
- run_id,
435
- ok: false,
436
- error: 'Cannot remove a running worktree',
437
- code: 'running',
438
- };
439
- }
550
+ let status = reg.status || 'unknown';
551
+ if (reg.worktree_path && existsSync(reg.worktree_path)) {
552
+ const actual = _readWorktreeStatus(reg.worktree_path);
553
+ if (actual) status = actual;
554
+ }
440
555
 
441
- const isResumable = RESUMABLE_STATUSES.has(status);
442
- const isGrouped = !!(reg.fleet_id || reg.workspace_id);
443
- if (!force && (isResumable || isGrouped)) {
444
- return {
445
- run_id,
446
- ok: false,
447
- error:
448
- 'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
449
- code: 'resumable_or_grouped',
450
- };
451
- }
556
+ if (status === 'running') {
557
+ rejected.push({
558
+ run_id,
559
+ ok: false,
560
+ error: 'Cannot remove a running worktree',
561
+ code: 'running',
562
+ });
563
+ continue;
564
+ }
452
565
 
453
- try {
454
- await removeWorktree(worcaDir, run_id, { skipPrune: true });
455
- if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
456
- return { run_id, ok: true };
457
- } catch (err) {
458
- return { run_id, ok: false, error: err.message };
459
- }
460
- },
461
- }));
566
+ const isResumable = RESUMABLE_STATUSES.has(status);
567
+ const isGrouped = !!(reg.fleet_id || reg.workspace_id);
568
+ if (!force && (isResumable || isGrouped)) {
569
+ rejected.push({
570
+ run_id,
571
+ ok: false,
572
+ error:
573
+ 'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
574
+ code: 'resumable_or_grouped',
575
+ });
576
+ continue;
577
+ }
462
578
 
463
- let results;
464
- try {
465
- results = await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
466
- } catch (err) {
467
- return res.status(500).json({ ok: false, error: err.message });
579
+ // Stamp pending so a reload mid-cleanup shows the same state.
580
+ _patchRegistry(worcaDir, run_id, {
581
+ cleanup_state: 'pending',
582
+ cleanup_error: undefined,
583
+ });
584
+ accepted.push({ run_id, reg });
468
585
  }
469
586
 
470
- try {
471
- await pruneWorktrees(worcaDir);
472
- } catch {
473
- /* non-fatal */
474
- }
587
+ // Respond immediately — the client polls GET /worktrees to observe progress.
588
+ res.status(202).json({
589
+ ok: rejected.length === 0,
590
+ accepted: accepted.map((a) => a.run_id),
591
+ rejected,
592
+ });
475
593
 
476
- const failed_count = results.reduce((n, r) => (r.ok ? n : n + 1), 0);
477
- res.json({ ok: failed_count === 0, failed_count, results });
594
+ // Fire-and-forget background removal. Errors are persisted into the
595
+ // registry so the client can render them; nothing here is awaited by
596
+ // the HTTP request.
597
+ void _runCleanupBatch(worcaDir, accepted);
478
598
  });
479
599
 
480
600
  return router;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Fleet manifest watcher — monitors ~/.worca/fleet-runs/<fleet_id>.json for changes.
3
+ * Emits fleet-update WS events when a fleet manifest is written (§13.5).
4
+ */
5
+
6
+ import { existsSync, readFileSync, watch } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { effectiveFleetStatus } from './fleet-routes.js';
10
+
11
+ const FLEET_DEBOUNCE_MS = 200;
12
+ const DEFAULT_FLEET_RUNS_DIR = join(homedir(), '.worca', 'fleet-runs');
13
+
14
+ const FAILURE_STATES = new Set(['failed', 'setup_failed', 'unrecoverable']);
15
+
16
+ function readJson(path) {
17
+ try {
18
+ return JSON.parse(readFileSync(path, 'utf8'));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function resolveChildStatus(child) {
25
+ const { project_path, run_id } = child;
26
+ if (!project_path || !run_id) return 'running';
27
+ const registryPath = join(
28
+ project_path,
29
+ '.worca',
30
+ 'multi',
31
+ 'pipelines.d',
32
+ `${run_id}.json`,
33
+ );
34
+ const entry = readJson(registryPath);
35
+ return entry?.status ?? 'running';
36
+ }
37
+
38
+ /**
39
+ * @param {{ broadcaster: { broadcast: Function }, fleetRunsDir?: string }} deps
40
+ */
41
+ export function createFleetManifestWatcher({
42
+ broadcaster,
43
+ fleetRunsDir = DEFAULT_FLEET_RUNS_DIR,
44
+ }) {
45
+ let fsWatcher = null;
46
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
47
+ const debounceTimers = new Map();
48
+
49
+ function broadcastFleetUpdate(fleetId, manifestPath) {
50
+ const manifest = readJson(manifestPath);
51
+ if (!manifest) return;
52
+
53
+ const rawChildren = Array.isArray(manifest.children)
54
+ ? manifest.children
55
+ : [];
56
+ const children = rawChildren.map((child) => ({
57
+ run_id: child.run_id,
58
+ project_path: child.project_path,
59
+ status: resolveChildStatus(child),
60
+ }));
61
+
62
+ const completed_children = children.filter(
63
+ (c) => c.status === 'completed',
64
+ ).length;
65
+ const failed_children = children.filter((c) =>
66
+ FAILURE_STATES.has(c.status),
67
+ ).length;
68
+
69
+ // Derive the effective status (same rules as REST) instead of broadcasting
70
+ // raw manifest.status — otherwise cards stay "running" forever, because
71
+ // run_fleet.py never writes a terminal status after it exits. Pure
72
+ // function: persists nothing, so we don't trigger a watch→write→watch loop.
73
+ const { status, halt_reason } = effectiveFleetStatus(
74
+ manifest,
75
+ children.map((c) => c.status),
76
+ );
77
+
78
+ broadcaster.broadcast('fleet-update', {
79
+ fleet_id: fleetId,
80
+ status,
81
+ halt_reason,
82
+ completed_children,
83
+ failed_children,
84
+ children,
85
+ });
86
+ }
87
+
88
+ function scheduleUpdate(fleetId, manifestPath) {
89
+ const existing = debounceTimers.get(fleetId);
90
+ if (existing) clearTimeout(existing);
91
+ debounceTimers.set(
92
+ fleetId,
93
+ setTimeout(() => {
94
+ debounceTimers.delete(fleetId);
95
+ broadcastFleetUpdate(fleetId, manifestPath);
96
+ }, FLEET_DEBOUNCE_MS),
97
+ );
98
+ }
99
+
100
+ try {
101
+ if (existsSync(fleetRunsDir)) {
102
+ fsWatcher = watch(
103
+ fleetRunsDir,
104
+ { persistent: false },
105
+ (_event, filename) => {
106
+ if (!filename?.endsWith('.json')) return;
107
+ const fleetId = filename.slice(0, -5);
108
+ scheduleUpdate(fleetId, join(fleetRunsDir, filename));
109
+ },
110
+ );
111
+ }
112
+ } catch {
113
+ // fs.watch unsupported or dir unavailable — skip silently
114
+ }
115
+
116
+ function destroy() {
117
+ if (fsWatcher) {
118
+ try {
119
+ fsWatcher.close();
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ fsWatcher = null;
124
+ }
125
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
126
+ debounceTimers.clear();
127
+ }
128
+
129
+ return { destroy };
130
+ }
@@ -80,6 +80,22 @@ export function createMessageRouter({
80
80
  };
81
81
  }
82
82
 
83
+ // When a run-scoped subscribe arrives with a `payload.projectId` that
84
+ // differs from the client's currently-bound subs.projectId, re-bind the
85
+ // WS to that project. Without this, the targeted project's WatcherSet
86
+ // stays in POLLING tier (no logWatcher / no live updates), and the
87
+ // backfill / live stream silently never arrive — symptoms reported by
88
+ // the user as "no logs / no agent prompts" on the run-detail page in
89
+ // global mode. We keep the previous projectId reference so demotion
90
+ // still happens via the normal client-count mechanism.
91
+ function _adoptProjectFromPayload(ws, payload) {
92
+ const requested = payload?.projectId;
93
+ if (!requested || !watcherSets.has(requested)) return;
94
+ const subs = clientManager.getSubs(ws);
95
+ if (subs?.projectId === requested) return;
96
+ clientManager.setProtocol(ws, subs?.protocolVersion ?? 1, requested);
97
+ }
98
+
83
99
  async function handleMessage(ws, data) {
84
100
  let json;
85
101
  try {
@@ -147,6 +163,7 @@ export function createMessageRouter({
147
163
  );
148
164
  return;
149
165
  }
166
+ _adoptProjectFromPayload(ws, req.payload);
150
167
  const proj = resolveProject(ws, req.payload);
151
168
  const runs = discoverRuns(proj.worcaDir);
152
169
  const run = runs.find((r) => r.id === runId);
@@ -294,6 +311,7 @@ export function createMessageRouter({
294
311
  );
295
312
  return;
296
313
  }
314
+ _adoptProjectFromPayload(ws, req.payload);
297
315
  const proj = resolveProject(ws, req.payload);
298
316
  if (!proj) {
299
317
  ws.send(
@@ -336,6 +354,7 @@ export function createMessageRouter({
336
354
  // subscribe-log
337
355
  if (req.type === 'subscribe-log') {
338
356
  const { stage, runId, iteration } = req.payload || {};
357
+ _adoptProjectFromPayload(ws, req.payload);
339
358
  const proj = resolveProject(ws, req.payload);
340
359
  const s = clientManager.ensureSubs(ws);
341
360
  s.logStage = stage || '*';
@@ -652,6 +671,7 @@ export function createMessageRouter({
652
671
  );
653
672
  return;
654
673
  }
674
+ _adoptProjectFromPayload(ws, req.payload);
655
675
  const proj = resolveProject(ws, req.payload);
656
676
  if (!proj.wset.beadsWatcher) {
657
677
  ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { existsSync, watch } from 'node:fs';
10
+ import { homedir } from 'node:os';
10
11
  import { join } from 'node:path';
11
12
  import { WebSocketServer } from 'ws';
12
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
@@ -14,6 +15,7 @@ import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
14
15
  import { readProjectWorcaVersion } from './worca-setup.js';
15
16
  import { createBroadcaster } from './ws-broadcaster.js';
16
17
  import { createClientManager } from './ws-client-manager.js';
18
+ import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
17
19
  import { createMessageRouter } from './ws-message-router.js';
18
20
  import { resolveLatestRunDir } from './ws-status-watcher.js';
19
21
 
@@ -45,7 +47,13 @@ export function attachWsServer(httpServer, config) {
45
47
  getSubs: clientManager.getSubs,
46
48
  });
47
49
 
48
- // 3. Create WatcherSet(s)one per project
50
+ // 3a. Fleet manifest watcher global, not per-project (§13.5)
51
+ const fleetManifestWatcher = createFleetManifestWatcher({
52
+ broadcaster,
53
+ fleetRunsDir: join(homedir(), '.worca', 'fleet-runs'),
54
+ });
55
+
56
+ // 3b. Create WatcherSet(s) — one per project
49
57
  /** @type {Map<string, WatcherSet>} */
50
58
  const watcherSets = new Map();
51
59
 
@@ -269,6 +277,7 @@ export function attachWsServer(httpServer, config) {
269
277
 
270
278
  wss.on('close', () => {
271
279
  clientManager.destroy();
280
+ fleetManifestWatcher.destroy();
272
281
  if (dirWatcher) {
273
282
  try {
274
283
  dirWatcher.close();