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
@@ -0,0 +1,312 @@
1
+ 'use strict';
2
+
3
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/watchdog-helpers.test.cjs
4
+
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const {
10
+ heartbeatFresh,
11
+ readLastHeartbeat,
12
+ localDateStr,
13
+ maybeFinalizeHistory,
14
+ tryAcquireLock,
15
+ releaseLock,
16
+ } = require('../watchdogHelpers.cjs');
17
+
18
+ function tmpFile() {
19
+ return path.join(os.tmpdir(), `watchdog-test-${process.pid}-${Math.floor(Math.random() * 1e9)}.log`);
20
+ }
21
+
22
+ function tmpDir() {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-history-test-'));
24
+ }
25
+
26
+ function writeLines(p, lines) {
27
+ fs.writeFileSync(p, lines.join('\n') + '\n');
28
+ }
29
+
30
+ test('fresh ts (just now) → true', () => {
31
+ const p = tmpFile();
32
+ try {
33
+ writeLines(p, [JSON.stringify({ ts: Date.now(), counts: {}, paused: null })]);
34
+ assert.equal(heartbeatFresh(p, 180_000), true);
35
+ } finally {
36
+ try { fs.unlinkSync(p); } catch {}
37
+ }
38
+ });
39
+
40
+ test('stale ts (10 min old) → false', () => {
41
+ const p = tmpFile();
42
+ try {
43
+ const staleTs = Date.now() - 10 * 60 * 1000;
44
+ writeLines(p, [JSON.stringify({ ts: staleTs, counts: {}, paused: null })]);
45
+ assert.equal(heartbeatFresh(p, 180_000), false);
46
+ } finally {
47
+ try { fs.unlinkSync(p); } catch {}
48
+ }
49
+ });
50
+
51
+ test('missing file → false', () => {
52
+ const p = path.join(os.tmpdir(), 'watchdog-test-nonexistent-999999.log');
53
+ assert.equal(heartbeatFresh(p, 180_000), false);
54
+ });
55
+
56
+ test('garbage last line → false', () => {
57
+ const p = tmpFile();
58
+ try {
59
+ writeLines(p, ['not json at all!!!']);
60
+ assert.equal(heartbeatFresh(p, 180_000), false);
61
+ } finally {
62
+ try { fs.unlinkSync(p); } catch {}
63
+ }
64
+ });
65
+
66
+ test('last non-empty line wins (fresh after stale)', () => {
67
+ const p = tmpFile();
68
+ try {
69
+ const staleTs = Date.now() - 10 * 60 * 1000;
70
+ writeLines(p, [
71
+ JSON.stringify({ ts: staleTs }),
72
+ JSON.stringify({ ts: Date.now() }),
73
+ ]);
74
+ assert.equal(heartbeatFresh(p, 180_000), true);
75
+ } finally {
76
+ try { fs.unlinkSync(p); } catch {}
77
+ }
78
+ });
79
+
80
+ test('empty file → false', () => {
81
+ const p = tmpFile();
82
+ try {
83
+ fs.writeFileSync(p, '');
84
+ assert.equal(heartbeatFresh(p, 180_000), false);
85
+ } finally {
86
+ try { fs.unlinkSync(p); } catch {}
87
+ }
88
+ });
89
+
90
+ test('JSON line without ts field → false', () => {
91
+ const p = tmpFile();
92
+ try {
93
+ writeLines(p, [JSON.stringify({ counts: {}, paused: null })]);
94
+ assert.equal(heartbeatFresh(p, 180_000), false);
95
+ } finally {
96
+ try { fs.unlinkSync(p); } catch {}
97
+ }
98
+ });
99
+
100
+ // ── readLastHeartbeat ─────────────────────────────────────────────────────────
101
+
102
+ test('readLastHeartbeat: returns full parsed object including pid', () => {
103
+ const p = tmpFile();
104
+ try {
105
+ const entry = { ts: Date.now(), pid: 12345, counts: {}, paused: null };
106
+ writeLines(p, [JSON.stringify(entry)]);
107
+ const result = readLastHeartbeat(p);
108
+ assert.equal(result.ts, entry.ts);
109
+ assert.equal(result.pid, 12345);
110
+ } finally {
111
+ try { fs.unlinkSync(p); } catch {}
112
+ }
113
+ });
114
+
115
+ test('readLastHeartbeat: returns null for missing file', () => {
116
+ const p = path.join(os.tmpdir(), 'watchdog-helpers-test-nonexistent-999999.log');
117
+ assert.equal(readLastHeartbeat(p), null);
118
+ });
119
+
120
+ test('readLastHeartbeat: returns null for empty file', () => {
121
+ const p = tmpFile();
122
+ try {
123
+ fs.writeFileSync(p, '');
124
+ assert.equal(readLastHeartbeat(p), null);
125
+ } finally {
126
+ try { fs.unlinkSync(p); } catch {}
127
+ }
128
+ });
129
+
130
+ test('readLastHeartbeat: returns object without pid for legacy entry', () => {
131
+ const p = tmpFile();
132
+ try {
133
+ const staleTs = Date.now() - 10 * 60 * 1000;
134
+ writeLines(p, [JSON.stringify({ ts: staleTs, counts: {}, paused: null })]);
135
+ const result = readLastHeartbeat(p);
136
+ assert.ok(result !== null);
137
+ assert.equal(result.ts, staleTs);
138
+ assert.equal(result.pid, undefined);
139
+ } finally {
140
+ try { fs.unlinkSync(p); } catch {}
141
+ }
142
+ });
143
+
144
+ // ── maybeFinalizeHistory: stamp gate ─────────────────────────────────────────
145
+
146
+ test('maybeFinalizeHistory: same-day stamp → skip in O(1), finalizeFn never called', async () => {
147
+ const dir = tmpDir();
148
+ try {
149
+ const stampPath = path.join(dir, 'history-rollup.stamp');
150
+ const lockPath = path.join(dir, 'history-rollup.lock');
151
+ fs.writeFileSync(stampPath, localDateStr(), 'utf8');
152
+ let called = false;
153
+ const result = await maybeFinalizeHistory({
154
+ stampPath,
155
+ lockPath,
156
+ finalizeFn: async () => { called = true; return { finalizedDates: [], partial: false }; },
157
+ });
158
+ assert.equal(called, false);
159
+ assert.equal(result.ran, false);
160
+ assert.equal(result.reason, 'already-finalized-today');
161
+ } finally {
162
+ fs.rmSync(dir, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ test('maybeFinalizeHistory: new day (stale/missing stamp) → runs and stamps today', async () => {
167
+ const dir = tmpDir();
168
+ try {
169
+ const stampPath = path.join(dir, 'history-rollup.stamp');
170
+ const lockPath = path.join(dir, 'history-rollup.lock');
171
+ fs.writeFileSync(stampPath, '2000-01-01', 'utf8');
172
+ let called = false;
173
+ const result = await maybeFinalizeHistory({
174
+ stampPath,
175
+ lockPath,
176
+ finalizeFn: async (opts) => { called = true; assert.equal(opts.budgetMs, 60_000); return { finalizedDates: ['2000-01-02'], partial: false }; },
177
+ });
178
+ assert.equal(called, true);
179
+ assert.equal(result.ran, true);
180
+ assert.equal(result.reason, 'finalized');
181
+ assert.equal(fs.readFileSync(stampPath, 'utf8').trim(), localDateStr());
182
+ } finally {
183
+ fs.rmSync(dir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test('maybeFinalizeHistory: missing stamp file → treated as new day, runs', async () => {
188
+ const dir = tmpDir();
189
+ try {
190
+ const stampPath = path.join(dir, 'history-rollup.stamp');
191
+ const lockPath = path.join(dir, 'history-rollup.lock');
192
+ const result = await maybeFinalizeHistory({
193
+ stampPath,
194
+ lockPath,
195
+ finalizeFn: async () => ({ finalizedDates: [], partial: false }),
196
+ });
197
+ assert.equal(result.ran, true);
198
+ } finally {
199
+ fs.rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ // ── maybeFinalizeHistory: lock contention ────────────────────────────────────
204
+
205
+ test('maybeFinalizeHistory: lock already held (fresh) by another caller → skip silently', async () => {
206
+ const dir = tmpDir();
207
+ try {
208
+ const stampPath = path.join(dir, 'history-rollup.stamp');
209
+ const lockPath = path.join(dir, 'history-rollup.lock');
210
+ // Simulate a concurrent holder (e.g. the in-app Electron boot pass).
211
+ assert.equal(tryAcquireLock(lockPath, 10 * 60 * 1000), true);
212
+ let called = false;
213
+ const result = await maybeFinalizeHistory({
214
+ stampPath,
215
+ lockPath,
216
+ finalizeFn: async () => { called = true; return { finalizedDates: [], partial: false }; },
217
+ });
218
+ assert.equal(called, false);
219
+ assert.equal(result.ran, false);
220
+ assert.equal(result.reason, 'lock-contended');
221
+ // The other holder's lock must be untouched by the loser.
222
+ assert.equal(fs.existsSync(lockPath), true);
223
+ } finally {
224
+ releaseLock(path.join(dir, 'history-rollup.lock'));
225
+ fs.rmSync(dir, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('maybeFinalizeHistory: stale lock (older than staleLockMs) is reclaimed and runs', async () => {
230
+ const dir = tmpDir();
231
+ try {
232
+ const stampPath = path.join(dir, 'history-rollup.stamp');
233
+ const lockPath = path.join(dir, 'history-rollup.lock');
234
+ fs.writeFileSync(lockPath, '99999', 'utf8');
235
+ const staleTime = new Date(Date.now() - 20 * 60 * 1000);
236
+ fs.utimesSync(lockPath, staleTime, staleTime);
237
+
238
+ let called = false;
239
+ const result = await maybeFinalizeHistory({
240
+ stampPath,
241
+ lockPath,
242
+ staleLockMs: 10 * 60 * 1000,
243
+ finalizeFn: async () => { called = true; return { finalizedDates: [], partial: false }; },
244
+ });
245
+ assert.equal(called, true);
246
+ assert.equal(result.ran, true);
247
+ // Lock is released after the run.
248
+ assert.equal(fs.existsSync(lockPath), false);
249
+ } finally {
250
+ fs.rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ test('maybeFinalizeHistory: releases the lock even when finalizeFn throws', async () => {
255
+ const dir = tmpDir();
256
+ try {
257
+ const stampPath = path.join(dir, 'history-rollup.stamp');
258
+ const lockPath = path.join(dir, 'history-rollup.lock');
259
+ await assert.rejects(() => maybeFinalizeHistory({
260
+ stampPath,
261
+ lockPath,
262
+ finalizeFn: async () => { throw new Error('boom'); },
263
+ }));
264
+ assert.equal(fs.existsSync(lockPath), false);
265
+ } finally {
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+ });
269
+
270
+ // ── maybeFinalizeHistory: budget-partial ─────────────────────────────────────
271
+
272
+ test('maybeFinalizeHistory: partial finalize (budget exceeded) does NOT stamp the day complete', async () => {
273
+ const dir = tmpDir();
274
+ try {
275
+ const stampPath = path.join(dir, 'history-rollup.stamp');
276
+ const lockPath = path.join(dir, 'history-rollup.lock');
277
+ const result = await maybeFinalizeHistory({
278
+ stampPath,
279
+ lockPath,
280
+ finalizeFn: async () => ({ finalizedDates: [], partial: true }),
281
+ });
282
+ assert.equal(result.ran, true);
283
+ assert.equal(result.reason, 'partial');
284
+ assert.equal(fs.existsSync(stampPath), false);
285
+ } finally {
286
+ fs.rmSync(dir, { recursive: true, force: true });
287
+ }
288
+ });
289
+
290
+ // ── maybeFinalizeHistory: dry run ────────────────────────────────────────────
291
+
292
+ test('maybeFinalizeHistory: dryRun computes without stamping or requiring a write', async () => {
293
+ const dir = tmpDir();
294
+ try {
295
+ const stampPath = path.join(dir, 'history-rollup.stamp');
296
+ const lockPath = path.join(dir, 'history-rollup.lock');
297
+ let seenDryRun;
298
+ const result = await maybeFinalizeHistory({
299
+ stampPath,
300
+ lockPath,
301
+ dryRun: true,
302
+ finalizeFn: async (opts) => { seenDryRun = opts.dryRun; return { finalizedDates: ['2026-07-01'], partial: false }; },
303
+ });
304
+ assert.equal(seenDryRun, true);
305
+ assert.equal(result.ran, false);
306
+ assert.equal(result.reason, 'dry-run');
307
+ assert.deepEqual(result.finalizedDates, ['2026-07-01']);
308
+ assert.equal(fs.existsSync(stampPath), false);
309
+ } finally {
310
+ fs.rmSync(dir, { recursive: true, force: true });
311
+ }
312
+ });
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/watchdog-relaunch.test.cjs
4
+
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const { spawnSync } = require('node:child_process');
10
+ const {
11
+ maybeRelaunchApp,
12
+ readRelaunchState,
13
+ DEFAULT_MAX_RELAUNCH_ATTEMPTS,
14
+ } = require('../watchdogHelpers.cjs');
15
+
16
+ function tmpDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-relaunch-test-'));
18
+ }
19
+
20
+ function writeHeartbeat(hbPath, tsMs, pid) {
21
+ fs.writeFileSync(hbPath, JSON.stringify({ ts: tsMs, pid }) + '\n');
22
+ }
23
+
24
+ function makeSpawnSpy() {
25
+ const calls = [];
26
+ const fn = (opts) => { calls.push(opts); };
27
+ fn.calls = calls;
28
+ return fn;
29
+ }
30
+
31
+ // heartbeatFresh() reads the real wall clock internally (no injectable `now`),
32
+ // so every heartbeat ts below is anchored to actual Date.now() at test start —
33
+ // only the `now` passed to maybeRelaunchApp (which drives debounce/cap math)
34
+ // is offset from that same anchor.
35
+ //
36
+ // isPidAlive() treats EPERM (pid exists, owned by another user — e.g. pid 1)
37
+ // as alive, so a genuinely-dead pid must actually be gone (ESRCH), not just
38
+ // inaccessible. Spawn a short-lived same-user child and use its exited pid —
39
+ // guaranteed ESRCH under our own uid, unlike a hardcoded low pid number.
40
+ const DEAD_PID = (() => {
41
+ const result = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
42
+ return result.pid;
43
+ })();
44
+
45
+ test('fresh heartbeat → no relaunch, no spawn', () => {
46
+ const dir = tmpDir();
47
+ try {
48
+ const hbPath = path.join(dir, 'heartbeat.log');
49
+ const statePath = path.join(dir, 'state.json');
50
+ const now = Date.now();
51
+ writeHeartbeat(hbPath, now - 1_000, DEAD_PID); // 1s old — fresh
52
+ const spawnFn = makeSpawnSpy();
53
+
54
+ const result = maybeRelaunchApp({
55
+ heartbeatPath: hbPath,
56
+ maxAgeMs: 180_000,
57
+ statePath,
58
+ now,
59
+ spawnFn,
60
+ });
61
+
62
+ assert.equal(result.relaunched, false);
63
+ assert.equal(result.reason, 'alive');
64
+ assert.equal(spawnFn.calls.length, 0);
65
+ } finally {
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test('stale heartbeat + dead pid → relaunch fires', () => {
71
+ const dir = tmpDir();
72
+ try {
73
+ const hbPath = path.join(dir, 'heartbeat.log');
74
+ const statePath = path.join(dir, 'state.json');
75
+ const now = Date.now();
76
+ writeHeartbeat(hbPath, now - 10 * 60_000, DEAD_PID);
77
+ const spawnFn = makeSpawnSpy();
78
+
79
+ const result = maybeRelaunchApp({
80
+ heartbeatPath: hbPath,
81
+ maxAgeMs: 180_000,
82
+ statePath,
83
+ debounceMs: 90_000,
84
+ now,
85
+ spawnFn,
86
+ });
87
+
88
+ assert.equal(result.relaunched, true);
89
+ assert.equal(result.attemptCount, 1);
90
+ assert.equal(spawnFn.calls.length, 1);
91
+
92
+ const state = readRelaunchState(statePath);
93
+ assert.equal(state.attemptCount, 1);
94
+ assert.equal(state.lastAttemptTs, now);
95
+ } finally {
96
+ fs.rmSync(dir, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test('stale heartbeat but debounce window not elapsed → no relaunch', () => {
101
+ const dir = tmpDir();
102
+ try {
103
+ const hbPath = path.join(dir, 'heartbeat.log');
104
+ const statePath = path.join(dir, 'state.json');
105
+ const now = Date.now();
106
+ writeHeartbeat(hbPath, now - 10 * 60_000, DEAD_PID);
107
+
108
+ // First tick: relaunch fires.
109
+ const spawnFn1 = makeSpawnSpy();
110
+ const first = maybeRelaunchApp({
111
+ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, debounceMs: 90_000, now, spawnFn: spawnFn1,
112
+ });
113
+ assert.equal(first.relaunched, true);
114
+
115
+ // Second tick 30s later — inside the 90s debounce window.
116
+ const spawnFn2 = makeSpawnSpy();
117
+ const second = maybeRelaunchApp({
118
+ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, debounceMs: 90_000, now: now + 30_000, spawnFn: spawnFn2,
119
+ });
120
+
121
+ assert.equal(second.relaunched, false);
122
+ assert.equal(second.reason, 'debounce');
123
+ assert.equal(spawnFn2.calls.length, 0);
124
+ } finally {
125
+ fs.rmSync(dir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test('3 failed attempts → stops attempting and logs, no 4th attempt', () => {
130
+ const dir = tmpDir();
131
+ try {
132
+ const hbPath = path.join(dir, 'heartbeat.log');
133
+ const statePath = path.join(dir, 'state.json');
134
+ const logPath = path.join(dir, 'relaunch.log');
135
+ let now = Date.now();
136
+ writeHeartbeat(hbPath, now - 10 * 60_000, DEAD_PID); // stays stale/dead throughout
137
+
138
+ let lastResult;
139
+ for (let i = 0; i < DEFAULT_MAX_RELAUNCH_ATTEMPTS; i++) {
140
+ const spawnFn = makeSpawnSpy();
141
+ lastResult = maybeRelaunchApp({
142
+ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, logPath, debounceMs: 90_000, now, spawnFn,
143
+ });
144
+ assert.equal(lastResult.relaunched, true, `attempt ${i + 1} should relaunch`);
145
+ assert.equal(spawnFn.calls.length, 1);
146
+ now += 100_000; // past the 90s debounce for the next tick
147
+ }
148
+
149
+ // 4th tick: capped — must NOT spawn again.
150
+ const spawnFn4 = makeSpawnSpy();
151
+ const fourth = maybeRelaunchApp({
152
+ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, logPath, debounceMs: 90_000, now, spawnFn: spawnFn4,
153
+ });
154
+
155
+ assert.equal(fourth.relaunched, false);
156
+ assert.equal(fourth.reason, 'capped');
157
+ assert.equal(spawnFn4.calls.length, 0);
158
+ assert.equal(fourth.attemptCount, DEFAULT_MAX_RELAUNCH_ATTEMPTS);
159
+
160
+ const logContent = fs.readFileSync(logPath, 'utf8');
161
+ assert.match(logContent, /giving up/i);
162
+ } finally {
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test('attemptCount resets to 0 once a fresh heartbeat is observed', () => {
168
+ const dir = tmpDir();
169
+ try {
170
+ const hbPath = path.join(dir, 'heartbeat.log');
171
+ const statePath = path.join(dir, 'state.json');
172
+ const now = Date.now();
173
+ writeHeartbeat(hbPath, now - 10 * 60_000, DEAD_PID);
174
+
175
+ const spawnFn = makeSpawnSpy();
176
+ maybeRelaunchApp({ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, debounceMs: 90_000, now, spawnFn });
177
+ assert.equal(readRelaunchState(statePath).attemptCount, 1);
178
+
179
+ // App comes back up — fresh heartbeat observed on a later tick, recorded
180
+ // with this test process's own (guaranteed-alive) pid.
181
+ const later = now + 200_000;
182
+ writeHeartbeat(hbPath, later - 1_000, process.pid);
183
+ const result = maybeRelaunchApp({
184
+ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, debounceMs: 90_000, now: later, spawnFn,
185
+ });
186
+
187
+ assert.equal(result.relaunched, false);
188
+ assert.equal(result.reason, 'alive');
189
+ assert.equal(readRelaunchState(statePath).attemptCount, 0);
190
+ } finally {
191
+ fs.rmSync(dir, { recursive: true, force: true });
192
+ }
193
+ });
@@ -11,7 +11,8 @@
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
13
  const path = require('node:path');
14
- const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('../../src/main/lib/gitWorktree.cjs');
14
+ const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('./gitWorktree.cjs');
15
+ const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
15
16
 
16
17
  const HOME = os.homedir();
17
18
  const TMPDIR = os.tmpdir();
@@ -41,6 +42,27 @@ const TAIL_BYTES = 64 * 1024;
41
42
  // Safety cap on distinct cwds to bound result set size. O(1) extra space.
42
43
  const MAX_CWDS = 50;
43
44
 
45
+ // activeProjectCwds' scan is a readdirSync of every dir under ~/.claude
46
+ // /projects (2209 dirs / 8.8 GB observed) plus a statSync per transcript —
47
+ // ~270 ms warm. reconcile() calls into it (directly or via allProjectCwds)
48
+ // several times per pass, so cache the resolved cwd list keyed on the
49
+ // resolved argument TUPLE (projectsDir, maxAgeMin, maxCwds) — never on
50
+ // `!opts`, since allProjectCwds always constructs an options object and that
51
+ // guard would cache nothing on the hottest path.
52
+ //
53
+ // Invalidation: projectsDir's own mtime (bumps when a project DIRECTORY is
54
+ // added/removed — NOT when an existing transcript gains a line, which is
55
+ // fine: a project already in the list stays in the list) plus a short TTL
56
+ // as the backstop for everything else. Same mtime-keyed idiom as
57
+ // scheduler/prdParser.cjs's dirCache and queueHistory.cjs's historyCacheKey.
58
+ const CACHE_TTL_MS = 30_000;
59
+ const cwdScanCache = new Map(); // key -> { dirMtimeMs, cachedAt, result }
60
+
61
+ /** Forces the next activeProjectCwds/allProjectCwds call to rescan. */
62
+ function bustProjectCwdCache() {
63
+ cwdScanCache.clear();
64
+ }
65
+
44
66
  // The ops-root folder name. A transcript's `cwd` can point INSIDE it whenever
45
67
  // an agent `cd`s into an artifact directory (a PRD folder, prompt-sessions,
46
68
  // scheduler/state) — Claude Code records the new absolute cwd on every
@@ -55,7 +77,7 @@ const MAX_CWDS = 50;
55
77
  // Truncating at the segment is strictly better than dropping the row: the
56
78
  // ancestor IS the project the agent was working in, so the active-project
57
79
  // signal survives instead of being silently lost.
58
- const OPS_DIRNAME = 'session-manager-operations';
80
+ const OPS_DIRNAME = OPS_ROOT_DIR;
59
81
 
60
82
  // Bound on the ancestor walk in worktreeMainRootOf — well past any real
61
83
  // filesystem depth, purely to guarantee termination without relying on
@@ -190,6 +212,23 @@ function activeProjectCwds(maxAgeMin = 90, {
190
212
  maxCwds = MAX_CWDS,
191
213
  tmpDropRoots = TMP_DROP_ROOTS,
192
214
  } = {}) {
215
+ const cacheKey = [projectsDir, maxAgeMin, maxCwds].join('|');
216
+ let dirMtimeMs;
217
+ try {
218
+ dirMtimeMs = fs.statSync(projectsDir).mtimeMs;
219
+ } catch {
220
+ // Missing/unreadable dir: nothing to scan, and any prior cache entry for
221
+ // this key must not survive to be handed back once the dir later
222
+ // appears — drop it rather than caching this empty result.
223
+ cwdScanCache.delete(cacheKey);
224
+ return [];
225
+ }
226
+ const cached = cwdScanCache.get(cacheKey);
227
+ const now0 = Date.now();
228
+ if (cached && cached.dirMtimeMs === dirMtimeMs && now0 - cached.cachedAt < CACHE_TTL_MS) {
229
+ return cached.result;
230
+ }
231
+
193
232
  // maxAgeMin === Infinity means "every project ever seen, no recency filter"
194
233
  // (allProjectCwds below). Date.now() - Infinity is -Infinity, which every
195
234
  // mtime clears — spelled out here because it reads like an accident.
@@ -231,7 +270,7 @@ function activeProjectCwds(maxAgeMin = 90, {
231
270
 
232
271
  // Scan ~/.claude/projects/*/ transcript *.jsonl files.
233
272
  let slugs;
234
- try { slugs = fs.readdirSync(projectsDir); } catch { return result; }
273
+ try { slugs = fs.readdirSync(projectsDir); } catch { cwdScanCache.delete(cacheKey); return result; }
235
274
 
236
275
  for (const slug of slugs) {
237
276
  if (result.length >= maxCwds) break;
@@ -266,6 +305,7 @@ function activeProjectCwds(maxAgeMin = 90, {
266
305
  }
267
306
  }
268
307
 
308
+ cwdScanCache.set(cacheKey, { dirMtimeMs, cachedAt: now0, result });
269
309
  return result;
270
310
  }
271
311
 
@@ -288,4 +328,10 @@ function allProjectCwds(opts = {}) {
288
328
  return activeProjectCwds(Infinity, { maxCwds: 500, ...opts });
289
329
  }
290
330
 
291
- module.exports = { activeProjectCwds, allProjectCwds, projectRootOf, worktreeMainRootOf };
331
+ module.exports = {
332
+ activeProjectCwds,
333
+ allProjectCwds,
334
+ projectRootOf,
335
+ worktreeMainRootOf,
336
+ bustProjectCwdCache,
337
+ };