claude-code-session-manager 0.85.0 → 0.87.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 (147) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +15 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  40. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  41. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  42. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  43. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  44. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  45. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  46. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  47. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  48. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  49. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  50. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  51. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  52. package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
  53. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  54. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  55. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  56. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  57. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  58. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  59. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  60. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  61. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  62. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  63. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  64. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  65. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  66. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  67. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  68. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  69. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  70. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
  71. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  72. package/src/main/__tests__/telemetryClient.test.cjs +75 -5
  73. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  74. package/src/main/chatRunner.cjs +8 -5
  75. package/src/main/health.cjs +76 -3
  76. package/src/main/historyAggregator.cjs +5 -0
  77. package/src/main/index.cjs +95 -44
  78. package/src/main/ipcSchemas.cjs +47 -0
  79. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  80. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  81. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  82. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  83. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  84. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  85. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  86. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  87. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  88. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  89. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
  90. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  91. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  92. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  93. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  94. package/src/main/lib/agentModelResolve.cjs +65 -27
  95. package/src/main/lib/bootSelfHeal.cjs +88 -0
  96. package/src/main/lib/delegationReadiness.cjs +290 -225
  97. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  98. package/src/main/lib/ephemeralCwd.cjs +1 -1
  99. package/src/main/lib/epicMint.cjs +3 -3
  100. package/src/main/lib/gitWorktree.cjs +42 -12
  101. package/src/main/lib/guardShims.cjs +156 -0
  102. package/src/main/lib/jobDirtFilter.cjs +7 -2
  103. package/src/main/lib/launchFailure.cjs +2 -1
  104. package/src/main/lib/loadGate.cjs +23 -1
  105. package/src/main/lib/machineProfile.cjs +15 -0
  106. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  107. package/src/main/lib/needsReviewLedger.cjs +205 -0
  108. package/src/main/lib/opsErrorLog.cjs +1 -1
  109. package/src/main/lib/opsOwnership.cjs +1 -1
  110. package/src/main/lib/prdCreate.cjs +56 -1
  111. package/src/main/lib/prdDisposition.cjs +199 -0
  112. package/src/main/lib/prdFrontmatter.cjs +8 -2
  113. package/src/main/lib/prdLocations.cjs +167 -45
  114. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  115. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  116. package/src/main/lib/projectRootResolve.cjs +1 -1
  117. package/src/main/lib/queueHistory.cjs +19 -1
  118. package/src/main/lib/queueStore.cjs +6 -1
  119. package/src/main/lib/reaperHelpers.cjs +181 -15
  120. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  121. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  122. package/src/main/lib/schedulerBatch.cjs +12 -1
  123. package/src/main/lib/schedulerConfig.cjs +37 -0
  124. package/src/main/lib/telemetryBoot.cjs +11 -8
  125. package/src/main/lib/telemetryClient.cjs +44 -2
  126. package/src/main/lib/telemetrySettings.cjs +20 -3
  127. package/src/main/lib/usageCircuit.cjs +159 -0
  128. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  129. package/src/main/pty.cjs +9 -0
  130. package/src/main/scheduler/prdParser.cjs +13 -0
  131. package/src/main/scheduler.cjs +1869 -193
  132. package/src/main/templates/PRD_AUTHORING.md +50 -0
  133. package/src/main/templates/project-pages-catalog.json +1 -1
  134. package/src/main/usage.cjs +21 -3
  135. package/src/preload/api.d.ts +92 -1
  136. package/src/preload/index.cjs +10 -0
  137. package/web/README.md +41 -0
  138. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  139. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  140. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  141. package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
  142. package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
  143. package/dist/assets/Permissions-DKoNVgzj.js +0 -3
  144. package/dist/assets/Scheduler-CbES7MC8.js +0 -14
  145. package/dist/assets/Settings-BX3FElXk.js +0 -3
  146. package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
  147. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * prdDisposition.test.cjs — pure logic behind the scheduler wave-disposition
