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.
- package/dist/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-DTFL7y8G.js} +1 -1
- package/dist/assets/{DataModel-BltREYde.js → DataModel-Q4jhl24R.js} +1 -1
- package/dist/assets/{History-BZxFkOp6.js → History-Cj2FejEo.js} +1 -1
- package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-CaelQI6t.js} +1 -1
- package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko--v7cMR8I.js} +1 -1
- package/dist/assets/{Library-Bi2Fn3w9.js → Library-DgI9oCCZ.js} +1 -1
- package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-DYUZN-x-.js} +1 -1
- package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-DCIubYWf.js} +1 -1
- package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ypCYURh3.js} +1 -1
- package/dist/assets/{Memory-Dkl80uj_.js → Memory-C2qYp-3M.js} +1 -1
- package/dist/assets/{Panel-LurG5VfD.js → Panel-Cj2kw-Zv.js} +1 -1
- package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-BiYZNGYW.js} +1 -1
- package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-C1Vj8_dU.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DczPNM5U.js} +1 -1
- package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-Cd_7U6Gb.js} +1 -1
- package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-DcLBiJBq.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-DVSyI44-.js} +1 -1
- package/dist/assets/{Settings-B6H3v2am.js → Settings-Cv-pRyms.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-CUv1_Q2c.js} +1 -1
- package/dist/assets/{Skills-DwuHuA08.js → Skills-C_YHkAy-.js} +1 -1
- package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-B8R7T9xn.js} +1 -1
- package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-dj9YHWyy.js} +1 -1
- package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-DnSBUjHE.js} +1 -1
- package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-CjV_BJn6.js} +1 -1
- package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-QLRf0epp.js → index-CXFQIPhO.js} +3 -3
- package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-BJVciriw.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +135 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +144 -0
- package/src/main/__tests__/telemetryClient.test.cjs +250 -5
- package/src/main/__tests__/telemetryContract.test.cjs +74 -27
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/health.cjs +75 -2
- package/src/main/index.cjs +2 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +22 -13
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/queueStore.cjs +8 -2
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +13 -0
- package/src/main/lib/telemetryBoot.cjs +18 -10
- package/src/main/lib/telemetryClient.cjs +162 -6
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/pty.cjs +9 -0
- 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
|
|
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.
|
|
541
|
+
await client.reportInstall(profile);
|
|
500
542
|
await client.flush('manual');
|
|
501
543
|
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
expect(
|
|
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
|
|
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
|
-
|
|
570
|
+
let installId = null;
|
|
528
571
|
|
|
529
|
-
function
|
|
530
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|
package/src/main/health.cjs
CHANGED
|
@@ -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,
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
94
|
-
expect(install.
|
|
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];
|