claude-code-session-manager 0.84.0 → 0.86.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 (61) hide show
  1. package/dist/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-DTFL7y8G.js} +1 -1
  2. package/dist/assets/{DataModel-BltREYde.js → DataModel-Q4jhl24R.js} +1 -1
  3. package/dist/assets/{History-BZxFkOp6.js → History-Cj2FejEo.js} +1 -1
  4. package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-CaelQI6t.js} +1 -1
  5. package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko--v7cMR8I.js} +1 -1
  6. package/dist/assets/{Library-Bi2Fn3w9.js → Library-DgI9oCCZ.js} +1 -1
  7. package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-DYUZN-x-.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-DCIubYWf.js} +1 -1
  9. package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ypCYURh3.js} +1 -1
  10. package/dist/assets/{Memory-Dkl80uj_.js → Memory-C2qYp-3M.js} +1 -1
  11. package/dist/assets/{Panel-LurG5VfD.js → Panel-Cj2kw-Zv.js} +1 -1
  12. package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-BiYZNGYW.js} +1 -1
  13. package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-C1Vj8_dU.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DczPNM5U.js} +1 -1
  15. package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-Cd_7U6Gb.js} +1 -1
  16. package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-DcLBiJBq.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-DVSyI44-.js} +1 -1
  18. package/dist/assets/{Settings-B6H3v2am.js → Settings-Cv-pRyms.js} +3 -3
  19. package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-CUv1_Q2c.js} +1 -1
  20. package/dist/assets/{Skills-DwuHuA08.js → Skills-C_YHkAy-.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-B8R7T9xn.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-dj9YHWyy.js} +1 -1
  23. package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-DnSBUjHE.js} +1 -1
  24. package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-CjV_BJn6.js} +1 -1
  25. package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
  26. package/dist/assets/{index-QLRf0epp.js → index-CXFQIPhO.js} +3 -3
  27. package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-BJVciriw.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +3 -2
  30. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  31. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  32. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  33. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
  34. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  35. package/src/main/__tests__/queue-starvation-per-project.test.cjs +135 -0
  36. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  37. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  38. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  39. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  40. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +144 -0
  41. package/src/main/__tests__/telemetryClient.test.cjs +250 -5
  42. package/src/main/__tests__/telemetryContract.test.cjs +74 -27
  43. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  44. package/src/main/health.cjs +75 -2
  45. package/src/main/index.cjs +2 -2
  46. package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
  47. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  48. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
  49. package/src/main/lib/__tests__/telemetryBoot.test.cjs +22 -13
  50. package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
  51. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
  52. package/src/main/lib/loadGate.cjs +23 -1
  53. package/src/main/lib/machineProfile.cjs +15 -0
  54. package/src/main/lib/queueStore.cjs +8 -2
  55. package/src/main/lib/schedulerBatch.cjs +12 -1
  56. package/src/main/lib/schedulerConfig.cjs +13 -0
  57. package/src/main/lib/telemetryBoot.cjs +18 -10
  58. package/src/main/lib/telemetryClient.cjs +162 -6
  59. package/src/main/lib/telemetrySettings.cjs +20 -3
  60. package/src/main/pty.cjs +9 -0
  61. package/src/main/scheduler.cjs +600 -66
@@ -78,8 +78,9 @@ async function createStubServer() {
78
78
  events: [], // { event, tool, metadata, session_id, visitor_id, path, version }
79
79
  logs: [], // { app, version, level, msg, visitor_id, session_id, fields_json, created_at }
80
80
  errors: [], // { app, version, name, msg, stack, url, ua, visitor_id, session_id, context_json, created_at }
81
+ installsById: new Map(), // install_id -> materialised app_installs row (upsert, mirrors the deployed route)
81
82
  allRequests: [], // { path, headers, rawBody, parsedBody, channel }
82
- overrides: { event: [], log: [], error: [] },
83
+ overrides: { event: [], log: [], error: [], install: [] },
83
84
  };
84
85
 
