claude-code-session-manager 0.82.0 → 0.84.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 (92) hide show
  1. package/dist/assets/{AgentLibrary-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +810 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +653 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-BVrAle90.js +0 -3
  92. package/dist/assets/SystemPrompt-8PiTyUyL.js +0 -1
@@ -0,0 +1,97 @@
1
+ /**
2
+ * health-usage-poller.test.cjs — `npm run health` must report non-GREEN when
3
+ * the usage/rate-limit poller's (scheduler.cjs pollLoop) consecutiveFailures
4
+ * streak, persisted to ~/.claude/session-manager/scheduler-state.json,
5
+ * reaches FAILURE_STREAK_WARN_THRESHOLD — the SAME count that gates the
6
+ * one-time opsErrorLog WARN (rateLimitPollerStreak.test.cjs shouldWarnFailureStreak
7
+ * fires at `>= threshold`), so the log line and the health-GREEN flip happen
8
+ * on the exact same failure, not one apart. Exercises evaluateUsagePollerHealth()
9
+ * and loadUsagePollerState() directly since both are pure/file-scoped, matching
10
+ * every other evaluate* helper's test pattern in this file — deliberately NOT
11
+ * driving the full (slow, machine-state-coupled) check() for this.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-usage-poller.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect } from 'vitest';
19
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ const { evaluateUsagePollerHealth, loadUsagePollerState } = require('../health.cjs');
23
+ const { FAILURE_STREAK_WARN_THRESHOLD } = require('../scheduler.cjs');
24
+
25
+ test('missing state (fresh install / poller never ran) is ok, not applicable', () => {
26
+ expect(evaluateUsagePollerHealth(null)).toEqual({ ok: true, applicable: false });
27
+ expect(evaluateUsagePollerHealth(undefined)).toEqual({ ok: true, applicable: false });
28
+ });
29
+
30
+ test('consecutiveFailures below the threshold is GREEN', () => {
31
+ const result = evaluateUsagePollerHealth({ consecutiveFailures: FAILURE_STREAK_WARN_THRESHOLD - 1, backoffMs: 240_000, lastPollAt: Date.now() });
32
+ expect(result.ok).toBe(true);
33
+ expect(result.applicable).toBe(true);
34
+ expect(result.message).toBeUndefined();
35
+ });
36
+
37
+ test('consecutiveFailures AT the threshold is already non-GREEN — same count the WARN fires at', () => {
38
+ // Must match shouldWarnFailureStreak's `>= threshold` exactly (scheduler.cjs)
39
+ // so the opsErrorLog WARN and this health flip happen on the same failure.
40
+ const result = evaluateUsagePollerHealth({ consecutiveFailures: FAILURE_STREAK_WARN_THRESHOLD, backoffMs: 480_000, lastPollAt: Date.now() });
41
+ expect(result.ok).toBe(false);
42
+ expect(result.message).toMatch(new RegExp(`${FAILURE_STREAK_WARN_THRESHOLD} consecutive failures`));
43
+ });
44
+
45
+ test('consecutiveFailures well past the threshold is non-GREEN with a diagnostic message', () => {
46
+ const result = evaluateUsagePollerHealth({ consecutiveFailures: 57, backoffMs: 480_000, lastPollAt: Date.now() });
47
+ expect(result.ok).toBe(false);
48
+ expect(result.consecutiveFailures).toBe(57);
49
+ expect(result.backoffMs).toBe(480_000);
50
+ expect(result.message).toMatch(/57 consecutive failures/);
51
+ });
52
+
53
+ test('respects a custom threshold argument', () => {
54
+ expect(evaluateUsagePollerHealth({ consecutiveFailures: 3 }, 3).ok).toBe(false);
55
+ expect(evaluateUsagePollerHealth({ consecutiveFailures: 2 }, 3).ok).toBe(true);
56
+ });
57
+
58
+ test('tolerates a malformed/partial state object without throwing', () => {
59
+ const result = evaluateUsagePollerHealth({});
60
+ expect(result.ok).toBe(true);
61
+ expect(result.consecutiveFailures).toBe(0);
62
+ });
63
+
64
+ // loadUsagePollerState: must distinguish "never ran" (ENOENT) from "corrupt"
65
+ // (unparseable) — the exact silent-masking bug class as the sibling
66
+ // scheduler-machine.json torn-write incident (f56bdc0), just for this sidecar.
67
+ // Uses a real scratch temp dir/file, never the real ~/.claude/session-manager/.
68
+ let scratchDir;
69
+ function makeScratchPath() {
70
+ scratchDir = mkdtempSync(join(tmpdir(), 'sm-usage-poller-health-'));
71
+ return join(scratchDir, 'scheduler-state.json');
72
+ }
73
+
74
+ test('loadUsagePollerState: missing file -> { missing: true }', () => {
75
+ const p = join(makeScratchPath()); // never written
76
+ const result = loadUsagePollerState(p);
77
+ expect(result).toEqual({ missing: true });
78
+ rmSync(scratchDir, { recursive: true, force: true });
79
+ });
80
+
81
+ test('loadUsagePollerState: corrupt/unparseable JSON -> errorMessage, not missing', () => {
82
+ const p = makeScratchPath();
83
+ writeFileSync(p, '{ not: valid json', 'utf8');
84
+ const result = loadUsagePollerState(p);
85
+ expect(result.missing).toBeUndefined();
86
+ expect(result.state).toBeUndefined();
87
+ expect(result.errorMessage).toMatch(/corrupt/);
88
+ rmSync(scratchDir, { recursive: true, force: true });
89
+ });
90
+
91
+ test('loadUsagePollerState: valid JSON -> { state }', () => {
92
+ const p = makeScratchPath();
93
+ writeFileSync(p, JSON.stringify({ consecutiveFailures: 12, backoffMs: 480_000, lastPollAt: 123 }), 'utf8');
94
+ const result = loadUsagePollerState(p);
95
+ expect(result.state).toEqual({ consecutiveFailures: 12, backoffMs: 480_000, lastPollAt: 123 });
96
+ rmSync(scratchDir, { recursive: true, force: true });
97
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * health-worktree-cap-blocked.test.cjs — `npm run health` must report
3
+ * non-GREEN when the worktree cap (gitWorktree.cjs's reserveWorktreeSlot) is
4
+ * blocking every dispatchable pending job with nothing running. Before this
5
+ * check existed that state was invisible to health.cjs: rows in four
6
+ * separate project queues carried heldReason 'worktree cap reached (5
7
+ * concurrent)' for 16 hours with zero entries in
8
+ * session-manager-operations/logs/ and nothing non-GREEN here — only a
9
+ * console.log line and the heldReason field itself, which nothing surfaced.
10
+ *
11
+ * Exercises evaluateWorktreeCapBlocked() directly — pure, no fs — matching
12
+ * every other evaluate* helper's test pattern in this file (see
13
+ * health-usage-poller.test.cjs's header).
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-worktree-cap-blocked.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect } from 'vitest';
21
+ const { evaluateWorktreeCapBlocked } = require('../health.cjs');
22
+
23
+ test('a pending job held on "worktree cap reached" with 0 running is non-GREEN, blocked', () => {
24
+ const jobs = [
25
+ { slug: 'a', status: 'pending', heldReason: 'worktree cap reached (5 concurrent)' },
26
+ { slug: 'b', status: 'pending', heldReason: 'worktree cap reached (5 concurrent)' },
27
+ ];
28
+ const result = evaluateWorktreeCapBlocked(jobs, 0);
29
+ expect(result.ok).toBe(false);
30
+ expect(result.blocked).toBe(true);
31
+ expect(result.capBlockedSlugs).toEqual(['a', 'b']);
32
+ });
33
+
34
+ test('accepts a job map (queueStore.readMergedSync federated shape), not just an array', () => {
35
+ const jobs = {
36
+ a: { slug: 'a', status: 'pending', heldReason: 'worktree cap reached (5 concurrent)' },
37
+ b: { slug: 'b', status: 'completed' },
38
+ };
39
+ const result = evaluateWorktreeCapBlocked(jobs, 0);
40
+ expect(result.ok).toBe(false);
41
+ expect(result.capBlockedSlugs).toEqual(['a']);
42
+ });
43
+
44
+ test('the same cap-reached row stays GREEN while at least one job is running', () => {
45
+ const jobs = [{ slug: 'a', status: 'pending', heldReason: 'worktree cap reached (5 concurrent)' }];
46
+ const result = evaluateWorktreeCapBlocked(jobs, 1);
47
+ expect(result.ok).toBe(true);
48
+ expect(result.blocked).toBe(false);
49
+ });
50
+
51
+ test('a pending row with an unrelated heldReason (or none) stays GREEN', () => {
52
+ const jobs = [
53
+ { slug: 'a', status: 'pending', heldReason: 'launch blocked (rate_limit) — re-probe at 2026-09-11T00:00:00Z' },
54
+ { slug: 'b', status: 'pending' },
55
+ ];
56
+ const result = evaluateWorktreeCapBlocked(jobs, 0);
57
+ expect(result.ok).toBe(true);
58
+ expect(result.blocked).toBe(false);
59
+ });
60
+
61
+ test('no pending jobs at all stays GREEN', () => {
62
+ const result = evaluateWorktreeCapBlocked([], 0);
63
+ expect(result.ok).toBe(true);
64
+ expect(result.blocked).toBe(false);
65
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * opsErrorLogTelemetryTap.test.cjs — unit tests for appendError()'s telemetry
3
+ * mirror. Kept separate from opsErrorLog.test.cjs (whose pre-existing tests
4
+ * stay unmodified in intent) since this is new behavior this PRD adds.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/opsErrorLogTelemetryTap.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, afterEach } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const fsp = require('node:fs/promises');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ const opsErrorLog = require('../lib/opsErrorLog.cjs');
17
+
18
+ const tmpDirs = [];
19
+ let originalHome;
20
+
21
+ afterEach(async () => {
22
+ if (originalHome !== undefined) process.env.HOME = originalHome;
23
+ const telemetryPath = require.resolve('../lib/telemetryClient.cjs');
24
+ delete require.cache[telemetryPath];
25
+ const activeSessionsPath = require.resolve('../../../scripts/lib/activeSessions.cjs');
26
+ delete require.cache[activeSessionsPath];
27
+ while (tmpDirs.length) {
28
+ const d = tmpDirs.pop();
29
+ await fsp.rm(d, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ function mkTmpProject() {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opslog-telemetry-'));
35
+ tmpDirs.push(dir);
36
+ return dir;
37
+ }
38
+
39
+ function readLines(cwd) {
40
+ const file = opsErrorLog.todayFile(cwd);
41
+ if (!fs.existsSync(file)) return [];
42
+ return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
43
+ }
44
+
45
+ function stubTelemetryClient(exportsObj) {
46
+ const telemetryPath = require.resolve('../lib/telemetryClient.cjs');
47
+ require.cache[telemetryPath] = { id: telemetryPath, filename: telemetryPath, loaded: true, exports: exportsObj };
48
+ }
49
+
50
+ // ─── AFTER local write, never blocks it ──────────────────────────────────
51
+
52
+ test('a throwing telemetry stub never prevents or corrupts the local JSONL write', () => {
53
+ const cwd = mkTmpProject();
54
+ stubTelemetryClient({
55
+ reportError: () => { throw new Error('telemetry boom'); },
56
+ logLine: () => { throw new Error('telemetry boom'); },
57
+ });
58
+
59
+ expect(() => opsErrorLog.appendError({ cwd, scope: 'pty', message: 'boom happened' })).not.toThrow();
60
+
61
+ const lines = readLines(cwd);
62
+ expect(lines.length).toBe(1);
63
+ expect(lines[0].message).toBe('boom happened');
64
+ });
65
+
66
+ test('level "warn" routes to telemetryClient.logLine, default "error" routes to reportError', () => {
67
+ const cwd = mkTmpProject();
68
+ const reportErrorCalls = [];
69
+ const logLineCalls = [];
70
+ stubTelemetryClient({
71
+ reportError: (o) => reportErrorCalls.push(o),
72
+ logLine: (o) => logLineCalls.push(o),
73
+ });
74
+
75
+ opsErrorLog.appendError({ cwd, scope: 'chatRunner', level: 'warn', message: 'careful' });
76
+ expect(logLineCalls).toHaveLength(1);
77
+ expect(reportErrorCalls).toHaveLength(0);
78
+
79
+ opsErrorLog.appendError({ cwd, scope: 'chatRunner', message: 'broke' });
80
+ expect(reportErrorCalls).toHaveLength(1);
81
+ });
82
+
83
+ // ─── ephemeral cwd: local refused, telemetry still fires ─────────────────
84
+
85
+ test('an ephemeral cwd yields zero local lines but one telemetry record, with a normalized projectHash', () => {
86
+ const { KIND_CONFIG } = require('../lib/gitWorktree.cjs');
87
+ const worktreeCwd = path.join(KIND_CONFIG.epic.root, 'fakehash', 'fake-epic-id');
88
+ const realRoot = '/home/bilko/Projects/real-project';
89
+
90
+ const activeSessionsPath = require.resolve('../../../scripts/lib/activeSessions.cjs');
91
+ const original = require('../../../scripts/lib/activeSessions.cjs');
92
+ require.cache[activeSessionsPath] = {
93
+ id: activeSessionsPath,
94
+ filename: activeSessionsPath,
95
+ loaded: true,
96
+ exports: { ...original, projectRootOf: (p) => (p === worktreeCwd ? realRoot : p) },
97
+ };
98
+
99
+ const reportErrorCalls = [];
100
+ stubTelemetryClient({ reportError: (o) => reportErrorCalls.push(o), logLine: () => {} });
101
+
102
+ opsErrorLog.appendError({ cwd: worktreeCwd, scope: 'chatRunner', message: 'worktree err' });
103
+
104
+ expect(fs.existsSync(opsErrorLog.todayFile(worktreeCwd))).toBe(false);
105
+ expect(reportErrorCalls).toHaveLength(1);
106
+ expect(reportErrorCalls[0].context.cwd).toBe(realRoot);
107
+ expect(reportErrorCalls[0].context.cwd).not.toBe(worktreeCwd);
108
+ });
109
+
110
+ // ─── PII: no prompt text, no absolute path ────────────────────────────────
111
+
112
+ test('a realistic error with an absolute path and a prompt-like string never reaches the telemetry payload verbatim', async () => {
113
+ originalHome = process.env.HOME;
114
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opslog-telemetry-home-'));
115
+ tmpDirs.push(home);
116
+ process.env.HOME = home;
117
+
118
+ for (const p of ['../lib/telemetryClient.cjs', '../config.cjs', '../lib/telemetrySettings.cjs', '../lib/machineProfile.cjs']) {
119
+ const resolved = require.resolve(p);
120
+ delete require.cache[resolved];
121
+ }
122
+ const client = require('../lib/telemetryClient.cjs');
123
+ client._setMachineProfileBuilder(async () => ({
124
+ appVersion: '0.83.0', platform: 'linux', arch: 'x64', machineDigest: 'deadbeefcafe',
125
+ }));
126
+
127
+ const cwd = mkTmpProject();
128
+ const absPath = `${home}/Projects/super-secret-client/src/main/index.cjs`;
129
+ const promptLike = 'write me a function that deletes all files matching *.secret and email me the results';
130
+ opsErrorLog.appendError({
131
+ cwd,
132
+ scope: 'chatRunner',
133
+ message: `run failed while processing "${promptLike}" at ${absPath}`,
134
+ });
135
+
136
+ await new Promise((r) => setTimeout(r, 50));
137
+
138
+ const raw = await fsp.readFile(client.queuePath(), 'utf8');
139
+ expect(raw).not.toContain(home);
140
+ expect(raw).not.toContain(absPath);
141
+ expect(raw).not.toContain(promptLike);
142
+ client.shutdown();
143
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * pollLoop-dispatch-on-failure.test.cjs — PRD: 27 pending jobs across two
3
+ * projects sat at 0 running for days while every surface reported healthy.
4
+ * Root cause: under firePolicy 'when-available', maybeLaunchWhenAvailable()
5
+ * (the only thing that ever calls tickQueue() on the poll timer) was reached
6
+ * from exactly two of pollLoop()'s branches — 'ok' and 'meter_rate_limited'.
7
+ * The auth branch, the transient/config branch, and the outer catch all
8
+ * incremented consecutiveFailures and returned WITHOUT attempting a
9
+ * dispatch, so a poller that started failing (for any reason) silently
10
+ * stopped driving the queue even though slots/memory/utilization were fine.
11
+ *
12
+ * This suite drives the REAL pollLoop() (not a reimplementation) through a
13
+ * transient billing failure and a thrown error, and asserts a dispatch was
14
+ * actually attempted in both cases — observed via `lastDispatchAttemptAt`,
15
+ * which tickQueue() stamps the moment it reaches the picker, independent of
16
+ * whether that pass ends in an actual job launch (see classifyQueueStarvation's
17
+ * header for why this must be distinct from `lastRunAt`).
18
+ *
19
+ * No existing test in this repo mocks a module import (see
20
+ * pty-epic-worktree-spawn-cwd.test.cjs's header) — this monkey-patches
21
+ * usage.cjs's cached module object directly instead, same pattern.
22
+ *
23
+ * Run: timeout 180 npx vitest run src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ import { test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
29
+ const fs = require('node:fs');
30
+ const os = require('node:os');
31
+ const path = require('node:path');
32
+
33
+ let tmpHome;
34
+ let originalHome;
35
+ let scheduler;
36
+ let queueStore;
37
+ let billing;
38
+ let originalFetchUsage;
39
+
40
+ beforeAll(() => {
41
+ originalHome = process.env.HOME;
42
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pollloop-dispatch-'));
43
+ process.env.HOME = tmpHome;
44
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
45
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
46
+ scheduler = require('../scheduler.cjs');
47
+ queueStore = require('../lib/queueStore.cjs');
48
+ billing = require('../usage.cjs');
49
+ originalFetchUsage = billing.fetchUsage;
50
+ });
51
+
52
+ afterAll(() => {
53
+ billing.fetchUsage = originalFetchUsage;
54
+ process.env.HOME = originalHome;
55
+ fs.rmSync(tmpHome, { recursive: true, force: true });
56
+ });
57
+
58
+ afterEach(() => {
59
+ billing.fetchUsage = originalFetchUsage;
60
+ delete process.env.SM_E2E;
61
+ delete process.env.SM_MOCK_BILLING_KIND;
62
+ });
63
+
64
+ // allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds) discover
65
+ // project cwds by scanning ~/.claude/projects/*/*.jsonl for a `cwd` field —
66
+ // fake one project transcript pointing at the fixture cwd, same pattern as
67
+ // prdAdminRoutes.test.cjs / scheduler-verify-prd-path.test.cjs.
68
+ function mkProject() {
69
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pollloop-project-'));
70
+ const slug = `9001-pollloop-dispatch-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
71
+ const projDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'projects', 'sm-pollloop-fake-'));
72
+ fs.writeFileSync(path.join(projDir, 'session.jsonl'), `${JSON.stringify({ cwd })}\n`, 'utf8');
73
+ return { cwd, slug };
74
+ }
75
+
76
+ async function seedOnePendingJob() {
77
+ const { cwd, slug } = mkProject();
78
+ await scheduler.writeQueue({
79
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
80
+ config: {},
81
+ paused: null,
82
+ });
83
+ return { cwd, slug };
84
+ }
85
+
86
+ async function lastDispatchAttemptAt() {
87
+ return queueStore.readMergedSync().lastDispatchAttemptAt;
88
+ }
89
+
90
+ // After pollLoop() fires maybeLaunchWhenAvailable() fire-and-forget, the tick
91
+ // it enqueues lands on the shared, serialized tickTail chain that tickQueue()
92
+ // itself maintains. A follow-up tickQueue() call is appended to that SAME
93
+ // chain, so awaiting it guarantees the earlier tick (if any was enqueued)
94
+ // has already settled.
95
+ async function flushPendingTick() {
96
+ await scheduler.tickQueue();
97
+ }
98
+
99
+ test('pollLoop still attempts a dispatch after a transient billing failure', async () => {
100
+ await seedOnePendingJob();
101
+ expect(await lastDispatchAttemptAt()).toBeNull();
102
+
103
+ process.env.SM_E2E = '1';
104
+ process.env.SM_MOCK_BILLING_KIND = 'transient';
105
+ await scheduler.pollLoop();
106
+ await flushPendingTick();
107
+
108
+ expect(await lastDispatchAttemptAt()).not.toBeNull();
109
+ });
110
+
111
+ test('pollLoop still attempts a dispatch after the billing poll throws', async () => {
112
+ await seedOnePendingJob();
113
+ expect(await lastDispatchAttemptAt()).toBeNull();
114
+
115
+ billing.fetchUsage = async () => { throw new Error('simulated IPC transport failure'); };
116
+ await scheduler.pollLoop();
117
+ await flushPendingTick();
118
+
119
+ expect(await lastDispatchAttemptAt()).not.toBeNull();
120
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * queue-starvation-dispatch-driver.test.cjs — two liveness gaps in the
3
+ * queue-starvation watchdog itself, both observed live on 2026-09-11
4
+ * (27 pending jobs across two projects, 0 running, for days):
5
+ *
6
+ * 1. classifyQueueStarvation/runQueueStarvationWatchdog used to measure
7
+ * idleness from `lastRunAt`, which the poll loop kept looking fresh even
8
+ * while nothing was actually dispatching — masking the very stall the
9
+ * watchdog exists to catch. It must measure from `lastDispatchAttemptAt`
10
+ * instead (stamped by tickQueue() the moment it reaches the picker,
11
+ * regardless of outcome — see tickQueue's own comment).
12
+ * 2. tickQueue()'s very first real guard returns {fired:false,
13
+ * reason:'cancelled'} whenever the in-process cancelToken is wedged
14
+ * true — historically only ever reset by runDueJobs() (force-tick/
15
+ * run-now/resume-timer). A pause that clears WITHOUT going through
16
+ * clearPause() (e.g. a direct queue-state write) leaves the token
17
+ * wedged forever with nothing left to un-wedge it. The starvation
18
+ * watchdog is the last line of defence, so it must reset the token
19
+ * itself before forcing its tick.
20
+ *
21
+ * Run: timeout 180 npx vitest run src/main/__tests__/queue-starvation-dispatch-driver.test.cjs
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ import { test, expect, beforeAll, afterAll } from 'vitest';
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ let tmpHome;
32
+ let originalHome;
33
+ let originalClaudeBin;
34
+ let scheduler;
35
+ let queueStore;
36
+
37
+ // A forced tick actually dispatches the one pending job it finds. Without a
38
+ // stub, that spawns the real `claude` binary (or fails trying to), leaving
39
+ // scheduler.cjs's module-scoped runningSet non-empty by the time the next
40
+ // test in this file runs — classifyQueueStarvation's `runningCount > 0`
41
+ // guard then makes runQueueStarvationWatchdog return null for a reason that
42
+ // has nothing to do with what that test is actually checking.
43
+ function writeClaudeStub() {
44
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-starve-driver-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
45
+ const body = `
46
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
47
+ process.exit(0);
48
+ `;
49
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
50
+ return stubPath;
51
+ }
52
+
53
+ beforeAll(() => {
54
+ originalHome = process.env.HOME;
55
+ originalClaudeBin = process.env.SM_CLAUDE_BIN;
56
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-starve-driver-'));
57
+ process.env.HOME = tmpHome;
58
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
59
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
60
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
61
+ scheduler = require('../scheduler.cjs');
62
+ queueStore = require('../lib/queueStore.cjs');
63
+ });
64
+
65
+ afterAll(() => {
66
+ process.env.HOME = originalHome;
67
+ if (originalClaudeBin === undefined) delete process.env.SM_CLAUDE_BIN;
68
+ else process.env.SM_CLAUDE_BIN = originalClaudeBin;
69
+ fs.rmSync(tmpHome, { recursive: true, force: true });
70
+ });
71
+
72
+ // allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds) discover
73
+ // project cwds by scanning ~/.claude/projects/*/*.jsonl for a `cwd` field —
74
+ // fake one project transcript pointing at the fixture cwd, same pattern as
75
+ // prdAdminRoutes.test.cjs / scheduler-verify-prd-path.test.cjs.
76
+ function mkProject() {
77
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-starve-driver-project-'));
78
+ const slug = `9002-starve-driver-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
79
+ const projDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'projects', 'sm-starve-driver-fake-'));
80
+ fs.writeFileSync(path.join(projDir, 'session.jsonl'), `${JSON.stringify({ cwd })}\n`, 'utf8');
81
+ return { cwd, slug };
82
+ }
83
+
84
+ test('the watchdog reads idleness from lastDispatchAttemptAt, not the always-fresh poll timestamp', async () => {
85
+ const { cwd, slug } = mkProject();
86
+ await scheduler.writeQueue({
87
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
88
+ config: {},
89
+ paused: null,
90
+ });
91
+
92
+ const now = Date.now();
93
+ // The exact shape of the live incident: the poll timestamp is seconds old
94
+ // (the poll loop itself keeps succeeding), but nothing has actually
95
+ // attempted a dispatch in 40 minutes.
96
+ const state = {
97
+ ...queueStore.readMergedSync(),
98
+ lastRunAt: new Date(now - 5_000).toISOString(),
99
+ lastDispatchAttemptAt: new Date(now - 40 * 60_000).toISOString(),
100
+ };
101
+
102
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
103
+ expect(verdict).not.toBeNull();
104
+ expect(verdict.kind).toBe('starved');
105
+ });
106
+
107
+ test('a wedged cancelToken does not block the watchdog from actually driving a tick', async () => {
108
+ const { cwd, slug } = mkProject();
109
+ await scheduler.writeQueue({
110
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
111
+ config: {},
112
+ paused: null,
113
+ });
114
+
115
+ // Wedge the in-process cancel token exactly the way a pause does, then
116
+ // clear the pause through a path that does NOT run clearPause()'s own
117
+ // applyPauseCleared reset (a direct state write, not the clearPause()
118
+ // helper) — leaving the token permanently cancelled with paused: null.
119
+ await scheduler.setPaused('rate_limit', null);
120
+ await scheduler.writeQueue({
121
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
122
+ config: {},
123
+ paused: null,
124
+ });
125
+
126
+ const before = queueStore.readMergedSync().lastDispatchAttemptAt;
127
+
128
+ const now = Date.now();
129
+ const state = {
130
+ ...queueStore.readMergedSync(),
131
+ lastRunAt: null,
132
+ lastDispatchAttemptAt: new Date(now - 40 * 60_000).toISOString(),
133
+ };
134
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
135
+ expect(verdict.kind).toBe('starved');
136
+
137
+ // tickQueue() only stamps lastDispatchAttemptAt once it gets PAST the
138
+ // cancelToken guard — so this only advances if the watchdog actually
139
+ // un-wedged the token before forcing its tick.
140
+ const after = queueStore.readMergedSync().lastDispatchAttemptAt;
141
+ expect(after).not.toBe(before);
142
+ expect(after).not.toBeNull();
143
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * rateLimitPollerStreak.test.cjs — covers the fix for the silent
3
+ * consecutiveFailures streak on the usage/rate-limit poller (scheduler.cjs
4
+ * pollLoop): the 'meter_rate_limited' branch used to never back off and
5
+ * never call persistSchedulerState(), letting scheduler-state.json freeze
6
+ * stale while the loop kept failing every POLL_INTERVAL_MS underneath it.
7
+ * This suite covers the two pure pieces of the fix: the shared backoff
8
+ * curve/cap (nextBackoffMs) and the once-per-streak WARN gate
9
+ * (shouldWarnFailureStreak).
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/rateLimitPollerStreak.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect } from 'vitest';
17
+ const {
18
+ nextBackoffMs,
19
+ shouldWarnFailureStreak,
20
+ BACKOFF_MAX_MS,
21
+ FAILURE_STREAK_WARN_THRESHOLD,
22
+ } = require('../scheduler.cjs');
23
+
24
+ test('nextBackoffMs starts at 30s from a zero/falsy backoff', () => {
25
+ expect(nextBackoffMs(0)).toBe(30_000);
26
+ expect(nextBackoffMs(null)).toBe(30_000);
27
+ });
28
+
29
+ test('nextBackoffMs doubles each call', () => {
30
+ let ms = 0;
31
+ ms = nextBackoffMs(ms); // 30_000
32
+ ms = nextBackoffMs(ms); // 60_000
33
+ ms = nextBackoffMs(ms); // 120_000
34
+ expect(ms).toBe(120_000);
35
+ });
36
+
37
+ test('nextBackoffMs caps at BACKOFF_MAX_MS (8 minutes) and never exceeds it', () => {
38
+ let ms = 0;
39
+ for (let i = 0; i < 20; i++) ms = nextBackoffMs(ms);
40
+ expect(ms).toBe(BACKOFF_MAX_MS);
41
+ expect(BACKOFF_MAX_MS).toBe(480_000);
42
+ // One more doubling from an already-capped value must still be capped, not climb further.
43
+ expect(nextBackoffMs(ms)).toBe(BACKOFF_MAX_MS);
44
+ });
45
+
46
+ test('shouldWarnFailureStreak is false below the threshold', () => {
47
+ expect(shouldWarnFailureStreak(1, false)).toBe(false);
48
+ expect(shouldWarnFailureStreak(FAILURE_STREAK_WARN_THRESHOLD - 1, false)).toBe(false);
49
+ });
50
+
51
+ test('shouldWarnFailureStreak fires exactly once when the threshold is crossed', () => {
52
+ expect(shouldWarnFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, false)).toBe(true);
53
+ });
54
+
55
+ test('shouldWarnFailureStreak does not spam on every subsequent failure in the same streak', () => {
56
+ // Simulate a 57-failure streak: warn fires once, then `alreadyWarned` stays
57
+ // true for every remaining failure — this is the no-repeat property.
58
+ let warned = false;
59
+ let warnCount = 0;
60
+ for (let failures = 1; failures <= 57; failures++) {
61
+ if (shouldWarnFailureStreak(failures, warned)) {
62
+ warned = true;
63
+ warnCount++;
64
+ }
65
+ }
66
+ expect(warnCount).toBe(1);
67
+ expect(warned).toBe(true);
68
+ });
69
+
70
+ test('shouldWarnFailureStreak re-arms after a streak resets (alreadyWarned back to false)', () => {
71
+ expect(shouldWarnFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, false)).toBe(true);
72
+ // A later, independent streak crossing the threshold again must warn again.
73
+ expect(shouldWarnFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, false)).toBe(true);
74
+ });
75
+
76
+ test('shouldWarnFailureStreak respects a custom threshold', () => {
77
+ expect(shouldWarnFailureStreak(3, false, 3)).toBe(true);
78
+ expect(shouldWarnFailureStreak(2, false, 3)).toBe(false);
79
+ });