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.
- package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +14 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +1 -1
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerConfig.cjs +24 -0
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1285 -143
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
- package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
- package/dist/assets/Permissions-BiYZNGYW.js +0 -3
- package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
- package/dist/assets/Settings-Cv-pRyms.js +0 -3
- package/dist/assets/settingsSchema-BJVciriw.js +0 -3
- /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('
|
|
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 =
|
|
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 = {
|
|
331
|
+
module.exports = {
|
|
332
|
+
activeProjectCwds,
|
|
333
|
+
allProjectCwds,
|
|
334
|
+
projectRootOf,
|
|
335
|
+
worktreeMainRootOf,
|
|
336
|
+
bustProjectCwdCache,
|
|
337
|
+
};
|