3
+ * PRD: does a new PRD wave extend an Epic's existing dependsOn chain
4
+ * ('append', gaining a dependsOn on the chain's current terminal PRD(s)) or
5
+ * start an independent, parallel-eligible root ('new-head')? And, after the
6
+ * fact, can a human safely rewrite that choice from the Scheduler UI?
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/prdDisposition.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect } from 'vitest';
14
+
15
+ const {
16
+ isIncomplete,
17
+ resolveChainTerminals,
18
+ wouldCreateCycle,
19
+ computeDispositionRewrite,
20
+ } = require('../prdDisposition.cjs');
21
+
22
+ function row(overrides) {
23
+ return { slug: 'x', status: 'pending', dependsOn: [], ...overrides };
24
+ }
25
+
26
+ // ─────────────────────────────────────────── isIncomplete
27
+
28
+ test('isIncomplete: completed is the only complete status', () => {
29
+ expect(isIncomplete('completed')).toBe(false);
30
+ for (const status of [null, undefined, 'pending', 'running', 'needs_review', 'failed', 'quarantined']) {
31
+ expect(isIncomplete(status)).toBe(true);
32
+ }
33
+ });
34
+
35
+ // ─────────────────────────────────────────── resolveChainTerminals
36
+
37
+ test('resolveChainTerminals: a lone root is its own terminal', () => {
38
+ const rows = [row({ slug: 'a' })];
39
+ expect(resolveChainTerminals(rows)).toEqual(['a']);
40
+ });
41
+
42
+ test('resolveChainTerminals: a linear chain terminates at its tail', () => {
43
+ const rows = [
44
+ row({ slug: 'a' }),
45
+ row({ slug: 'b', dependsOn: ['a'] }),
46
+ row({ slug: 'c', dependsOn: ['b'] }),
47
+ ];
48
+ expect(resolveChainTerminals(rows)).toEqual(['c']);
49
+ });
50
+
51
+ test('resolveChainTerminals: a fork yields both leaves', () => {
52
+ const rows = [
53
+ row({ slug: 'a' }),
54
+ row({ slug: 'b', dependsOn: ['a'] }),
55
+ row({ slug: 'c', dependsOn: ['a'] }),
56
+ ];
57
+ expect(resolveChainTerminals(rows).sort()).toEqual(['b', 'c']);
58
+ });
59
+
60
+ test('resolveChainTerminals: two independent roots are both terminals (existing multi-head Epic)', () => {
61
+ const rows = [row({ slug: 'a' }), row({ slug: 'z' })];
62
+ expect(resolveChainTerminals(rows).sort()).toEqual(['a', 'z']);
63
+ });
64
+
65
+ test('resolveChainTerminals: a dependsOn cycle contributes no terminal', () => {
66
+ const rows = [
67
+ row({ slug: 'a', dependsOn: ['b'] }),
68
+ row({ slug: 'b', dependsOn: ['a'] }),
69
+ ];
70
+ expect(resolveChainTerminals(rows)).toEqual([]);
71
+ });
72
+
73
+ test('resolveChainTerminals: empty input yields no terminals', () => {
74
+ expect(resolveChainTerminals([])).toEqual([]);
75
+ });
76
+
77
+ // ─────────────────────────────────────────── wouldCreateCycle
78
+
79
+ test('wouldCreateCycle: false for a fresh, unrelated edge', () => {
80
+ const rows = [row({ slug: 'a' }), row({ slug: 'b' })];
81
+ expect(wouldCreateCycle(rows, 'b', ['a'])).toBe(false);
82
+ });
83
+
84
+ test('wouldCreateCycle: true when the target already (transitively) depends on the source', () => {
85
+ // a depends on b; proposing b depends on a closes the loop.
86
+ const rows = [
87
+ row({ slug: 'a', dependsOn: ['b'] }),
88
+ row({ slug: 'b' }),
89
+ ];
90
+ expect(wouldCreateCycle(rows, 'b', ['a'])).toBe(true);
91
+ });
92
+
93
+ test('wouldCreateCycle: true for a direct self-reference', () => {
94
+ const rows = [row({ slug: 'a' })];
95
+ expect(wouldCreateCycle(rows, 'a', ['a'])).toBe(true);
96
+ });
97
+
98
+ test('wouldCreateCycle: false through an unrelated third row', () => {
99
+ const rows = [
100
+ row({ slug: 'a' }),
101
+ row({ slug: 'b', dependsOn: ['a'] }),
102
+ row({ slug: 'c' }),
103
+ ];
104
+ expect(wouldCreateCycle(rows, 'c', ['b'])).toBe(false);
105
+ });
106
+
107
+ // ─────────────────────────────────────────── computeDispositionRewrite
108
+
109
+ test('append sets the expected edges', () => {
110
+ const rows = [
111
+ row({ slug: 'wave1-tail' }),
112
+ row({ slug: 'wave2-root' }),
113
+ ];
114
+ const result = computeDispositionRewrite({
115
+ slug: 'wave2-root',
116
+ disposition: 'append',
117
+ dependsOn: ['wave1-tail'],
118
+ rows,
119
+ });
120
+ expect(result).toEqual({ ok: true, dependsOn: ['wave1-tail'] });
121
+ });
122
+
123
+ test('new-head sets none', () => {
124
+ const rows = [
125
+ row({ slug: 'wave1-tail' }),
126
+ row({ slug: 'wave2-root', dependsOn: ['wave1-tail'] }),
127
+ ];
128
+ const result = computeDispositionRewrite({
129
+ slug: 'wave2-root',
130
+ disposition: 'new-head',
131
+ dependsOn: [],
132
+ rows,
133
+ });
134
+ expect(result).toEqual({ ok: true, dependsOn: [] });
135
+ });
136
+
137
+ test('promoting a wave to a head removes exactly its root edges', () => {
138
+ const rows = [
139
+ row({ slug: 'wave1-tail' }),
140
+ row({ slug: 'other-head' }),
141
+ row({ slug: 'wave2-root', dependsOn: ['wave1-tail'] }),
142
+ ];
143
+ const result = computeDispositionRewrite({
144
+ slug: 'wave2-root',
145
+ disposition: 'new-head',
146
+ dependsOn: [],
147
+ rows,
148
+ });
149
+ expect(result.ok).toBe(true);
150
+ expect(result.dependsOn).toEqual([]);
151
+ // The unrelated head's own row is untouched — this function only ever
152
+ // returns the target row's new dependsOn, never mutates `rows`.
153
+ expect(rows.find((r) => r.slug === 'other-head').dependsOn).toEqual([]);
154
+ });
155
+
156
+ test('a change that would create a cycle is rejected', () => {
157
+ const rows = [
158
+ row({ slug: 'a', dependsOn: ['b'] }),
159
+ row({ slug: 'b' }),
160
+ ];
161
+ const result = computeDispositionRewrite({
162
+ slug: 'b',
163
+ disposition: 'append',
164
+ dependsOn: ['a'],
165
+ rows,
166
+ });
167
+ expect(result.ok).toBe(false);
168
+ expect(result.error).toMatch(/cycle/i);
169
+ });
170
+
171
+ test('running rows are untouched', () => {
172
+ const rows = [
173
+ row({ slug: 'wave1-tail' }),
174
+ row({ slug: 'wave2-root', status: 'running', dependsOn: ['wave1-tail'] }),
175
+ ];
176
+ const result = computeDispositionRewrite({
177
+ slug: 'wave2-root',
178
+ disposition: 'new-head',
179
+ dependsOn: [],
180
+ rows,
181
+ });
182
+ expect(result.ok).toBe(false);
183
+ expect(result.error).toMatch(/running/i);
184
+ });
185
+
186
+ test('completed rows are untouched', () => {
187
+ const rows = [
188
+ row({ slug: 'wave1-tail' }),
189
+ row({ slug: 'wave2-root', status: 'completed', dependsOn: ['wave1-tail'] }),
190
+ ];
191
+ const result = computeDispositionRewrite({
192
+ slug: 'wave2-root',
193
+ disposition: 'append',
194
+ dependsOn: ['wave1-tail'],
195
+ rows,
196
+ });
197
+ expect(result.ok).toBe(false);
198
+ expect(result.error).toMatch(/completed/i);
199
+ });
200
+
201
+ test('refuses to rewrite a row whose existing blocker has already completed', () => {
202
+ const rows = [
203
+ row({ slug: 'blocker', status: 'completed' }),
204
+ row({ slug: 'dependent', status: 'pending', dependsOn: ['blocker'] }),
205
+ ];
206
+ const result = computeDispositionRewrite({
207
+ slug: 'dependent',
208
+ disposition: 'new-head',
209
+ dependsOn: [],
210
+ rows,
211
+ });
212
+ expect(result.ok).toBe(false);
213
+ expect(result.error).toMatch(/already completed|satisfied/i);
214
+ });
215
+
216
+ test('unknown slug is refused, not silently ignored', () => {
217
+ const result = computeDispositionRewrite({
218
+ slug: 'ghost',
219
+ disposition: 'new-head',
220
+ dependsOn: [],
221
+ rows: [row({ slug: 'a' })],
222
+ });
223
+ expect(result.ok).toBe(false);
224
+ });
@@ -23,7 +23,8 @@ const { spawn } = require('node:child_process');
23
23
  const {
24
24
  selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome,
25
25
  findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome,
26
- isAlreadySatisfiedOnMain,
26
+ isAlreadySatisfiedOnMain, resolvePidlessFailureOverride,
27
+ readSpawnedPidFromLog, isLogFresh,
27
28
  } = require('../reaperHelpers.cjs');
