claude-code-session-manager 0.86.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 (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.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 +14 -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__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -6,7 +6,7 @@
6
6
  */
7
7
  'use strict';
8
8
 
9
- import { test, expect } from 'vitest';
9
+ import { test, expect, vi, beforeEach, afterEach } from 'vitest';
10
10
  const fs = require('node:fs');
11
11
  const crypto = require('node:crypto');
12
12
  const {
@@ -17,6 +17,7 @@ const {
17
17
  _resetRefusedTransitionCountForTests,
18
18
  } = require('../lib/scheduleJobTransitions.cjs');
19
19
  const auditLog = require('../lib/auditLog.cjs');
20
+ const queueHistory = require('../lib/queueHistory.cjs');
20
21
 
21
22
  function uniqueSlug(label) {
22
23
  return `test-${label}-${crypto.randomUUID().slice(0, 8)}`;
@@ -175,3 +176,102 @@ test('_resetRefusedTransitionCountForTests resets the counter', () => {
175
176
  _resetRefusedTransitionCountForTests();
176
177
  expect(getRefusedTransitionCount()).toBe(0);
177
178
  });
179
+
180
+ // ---------- needs_review ledger (PRD: needs_review durability) ----------
181
+ //
182
+ // transitionJob is the ONE place every needs_review entry/resolution is
183
+ // decided (this file's own header comment) — these tests exercise that
184
+ // hook directly rather than going through scheduler.cjs's many call sites.
185
+ // queueHistory.appendHistory is mocked so these tests never touch disk or
186
+ // race its own fire-and-forget promise; queueHistory.test.cjs already
187
+ // covers appendHistory's real I/O.
188
+
189
+ let appendHistorySpy;
190
+
191
+ beforeEach(() => {
192
+ appendHistorySpy = vi.spyOn(queueHistory, 'appendHistory').mockResolvedValue({ appended: 1 });
193
+ });
194
+
195
+ afterEach(() => {
196
+ appendHistorySpy.mockRestore();
197
+ });
198
+
199
+ test('needs_review entry: transitioning INTO needs_review appends a kind:needs_review_entry ledger line', () => {
200
+ const slug = uniqueSlug('nr-entry');
201
+ const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-abc', verifierVerdict: 'uncommitted_changes' };
202
+
203
+ transitionJob(job, 'needs_review', { reason: 'finish protocol incomplete', source: 'spawnJob:finalize' });
204
+
205
+ expect(appendHistorySpy).toHaveBeenCalledTimes(1);
206
+ const [lines] = appendHistorySpy.mock.calls[0];
207
+ expect(lines).toHaveLength(1);
208
+ expect(lines[0]).toMatchObject({
209
+ kind: 'needs_review_entry',
210
+ slug,
211
+ cwd: '/tmp/proj',
212
+ runId: 'run-abc',
213
+ reason: 'uncommitted_changes',
214
+ source: 'spawnJob:finalize',
215
+ });
216
+ expect(typeof lines[0].at).toBe('string');
217
+ // Episode bookkeeping stamped on the job so a later resolution can
218
+ // reference this SAME runId even if job.runId is reassigned meanwhile.
219
+ expect(job.needsReviewEntryRunId).toBe('run-abc');
220
+ expect(job.needsReviewEnteredAt).toBe(lines[0].at);
221
+ });
222
+
223
+ test('needs_review resolution: transitioning OUT of needs_review appends a paired line referencing the ENTRY runId, with dwellMs', () => {
224
+ const slug = uniqueSlug('nr-resolve');
225
+ const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-1' };
226
+
227
+ transitionJob(job, 'needs_review', { reason: 'park', source: 'spawnJob:finalize' });
228
+ const enteredAt = job.needsReviewEnteredAt;
229
+
230
+ // A resume-recovery re-dispatch (or any later attempt) mints a FRESH runId
231
+ // before this episode resolves — the resolution must still reference the
232
+ // ENTRY's runId, not whatever the job carries now.
233
+ job.runId = 'run-2';
234
+ transitionJob(job, 'completed', { reason: 'boot reverify: stale needs_review healed', source: 'reverifyNeedsReview:heal' });
235
+
236
+ expect(appendHistorySpy).toHaveBeenCalledTimes(2);
237
+ const [resolutionLines] = appendHistorySpy.mock.calls[1];
238
+ expect(resolutionLines).toHaveLength(1);
239
+ const resolution = resolutionLines[0];
240
+ expect(resolution.kind).toBe('needs_review_resolution');
241
+ expect(resolution.runId).toBe('run-1'); // the ENTRY's runId
242
+ expect(resolution.resolvedTo).toBe('completed');
243
+ expect(resolution.ladderRung).toBe('reverify');
244
+ expect(typeof resolution.dwellMs).toBe('number');
245
+ expect(resolution.dwellMs).toBeGreaterThanOrEqual(0);
246
+ expect(Date.parse(enteredAt) + resolution.dwellMs).toBe(Date.parse(resolution.at));
247
+
248
+ // Episode bookkeeping cleared once resolved.
249
+ expect(job.needsReviewEntryRunId).toBeUndefined();
250
+ expect(job.needsReviewEnteredAt).toBeUndefined();
251
+ });
252
+
253
+ test('a slug that parks twice produces two independent entry/resolution pairs, not one merged episode', () => {
254
+ const { reduceNeedsReviewLedger } = require('../lib/needsReviewLedger.cjs');
255
+ const slug = uniqueSlug('nr-twice');
256
+ const job = { slug, cwd: '/tmp/proj', status: 'running', runId: 'run-A' };
257
+
258
+ transitionJob(job, 'needs_review', { reason: 'park 1', source: 'spawnJob:finalize' });
259
+ transitionJob(job, 'pending', { reason: 'manual reset', source: 'ipc:schedule:reset-job' });
260
+ transitionJob(job, 'running', { reason: 'redispatched', source: 'spawnJob:dispatch' });
261
+ job.runId = 'run-B';
262
+ transitionJob(job, 'needs_review', { reason: 'park 2', source: 'spawnJob:finalize' });
263
+ transitionJob(job, 'completed', { reason: 'healed', source: 'reverifyNeedsReview:heal' });
264
+
265
+ const ledgerLines = appendHistorySpy.mock.calls.map((call) => call[0][0]);
266
+ const entries = ledgerLines.filter((l) => l.kind === 'needs_review_entry');
267
+ const resolutions = ledgerLines.filter((l) => l.kind === 'needs_review_resolution');
268
+ expect(entries).toHaveLength(2);
269
+ expect(resolutions).toHaveLength(2);
270
+ expect(entries.map((l) => l.runId).sort()).toEqual(['run-A', 'run-B']);
271
+ expect(resolutions.map((l) => l.runId).sort()).toEqual(['run-A', 'run-B']);
272
+
273
+ const rollup = reduceNeedsReviewLedger(ledgerLines);
274
+ expect(rollup.unresolvedCount).toBe(0);
275
+ expect(rollup.byLadderRung['manual-reset']).toBe(1);
276
+ expect(rollup.byLadderRung['reverify']).toBe(1);
277
+ });
@@ -2,7 +2,7 @@
2
2
  * scheduler-boot-orphans.test.cjs — boot-time reconciliation of 'running' jobs
3
3
  * left behind by an app crash/restart (PRD 686: consolidated in from the
4
4
  * external watchdog's reconcileQueueOffline(), which is now deleted from
5
- * scripts/lib/watchdogHelpers.cjs).
5
+ * src/main/lib/watchdogHelpers.cjs).
6
6
  *
7
7
  * Covers the two behaviors most likely to drift in the move:
8
8
  * - a still-alive orphaned pid must be DEFERRED, never classified from its
@@ -101,7 +101,7 @@ test('feedbackSweepDue gates on the tick interval, not every tick', () => {
101
101
  });
102
102
 
103
103
  test('scheduler.cjs reuses watchdogHelpers.sweep verbatim (no forked copy)', () => {
104
- const { sweep } = require('../../../scripts/lib/watchdogHelpers.cjs');
104
+ const { sweep } = require('../lib/watchdogHelpers.cjs');
105
105
  assert.equal(sweepFeedback, sweep, 'scheduler.cjs must import the same sweep function, not reimplement it');
106
106
  });
107
107
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * scheduler-broadcast-reconcile.test.cjs — broadcast() used to do its own bare
3
+ * `readQueue() -> reconcile(state) -> writeQueue(state)` BEFORE ever touching
4
+ * the broadcastCoalescer, so a burst of N broadcast() calls inside one
5
+ * BROADCAST_COALESCE_MS window cost N full reconcile passes + N queue writes
6
+ * to produce ONE coalesced payload — and did so outside mutate()'s lock, so a
7
+ * concurrent mutation's update could be lost. The fix moves the
8
+ * reconcile+write pair inside the coalescer's `getPayload`, run through
9
+ * `mutate()`, so N broadcast() calls in one window cost exactly one reconcile
10
+ * and one write.
11
+ *
12
+ * `reconcile` is a bare local call inside getPayload's mutate() callback, so
13
+ * — matching this file's existing testable-seam convention (module.exports.
14
+ * stashList / evaluateSharedTreeGuard / committedInWindow) — it is invoked as
15
+ * `module.exports.reconcile(state)` specifically so tests can spy on it here.
16
+ * `queueStore.readMerged`/`writeSplit` are genuine cross-module property
17
+ * calls already, so they're spied on directly with no source change needed.
18
+ *
19
+ * HOME-isolated (mirrors reconcileFlatPrdSweep.test.cjs): stub HOME to a
20
+ * mkdtemp dir before requiring scheduler.cjs so `ensureDirs()` (called by the
21
+ * real `writeQueue()` wrapper before the now-mocked `queueStore.writeSplit`)
22
+ * never touches the real machine.
23
+ *
24
+ * Run: timeout 60 npx vitest run src/main/__tests__/scheduler-broadcast-reconcile.test.cjs
25
+ */
26
+ 'use strict';
27
+
28
+ import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } 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
+
38
+ beforeAll(() => {
39
+ originalHome = process.env.HOME;
40
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-broadcast-reconcile-home-'));
41
+ process.env.HOME = tmpHome;
42
+
43
+ scheduler = require('../scheduler.cjs');
44
+ queueStore = require('../lib/queueStore.cjs');
45
+
46
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
47
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
48
+ }
49
+ });
50
+
51
+ afterAll(() => {
52
+ process.env.HOME = originalHome;
53
+ fs.rmSync(tmpHome, { recursive: true, force: true });
54
+ });
55
+
56
+ function freshState() {
57
+ return { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
58
+ }
59
+
60
+ function fakeWindow() {
61
+ return {
62
+ isDestroyed: () => false,
63
+ webContents: { isDestroyed: () => false, isCrashed: () => false, send: vi.fn() },
64
+ };
65
+ }
66
+
67
+ function wait(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+
71
+ afterEach(() => {
72
+ vi.restoreAllMocks();
73
+ scheduler.attachWindow(null);
74
+ });
75
+
76
+ test('10 broadcast() calls inside one coalesce window trigger exactly one reconcile and one queue write', async () => {
77
+ vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
78
+ const writeSpy = vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
79
+ const reconcileSpy = vi.spyOn(scheduler, 'reconcile').mockResolvedValue(undefined);
80
+
81
+ for (let i = 0; i < 10; i += 1) scheduler.broadcast();
82
+
83
+ // BROADCAST_COALESCE_MS is 200ms; give the trailing-edge timer room to fire.
84
+ await wait(400);
85
+
86
+ expect(reconcileSpy).toHaveBeenCalledTimes(1);
87
+ expect(writeSpy).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ test('a window-less (flush) broadcast() still reconciles, even with no window attached', async () => {
91
+ scheduler.attachWindow(null);
92
+ vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
93
+ const writeSpy = vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
94
+ const reconcileSpy = vi.spyOn(scheduler, 'reconcile').mockResolvedValue(undefined);
95
+
96
+ await scheduler.broadcast({ flush: true });
97
+
98
+ expect(reconcileSpy).toHaveBeenCalledTimes(1);
99
+ expect(writeSpy).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ test('a reconcile() that throws does not wedge the coalescer — the next broadcast() still sends', async () => {
103
+ const win = fakeWindow();
104
+ scheduler.attachWindow(win);
105
+ vi.spyOn(queueStore, 'readMerged').mockImplementation(async () => freshState());
106
+ vi.spyOn(queueStore, 'writeSplit').mockResolvedValue(undefined);
107
+ const reconcileSpy = vi.spyOn(scheduler, 'reconcile')
108
+ .mockRejectedValueOnce(new Error('boom'))
109
+ .mockResolvedValue(undefined);
110
+
111
+ scheduler.broadcast();
112
+ await wait(400);
113
+ expect(win.webContents.send).not.toHaveBeenCalled();
114
+
115
+ scheduler.broadcast();
116
+ await wait(400);
117
+
118
+ expect(reconcileSpy).toHaveBeenCalledTimes(2);
119
+ expect(win.webContents.send).toHaveBeenCalledTimes(1);
120
+ expect(win.webContents.send).toHaveBeenCalledWith('schedule:state', expect.any(Object));
121
+ });
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // Ported from the retired top-level test-root directory (a node:test suite,
4
+ // never runnable by this repo's vitest-only setup — see CLAUDE.md's Commands
5
+ // section). Pins the two basic cross-project pickNextBatch contracts that
6
+ // were asserted there but not literally exercised elsewhere: N independent
7
+ // projects each with a single pending job all fire together when the global
8
+ // slot pool is generous, and a global cap BELOW the total pending count
9
+ // across projects admits exactly that many, not more.
10
+ //
11
+ // Run: timeout 120 npx vitest run src/main/__tests__/scheduler-cross-project-batch.test.cjs
12
+
13
+ const assert = require('node:assert/strict');
14
+ const { pickNextBatch } = require('../lib/schedulerBatch.cjs');
15
+
16
+ function job(slug, status, cwd, parallelGroup) {
17
+ return { slug, status, cwd, parallelGroup, dependsOn: [] };
18
+ }
19
+
20
+ test('3 independent projects, 1 pending each, generous cap (6) → all 3 fire', () => {
21
+ const jobs = [
22
+ job('a-job', 'pending', '/a', 5),
23
+ job('b-job', 'pending', '/b', 5),
24
+ job('c-job', 'pending', '/c', 5),
25
+ ];
26
+ const { batch, reason } = pickNextBatch(jobs, new Set(), 6);
27
+ assert.equal(batch.length, 3);
28
+ assert.equal(reason, null);
29
+ const slugs = new Set(batch.map((j) => j.slug));
30
+ assert.ok(slugs.has('a-job'));
31
+ assert.ok(slugs.has('b-job'));
32
+ assert.ok(slugs.has('c-job'));
33
+ });
34
+
35
+ test('3 independent projects, 1 pending each, cap 2 (below total pending) → exactly 2 fire', () => {
36
+ const jobs = [
37
+ job('a-job', 'pending', '/a', 5),
38
+ job('b-job', 'pending', '/b', 5),
39
+ job('c-job', 'pending', '/c', 5),
40
+ ];
41
+ const { batch } = pickNextBatch(jobs, new Set(), 2);
42
+ assert.equal(batch.length, 2);
43
+ });
@@ -0,0 +1,344 @@
1
+ /**
2
+ * scheduler-guard-verdict-autoresolve.test.cjs — needs_review ladder gap
3
+ * (2026-09-12, PRD 1181 shape).
4
+ *
5
+ * Auto-fix investigations only ever launch for FAILING runs — a job that
6
+ * exits 0 and gets parked by a post-run GUARD verdict (commit-guard
7
+ * 'silent_no_op', shared-tree-guard 'shared_tree_reverted') never has
8
+ * job.autoFixAttempted set, so isExhaustedAutoFix(job) is false forever and
9
+ * the row is invisible to the existing needs_review auto-resolve ladder —
10
+ * it sits in needs_review permanently, blocking every dependsOn row behind
11
+ * it, until a human clears it by hand. PRD 1181 proved it: exit 0, commit
12
+ * aff5607 landed, parked on a guard verdict, needed a human.
13
+ *
14
+ * This file covers the widened entry gate (isGuardParkedWithoutAutoFix /
15
+ * isEligibleForNeedsReviewAutoResolve) that admits these rows to the SAME
16
+ * ladder (selectExhaustedNeedsReviewTargets / applyNeedsReviewAutoResolve)
17
+ * the exhausted-auto-fix path already uses, keyed on landedCommit +
18
+ * looksDone evidence instead of a spent auto-fix attempt — and the closed
19
+ * set of verdicts eligible for this (GUARD_VERDICT_EVIDENCE_ELIGIBLE)
20
+ * excludes 'worktree_integration_failed', whose damage IS a commit (one
21
+ * stranded on an unmerged branch), so a landedCommit there is not evidence
22
+ * against the verdict.
23
+ *
24
+ * scheduler-needs-review-autoresolve.test.cjs's own suite covers the
25
+ * pre-existing exhausted-auto-fix ladder and must keep passing UNCHANGED —
26
+ * this file only adds new coverage for the second door into the same ladder.
27
+ *
28
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
29
+ * reason as scheduler-needs-review-autoresolve.test.cjs (appendAuditEvent
30
+ * writes under $HOME/.claude/session-manager/audit-log.jsonl).
31
+ *
32
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const assert = require('node:assert/strict');
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+
42
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-verdict-autoresolve-test-'));
43
+ process.env.HOME = tmpHome;
44
+
45
+ const { execFileSync } = require('node:child_process');
46
+
47
+ const {
48
+ selectExhaustedNeedsReviewTargets,
49
+ applyNeedsReviewAutoResolve,
50
+ NEEDS_REVIEW_RESOLVE_CAP,
51
+ GUARD_VERDICT_EVIDENCE_ELIGIBLE,
52
+ isGuardParkedWithoutAutoFix,
53
+ isEligibleForNeedsReviewAutoResolve,
54
+ isExhaustedAutoFix,
55
+ reverifyNeedsReview,
56
+ } = require('../scheduler.cjs');
57
+
58
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
59
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
60
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
61
+
62
+ const MIN_MS = 60_000;
63
+ const THRESHOLD_MS = 30 * MIN_MS;
64
+
65
+ function guardParkedJob(overrides = {}) {
66
+ return {
67
+ slug: '1181-guard-parked-row',
68
+ cwd: '/home/user/project',
69
+ status: 'needs_review',
70
+ exitCode: 0,
71
+ verifierVerdict: 'silent_no_op',
72
+ landedCommit: 'aff5607abc123',
73
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * MIN_MS).toISOString() }],
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ // --- enumeration: which guard verdicts are eligible ---
79
+
80
+ test('GUARD_VERDICT_EVIDENCE_ELIGIBLE contains exactly the benign guard verdicts, never worktree_integration_failed', () => {
81
+ assert.ok(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('silent_no_op'));
82
+ assert.ok(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('shared_tree_reverted'));
83
+ assert.equal(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('worktree_integration_failed'), false);
84
+ });
85
+
86
+ // --- isGuardParkedWithoutAutoFix / isEligibleForNeedsReviewAutoResolve ---
87
+
88
+ test('a guard-verdict park with no auto-fix history is guard-parked-without-autofix', () => {
89
+ const job = guardParkedJob();
90
+ assert.equal(isGuardParkedWithoutAutoFix(job), true);
91
+ assert.equal(isExhaustedAutoFix(job), false, 'never went through auto-fix — must not double-count as exhausted');
92
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), true);
93
+ });
94
+
95
+ test('a row that DID get an auto-fix investigation is left to the exhausted-auto-fix door, not this one', () => {
96
+ const job = guardParkedJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 1 });
97
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
98
+ assert.equal(isExhaustedAutoFix(job), true);
99
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), true, 'still eligible overall, via the OTHER door');
100
+ });
101
+
102
+ test('(c) worktree_integration_failed is never guard-parked-without-autofix, whatever evidence it carries', () => {
103
+ const job = guardParkedJob({ verifierVerdict: 'worktree_integration_failed' });
104
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
105
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), false);
106
+ });
107
+
108
+ test('a needs_review row with an unrecognized verifierVerdict and no auto-fix history is not eligible', () => {
109
+ const job = guardParkedJob({ verifierVerdict: 'transcript_errors' });
110
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
111
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), false);
112
+ });
113
+
114
+ // --- selectExhaustedNeedsReviewTargets: the widened selector ---
115
+
116
+ test('a fresh guard-parked row (under threshold) is not selected', () => {
117
+ const job = guardParkedJob({
118
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
119
+ });
120
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
121
+ });
122
+
123
+ test('a guard-parked row past threshold, under the cap, is selected', () => {
124
+ const job = guardParkedJob();
125
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
126
+ assert.equal(found.length, 1);
127
+ assert.equal(found[0].slug, '1181-guard-parked-row');
128
+ });
129
+
130
+ test('(c) worktree_integration_failed is never selected, even past threshold with a landedCommit', () => {
131
+ const job = guardParkedJob({ verifierVerdict: 'worktree_integration_failed' });
132
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
133
+ });
134
+
135
+ // --- applyNeedsReviewAutoResolve: the three-branch policy, entered via the guard door ---
136
+
137
+ test('(a) the 1181 shape — exit 0 + guard verdict + landedCommit + looksDone — auto-completes', () => {
138
+ const job = guardParkedJob({
139
+ looksDone: { commits: ['aff5607'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
140
+ });
141
+ const outcome = applyNeedsReviewAutoResolve(job);
142
+ assert.equal(outcome, 'completed');
143
+ assert.equal(job.status, 'completed');
144
+ assert.match(job.statusHistory.at(-1).reason, /silent_no_op/, 'the reason must name the evidence, not just "auto-resolved"');
145
+ });
146
+
147
+ test('(b) the same shape WITHOUT looksDone does not auto-complete on the first pass — bounded requeue instead', () => {
148
+ const job = guardParkedJob({ exhaustedResolveAttempts: 0 });
149
+ assert.equal(job.looksDone, undefined);
150
+ const outcome = applyNeedsReviewAutoResolve(job);
151
+ assert.equal(outcome, 'requeued');
152
+ assert.equal(job.status, 'pending');
153
+ assert.equal(job.exhaustedResolveAttempts, 1);
154
+ assert.match(job.statusHistory.at(-1).reason, /silent_no_op/);
155
+ });
156
+
157
+ test('a guard-parked row with no evidence, cap spent, auto-skips with the guard verdict named in job.error', () => {
158
+ const job = guardParkedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
159
+ const outcome = applyNeedsReviewAutoResolve(job);
160
+ assert.equal(outcome, 'skipped');
161
+ assert.equal(job.status, 'skipped');
162
+ assert.equal(job.needsReviewAutoResolvedSkip, true);
163
+ assert.match(job.error, /silent_no_op/, 'a human reading the queue must see WHY, not a generic auto-resolved label');
164
+ assert.doesNotMatch(job.error, /exhausted auto-fix path/, 'this row never went through auto-fix — must not claim it did');
165
+ });
166
+
167
+ test('(c) worktree_integration_failed never auto-completes on landed-commit + looksDone evidence', () => {
168
+ const job = guardParkedJob({
169
+ verifierVerdict: 'worktree_integration_failed',
170
+ looksDone: { commits: ['aff5607'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
171
+ });
172
+ const outcome = applyNeedsReviewAutoResolve(job);
173
+ assert.equal(outcome, null, 'this verdict has no door into this ladder at all');
174
+ assert.equal(job.status, 'needs_review', 'must stay parked — its own mechanical-recovery path owns this verdict, not this ladder');
175
+ });
176
+
177
+ test('the pre-existing exhausted-auto-fix ladder is unaffected: same branch, same cap, same audit shape', () => {
178
+ const job = {
179
+ slug: 'exhausted-row',
180
+ cwd: '/home/user/project',
181
+ status: 'needs_review',
182
+ autoFixAttempted: true,
183
+ autoFixOutcome: 'no-plan',
184
+ autoFixRetries: 1,
185
+ exhaustedResolveAttempts: 1,
186
+ looksDone: { commits: ['abc1234'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
187
+ };
188
+ const outcome = applyNeedsReviewAutoResolve(job);
189
+ assert.equal(outcome, 'completed');
190
+ assert.match(job.statusHistory.at(-1).reason, /verifier annotation shows work landed/);
191
+ });
192
+
193
+ // --- (d) chain drain: a downstream dependsOn row becomes eligible once the guard-parked blocker resolves ---
194
+
195
+ test('(d) a dependsOn chain drains once a guard-parked blocker (no auto-fix, no evidence, cap spent) auto-skips', () => {
196
+ const now = Date.now();
197
+ const rowA = { slug: 'a-first', cwd: '/home/user/project', status: 'completed' };
198
+ const rowB = guardParkedJob({
199
+ slug: 'b-middle',
200
+ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
201
+ dependsOn: ['a-first'],
202
+ });
203
+ const rowC = { slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'] };
204
+ const jobs = [rowA, rowB, rowC];
205
+
206
+ const before = pickForProject(jobs, new Set(), 5);
207
+ assert.equal(before.batch.length, 0, 'C must not be eligible while B still sits in needs_review');
208
+
209
+ const targets = selectExhaustedNeedsReviewTargets(jobs, now, THRESHOLD_MS);
210
+ assert.equal(targets.length, 1);
211
+ assert.equal(targets[0].slug, 'b-middle');
212
+ const outcome = applyNeedsReviewAutoResolve(rowB);
213
+ assert.equal(outcome, 'skipped');
214
+
215
+ const after = pickForProject(jobs, new Set(), 5);
216
+ assert.ok(after.batch.some((j) => j.slug === 'c-last'), 'C must become eligible once B is auto-skipped');
217
+ });
218
+
219
+ // --- end-to-end: reverifyNeedsReview computes looksDone for a guard-parked row ---
220
+
221
+ function git(args, cwd) {
222
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
223
+ }
224
+
225
+ function initRepo(dir) {
226
+ fs.mkdirSync(dir, { recursive: true });
227
+ git(['init', '-q'], dir);
228
+ git(['config', 'user.email', 'test@example.com'], dir);
229
+ git(['config', 'user.name', 'Test'], dir);
230
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
231
+ git(['add', '-A'], dir);
232
+ git(['commit', '-q', '-m', 'initial'], dir);
233
+ }
234
+
235
+ function commitFile(dir, relPath, content, message) {
236
+ const abs = path.join(dir, relPath);
237
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
238
+ fs.writeFileSync(abs, content);
239
+ git(['add', relPath], dir);
240
+ git(['commit', '-q', '-m', message], dir);
241
+ }
242
+
243
+ function registerActiveProject(cwd, slug) {
244
+ const slugDir = path.join(tmpHome, '.claude', 'projects', slug);
245
+ fs.mkdirSync(slugDir, { recursive: true });
246
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
247
+ bustCwdCache();
248
+ }
249
+
250
+ function writeProjectQueue(cwd, jobs) {
251
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
252
+ fs.mkdirSync(stateDir, { recursive: true });
253
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
254
+ return path.join(stateDir, 'queue.json');
255
+ }
256
+
257
+ function writePrd(cwd, slug, body) {
258
+ const dir = resolvePrdWriteDir(cwd);
259
+ fs.mkdirSync(dir, { recursive: true });
260
+ const text = `---\ntitle: Test PRD\ncwd: ${cwd}\nestimateMinutes: 30\n---\n${body}\n`;
261
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
262
+ }
263
+
264
+ async function wait(ms) {
265
+ await new Promise((r) => setTimeout(r, ms));
266
+ }
267
+
268
+ test('reverifyNeedsReview computes looksDone for a guard-parked (silent_no_op) row with no auto-fix history', async () => {
269
+ const projectCwd = path.join(tmpHome, 'proj-guard-parked');
270
+ initRepo(projectCwd);
271
+ registerActiveProject(projectCwd, 'proj-guard-parked-slug');
272
+ writePrd(projectCwd, '1181-guard-parked', [
273
+ '# Implementation notes',
274
+ 'Edit `src/thing.js`.',
275
+ ].join('\n'));
276
+
277
+ const startedAt = new Date().toISOString();
278
+ const queuePath = writeProjectQueue(projectCwd, [
279
+ {
280
+ slug: '1181-guard-parked',
281
+ status: 'needs_review',
282
+ cwd: projectCwd,
283
+ runId: 'run-1181',
284
+ startedAt,
285
+ finishedAt: new Date().toISOString(),
286
+ exitCode: 0,
287
+ verifierVerdict: 'silent_no_op',
288
+ landedCommit: 'deadbeef',
289
+ error: 'commit-guard: made no commit on an already-clean tree',
290
+ },
291
+ ]);
292
+
293
+ await wait(1100); // git --since has 1s resolution
294
+ // Must be attributable to THIS job (see attributeLandedCommits) — path
295
+ // overlap alone is no longer evidence. The row's own `landedCommit`
296
+ // ('deadbeef') is a stale/unresolvable placeholder, so this falls to the
297
+ // 'slug trailer' rule by naming the job's own slug in the commit message.
298
+ commitFile(projectCwd, 'src/thing.js', 'hello', 'the actual fix for 1181-guard-parked landed');
299
+
300
+ await reverifyNeedsReview();
301
+
302
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
303
+ assert.equal(jobs.length, 1);
304
+ assert.equal(jobs[0].status, 'needs_review', 'looksDone alone never auto-completes — only applyNeedsReviewAutoResolve does');
305
+ assert.ok(jobs[0].looksDone, 'expected a looksDone annotation computed for this guard-parked row');
306
+ assert.equal(jobs[0].looksDone.commits.length, 1);
307
+ assert.equal(jobs[0].looksDone.rule, 'slug trailer');
308
+ });
309
+
310
+ test('reverifyNeedsReview never computes looksDone for a worktree_integration_failed row', async () => {
311
+ const projectCwd = path.join(tmpHome, 'proj-stranded-branch');
312
+ initRepo(projectCwd);
313
+ registerActiveProject(projectCwd, 'proj-stranded-branch-slug');
314
+ writePrd(projectCwd, '1181-stranded', [
315
+ '# Implementation notes',
316
+ 'Edit `src/thing.js`.',
317
+ ].join('\n'));
318
+
319
+ const startedAt = new Date().toISOString();
320
+ const queuePath = writeProjectQueue(projectCwd, [
321
+ {
322
+ slug: '1181-stranded',
323
+ status: 'needs_review',
324
+ cwd: projectCwd,
325
+ runId: 'run-stranded',
326
+ startedAt,
327
+ finishedAt: new Date().toISOString(),
328
+ exitCode: 0,
329
+ verifierVerdict: 'worktree_integration_failed',
330
+ landedCommit: 'deadbeef',
331
+ error: 'worktree branch integration failed',
332
+ },
333
+ ]);
334
+
335
+ await wait(1100);
336
+ commitFile(projectCwd, 'src/thing.js', 'hello', 'unrelated later commit');
337
+
338
+ await reverifyNeedsReview();
339
+
340
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
341
+ assert.equal(jobs.length, 1);
342
+ assert.equal(jobs[0].looksDone, undefined, 'a stranded-branch verdict must never be treated as evidence-gatherable');
343
+ assert.equal(jobs[0].status, 'needs_review');
344
+ });