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
@@ -21,7 +21,7 @@
21
21
 
22
22
  'use strict';
23
23
 
24
- import { test, expect, afterEach } from 'vitest';
24
+ import { test, expect, afterEach, vi } from 'vitest';
25
25
  const fs = require('node:fs');
26
26
  const fsp = require('node:fs/promises');
27
27
  const os = require('node:os');
@@ -150,3 +150,45 @@ test('scheduler.cjs exports candidateArchivedPrdsDirs alongside candidatePrdsDir
150
150
  expect(Array.isArray(candidatePrdsDirs())).toBe(true);
151
151
  expect(Array.isArray(candidateArchivedPrdsDirs())).toBe(true);
152
152
  });
153
+
154
+ test('resolveArchivedPrdsDirs: a prds-archived/ dir created inside an ALREADY-EXISTING Epic (the archiveCompletedPrd shape — does not bump the Epics root mtime) is bounded-stale, not stale forever', async () => {
155
+ // archiveCompletedPrd (scheduler.cjs) creates `epics/<id>/prds-archived/`
156
+ // as a new entry INSIDE an already-existing `epics/<id>/` dir — unlike a
157
+ // brand-new Epic's `prds/` (created in the same mkdirSync call as
158
+ // `epics/<id>/` itself), this does NOT bump the Epics root's own mtime,
159
+ // so the mtime-keyed freshness key alone would never invalidate. The
160
+ // short TTL backstop (same idiom as activeSessions.cjs's cwdScanCache)
161
+ // bounds that blind spot instead of leaving it stale indefinitely.
162
+ vi.useFakeTimers();
163
+ try {
164
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-blindspot-projects-'));
165
+ tmpDirs.push(projectsDir);
166
+ const projectCwd = await mkEpicProject();
167
+ tmpDirs.push(projectCwd);
168
+
169
+ const epicId = `blindspot-epic-${process.pid}`;
170
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, epicId);
171
+ fs.mkdirSync(epicPrdsDir, { recursive: true }); // Epic already exists, well before archiving.
172
+
173
+ const projDir = path.join(projectsDir, 'blindspot-project');
174
+ fs.mkdirSync(projDir, { recursive: true });
175
+ fs.writeFileSync(path.join(projDir, 'session1.jsonl'), `${JSON.stringify({ cwd: projectCwd })}\n`);
176
+
177
+ // Prime the cache — no prds-archived/ dir exists yet.
178
+ const before = resolveArchivedPrdsDirs(90, { projectsDir });
179
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
180
+ expect(before).not.toContain(path.resolve(epicArchiveDir));
181
+
182
+ // The archiveCompletedPrd shape: a NEW prds-archived/ dir appears inside
183
+ // the ALREADY-EXISTING epic dir. This does not change the Epics root's
184
+ // own mtime.
185
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
186
+
187
+ // Bounded staleness is acceptable — but it must actually be bounded.
188
+ vi.advanceTimersByTime(31_000);
189
+ const after = resolveArchivedPrdsDirs(90, { projectsDir });
190
+ expect(after.map((d) => path.resolve(d))).toContain(path.resolve(epicArchiveDir));
191
+ } finally {
192
+ vi.useRealTimers();
193
+ }
194
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * prdSetDisposition.test.cjs — scheduler wave-disposition PRD:
3
+ * scheduler.remote.setPrdDisposition (backing the renderer's
4
+ * `schedule:set-prd-disposition` IPC action) is how a human changes an
5
+ * already-written PRD's disposition from the Scheduler UI — promoting an
6
+ * appended wave to its own head, or re-attaching a head behind another
7
+ * chain. Validates via prdDisposition.cjs's computeDispositionRewrite
8
+ * (cycle-safety, running/completed rows untouched) before delegating the
9
+ * actual write to the SAME remote.updatePrd every other PRD edit path uses.
10
+ *
11
+ * Mirrors prdUpdateDependsOn.test.cjs's HOME-isolation fixture.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdSetDisposition.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let scheduler;
26
+ let queueStore;
27
+ let config;
28
+
29
+ beforeAll(() => {
30
+ originalHome = process.env.HOME;
31
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-set-disposition-home-'));
32
+ process.env.HOME = tmpHome;
33
+
34
+ scheduler = require('../scheduler.cjs');
35
+ queueStore = require('../lib/queueStore.cjs');
36
+ config = require('../config.cjs');
37
+
38
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
39
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
40
+ }
41
+ });
42
+
43
+ afterAll(() => {
44
+ process.env.HOME = originalHome;
45
+ fs.rmSync(tmpHome, { recursive: true, force: true });
46
+ });
47
+
48
+ function registerActiveProject(cwd) {
49
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
50
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
51
+ fs.mkdirSync(slugDir, { recursive: true });
52
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
53
+ }
54
+
55
+ function makeFixtureProject(prefix) {
56
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
57
+ registerActiveProject(cwd);
58
+ config.addAllowedRoot(cwd);
59
+ const opsRoot = path.join(cwd, 'session-manager-operations');
60
+ const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
61
+ const stateDir = path.join(opsRoot, 'scheduler', 'state');
62
+ fs.mkdirSync(prdsDir, { recursive: true });
63
+ fs.mkdirSync(stateDir, { recursive: true });
64
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
65
+ queueStore.bustCwdCache();
66
+ return { cwd, prdsDir, stateDir };
67
+ }
68
+
69
+ function writePrd(prdsDir, filename, cwd, extraFrontmatter = '') {
70
+ fs.writeFileSync(
71
+ path.join(prdsDir, filename),
72
+ `---\ntitle: ${filename}\ncwd: ${cwd}\nestimateMinutes: 10\ncreatedVia: scheduler-api\n${extraFrontmatter}---\n\n# Goal\nDo the thing.\n`,
73
+ 'utf8',
74
+ );
75
+ }
76
+
77
+ /** Seeds a queue.json row for `slug` with the given status — setPrdDisposition
78
+ * (like updatePrd) reads job status off the live queue, not the PRD file. */
79
+ function seedQueueJob(stateDir, slug, status) {
80
+ fs.writeFileSync(
81
+ path.join(stateDir, 'queue.json'),
82
+ JSON.stringify({ jobs: [{ slug, status, title: slug, cwd: '' }] }, null, 2),
83
+ 'utf8',
84
+ );
85
+ queueStore.bustCwdCache();
86
+ }
87
+
88
+ test('append sets the expected edges', async () => {
89
+ const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-append-');
90
+ try {
91
+ writePrd(prdsDir, '3-wave1-tail.md', cwd);
92
+ writePrd(prdsDir, '4-wave2-root.md', cwd);
93
+
94
+ const result = await scheduler.remote.setPrdDisposition({
95
+ slug: '4-wave2-root',
96
+ cwd,
97
+ disposition: 'append',
98
+ dependsOn: ['3-wave1-tail'],
99
+ });
100
+
101
+ expect(result.ok).toBe(true);
102
+ const written = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
103
+ expect(written).toMatch(/^dependsOn: \[3-wave1-tail\]$/m);
104
+ expect(written).toMatch(/^disposition: append$/m);
105
+ } finally {
106
+ fs.rmSync(cwd, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ test('new-head sets none', async () => {
111
+ const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-new-head-');
112
+ try {
113
+ writePrd(prdsDir, '3-wave1-tail.md', cwd);
114
+ writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
115
+
116
+ const result = await scheduler.remote.setPrdDisposition({
117
+ slug: '4-wave2-root',
118
+ cwd,
119
+ disposition: 'new-head',
120
+ });
121
+
122
+ expect(result.ok).toBe(true);
123
+ const written = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
124
+ expect(written).not.toContain('dependsOn');
125
+ expect(written).toMatch(/^disposition: new-head$/m);
126
+ } finally {
127
+ fs.rmSync(cwd, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test('promoting a wave to a head removes exactly its root edges', async () => {
132
+ const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-promote-');
133
+ try {
134
+ writePrd(prdsDir, '3-wave1-tail.md', cwd);
135
+ writePrd(prdsDir, '5-other-head.md', cwd);
136
+ writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
137
+
138
+ const result = await scheduler.remote.setPrdDisposition({
139
+ slug: '4-wave2-root',
140
+ cwd,
141
+ disposition: 'new-head',
142
+ });
143
+
144
+ expect(result.ok).toBe(true);
145
+ const promoted = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
146
+ expect(promoted).not.toContain('dependsOn');
147
+ // The unrelated sibling head is untouched by this call.
148
+ const other = fs.readFileSync(path.join(prdsDir, '5-other-head.md'), 'utf8');
149
+ expect(other).not.toContain('dependsOn');
150
+ } finally {
151
+ fs.rmSync(cwd, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ test('a change that would create a cycle is rejected, and nothing is written', async () => {
156
+ const { cwd, prdsDir } = makeFixtureProject('sm-set-disposition-cycle-');
157
+ try {
158
+ writePrd(prdsDir, '3-a.md', cwd, 'dependsOn: [b]\n');
159
+ writePrd(prdsDir, '4-b.md', cwd);
160
+ const before = fs.readFileSync(path.join(prdsDir, '4-b.md'), 'utf8');
161
+
162
+ const result = await scheduler.remote.setPrdDisposition({
163
+ slug: '4-b',
164
+ cwd,
165
+ disposition: 'append',
166
+ dependsOn: ['3-a'],
167
+ });
168
+
169
+ expect(result.ok).toBe(false);
170
+ expect(result.error).toMatch(/cycle/i);
171
+ const after = fs.readFileSync(path.join(prdsDir, '4-b.md'), 'utf8');
172
+ expect(after).toBe(before);
173
+ } finally {
174
+ fs.rmSync(cwd, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test('running rows are untouched', async () => {
179
+ const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-set-disposition-running-');
180
+ try {
181
+ writePrd(prdsDir, '3-wave1-tail.md', cwd);
182
+ writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
183
+ seedQueueJob(stateDir, '4-wave2-root', 'running');
184
+ const before = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
185
+
186
+ const result = await scheduler.remote.setPrdDisposition({
187
+ slug: '4-wave2-root',
188
+ cwd,
189
+ disposition: 'new-head',
190
+ });
191
+
192
+ expect(result.ok).toBe(false);
193
+ expect(result.error).toMatch(/running/i);
194
+ const after = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
195
+ expect(after).toBe(before);
196
+ } finally {
197
+ fs.rmSync(cwd, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ test('completed rows are untouched', async () => {
202
+ const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-set-disposition-completed-');
203
+ try {
204
+ writePrd(prdsDir, '3-wave1-tail.md', cwd);
205
+ writePrd(prdsDir, '4-wave2-root.md', cwd, 'dependsOn: [wave1-tail]\ndisposition: append\n');
206
+ seedQueueJob(stateDir, '4-wave2-root', 'completed');
207
+ const before = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
208
+
209
+ const result = await scheduler.remote.setPrdDisposition({
210
+ slug: '4-wave2-root',
211
+ cwd,
212
+ disposition: 'new-head',
213
+ });
214
+
215
+ expect(result.ok).toBe(false);
216
+ expect(result.error).toMatch(/completed/i);
217
+ const after = fs.readFileSync(path.join(prdsDir, '4-wave2-root.md'), 'utf8');
218
+ expect(after).toBe(before);
219
+ } finally {
220
+ fs.rmSync(cwd, { recursive: true, force: true });
221
+ }
222
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * pty-session-open-telemetry.test.cjs — proves the `session.open` counter tap
3
+ * (telemetryCounters.trackSessionOpen(), wired at pty.cjs's spawn() call
4
+ * site) actually fires on a real fresh PTY spawn, and documents/locks in the
5
+ * deliberate choice that a reattach (same tabId already registered in this
6
+ * process — a renderer HMR reload, or switching back to an Epic's Terminal
7
+ * pane that's already running) does NOT fire it a second time, since it's
8
+ * the same underlying session, not a new one (see pty.cjs's spawn() comment
9
+ * above the reattach branch, and telemetry.md).
10
+ *
11
+ * Same monkey-patch-node-pty pattern as pty-epic-worktree-spawn-cwd.test.cjs
12
+ * — no existing test in this repo mocks a module import.
13
+ *
14
+ * Run: timeout 120 npx vitest run src/main/__tests__/pty-session-open-telemetry.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+
24
+ let tmpHome;
25
+ let originalHome;
26
+ let manager;
27
+ let nodePty;
28
+ let originalSpawn;
29
+ let telemetryCounters;
30
+
31
+ beforeAll(() => {
32
+ originalHome = process.env.HOME;
33
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pty-sessionopen-home-'));
34
+ process.env.HOME = tmpHome;
35
+ ({ manager } = require('../pty.cjs'));
36
+ nodePty = require('node-pty');
37
+ originalSpawn = nodePty.spawn;
38
+ telemetryCounters = require('../lib/telemetryCounters.cjs');
39
+ });
40
+
41
+ afterAll(() => {
42
+ nodePty.spawn = originalSpawn;
43
+ process.env.HOME = originalHome;
44
+ fs.rmSync(tmpHome, { recursive: true, force: true });
45
+ });
46
+
47
+ afterEach(() => {
48
+ nodePty.spawn = originalSpawn;
49
+ manager.sessions.clear();
50
+ manager.buffers.clear();
51
+ manager.killed.clear();
52
+ vi.restoreAllMocks();
53
+ });
54
+
55
+ function fakeProc() {
56
+ return {
57
+ pid: 4242,
58
+ exitCode: null,
59
+ onData: () => {},
60
+ onExit: () => {},
61
+ resize: () => {},
62
+ kill: () => {},
63
+ write: () => {},
64
+ };
65
+ }
66
+
67
+ test('a fresh spawn() fires trackSessionOpen exactly once', () => {
68
+ const spy = vi.spyOn(telemetryCounters, 'trackSessionOpen').mockImplementation(() => {});
69
+ const cwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-fresh-'));
70
+ const tabId = 'fresh-tab-1';
71
+ nodePty.spawn = () => fakeProc();
72
+
73
+ manager.spawn({ tabId, cwd, cols: 80, rows: 24 });
74
+
75
+ expect(spy).toHaveBeenCalledTimes(1);
76
+ manager.kill(tabId);
77
+ });
78
+
79
+ test('a reattach to an already-registered tabId does NOT fire trackSessionOpen again', () => {
80
+ const spy = vi.spyOn(telemetryCounters, 'trackSessionOpen').mockImplementation(() => {});
81
+ const cwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-reattach-'));
82
+ const tabId = 'reattach-tab-1';
83
+ nodePty.spawn = () => fakeProc();
84
+
85
+ const first = manager.spawn({ tabId, cwd, cols: 80, rows: 24 });
86
+ expect(first.reattached).toBe(false);
87
+ expect(spy).toHaveBeenCalledTimes(1);
88
+
89
+ const second = manager.spawn({ tabId, cwd, cols: 80, rows: 24 });
90
+ expect(second.reattached).toBe(true);
91
+ // Still exactly one call — the reattach path returns before the counter
92
+ // tap, by design (same session, not a new one).
93
+ expect(spy).toHaveBeenCalledTimes(1);
94
+
95
+ manager.kill(tabId);
96
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * queue-health-verdict.test.cjs — classifyQueueHealth is the Scheduler
3
+ * page's queue-health header: the single honest read of WHY a queue looks
4
+ * stale (saturated slots / blocked dependencies / genuinely idle / the
5
+ * dispatch driver itself stalled / a decision to pause / an open launch
6
+ * circuit breaker), reusing classifyQueueStarvation so the header can never
7
+ * disagree with the starvation watchdog's own read of the same state.
8
+ *
9
+ * Run: timeout 180 npx vitest run src/main/__tests__/queue-health-verdict.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, afterAll } from 'vitest';
15
+ const assert = require('node:assert/strict');
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+
20
+ // HOME before require: every state path is baked from os.homedir() at load.
21
+ const originalHome = process.env.HOME;
22
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-health-test-'));
23
+ process.env.HOME = tmpHome;
24
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
25
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
26
+
27
+ const { classifyQueueHealth, QUEUE_STARVATION_MS } = require('../scheduler.cjs');
28
+
29
+ afterAll(() => {
30
+ process.env.HOME = originalHome;
31
+ fs.rmSync(tmpHome, { recursive: true, force: true });
32
+ });
33
+
34
+ const CWD = '/home/bilko/Projects/starry-night-ships';
35
+ const NOW = Date.parse('2026-09-13T20:00:00.000Z');
36
+ const LONG = QUEUE_STARVATION_MS + 60_000;
37
+
38
+ test('saturated: jobs running, zero free slots', () => {
39
+ const jobs = [
40
+ { slug: '1-a', cwd: CWD, status: 'running', startedAt: new Date(NOW - LONG).toISOString(), dependsOn: [] },
41
+ { slug: '2-b', cwd: CWD, status: 'pending', dependsOn: [] },
42
+ ];
43
+ const v = classifyQueueHealth({
44
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(['1-a']),
45
+ freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
46
+ });
47
+ assert.equal(v.kind, 'saturated');
48
+ assert.equal(v.runningCount, 1);
49
+ assert.equal(v.totalSlots, 4);
50
+ // Dispatchable/blocked counts stay real numbers even in a kind whose
51
+ // headline cause is slot saturation, not dependency shape — the header
52
+ // must still be able to show them alongside the verdict.
53
+ assert.equal(v.pending, 1);
54
+ assert.equal(v.dispatchable, 1);
55
+ assert.deepEqual(v.blockedChains, []);
56
+ });
57
+
58
+ test('blocked: every pending row terminates in a failed dependency', () => {
59
+ const jobs = [
60
+ { slug: '1-a', cwd: CWD, status: 'failed', dependsOn: [] },
61
+ { slug: '2-b', cwd: CWD, status: 'pending', dependsOn: ['1-a'] },
62
+ ];
63
+ const v = classifyQueueHealth({
64
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
65
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
66
+ });
67
+ assert.equal(v.kind, 'blocked');
68
+ assert.equal(v.pending, 1);
69
+ assert.equal(v.dispatchable, 0);
70
+ assert.equal(v.blockedChains.length, 1);
71
+ assert.deepEqual(v.blockedChains[0].blockedBy, ['1-a']);
72
+ });
73
+
74
+ test('idle: nothing pending', () => {
75
+ const jobs = [
76
+ { slug: '1-a', cwd: CWD, status: 'completed', dependsOn: [] },
77
+ ];
78
+ const v = classifyQueueHealth({
79
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
80
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
81
+ });
82
+ assert.equal(v.kind, 'idle');
83
+ assert.equal(v.pending, 0);
84
+ assert.equal(v.dispatchable, 0);
85
+ });
86
+
87
+ test('stalled: dispatchable work, nothing running, driver idle past threshold', () => {
88
+ const jobs = [
89
+ { slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
90
+ ];
91
+ const v = classifyQueueHealth({
92
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
93
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
94
+ });
95
+ assert.equal(v.kind, 'stalled');
96
+ assert.equal(v.dispatchable, 1);
97
+ assert.ok(v.idleMs >= QUEUE_STARVATION_MS);
98
+ });
99
+
100
+ test('running: dispatchable work, nothing running yet, but driver has not been idle long enough to call it a stall', () => {
101
+ const jobs = [
102
+ { slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
103
+ ];
104
+ const v = classifyQueueHealth({
105
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
106
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
107
+ });
108
+ assert.equal(v.kind, 'running');
109
+ });
110
+
111
+ test('paused: a decision, not a stall — takes priority over everything else', () => {
112
+ const jobs = [
113
+ { slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [] },
114
+ ];
115
+ const v = classifyQueueHealth({
116
+ jobs, paused: { reason: 'manual', since: NOW }, launchBlocks: {}, runningSet: new Set(),
117
+ freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
118
+ });
119
+ assert.equal(v.kind, 'paused');
120
+ assert.equal(v.reason, 'manual');
121
+ assert.equal(v.pending, 1);
122
+ assert.equal(v.dispatchable, 1);
123
+ });
124
+
125
+ test('launch-blocked: an open circuit breaker for a persona a pending row actually uses', () => {
126
+ const jobs = [
127
+ { slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [], agentType: 'dev-lead' },
128
+ ];
129
+ const launchBlocks = {
130
+ 'dev-lead': { kind: 'model_config_rejected', attempts: 3, hint: 'update the CLI', until: null },
131
+ };
132
+ const v = classifyQueueHealth({
133
+ jobs, paused: null, launchBlocks, runningSet: new Set(),
134
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
135
+ });
136
+ assert.equal(v.kind, 'launch-blocked');
137
+ assert.equal(v.agentType, 'dev-lead');
138
+ assert.equal(v.pending, 1);
139
+ assert.equal(v.dispatchable, 1);
140
+ });
141
+
142
+ test('launch-blocked is scoped: a breaker for a persona nothing pending here uses does not fire', () => {
143
+ const jobs = [
144
+ { slug: '1-a', cwd: CWD, status: 'pending', dependsOn: [], agentType: 'dev-lead' },
145
+ ];
146
+ const launchBlocks = {
147
+ 'architect': { kind: 'model_config_rejected', attempts: 3, hint: 'update the CLI', until: null },
148
+ };
149
+ const v = classifyQueueHealth({
150
+ jobs, paused: null, launchBlocks, runningSet: new Set(),
151
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
152
+ });
153
+ assert.notEqual(v.kind, 'launch-blocked');
154
+ });
155
+
156
+ test('needsReviewCount and cwd scoping: another project\'s rows never leak into this scope\'s counts', () => {
157
+ const other = '/home/bilko/Projects/session-manager';
158
+ const jobs = [
159
+ { slug: '1-a', cwd: CWD, status: 'needs_review', dependsOn: [] },
160
+ { slug: '2-b', cwd: CWD, status: 'pending', dependsOn: [] },
161
+ { slug: '3-c', cwd: other, status: 'pending', dependsOn: [] },
162
+ { slug: '4-d', cwd: other, status: 'needs_review', dependsOn: [] },
163
+ ];
164
+ const v = classifyQueueHealth({
165
+ jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
166
+ freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
167
+ });
168
+ assert.equal(v.pending, 1);
169
+ assert.equal(v.needsReviewCount, 1);
170
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * queue-starvation-per-project.test.cjs — classifyQueueStarvation's
3
+ * runningCount used to be fed the MACHINE-WIDE runningSet.size, so one
4
+ * long-lived job in ANY project disarmed the starvation watchdog for EVERY
5
+ * other project on the box. Observed live 2026-09-12: a job in
6
+ * starry-night-ships ran 80+ minutes while two other projects sat
7
+ * starved/blocked for hours — the watchdog never fired once because "work is
8
+ * flowing" was true somewhere else.
9
+ *
10
+ * classifyQueueStarvationByProject partitions jobs by cwd (the same grouping
11
+ * computeBlockedChains already uses) and evaluates each project on its OWN
12
+ * running/pending/idle evidence.
13
+ *
14
+ * Run: timeout 180 npx vitest run src/main/__tests__/queue-starvation-per-project.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, afterAll } from 'vitest';
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ // HOME before require: every state path is baked from os.homedir() at load.
26
+ // beforeAll runs AFTER module evaluation, so this assignment must stay at
27
+ // top level — see pollLoop-dispatch-on-failure.test.cjs:40-56 for the
28
+ // beforeAll/afterAll pattern this restore mirrors.
29
+ const originalHome = process.env.HOME;
30
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-per-project-test-'));
31
+ process.env.HOME = tmpHome;
32
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
33
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
34
+
35
+ const {
36
+ classifyQueueStarvation,
37
+ classifyQueueStarvationByProject,
38
+ QUEUE_STARVATION_MS,
39
+ } = require('../scheduler.cjs');
40
+
41
+ afterAll(() => {
42
+ process.env.HOME = originalHome;
43
+ fs.rmSync(tmpHome, { recursive: true, force: true });
44
+ });
45
+
46
+ const PROJECT_A = '/home/bilko/Projects/starry-night-ships';
47
+ const PROJECT_B = '/home/bilko/Projects/session-manager';
48
+ const NOW = Date.parse('2026-09-12T20:00:00.000Z');
49
+ const LONG = QUEUE_STARVATION_MS + 60_000;
50
+ const idleFor = (ms) => NOW - ms;
51
+
52
+ test('one long-running job in project A no longer masks project B being starved', () => {
53
+ const jobs = [
54
+ { slug: '246-neptune', cwd: PROJECT_A, status: 'running', dependsOn: [] },
55
+ { slug: '1169-x', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
56
+ { slug: '1170-y', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
57
+ ];
58
+
59
+ // Pre-change shape: the machine-wide runningCount (1, from project A) hides
60
+ // project B's starvation entirely. This is the bug this PRD fixes — assert
61
+ // it still holds for the old, single-project entry point so a revert of
62
+ // the new driver is caught by this test going red.
63
+ const globalVerdict = classifyQueueStarvation({
64
+ jobs,
65
+ paused: false,
66
+ runningCount: 1,
67
+ lastRunAtMs: idleFor(LONG),
68
+ now: NOW,
69
+ });
70
+ assert.equal(globalVerdict, null);
71
+
72
+ // Per-project: project B has 0 of its OWN rows running and 2 dispatchable
73
+ // pending rows idle past the threshold — it must be reported as starved
74
+ // regardless of project A's running job.
75
+ const verdicts = classifyQueueStarvationByProject({
76
+ jobs,
77
+ paused: false,
78
+ runningSet: new Set(),
79
+ lastRunAtMs: idleFor(LONG),
80
+ now: NOW,
81
+ });
82
+ const forB = verdicts.find((v) => v.cwd === PROJECT_B);
83
+ assert.ok(forB, 'expected a verdict naming project B cwd');
84
+ assert.equal(forB.kind, 'starved');
85
+ assert.equal(forB.pending, 2);
86
+ assert.equal(forB.dispatchable, 2);
87
+
88
+ // Project A itself has a running row — never starved on its own account.
89
+ const forA = verdicts.find((v) => v.cwd === PROJECT_A);
90
+ assert.equal(forA, undefined);
91
+ });
92
+
93
+ test('a project fully blocked behind a terminal dependency is "blocked", not "starved", even while another project is starved in the same pass', () => {
94
+ const jobs = [
95
+ // Project A: every pending row sits behind a failed dependency — a tick
96
+ // cannot help this project, it needs a human/heal pass.
97
+ { slug: '206-lava-ape', cwd: PROJECT_A, status: 'failed', dependsOn: [] },
98
+ { slug: '207-body', cwd: PROJECT_A, status: 'pending', dependsOn: ['206-lava-ape'] },
99
+ // Project B: dispatchable right now — genuinely starved.
100
+ { slug: '1171-z', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
101
+ ];
102
+
103
+ const verdicts = classifyQueueStarvationByProject({
104
+ jobs,
105
+ paused: false,
106
+ runningSet: new Set(),
107
+ lastRunAtMs: idleFor(LONG),
108
+ now: NOW,
109
+ });
110
+
111
+ const forA = verdicts.find((v) => v.cwd === PROJECT_A);
112
+ assert.ok(forA, 'expected a verdict for the blocked project');
113
+ assert.equal(forA.kind, 'blocked');
114
+
115
+ const forB = verdicts.find((v) => v.cwd === PROJECT_B);
116
+ assert.ok(forB, 'expected a verdict for the starved project');
117
+ assert.equal(forB.kind, 'starved');
118
+ });
119
+
120
+ test('the whole-machine paused short-circuit is preserved for every project', () => {
121
+ const jobs = [
122
+ { slug: '1169-x', cwd: PROJECT_A, status: 'pending', dependsOn: [] },
123
+ { slug: '1170-y', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
124
+ ];
125
+ const verdicts = classifyQueueStarvationByProject({
126
+ jobs,
127
+ paused: { reason: 'rate_limit', resumeAt: null },
128
+ runningSet: new Set(),
129
+ lastRunAtMs: idleFor(LONG),
130
+ now: NOW,
131
+ });
132
+ assert.deepEqual(verdicts, []);
133
+ });
134
+
135
+ test('a running-set membership (not just a status:"running" row) counts as that project having flowing work', () => {
136
+ const jobs = [
137
+ { slug: '9001-a', cwd: PROJECT_A, status: 'pending', dependsOn: [] },
138
+ ];
139
+ const verdicts = classifyQueueStarvationByProject({
140
+ jobs,
141
+ paused: false,
142
+ runningSet: new Set(['9001-a']),
143
+ lastRunAtMs: idleFor(LONG),
144
+ now: NOW,
145
+ });
146
+ assert.deepEqual(verdicts, []);
147
+ });