28
29
  const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
29
30
 
@@ -339,6 +340,28 @@ test('findLiveProcessForJob: no live process anywhere → null', () => {
339
340
  assert.strictEqual(pid, null);
340
341
  });
341
342
 
343
+ // findLiveProcessForJob: runCwd — the 2026-09-13 in-place/shared-tree fix.
344
+ // A run with no dedicated worktree (no worktreeDir) and no slug in argv
345
+ // (dispatch never puts it there) is invisible to both existing matches; the
346
+ // runCwd+cmdline-claude combo match exists for exactly this shape. `sleep`
347
+ // is used as the /proc-visible process here (not a real `claude` binary), so
348
+ // this exercises the cwd-plus-cmdline-substring code path directly rather
349
+ // than depending on a real claude binary being installed.
350
+ test('findLiveProcessForJob: runCwd match requires BOTH cwd AND a claude-looking cmdline — bare cwd match is never sufficient', async () => {
351
+ if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
352
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-runcwd-'));
353
+ // Runs in `dir` but its cmdline has no "claude" in it — must NOT match.
354
+ const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
355
+ try {
356
+ await new Promise((resolve) => setTimeout(resolve, 200));
357
+ const pid = findLiveProcessForJob({ slug: 'unrelated-slug' }, { runCwd: dir });
358
+ assert.strictEqual(pid, null, 'a bare cwd match without a claude-looking cmdline must never match — shared-tree cross-match hazard');
359
+ } finally {
360
+ child.kill('SIGKILL');
361
+ fs.rmSync(dir, { recursive: true, force: true });
362
+ }
363
+ });
364
+
342
365
  // logHasOutput — the literal "did the run dir produce any log output" check