85
86
  function nextOverride(channel) {
@@ -95,6 +96,7 @@ async function createStubServer() {
95
96
  const channel = urlPath.endsWith('/event') ? 'event'
96
97
  : urlPath.endsWith('/log') ? 'log'
97
98
  : urlPath.endsWith('/error') ? 'error'
99
+ : urlPath.endsWith('/install') ? 'install'
98
100
  : null;
99
101
  state.allRequests.push({ path: urlPath, headers: req.headers, rawBody, parsedBody, channel });
100
102
 
@@ -111,6 +113,41 @@ async function createStubServer() {
111
113
  return;
112
114
  }
113
115
 
116
+ if (channel === 'install') {
117
+ // Flat body (never {batch:[...]}) — mirrors the real route's upsert
118
+ // keyed on install_id: first_seen_at preserved, everything else overwritten.
119
+ const b = parsedBody;
120
+ const installId = clamp(b.install_id, 80);
121
+ if (!installId) {
122
+ res.writeHead(400, { 'content-type': 'application/json' });
123
+ res.end(JSON.stringify({ error: 'install_id_required' }));
124
+ return;
125
+ }
126
+ const now = Math.floor(Date.now() / 1000);
127
+ const existing = state.installsById.get(installId);
128
+ state.installsById.set(installId, {
129
+ install_id: installId,
130
+ app: clamp(b.app, 60),
131
+ app_version: clamp(b.app_version, 20),
132
+ platform: clamp(b.platform, 20),
133
+ os_release: clamp(b.os_release, 80),
134
+ arch: clamp(b.arch, 20),
135
+ cpu_count: Number(b.cpu_count) || 0,
136
+ total_mem_mb: Number(b.total_mem_mb) || 0,
137
+ node_version: clamp(b.node_version, 20),
138
+ electron_version: clamp(b.electron_version, 20),
139
+ install_channel: clamp(b.install_channel, 20),
140
+ locale: clamp(b.locale, 20),
141
+ timezone: clamp(b.timezone, 20),
142
+ first_seen_at: existing ? existing.first_seen_at : now,
143
+ last_seen_at: now,
144
+ seen_count: existing ? existing.seen_count + 1 : 1,
145
+ });
146
+ res.writeHead(200, { 'content-type': 'application/json' });
147
+ res.end(JSON.stringify({ ok: true }));
148
+ return;
149
+ }
150
+
114
151
  const batchRaw = Array.isArray(parsedBody.batch) ? parsedBody.batch : [];
115
152
  const batch = batchRaw.slice(0, MAX_BATCH); // MAX_BATCH truncation, exactly as the deployed route does
116
153
 
@@ -228,6 +265,7 @@ let originalHome;
228
265
  let originalSmTelemetry;
229
266
  let originalSmTelemetryEndpoint;
230
267
  let originalSmBeaconKey;
268
+ let originalSmTelemetrySpool;
231
269
  let stubServer;
232
270
 
233
271
  beforeAll(async () => {
@@ -247,6 +285,7 @@ beforeEach(() => {
247
285
  originalSmTelemetry = process.env.SM_TELEMETRY;
248
286
  originalSmTelemetryEndpoint = process.env.SM_TELEMETRY_ENDPOINT;
249
287
  originalSmBeaconKey = process.env.SM_BEACON_KEY;
288
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
250
289
  process.env.SM_TELEMETRY_ENDPOINT = stubServer.url;
251
290
  delete process.env.SM_TELEMETRY;
252
291
  });
@@ -256,6 +295,7 @@ afterEach(async () => {
256
295
  if (originalSmTelemetry === undefined) delete process.env.SM_TELEMETRY; else process.env.SM_TELEMETRY = originalSmTelemetry;
257
296
  if (originalSmTelemetryEndpoint === undefined) delete process.env.SM_TELEMETRY_ENDPOINT; else process.env.SM_TELEMETRY_ENDPOINT = originalSmTelemetryEndpoint;
258
297
  if (originalSmBeaconKey === undefined) delete process.env.SM_BEACON_KEY; else process.env.SM_BEACON_KEY = originalSmBeaconKey;
298
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
259
299
  vi.useRealTimers();
260
300
  while (tmpDirs.length) {
261
301
  const d = tmpDirs.pop();
@@ -306,6 +346,9 @@ const HOME_DEPENDENT_MODULES = ['../lib/telemetryClient.cjs', '../config.cjs', '
306
346
  /** A fresh telemetryClient bound to `home`, simulating a real relaunch: only on-disk state carries over. */
307
347
  function freshClient(home, profileOverrides) {
308
348
  process.env.HOME = home;
349
+ // Explicit opt-in: overrides the test-environment no-op guard so this
350
+ // suite's real telemetryClient calls actually persist into an isolated dir.
351
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
309
352
  for (const p of HOME_DEPENDENT_MODULES) {
310
353
  const resolved = require.resolve(p);
311
354
  delete require.cache[resolved];
@@ -490,30 +533,30 @@ test('an emitted event, log, and error each map to non-NULL values for every col
490
533
  // AC: machine-profile record survives redaction + clamping intact
491
534
  // ═══════════════════════════════════════════════════════════════════════
492
535
 
493
- test('the machine-profile record arrives with every buildMachineProfile() field present after redaction/clamping, cpuModel verbatim', async () => {
536
+ test('the install record arrives at app_installs with every mapped field present, snake_case, cpu_count/total_mem_mb numeric', async () => {
494
537
  const home = await mkHome();
495
538
  const client = freshClient(home);
496
539
  const profile = fakeProfile();
497
- const before = stubServer.state.events.length;
498
540
 
499
- await client.track('install.machine', profile);
541
+ await client.reportInstall(profile);
500
542
  await client.flush('manual');
501
543
 
502
- const row = stubServer.state.events[before];
503
- expect(row.event).toBe('install.machine');
504
- const meta = JSON.parse(row.metadata);
505
-
506
- const expectedFields = [
507
- 'appVersion', 'installChannel', 'machineDigest', 'platform', 'osRelease', 'arch',
508
- 'cpuModel', 'cpuCount', 'cpuSpeedMhz', 'totalMemMb', 'nodeVersion', 'electronVersion',
509
- 'chromeVersion', 'v8Version', 'claudeCliVersion', 'locale', 'timezoneOffsetMinutes', 'firstSeenAt',
510
- ];
511
- for (const f of expectedFields) {
512
- expect(meta[f]).not.toBeUndefined();
513
- expect(meta[f]).not.toBeNull();
514
- }
515
- // The over-eager-sanitiser casualty: a CPU model string containing spaces and '@'.
516
- expect(meta.cpuModel).toBe('Intel(R) Xeon(R) CPU @ 2.20GHz');
544
+ const settings = require('../lib/telemetrySettings.cjs');
545
+ const persisted = await settings.load();
546
+ const row = stubServer.state.installsById.get(persisted.installId);
547
+ expect(row).toBeDefined();
548
+ expect(row.app).toBe('session-manager');
549
+ expect(row.app_version).toBe(profile.appVersion);
550
+ expect(row.platform).toBe(profile.platform);
551
+ expect(row.os_release).toBe(profile.osRelease);
552
+ expect(row.arch).toBe(profile.arch);
553
+ expect(row.cpu_count).toBe(profile.cpuCount);
554
+ expect(row.total_mem_mb).toBe(profile.totalMemMb);
555
+ expect(row.node_version).toBe(profile.nodeVersion);
556
+ expect(row.electron_version).toBe(profile.electronVersion);
557
+ expect(row.install_channel).toBe(profile.installChannel);
558
+ expect(row.locale).toBe(profile.locale);
559
+ expect(row.timezone).toBe(String(profile.timezoneOffsetMinutes));
517
560
 
518
561
  client.shutdown();
519
562
  });
@@ -522,23 +565,27 @@ test('the machine-profile record arrives with every buildMachineProfile() field
522
565
  // AC: machine profile sent once per version + once per 30-day heartbeat
523
566
  // ═══════════════════════════════════════════════════════════════════════
524
567
 
525
- test('install.machine fires once per app version and once per 30-day heartbeat, not once per launch', async () => {
568
+ test('the install upsert fires once per app version and once per 30-day heartbeat, not once per launch', async () => {
526
569
  const home = await mkHome();
527
- const before = stubServer.state.events.length;
570
+ let installId = null;
528
571
 
529
- function countInstallMachine() {
530
- return stubServer.state.events.slice(before).filter((r) => r.event === 'install.machine').length;
572
+ function seenCount() {
573
+ if (!installId) return 0;
574
+ const row = stubServer.state.installsById.get(installId);
575
+ return row ? row.seen_count : 0;
531
576
  }
532
577
 
533
578
  async function simulatedLaunch({ now, appVersion }) {
534
579
  const client = freshClient(home, { appVersion });
535
580
  const boot = freshBoot();
581
+ const telemetrySettings = require('../lib/telemetrySettings.cjs');
582
+ if (!installId) installId = (await telemetrySettings.load()).installId;
536
583
  await boot.bootSequence({
537
584
  now,
538
585
  appVersion,
539
586
  installChannel: 'dev',
540
587
  deps: {
541
- telemetrySettings: require('../lib/telemetrySettings.cjs'),
588
+ telemetrySettings,
542
589
  telemetryClient: client,
543
590
  buildMachineProfile: async () => fakeProfile({ appVersion }),
544
591
  telemetryCounters: { trackAppLaunch: () => {} },
@@ -557,15 +604,15 @@ test('install.machine fires once per app version and once per 30-day heartbeat,
557
604
  await simulatedLaunch({ now: t0 + 2 * ONE_DAY, appVersion: '1.0.0' });
558
605
  await simulatedLaunch({ now: t0 + 3 * ONE_DAY, appVersion: '1.0.0' });
559
606
  await simulatedLaunch({ now: t0 + 4 * ONE_DAY, appVersion: '1.0.0' });
560
- expect(countInstallMachine()).toBe(1);
607
+ expect(seenCount()).toBe(1);
561
608
 
562
609
  // Launch 6: version bump.
563
610
  await simulatedLaunch({ now: t0 + 5 * ONE_DAY, appVersion: '1.1.0' });
564
- expect(countInstallMachine()).toBe(2);
611
+ expect(seenCount()).toBe(2);
565
612
 
566
613
  // Launch 7: 31 days after the version-bump report, same version -> heartbeat due.
567
614
  await simulatedLaunch({ now: t0 + 5 * ONE_DAY + 31 * ONE_DAY, appVersion: '1.1.0' });
568
- expect(countInstallMachine()).toBe(3);
615
+ expect(seenCount()).toBe(3);
569
616
  });
570
617
 
571
618
  // ═══════════════════════════════════════════════════════════════════════
@@ -168,6 +168,38 @@ test('isMachineReportDue: true when lastMachineReportAt is more than 30 days bef
168
168
  expect(telemetrySettings.isMachineReportDue(cfg, { now, appVersion: '0.81.0' })).toBe(true);
169
169
  });
170
170
 
171
+ test('save() merges a partial patch onto a fresh disk read — concurrent savers do not clobber each other', async () => {
172
+ const home = await mkHome();
173
+ const telemetrySettings = await freshModule(home);
174
+ const base = await telemetrySettings.load(); // mints + persists installId
175
+ expect(base.installId).not.toBe('');
176
+
177
+ // Two independent writers, each stamping a DIFFERENT field, racing each
178
+ // other — mirrors telemetryBoot's install-report stamp landing around the
179
+ // same time as telemetryClient's own daily-flush stamp. Neither holds the
180
+ // other's snapshot; each passes only the field it intends to change.
181
+ await Promise.all([
182
+ telemetrySettings.save({ lastMachineReportAt: '2026-01-01T00:00:00.000Z', lastMachineReportVersion: '2.0.0' }),
183
+ telemetrySettings.save({ lastDailyFlushAt: '2026-01-01T01:00:00.000Z' }),
184
+ ]);
185
+
186
+ const onDisk = await telemetrySettings.load();
187
+ expect(onDisk.lastMachineReportAt).toBe('2026-01-01T00:00:00.000Z');
188
+ expect(onDisk.lastMachineReportVersion).toBe('2.0.0');
189
+ expect(onDisk.lastDailyFlushAt).toBe('2026-01-01T01:00:00.000Z');
190
+ // Neither save touched installId/enabled/endpoint — they must survive too.
191
+ expect(onDisk.installId).toBe(base.installId);
192
+ expect(onDisk.enabled).toBe(base.enabled);
193
+ expect(onDisk.endpoint).toBe(base.endpoint);
194
+ });
195
+
196
+ test('save() rejects an unknown key without touching disk', async () => {
197
+ const home = await mkHome();
198
+ const telemetrySettings = await freshModule(home);
199
+ await telemetrySettings.load();
200
+ await expect(telemetrySettings.save({ notARealField: 'x' })).rejects.toThrow('Invalid telemetry config');
201
+ });
202
+
171
203
  test('isMachineReportDue: false when same version and reported less than 30 days ago', async () => {
172
204
  const home = await mkHome();
173
205
  const telemetrySettings = await freshModule(home);
@@ -16,7 +16,9 @@ const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
16
16
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
17
17
  const { migratePrds } = require('./lib/prdMigration.cjs');
18
18
  const queueStore = require('./lib/queueStore.cjs');
19
- const { computeStallSummary, SCHEDULER_STATE_PATH, FAILURE_STREAK_WARN_THRESHOLD, classifyQueueStarvation } = require('./scheduler.cjs');
19
+ const { computeStallSummary, SCHEDULER_STATE_PATH, FAILURE_STREAK_WARN_THRESHOLD, classifyQueueStarvation, STARVE_ESCALATION_MS } = require('./scheduler.cjs');
20
+ const { findStarvedProjects } = require('./lib/schedulerBatch.cjs');
21
+ const { AUDIT_LOG_PATH } = require('./lib/auditLog.cjs');
20
22
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
21
23
  const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
22
24
 
@@ -458,6 +460,63 @@ function evaluateQueueDispatchHealth(queueState, runningCount, now, thresholdMs
458
460
  };
459
461
  }
460
462
 
463
+ /**
464
+ * latestStarveEscalationReasons(auditLogPath) → { [cwd]: holdReason }
465
+ *
466
+ * health.cjs runs as its own cold process (`npm run health`), so it has no
467
+ * access to the live scheduler's in-memory `lastTick` — the durable
468
+ * audit-log.jsonl trail (auditLog.cjs) is the only place the hold reason a
469
+ * 'project_starve_escalated' event already recorded (scheduler.cjs's
470
+ * runStarveEscalationSweep) survives to be read from here. Reads the SAME
471
+ * recorded value rather than re-deriving it; the last record per cwd wins.
472
+ * Missing/unreadable log → {} (no reasons known yet), never a throw.
473
+ */
474
+ function latestStarveEscalationReasons(auditLogPath) {
475
+ let lines;
476
+ try {
477
+ lines = fs.readFileSync(auditLogPath, 'utf8').split('\n').filter(Boolean);
478
+ } catch {
479
+ return {};
480
+ }
481
+ const byCwd = {};
482
+ for (const line of lines) {
483
+ let rec;
484
+ try {
485
+ rec = JSON.parse(line);
486
+ } catch {
487
+ continue;
488
+ }
489
+ if (rec?.kind === 'project_starve_escalated' && rec.cwd) byCwd[rec.cwd] = rec.holdReason ?? 'unknown';
490
+ }
491
+ return byCwd;
492
+ }
493
+
494
+ /**
495
+ * evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons)
496
+ * → { ok, projects?, message? }
497
+ *
498
+ * Pure over its inputs. Reuses findStarvedProjects — the exact per-cwd
499
+ * STARVED verdict the live escalation (scheduler.cjs) acts on — so health
500
+ * reports the identical set of starved-past-threshold projects, never a
501
+ * re-derived one. `escalationReasons` supplies the hold reason per cwd (see
502
+ * latestStarveEscalationReasons); a cwd with no recorded reason yet reports
503
+ * 'unknown' rather than failing.
504
+ */
505
+ function evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons = {}) {
506
+ const starved = findStarvedProjects(jobs, now, thresholdMs);
507
+ if (starved.length === 0) return { ok: true };
508
+ const projects = starved.map((sp) => ({
509
+ cwd: sp.cwd,
510
+ pendingCount: sp.pendingCount,
511
+ ageMs: sp.ageMs,
512
+ holdReason: escalationReasons[sp.cwd] ?? 'unknown',
513
+ }));
514
+ const message = `Project(s) starved past the ${Math.round(thresholdMs / 60_000)}m escalation threshold: ${
515
+ projects.map((p) => `${p.cwd} (${Math.round(p.ageMs / 60_000)}m, ${p.pendingCount} pending, hold=${p.holdReason})`).join('; ')
516
+ }`;
517
+ return { ok: false, projects, message };
518
+ }
519
+
461
520
  function loadUsagePollerState(statePath) {
462
521
  let raw;
463
522
  try {
@@ -640,6 +699,18 @@ async function check() {
640
699
  status.issues.push(`Queue dispatch: ${status.components.queue_dispatch.message}`);
641
700
  }
642
701
 
702
+ // Per-project starve escalation (bounded consequence for project_starved
703
+ // — see runStarveEscalationSweep in scheduler.cjs). Distinct from
704
+ // queue_dispatch above: that's machine-wide dispatch liveness, this is
705
+ // "has any ONE project been starved past the LATER escalation threshold",
706
+ // the exact condition the 2026-09-12 19h Bilko starve went unreported by.
707
+ status.components.project_starve_escalation = evaluateStarveEscalationHealth(
708
+ queueState.jobs, now, STARVE_ESCALATION_MS, latestStarveEscalationReasons(AUDIT_LOG_PATH),
709
+ );
710
+ if (!status.components.project_starve_escalation.ok) {
711
+ status.issues.push(status.components.project_starve_escalation.message);
712
+ }
713
+
643
714
  // Worktree cap blocking every dispatchable pending job with nothing
644
715
  // running (see evaluateWorktreeCapBlocked's header) — a distinct
645
716
  // condition from generic tick/dispatch staleness above, since a leaked
@@ -877,7 +948,7 @@ async function check() {
877
948
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
878
949
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
879
950
  // Informational: app log age (shows if app is running, but not blocking).
880
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'queue_dispatch', 'usage_poller', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
951
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'queue_dispatch', 'project_starve_escalation', 'usage_poller', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
881
952
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
882
953
 
883
954
  status.elapsedMs = Date.now() - start;
@@ -909,6 +980,8 @@ module.exports = {
909
980
  evaluateUsagePollerHealth,
910
981
  loadUsagePollerState,
911
982
  evaluateQueueDispatchHealth,
983
+ evaluateStarveEscalationHealth,
984
+ latestStarveEscalationReasons,
912
985
  DISPATCH_STALL_THRESHOLD_MS,
913
986
  TICK_STALL_THRESHOLD_MS,
914
987
  HEARTBEAT_STALE_MS,
@@ -1070,8 +1070,8 @@ app.whenReady().then(async () => {
1070
1070
  });
1071
1071
 
1072
1072
  // Telemetry boot sequence: flush('boot') always, flush('version-change')
1073
- // when detected, and the once-per-machine install.machine profile when due
1074
- // (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
1073
+ // when detected, and the once-per-machine install upsert (app_installs) when
1074
+ // due (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
1075
1075
  require('./lib/telemetryBoot.cjs')
1076
1076
  .bootSequence({
1077
1077
  appVersion: app.getVersion(),
@@ -16,9 +16,11 @@ const { reportCrash } = require('../crashTelemetry.cjs');
16
16
 
17
17
  const tmpDirs = [];
18
18
  let originalHome;
19
+ let originalSmTelemetrySpool;
19
20
 
20
21
  afterEach(async () => {
21
22
  if (originalHome !== undefined) process.env.HOME = originalHome;
23
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
22
24
  while (tmpDirs.length) {
23
25
  const d = tmpDirs.pop();
24
26
  await fsp.rm(d, { recursive: true, force: true });
@@ -27,9 +29,13 @@ afterEach(async () => {
27
29
 
28
30
  async function mkHome() {
29
31
  originalHome = process.env.HOME;
32
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
30
33
  const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-crash-telemetry-home-'));
31
34
  tmpDirs.push(dir);
32
35
  process.env.HOME = dir;
36
+ // Explicit opt-in: overrides the test-environment no-op guard so this
37
+ // suite's real telemetryClient calls actually persist into an isolated dir.
38
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
33
39
  return dir;
34
40
  }
35
41
 
@@ -125,7 +125,7 @@ test('an ungated tick never audits', () => {
125
125
  assert.equal(g.evaluate().shouldAudit, false);
126
126
  });
127
127
 
128
- test('escalates once the gated stretch exceeds the escalation window, and the stretch resets when load drops', () => {
128
+ test('escalates once the gated stretch exceeds the escalation window, and the stretch only resets once load has stayed sub-threshold past the release window', () => {
129
129
  let l1 = 12.95;
130
130
  let t = 0;
131
131
  const g = createLoadGate({
@@ -134,6 +134,7 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
134
134
  now: () => t,
135
135
  threshold: 0.85,
136
136
  escalateAfterMs: 45 * 60_000,
137
+ releaseWindowMs: 2 * 60_000,
137
138
  });
138
139
  assert.equal(g.evaluate().escalate, false);
139
140
  t += 44 * 60_000;
@@ -142,12 +143,43 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
142
143
  const r = g.evaluate();
143
144
  assert.equal(r.escalate, true, 'at 45m: escalates');
144
145
  assert.equal(r.gatedSinceMs, 45 * 60_000);
146
+ // A single sub-threshold sample must NOT reset the stretch (the boundary-
147
+ // hovering bug this PRD fixes) — gatedSinceMs keeps growing.
145
148
  l1 = 1; t += 60_000;
146
- assert.equal(g.evaluate().gatedSinceMs, 0, 'load dropped: stretch resets');
149
+ const oneMinBelow = g.evaluate();
150
+ assert.equal(oneMinBelow.gated, false, 'gate decision is immediate');
151
+ assert.equal(oneMinBelow.gatedSinceMs, 46 * 60_000, 'stretch not yet released');
152
+ // Load climbs back above threshold before the release window elapses: the
153
+ // stretch was never actually cleared, so it just keeps accumulating.
154
+ l1 = 12.95; t += 30_000;
155
+ assert.equal(g.evaluate().gatedSinceMs, 46 * 60_000 + 30_000, 'stretch survived the brief dip');
156
+ // Now hold sub-threshold continuously past the release window (the window
157
+ // is measured from the first sub-threshold sample of this sustained drop,
158
+ // so it takes a tick to mark that start, then another once the window has
159
+ // actually elapsed).
160
+ l1 = 1; t += 1;
161
+ g.evaluate();
162
+ t += 2 * 60_000;
163
+ assert.equal(g.evaluate().gatedSinceMs, 0, 'sustained sub-threshold load past the release window clears the stretch');
147
164
  l1 = 12.95; t += 60_000;
148
165
  assert.equal(g.evaluate().gatedSinceMs, 0, 'a fresh stretch starts from zero');
149
166
  });
150
167
 
168
+ test('the gate decision itself is unaffected by hysteresis: a sub-threshold tick is never gated, even mid-stretch', () => {
169
+ let l1 = 12.95;
170
+ let t = 0;
171
+ const g = createLoadGate({
172
+ loadavg: () => [l1, 0, 0],
173
+ cores: () => 14,
174
+ now: () => t,
175
+ threshold: 0.85,
176
+ releaseWindowMs: 5 * 60_000,
177
+ });
178
+ assert.equal(g.evaluate().gated, true);
179
+ l1 = 1; t += 60_000;
180
+ assert.equal(g.evaluate().gated, false, 'below threshold: gated:false immediately, hysteresis notwithstanding');
181
+ });
182
+
151
183
  test('snapshot() reflects the last evaluation and is null before any', () => {
152
184
  const { g } = gateWith({});
153
185
  assert.equal(g.snapshot(), null);
@@ -157,3 +189,72 @@ test('snapshot() reflects the last evaluation and is null before any', () => {
157
189
  assert.equal(typeof s.at, 'string');
158
190
  assert.equal('shouldAudit' in s, false, 'per-tick flags are not part of the persisted snapshot');
159
191
  });
192
+
193
+ test('snapshot() exposes gated, ratio, threshold, loadavg1, cores and gatedSinceMs (what buildScheduleStatePayload surfaces as loadGate)', () => {
194
+ const { g } = gateWith({});
195
+ g.evaluate();
196
+ const s = g.snapshot();
197
+ assert.equal(typeof s.gated, 'boolean');
198
+ assert.equal(typeof s.ratio, 'number');
199
+ assert.equal(typeof s.threshold, 'number');
200
+ assert.equal(typeof s.loadavg1, 'number');
201
+ assert.equal(typeof s.cores, 'number');
202
+ assert.equal(typeof s.gatedSinceMs, 'number');
203
+ });
204
+
205
+ // ─── hysteresis: the real observed boundary-hovering sequence ──────────────
206
+
207
+ test('a boundary-hovering box (real observed sequence, alternating above/below threshold) grows gatedSinceMs monotonically across 50 simulated minutes and eventually escalates', () => {
208
+ // 14 cores, 0.85/core = 11.9 threshold; loadavg1 observed 2026-09-12
209
+ // oscillating 11.07 - 13.31, straddling the threshold every tick. Before
210
+ // this PRD, the immediate reset on ANY sub-threshold sample meant every
211
+ // one of these ticks reset gatedSince to null and escalate never fired.
212
+ const CORES = 14;
213
+ const THRESHOLD = 0.85;
214
+ const SEQUENCE = [11.07, 13.31, 11.51, 12.8];
215
+ let l1 = SEQUENCE[0];
216
+ let t = 0;
217
+ const g = createLoadGate({
218
+ loadavg: () => [l1, 0, 0],
219
+ cores: () => CORES,
220
+ now: () => t,
221
+ threshold: THRESHOLD,
222
+ escalateAfterMs: 20 * 60_000, // shorter than the 45m default so 50 sim-minutes crosses it
223
+ });
224
+
225
+ const TICK_MS = 60_000;
226
+ const TOTAL_MS = 50 * 60_000;
227
+ let prevGatedSinceMs = -1;
228
+ let sawEscalate = false;
229
+ let i = 0;
230
+ for (let elapsed = 0; elapsed < TOTAL_MS; elapsed += TICK_MS) {
231
+ l1 = SEQUENCE[i % SEQUENCE.length];
232
+ i += 1;
233
+ const r = g.evaluate();
234
+ assert.ok(r.gatedSinceMs >= prevGatedSinceMs, `gatedSinceMs must not shrink (was ${prevGatedSinceMs}, now ${r.gatedSinceMs})`);
235
+ prevGatedSinceMs = r.gatedSinceMs;
236
+ if (r.escalate) sawEscalate = true;
237
+ t += TICK_MS;
238
+ }
239
+ assert.equal(sawEscalate, true, 'escalate must fire once the (never-reset) stretch exceeds escalateAfterMs');
240
+ });
241
+
242
+ test('the release window actually releases: sustained sub-threshold load past the window reports gated:false and gatedSinceMs:0 on the next tick', () => {
243
+ let l1 = 13.31;
244
+ let t = 0;
245
+ const g = createLoadGate({
246
+ loadavg: () => [l1, 0, 0],
247
+ cores: () => 14,
248
+ now: () => t,
249
+ threshold: 0.85,
250
+ releaseWindowMs: 2 * 60_000,
251
+ });
252
+ assert.equal(g.evaluate().gated, true);
253
+ l1 = 5;
254
+ t += 1;
255
+ g.evaluate(); // marks the start of the sustained sub-threshold run
256
+ t += 2 * 60_000;
257
+ const r = g.evaluate();
258
+ assert.equal(r.gated, false);
259
+ assert.equal(r.gatedSinceMs, 0);
260
+ });
@@ -18,13 +18,16 @@ const { resolveProjectContext } = require('../projectRootResolve.cjs');
18
18
 
19
19
  const tmpDirs = [];
20
20
  let originalHome;
21
+ let originalSmTelemetrySpool;
21
22
 
22
23
  beforeEach(() => {
23
24
  originalHome = process.env.HOME;
25
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
24
26
  });
25
27
 
26
28
  afterEach(async () => {
27
29
  if (originalHome !== undefined) process.env.HOME = originalHome;
30
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
28
31
  vi.unstubAllGlobals();
29
32
  while (tmpDirs.length) {
30
33
  const d = tmpDirs.pop();
@@ -51,6 +54,9 @@ function fakeProfile(overrides = {}) {
51
54
  /** Reloads the HOME-dependent modules (config.cjs computes allowedRoots from os.homedir() at require time). */
52
55
  function freshTelemetry(home) {
53
56
  process.env.HOME = home;
57
+ // Explicit opt-in: overrides the test-environment no-op guard so this
58
+ // suite's real telemetryClient calls actually persist into an isolated dir.
59
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
54
60
  for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
55
61
  const resolved = require.resolve(p);
56
62
  delete require.cache[resolved];
@@ -27,7 +27,7 @@ function fakeDeps({ settings } = {}) {
27
27
  },
28
28
  telemetryClient: {
29
29
  flush: async (reason) => { flushCalls.push(reason); return { sent: [], failed: [], reason }; },
30
- track: async (name, props) => ({ accepted: true, name, props }),
30
+ reportInstall: async (profile) => ({ accepted: true, profile }),
31
31
  },
32
32
  buildMachineProfile: async () => ({ appVersion: '1.0.0', platform: 'linux', arch: 'x64', machineDigest: 'abc123' }),
33
33
  telemetryCounters: { trackAppLaunch: () => {} },
@@ -42,11 +42,11 @@ test('a second boot at the same version within 30 days sends nothing', async ()
42
42
  settings: { lastMachineReportAt: priorReportAt, lastMachineReportVersion: '1.0.0' },
43
43
  });
44
44
  const seen = [];
45
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
45
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
46
46
 
47
47
  await bootSequence({ now, appVersion: '1.0.0', deps });
48
48
 
49
- expect(seen.filter((e) => e.name === 'install.machine')).toHaveLength(0);
49
+ expect(seen).toHaveLength(0);
50
50
  expect(state().lastMachineReportAt).toBe(priorReportAt);
51
51
  });
52
52
 
@@ -56,12 +56,11 @@ test('a version bump sends the machine profile exactly once', async () => {
56
56
  settings: { lastMachineReportAt: new Date(now - DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
57
57
  });
58
58
  const seen = [];
59
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
59
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
60
60
 
61
61
  await bootSequence({ now, appVersion: '2.0.0', deps });
62
62
 
63
- const installEvents = seen.filter((e) => e.name === 'install.machine');
64
- expect(installEvents.length).toBe(1);
63
+ expect(seen.length).toBe(1);
65
64
  expect(state().lastMachineReportVersion).toBe('2.0.0');
66
65
  });
67
66
 
@@ -71,27 +70,26 @@ test('31 days on an unchanged version sends the machine profile exactly once (li
71
70
  settings: { lastMachineReportAt: new Date(now - 31 * DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
72
71
  });
73
72
  const seen = [];
74
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
73
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
75
74
 
76
75
  await bootSequence({ now, appVersion: '1.0.0', deps });
77
76
 
78
- const installEvents = seen.filter((e) => e.name === 'install.machine');
79
- expect(installEvents.length).toBe(1);
77
+ expect(seen.length).toBe(1);
80
78
  expect(state().lastMachineReportAt).toBe(new Date(now).toISOString());
81
79
  });
82
80
 
83
- test('the install.machine event appVersion prop equals the appVersion stamped on a sibling error record', async () => {
81
+ test('the install report appVersion equals the appVersion stamped on a sibling error record', async () => {
84
82
  const now = Date.now();
85
83
  const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '', lastMachineReportAt: null } });
86
84
  const seen = [];
87
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
85
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
88
86
  // Sibling error record stamped by the SAME process's telemetryClient with the same appVersion.
89
87
  const siblingErrorStampAppVersion = '1.0.0';
90
88
 
91
89
  await bootSequence({ now, appVersion: '1.0.0', deps });
92
90
 
93
- const [install] = seen.filter((e) => e.name === 'install.machine');
94
- expect(install.props.appVersion).toBe(siblingErrorStampAppVersion);
91
+ const [install] = seen;
92
+ expect(install.appVersion).toBe(siblingErrorStampAppVersion);
95
93
  });
96
94
 
97
95
  test('on app ready, flush("boot") always runs', async () => {
@@ -115,6 +113,17 @@ test('with no version change, flush("version-change") does NOT run', async () =>
115
113
  expect(flushCalls).toEqual(['boot']);
116
114
  });
117
115
 
116
+ test('a rejected reportInstall leaves lastMachineReportAt unset so the next boot retries', async () => {
117
+ const now = Date.now();
118
+ const { deps, state } = fakeDeps({ settings: { lastMachineReportVersion: '', lastMachineReportAt: null } });
119
+ deps.telemetryClient.reportInstall = async () => ({ accepted: false, reason: 'error' });
120
+
121
+ await bootSequence({ now, appVersion: '1.0.0', deps });
122
+
123
+ expect(state().lastMachineReportAt).toBe(null);
124
+ expect(state().lastMachineReportVersion).toBe('');
125
+ });
126
+
118
127
  test('fires app.launch via telemetryCounters with installChannel + appVersion', async () => {
119
128
  const now = Date.now();
120
129
  const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
@@ -13,13 +13,16 @@ const path = require('node:path');
13
13
 
14
14
  const tmpDirs = [];
15
15
  let originalHome;
16
+ let originalSmTelemetrySpool;
16
17
 
17
18
  beforeEach(() => {
18
19
  originalHome = process.env.HOME;
20
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
19
21
  });
20
22
 
21
23
  afterEach(async () => {
22
24
  if (originalHome !== undefined) process.env.HOME = originalHome; else delete process.env.HOME;
25
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
23
26
  vi.unstubAllGlobals();
24
27
  while (tmpDirs.length) {
25
28
  const d = tmpDirs.pop();
@@ -35,6 +38,9 @@ async function mkHome() {
35
38
 
36
39
  function freshModules(home) {
37
40
  process.env.HOME = home;
41
+ // Explicit opt-in: overrides the test-environment no-op guard so this
42
+ // suite's real telemetryClient calls actually persist into an isolated dir.
43
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
38
44
  for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
39
45
  const resolved = require.resolve(p);
40
46
  delete require.cache[resolved];