343
366
  // that gates whether a pidless reap may assert gateOutcome: 'never_ran'.
344
367
 
@@ -397,3 +420,158 @@ test('isAlreadySatisfiedOnMain: no satisfying commits → null — the gate must
397
420
  assert.strictEqual(isAlreadySatisfiedOnMain(null), null);
398
421
  assert.strictEqual(isAlreadySatisfiedOnMain(undefined), null);
399
422
  });
423
+
424
+ // resolvePidlessFailureOverride — PRD 1173. A pidless reap must never stamp
425
+ // 'failed' on a row that already carries proof (landedCommit) that its work
426
+ // shipped, purely because reproting bookkeeping (runtime.pid) was lost.
427
+
428
+ test('resolvePidlessFailureOverride: row with a landedCommit → override naming the recorded commit sha', () => {
429
+ const override = resolvePidlessFailureOverride({ slug: 'shipped-but-pidless', landedCommit: 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f' });
430
+ assert.ok(override, 'a row with landedCommit must produce a failure override');
431
+ assert.strictEqual(override.verdict, 'pidless_reap_with_landed_commit');
432
+ assert.strictEqual(override.landedCommit, 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f');
433
+ assert.match(override.reason, /f3e35a7f9e49f13a9eb8c338b0d086160e32a87f/, 'the reason string must name the recorded commit sha so a human can go straight to `git show <sha>`');
434
+ });
435
+
436
+ test('resolvePidlessFailureOverride: row with no landedCommit → null, existing failed transition untouched', () => {
437
+ assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran' }), null);
438
+ assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran', landedCommit: null }), null);
439
+ assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran', landedCommit: '' }), null);
440
+ });
441
+
442
+ // readSpawnedPidFromLog — recovers the pid a pidless row's spawn actually
443
+ // produced from the run's own log (2026-09-13 incident: runId
444
+ // 2026-09-13T16-15-56-818Z spawned pid=3138458, logged it, but the
445
+ // runtime.pid persistence mutate failed silently and the pidless-grace
446
+ // reaper terminalized a live job it could have found here).
447
+
448
+ test('readSpawnedPidFromLog: parses the scheduler\'s own spawned-pid line', () => {
449
+ const p = writeTmpLog('some preamble\n[scheduler] spawned pid=3138458 sessionId=345ca5b1-c7a7-4167-9b55-f4905e3afe01 (process group)\n\nmore output\n');
450
+ assert.strictEqual(readSpawnedPidFromLog(p), 3138458);
451
+ });
452
+
453
+ test('readSpawnedPidFromLog: missing file → null', () => {
454
+ assert.strictEqual(readSpawnedPidFromLog('/nonexistent/path/does-not-exist.log'), null);
455
+ });
456
+
457
+ test('readSpawnedPidFromLog: empty file → null', () => {
458
+ const p = writeTmpLog('');
459
+ assert.strictEqual(readSpawnedPidFromLog(p), null);
460
+ });
461
+
462
+ test('readSpawnedPidFromLog: log with no spawned-pid line → null', () => {
463
+ const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
464
+ assert.strictEqual(readSpawnedPidFromLog(p), null);
465
+ });
466
+
467
+ test('readSpawnedPidFromLog: null path → null', () => {
468
+ assert.strictEqual(readSpawnedPidFromLog(null), null);
469
+ });
470
+
471
+ // isLogFresh — pure predicate: a log modified within the window is positive
472
+ // proof of life, independent of runtime.pid bookkeeping.
473
+
474
+ test('isLogFresh: mtime within window → true', () => {
475
+ assert.strictEqual(isLogFresh(NOW - 60_000, NOW, 20 * 60_000), true);
476
+ });
477
+
478
+ test('isLogFresh: mtime outside window → false', () => {
479
+ assert.strictEqual(isLogFresh(NOW - 30 * 60_000, NOW, 20 * 60_000), false);
480
+ });
481
+
482
+ test('isLogFresh: missing/invalid mtime or window → false', () => {
483
+ assert.strictEqual(isLogFresh(null, NOW, 20 * 60_000), false);
484
+ assert.strictEqual(isLogFresh(NaN, NOW, 20 * 60_000), false);
485
+ assert.strictEqual(isLogFresh(NOW - 60_000, NOW, null), false);
486
+ });
487
+
488
+ // selectReapableJobs — Part B wiring: a pidless row past grace now consults
489
+ // the run log for a pid, and a fresh log skips the row entirely, BEFORE
490
+ // falling back to findLiveProcess or the existing terminal reap.
491
+
492
+ test('selectReapableJobs: pidless + past grace + log yields a LIVE pid → recovered, never reapable, row stays running', () => {
493
+ const jobs = [{ slug: 'zombie-log-alive', status: 'running', startedAt: agoMin(464) }];
494
+ const pidAlive = (pid) => pid === 3138458;
495
+ const getLogPid = () => 3138458;
496
+ const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
497
+ pidAlive, grace: GRACE, getLogPid,
498
+ });
499
+ assert.deepStrictEqual(reapable, []);
500
+ assert.deepStrictEqual(warnings, []);
501
+ assert.strictEqual(recovered.length, 1);
502
+ assert.strictEqual(recovered[0].slug, 'zombie-log-alive');
503
+ assert.strictEqual(recovered[0].pid, 3138458);
504
+ });
505
+
506
+ test('selectReapableJobs: pidless + past grace + log yields a DEAD pid → still reaps (no regression)', () => {
507
+ const jobs = [{ slug: 'zombie-log-dead', status: 'running', startedAt: agoMin(464) }];
508
+ const getLogPid = () => 3138458;
509
+ const { reapable, recovered } = selectReapableJobs(jobs, NOW, {
510
+ pidAlive: alwaysDead, grace: GRACE, getLogPid,
511
+ });
512
+ assert.deepStrictEqual(recovered, []);
513
+ assert.strictEqual(reapable.length, 1);
514
+ assert.strictEqual(reapable[0].pidless, true);
515
+ });
516
+
517
+ test('selectReapableJobs: pidless + past grace + FRESH log mtime → skipped entirely (neither reapable nor recovered)', () => {
518
+ const jobs = [{ slug: 'chatty-pidless', status: 'running', startedAt: agoMin(464) }];
519
+ const getLogMtimeMs = () => NOW - 60_000; // modified 1 minute ago
520
+ const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
521
+ pidAlive: alwaysAlive, grace: GRACE, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
522
+ });
523
+ assert.deepStrictEqual(reapable, []);
524
+ assert.deepStrictEqual(recovered, []);
525
+ assert.strictEqual(warnings.length, 1);
526
+ assert.strictEqual(warnings[0].slug, 'chatty-pidless');
527
+ assert.match(warnings[0].reason, /log evidence/);
528
+ });
529
+
530
+ test('selectReapableJobs: pidless + past grace + STALE log mtime + no recoverable pid → reaps with the existing reason string, byte-identical', () => {
531
+ const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
532
+ const getLogMtimeMs = () => NOW - 25 * 60_000; // stale relative to a 20m window
533
+ const { reapable } = selectReapableJobs(jobs, NOW, {
534
+ pidAlive: alwaysAlive, grace: GRACE, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
535
+ });
536
+ assert.strictEqual(reapable.length, 1);
537
+ assert.strictEqual(reapable[0].reason, 'reaped: no runtime.pid recorded after 10m — spawn never completed');
538
+ });
539
+
540
+ test('selectReapableJobs: a row WITH a live runtime.pid is untouched by every new code path', () => {
541
+ const jobs = [{ slug: 'live', status: 'running', runtime: { pid: 1 }, startedAt: agoMin(500) }];
542
+ const getLogPid = () => { throw new Error('must never be called for a live-pid row'); };
543
+ const getLogMtimeMs = () => { throw new Error('must never be called for a live-pid row'); };
544
+ const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
545
+ pidAlive: alwaysAlive, grace: GRACE, getLogPid, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
546
+ });
547
+ assert.deepStrictEqual(reapable, []);
548
+ assert.deepStrictEqual(recovered, []);
549
+ assert.deepStrictEqual(warnings, []);
550
+ });
551
+
552
+ test('selectReapableJobs: pidless + past grace + landedCommit on the row → reapable entry carries a failureOverride', () => {
553
+ const jobs = [{
554
+ slug: 'shipped-but-pidless',
555
+ status: 'running',
556
+ startedAt: agoMin(464),
557
+ landedCommit: 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f',
558
+ }];
559
+ const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
560
+ assert.strictEqual(reapable.length, 1);
561
+ assert.strictEqual(reapable[0].pidless, true);
562
+ assert.ok(reapable[0].failureOverride, 'the reapable entry must carry the evidence-based override');
563
+ assert.strictEqual(reapable[0].failureOverride.landedCommit, 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f');
564
+ // The existing byte-for-byte reason string is untouched by this PRD — the
565
+ // override is carried as a separate field, not folded into `reason`.
566
+ assert.strictEqual(
567
+ reapable[0].reason,
568
+ 'reaped: no runtime.pid recorded after 10m — spawn never completed',
569
+ );
570
+ });
571
+
572
+ test('selectReapableJobs: pidless + past grace + NO landedCommit on the row → failureOverride is null, unchanged from today', () => {
573
+ const jobs = [{ slug: 'genuinely-never-ran', status: 'running', startedAt: agoMin(464) }];
574
+ const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
575
+ assert.strictEqual(reapable.length, 1);
576
+ assert.strictEqual(reapable[0].failureOverride, null);
577
+ });
@@ -113,6 +113,17 @@ test('with no version change, flush("version-change") does NOT run', async () =>
113
113
  expect(flushCalls).toEqual(['boot']);
114
114
  });
115
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
+
116
127
  test('fires app.launch via telemetryCounters with installChannel + appVersion', async () => {
117
128
  const now = Date.now();
118
129
  const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * usageCircuit.test.cjs — breaker transitions, jitter bounds, bindingWindow
3
+ * fallback, reset freshness, degraded-budget floor/cap, singleFlight dedup.
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/lib/__tests__/usageCircuit.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test } from 'vitest';
11
+ const assert = require('node:assert/strict');
12
+ const {
13
+ createUsageCircuit,
14
+ backoffWithJitter,
15
+ isResetFresh,
16
+ bindingWindow,
17
+ degradedBudget,
18
+ singleFlight,
19
+ } = require('../usageCircuit.cjs');
20
+
21
+ test('state machine: closed -> open after 3 consecutive failures', () => {
22
+ let nowMs = 0;
23
+ const circuit = createUsageCircuit({ now: () => nowMs });
24
+ assert.strictEqual(circuit.state(), 'closed');
25
+ circuit.recordFailure('meter_rate_limited');
26
+ assert.strictEqual(circuit.state(), 'closed');
27
+ circuit.recordFailure('meter_rate_limited');
28
+ assert.strictEqual(circuit.state(), 'closed');
29
+ circuit.recordFailure('meter_rate_limited');
30
+ assert.strictEqual(circuit.state(), 'open');
31
+ });
32
+
33
+ test('open -> half_open after the backoff interval elapses, exactly one probe granted', () => {
34
+ let nowMs = 0;
35
+ const circuit = createUsageCircuit({ now: () => nowMs });
36
+ circuit.recordFailure('a');
37
+ circuit.recordFailure('a');
38
+ circuit.recordFailure('a');
39
+ assert.strictEqual(circuit.state(), 'open');
40
+
41
+ nowMs += 10_000; // still inside the (>=15s) minimum backoff window
42
+ assert.strictEqual(circuit.state(), 'open');
43
+
44
+ nowMs += 60 * 60 * 1000; // well past any possible backoff interval
45
+ assert.strictEqual(circuit.state(), 'half_open');
46
+ // Re-querying before the probe's result is recorded must not grant a second probe.
47
+ assert.strictEqual(circuit.state(), 'half_open');
48
+ });
49
+
50
+ test('half_open -> closed on a successful probe, resetting backoff', () => {
51
+ let nowMs = 0;
52
+ const circuit = createUsageCircuit({ now: () => nowMs });
53
+ circuit.recordFailure('a');
54
+ circuit.recordFailure('a');
55
+ circuit.recordFailure('a');
56
+ nowMs += 60 * 60 * 1000;
57
+ assert.strictEqual(circuit.state(), 'half_open');
58
+ circuit.recordSuccess({ five_hour: { utilization: 10, resets_at: null } });
59
+ assert.strictEqual(circuit.state(), 'closed');
60
+ });
61
+
62
+ test('half_open -> open on a failed probe, with backoff doubled (probe takes longer to reopen)', () => {
63
+ let nowMs = 0;
64
+ const circuit = createUsageCircuit({ now: () => nowMs });
65
+ circuit.recordFailure('a');
66
+ circuit.recordFailure('a');
67
+ circuit.recordFailure('a');
68
+ nowMs += 60 * 60 * 1000;
69
+ assert.strictEqual(circuit.state(), 'half_open');
70
+ circuit.recordFailure('a');
71
+ assert.strictEqual(circuit.state(), 'open');
72
+ nowMs += 25_000; // below the doubled 2nd-stage backoff's own 30s floor
73
+ assert.strictEqual(circuit.state(), 'open');
74
+ nowMs += 60 * 60 * 1000;
75
+ assert.strictEqual(circuit.state(), 'half_open');
76
+ });
77
+
78
+ test('backoffWithJitter: 1000 samples stay within [0.5x, 1.0x] of the doubled base, floor 30s / cap 8min', () => {
79
+ for (let i = 0; i < 1000; i++) {
80
+ const first = backoffWithJitter(0);
81
+ assert.ok(first >= 15_000 && first <= 30_000, `first=${first}`);
82
+
83
+ const second = backoffWithJitter(30_000);
84
+ assert.ok(second >= 30_000 && second <= 60_000, `second=${second}`);
85
+
86
+ const atCap = backoffWithJitter(8 * 60 * 1000);
87
+ assert.ok(atCap >= 4 * 60 * 1000 && atCap <= 8 * 60 * 1000, `atCap=${atCap}`);
88
+
89
+ const beyondCap = backoffWithJitter(20 * 60 * 1000); // doubling would exceed the 8min cap
90
+ assert.ok(beyondCap >= 4 * 60 * 1000 && beyondCap <= 8 * 60 * 1000, `beyondCap=${beyondCap}`);
91
+ }
92
+ });
93
+
94
+ test('isResetFresh: past -> false, future -> true, null -> false', () => {
95
+ const now = Date.parse('2026-09-13T12:00:00Z');
96
+ assert.strictEqual(isResetFresh('2026-09-13T11:59:59Z', now), false); // past
97
+ assert.strictEqual(isResetFresh('2026-09-13T12:00:00Z', now), false); // at now
98
+ assert.strictEqual(isResetFresh('2026-09-13T12:00:01Z', now), true); // future
99
+ assert.strictEqual(isResetFresh(null, now), false);
100
+ assert.strictEqual(isResetFresh(undefined, now), false);
101
+ assert.strictEqual(isResetFresh('not-a-date', now), false);
102
+ });
103
+
104
+ test('bindingWindow: limits[] with weekly_all is_active wins over five_hour', () => {
105
+ const payload = {
106
+ five_hour: { utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
107
+ limits: [
108
+ { type: 'five_hour', is_active: false, utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
109
+ { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
110
+ ],
111
+ };
112
+ const win = bindingWindow(payload);
113
+ assert.strictEqual(win.name, 'weekly_all');
114
+ assert.strictEqual(win.utilization, 81);
115
+ assert.strictEqual(win.resets_at, '2026-09-19T00:00:00Z');
116
+ });
117
+
118
+ test('bindingWindow: limits[] absent falls back to five_hour', () => {
119
+ const payload = { five_hour: { utilization: 42, resets_at: '2026-09-13T15:00:00Z' } };
120
+ const win = bindingWindow(payload);
121
+ assert.strictEqual(win.name, 'five_hour');
122
+ assert.strictEqual(win.utilization, 42);
123
+ });
124
+
125
+ test('bindingWindow: limits[] present but nothing is_active also falls back to five_hour', () => {
126
+ const payload = {
127
+ five_hour: { utilization: 12, resets_at: null },
128
+ limits: [
129
+ { type: 'five_hour', is_active: false, utilization: 12, resets_at: null },
130
+ { type: 'weekly_all', is_active: false, utilization: 81, resets_at: null },
131
+ ],
132
+ };
133
+ const win = bindingWindow(payload);
134
+ assert.strictEqual(win.name, 'five_hour');
135
+ assert.strictEqual(win.utilization, 12);
136
+ });
137
+
138
+ test('degradedBudget: never yields 0 utilization, even when the last good binding window read 0', () => {
139
+ const payload = { five_hour: { utilization: 0, resets_at: null } };
140
+ const { utilization } = degradedBudget(payload, {});
141
+ assert.notStrictEqual(utilization, 0);
142
+ assert.strictEqual(utilization, 100); // no real signal -> conservative default
143
+ });
144
+
145
+ test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
146
+ const payload = {
147
+ five_hour: { utilization: 0, resets_at: null },
148
+ limits: [
149
+ { type: 'five_hour', is_active: false, utilization: 0, resets_at: null },
150
+ { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
151
+ ],
152
+ };
153
+ const { utilization } = degradedBudget(payload, {});
154
+ assert.strictEqual(utilization, 81);
155
+ });
156
+
157
+ test('degradedBudget: caps concurrency at 2 regardless of a higher configured cap', () => {
158
+ const payload = { five_hour: { utilization: 30, resets_at: null } };
159
+ const { concurrencyCap } = degradedBudget(payload, { configuredCap: 5 });
160
+ assert.strictEqual(concurrencyCap, 2);
161
+ });
162
+
163
+ test('degradedBudget: respects a configured cap below 2', () => {
164
+ const payload = { five_hour: { utilization: 30, resets_at: null } };
165
+ const { concurrencyCap } = degradedBudget(payload, { configuredCap: 1 });
166
+ assert.strictEqual(concurrencyCap, 1);
167
+ });
168
+
169
+ test('degradedBudget: an executor-observed 429 whose window has not passed pins utilization to 100', () => {
170
+ const payload = { five_hour: { utilization: 5, resets_at: null } };
171
+ const now = Date.parse('2026-09-13T12:00:00Z');
172
+ const { utilization } = degradedBudget(payload, {
173
+ observed429: true,
174
+ resetsAt: '2026-09-13T13:00:00Z', // still in the future
175
+ now,
176
+ });
177
+ assert.strictEqual(utilization, 100);
178
+ });
179
+
180
+ test('degradedBudget: once the 429 window passes, it stops pinning to 100 and falls back to real utilization', () => {
181
+ const payload = { five_hour: { utilization: 5, resets_at: null } };
182
+ const now = Date.parse('2026-09-13T12:00:00Z');
183
+ const { utilization } = degradedBudget(payload, {
184
+ observed429: true,
185
+ resetsAt: '2026-09-13T11:00:00Z', // already passed
186
+ now,
187
+ });
188
+ assert.strictEqual(utilization, 5);
189
+ });
190
+
191
+ test('singleFlight: concurrent callers share the same in-flight promise, fn invoked once', async () => {
192
+ let calls = 0;
193
+ let resolveFn;
194
+ const fn = () => new Promise((resolve) => { resolveFn = resolve; calls += 1; });
195
+ const wrapped = singleFlight(fn);
196
+
197
+ const p1 = wrapped();
198
+ const p2 = wrapped();
199
+ assert.strictEqual(p1, p2);
200
+ assert.strictEqual(calls, 1);
201
+ resolveFn('done');
202
+ assert.strictEqual(await p1, 'done');
203
+
204
+ const p3 = wrapped();
205
+ assert.notStrictEqual(p3, p1);
206
+ assert.strictEqual(calls, 2);
207
+ resolveFn('done2');
208
+ await p3;
209
+ });
210
+
211
+ test('singleFlight: de-dupes even when fn rejects, next call gets a fresh attempt', async () => {
212
+ let calls = 0;
213
+ const fn = () => (calls++ === 0 ? Promise.reject(new Error('boom')) : Promise.resolve('ok'));
214
+ const wrapped = singleFlight(fn);
215
+
216
+ const p1 = wrapped();
217
+ const p2 = wrapped();
218
+ assert.strictEqual(p1, p2);
219
+ await assert.rejects(p1, /boom/);
220
+
221
+ const p3 = wrapped();
222
+ assert.strictEqual(await p3, 'ok');
223
+ assert.strictEqual(calls, 2);
224
+ });