claude-code-session-manager 0.37.0 → 0.37.2
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/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +829 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +21 -2
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +14 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +198 -54
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/preload/api.d.ts +12 -1
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- package/src/main/adminServer.cjs +0 -242
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// watchdogHelpers.cjs — pure helpers for scheduler-watchdog.cjs (no side effects).
|
|
4
|
+
// Testable without spawning the watchdog entry script.
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const { activeProjectCwds } = require('./activeSessions.cjs');
|
|
11
|
+
|
|
12
|
+
// Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
|
|
13
|
+
const DEFAULT_HEARTBEAT_PATH = path.join(
|
|
14
|
+
os.homedir(), '.claude', 'session-manager', 'scheduler-heartbeat.log',
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// The in-app heartbeat ticks every 60 s; 3 missed ticks = stale.
|
|
18
|
+
const DEFAULT_MAX_AGE_MS = 180_000;
|
|
19
|
+
|
|
20
|
+
/** Local-TZ 'YYYY-MM-DD' for `d` (default now). Single source of truth for
|
|
21
|
+
* the watchdog's notion of "today" — used for both the log filename and the
|
|
22
|
+
* history-rollup once-per-day gate. */
|
|
23
|
+
function localDateStr(d = new Date()) {
|
|
24
|
+
const y = d.getFullYear();
|
|
25
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
26
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
27
|
+
return `${y}-${m}-${day}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Tail bytes to read — enough to hold several JSON heartbeat lines without
|
|
31
|
+
// loading a potentially 1 MB file. O(1) in file size.
|
|
32
|
+
const TAIL_BYTES = 4096;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* readLastHeartbeat(heartbeatPath?) → object | null
|
|
36
|
+
*
|
|
37
|
+
* Reads the last ~4 KB of the heartbeat log, reverse-scans for the last
|
|
38
|
+
* non-empty line, and returns the full parsed JSON object.
|
|
39
|
+
* Returns null on missing / empty / unparseable file.
|
|
40
|
+
*
|
|
41
|
+
* Single source of truth for the file-read + reverse-scan logic; used by
|
|
42
|
+
* readLastHeartbeatTs() and heartbeatFresh().
|
|
43
|
+
*/
|
|
44
|
+
function readLastHeartbeat(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
|
|
45
|
+
let buf;
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.statSync(heartbeatPath);
|
|
48
|
+
const size = stat.size;
|
|
49
|
+
if (size === 0) return null;
|
|
50
|
+
|
|
51
|
+
const readSize = Math.min(TAIL_BYTES, size);
|
|
52
|
+
const fd = fs.openSync(heartbeatPath, 'r');
|
|
53
|
+
try {
|
|
54
|
+
buf = Buffer.alloc(readSize);
|
|
55
|
+
fs.readSync(fd, buf, 0, readSize, size - readSize);
|
|
56
|
+
} finally {
|
|
57
|
+
fs.closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = buf.toString('utf8').split('\n');
|
|
64
|
+
// Walk lines in reverse to find last non-empty parseable one.
|
|
65
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
66
|
+
const line = lines[i].trim();
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(line);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* readLastHeartbeatTs(heartbeatPath?) → number | null
|
|
79
|
+
*
|
|
80
|
+
* Returns the `ts` field (epoch ms) from the last heartbeat entry.
|
|
81
|
+
* Returns null on missing / empty / unparseable file or missing ts field.
|
|
82
|
+
*/
|
|
83
|
+
function readLastHeartbeatTs(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
|
|
84
|
+
const entry = readLastHeartbeat(heartbeatPath);
|
|
85
|
+
return (entry !== null && typeof entry.ts === 'number') ? entry.ts : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* heartbeatFresh(heartbeatPath?, maxAgeMs?) → boolean
|
|
90
|
+
*
|
|
91
|
+
* Returns true iff the last heartbeat ts is within maxAgeMs of `now`.
|
|
92
|
+
* Missing / empty / unparseable file → false.
|
|
93
|
+
*/
|
|
94
|
+
function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
95
|
+
const ts = readLastHeartbeatTs(heartbeatPath);
|
|
96
|
+
if (ts === null) return false;
|
|
97
|
+
return (Date.now() - ts) < maxAgeMs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default paths — callers can override via opts for testing.
|
|
101
|
+
const DEFAULT_QUEUE_PATH = path.join(
|
|
102
|
+
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
|
|
103
|
+
);
|
|
104
|
+
const DEFAULT_PRDS_DIR = path.join(
|
|
105
|
+
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
|
|
106
|
+
);
|
|
107
|
+
const PLUGIN_CACHE_ROOT = path.join(
|
|
108
|
+
os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* compareVersionsDesc(a, b) → number
|
|
113
|
+
*
|
|
114
|
+
* Numeric per-segment version compare (descending) for plugin-cache version
|
|
115
|
+
* dirs like "0.1.0" / "0.2.0" / "0.10.0". Plain lexicographic sort is wrong
|
|
116
|
+
* here — "0.10.0" < "0.2.0" as strings even though 10 > 2 numerically. Falls
|
|
117
|
+
* back to a string compare for any non-numeric segment (e.g. a stray dir name
|
|
118
|
+
* that isn't a version) so listSkillCandidates never throws on garbage input.
|
|
119
|
+
*/
|
|
120
|
+
function compareVersionsDesc(a, b) {
|
|
121
|
+
const as = a.split('.');
|
|
122
|
+
const bs = b.split('.');
|
|
123
|
+
const len = Math.max(as.length, bs.length);
|
|
124
|
+
for (let i = 0; i < len; i++) {
|
|
125
|
+
const an = Number(as[i]);
|
|
126
|
+
const bn = Number(bs[i]);
|
|
127
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) {
|
|
128
|
+
if (an !== bn) return bn - an;
|
|
129
|
+
} else if (as[i] !== bs[i]) {
|
|
130
|
+
return (as[i] ?? '') < (bs[i] ?? '') ? 1 : -1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot?) → string[]
|
|
138
|
+
*
|
|
139
|
+
* Ordered candidate paths for an inlined skill/standards file, most-specific
|
|
140
|
+
* first — the process-feedback SKILL.md and develop standards.md moved out of
|
|
141
|
+
* the legacy ~/.claude/skills/ location into the session-manager-dev plugin:
|
|
142
|
+
*
|
|
143
|
+
* 1. <cwd>/plugins/session-manager-dev/skills/<skillName>/<fileName> — repo-local
|
|
144
|
+
* copy, correct when the swept project vendors the plugin (e.g. session-manager itself).
|
|
145
|
+
* 2. <pluginCacheRoot>/<newest-version>/skills/<skillName>/<fileName> — the installed
|
|
146
|
+
* plugin cache (default ~/.claude/plugins/cache/session-manager/session-manager-dev).
|
|
147
|
+
* Enumerates every version dir present, newest (lexicographically-highest) first.
|
|
148
|
+
* 3. ~/.claude/skills/<skillName>/<fileName> — legacy location, kept last so an
|
|
149
|
+
* old install still works.
|
|
150
|
+
*
|
|
151
|
+
* pluginCacheRoot is overridable for tests; defaults to the real plugin cache.
|
|
152
|
+
*/
|
|
153
|
+
function listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
|
|
154
|
+
const candidates = [
|
|
155
|
+
path.join(cwd, 'plugins', 'session-manager-dev', 'skills', skillName, fileName),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
let entries = [];
|
|
159
|
+
try {
|
|
160
|
+
entries = fs.readdirSync(pluginCacheRoot, { withFileTypes: true });
|
|
161
|
+
} catch { /* plugin cache not installed */ }
|
|
162
|
+
const versions = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(compareVersionsDesc);
|
|
163
|
+
for (const version of versions) {
|
|
164
|
+
candidates.push(path.join(pluginCacheRoot, version, 'skills', skillName, fileName));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
candidates.push(path.join(os.homedir(), '.claude', 'skills', skillName, fileName));
|
|
168
|
+
return candidates;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot?) → string | null
|
|
173
|
+
*
|
|
174
|
+
* Returns the first candidate from listSkillCandidates() that is a readable
|
|
175
|
+
* file, or null if none resolve.
|
|
176
|
+
*/
|
|
177
|
+
function resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
|
|
178
|
+
for (const candidate of listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot)) {
|
|
179
|
+
try {
|
|
180
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
181
|
+
} catch { /* try next candidate */ }
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* isPidAlive(pid) → boolean
|
|
188
|
+
*
|
|
189
|
+
* Plain liveness check via process.kill(pid, 0) (no signal sent). Single
|
|
190
|
+
* source of truth for the "is this recorded pid still alive" check used by
|
|
191
|
+
* checkAppLiveness's defense-in-depth relaunch gate.
|
|
192
|
+
*
|
|
193
|
+
* EPERM (pid exists, owned by another user) counts as alive — only ESRCH
|
|
194
|
+
* (no such process) means dead. Watchdog and app run as the same user in
|
|
195
|
+
* practice, so this distinction rarely matters here, but treating EPERM as
|
|
196
|
+
* "dead" would be wrong on its face (the process demonstrably exists).
|
|
197
|
+
*/
|
|
198
|
+
function isPidAlive(pid) {
|
|
199
|
+
if (typeof pid !== 'number' || !Number.isFinite(pid)) return false;
|
|
200
|
+
try {
|
|
201
|
+
process.kill(pid, 0);
|
|
202
|
+
return true;
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return e?.code === 'EPERM';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Offline queue.json reconciliation (orphaned 'running' jobs whose pid died
|
|
209
|
+
// while Electron wasn't running) used to live here as reconcileQueueOffline().
|
|
210
|
+
// PRD 686 moved that responsibility into src/main/scheduler.cjs's own boot
|
|
211
|
+
// path (partitionBootOrphans/applyOrphanOutcome) now that PRD 685's watchdog
|
|
212
|
+
// relaunch means the app is never down long enough to need an external
|
|
213
|
+
// reconciler — the in-app scheduler is queue.json's single owner again.
|
|
214
|
+
|
|
215
|
+
// ── slugify ──────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
|
|
218
|
+
function slugify(name) {
|
|
219
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── YAML frontmatter stripper ─────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Strip leading YAML frontmatter block (---\n…\n---) from skill/standards files
|
|
226
|
+
* so they can be inlined cleanly into a PRD body.
|
|
227
|
+
*/
|
|
228
|
+
function stripFrontmatter(content) {
|
|
229
|
+
if (!content.startsWith('---')) return content;
|
|
230
|
+
const end = content.indexOf('\n---', 3);
|
|
231
|
+
if (end === -1) return content;
|
|
232
|
+
return content.slice(end + 4).replace(/^\n/, '');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Strip a leading H1 line (`# …\n`) — used to drop the `# Engineering standards`
|
|
237
|
+
* heading from standards.md before adding our own `## Engineering standards` heading.
|
|
238
|
+
*/
|
|
239
|
+
function stripLeadingH1(content) {
|
|
240
|
+
return content.replace(/^# [^\n]*\n/, '');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── hasOpenFeedback ───────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* hasOpenFeedback(cwd) → boolean
|
|
247
|
+
*
|
|
248
|
+
* Returns true iff `<cwd>/session-manager-operations/feedback/` (the PRD-462
|
|
249
|
+
* convention) or, as a transitional fallback for repos not yet relocated,
|
|
250
|
+
* the legacy `<cwd>/feedback/` exists AND contains at least one *.md file
|
|
251
|
+
* directly in its root (i.e. NOT inside `processed/` or any subdirectory).
|
|
252
|
+
* Pure filesystem check — no LLM call.
|
|
253
|
+
*
|
|
254
|
+
* Mirrors process-feedback skill step 0 (cheap quick-exit signal).
|
|
255
|
+
* Complexity: O(F) where F ≤ entries in the feedback folder root.
|
|
256
|
+
*/
|
|
257
|
+
function hasOpenFeedback(cwd) {
|
|
258
|
+
for (const folderName of [path.join('session-manager-operations', 'feedback'), 'feedback']) {
|
|
259
|
+
const folderPath = path.join(cwd, folderName);
|
|
260
|
+
let entries;
|
|
261
|
+
try {
|
|
262
|
+
entries = fs.readdirSync(folderPath);
|
|
263
|
+
} catch {
|
|
264
|
+
continue; // folder does not exist
|
|
265
|
+
}
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
if (!entry.endsWith('.md')) continue;
|
|
268
|
+
// README.md is the folder's convention doc, not an open feedback item —
|
|
269
|
+
// counting it makes a folder with only a README look perpetually "pending".
|
|
270
|
+
if (entry.toLowerCase() === 'readme.md') continue;
|
|
271
|
+
try {
|
|
272
|
+
const st = fs.statSync(path.join(folderPath, entry));
|
|
273
|
+
if (st.isFile()) return true;
|
|
274
|
+
} catch { continue; }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── emitFeedbackPRD ──────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* emitFeedbackPRD(cwd, opts?) → { emitted: boolean, slug?: string, prdPath?: string, reason?: string }
|
|
284
|
+
*
|
|
285
|
+
* De-dups against queue.json (pending/running jobs whose slug matches
|
|
286
|
+
* `^\d+-feedback-<project>$`). If a match exists, returns { emitted: false, reason: 'duplicate' }.
|
|
287
|
+
*
|
|
288
|
+
* Otherwise picks the next free NN by scanning prdsDir, writes a self-contained
|
|
289
|
+
* PRD file with the inlined process-feedback procedure + engineering standards,
|
|
290
|
+
* and returns { emitted: true, slug, prdPath }.
|
|
291
|
+
*
|
|
292
|
+
* Never writes to queue.json — the app's reconcile picks up new PRD files.
|
|
293
|
+
* Atomic write: tmp → rename (mirrors config.cjs writeJsonSync pattern).
|
|
294
|
+
*
|
|
295
|
+
* Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
|
|
296
|
+
*/
|
|
297
|
+
function emitFeedbackPRD(cwd, {
|
|
298
|
+
prdsDir = DEFAULT_PRDS_DIR,
|
|
299
|
+
queuePath = DEFAULT_QUEUE_PATH,
|
|
300
|
+
skillPath,
|
|
301
|
+
standardsPath,
|
|
302
|
+
pluginCacheRoot = PLUGIN_CACHE_ROOT,
|
|
303
|
+
} = {}) {
|
|
304
|
+
// Explicit overrides (used by tests) are honored verbatim and skip the
|
|
305
|
+
// candidate search entirely — only resolve via candidates when the caller
|
|
306
|
+
// didn't pass a path.
|
|
307
|
+
const skillPathExplicit = skillPath !== undefined;
|
|
308
|
+
const standardsPathExplicit = standardsPath !== undefined;
|
|
309
|
+
if (!skillPathExplicit) {
|
|
310
|
+
skillPath = resolveSkillFile(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
|
|
311
|
+
}
|
|
312
|
+
if (!standardsPathExplicit) {
|
|
313
|
+
standardsPath = resolveSkillFile(cwd, 'develop', 'standards.md', pluginCacheRoot);
|
|
314
|
+
}
|
|
315
|
+
const project = slugify(path.basename(cwd));
|
|
316
|
+
|
|
317
|
+
// De-dup: check queue.json for pending/running feedback job for this project.
|
|
318
|
+
let queueState = { jobs: [] };
|
|
319
|
+
try {
|
|
320
|
+
queueState = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
321
|
+
} catch { /* queue.json may not exist */ }
|
|
322
|
+
|
|
323
|
+
// project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
|
|
324
|
+
const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
|
|
325
|
+
const isDuplicate = (queueState.jobs ?? []).some(
|
|
326
|
+
(j) => (j.status === 'pending' || j.status === 'running') && dupRe.test(j.slug),
|
|
327
|
+
);
|
|
328
|
+
if (isDuplicate) {
|
|
329
|
+
return { emitted: false, reason: 'duplicate' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Pick next NN: max existing NN prefix + 1. O(P) scan.
|
|
333
|
+
// We ALSO dedup against the prds dir here: while the app is down it never
|
|
334
|
+
// ingests new PRD files into queue.json, so the queue check above can't see a
|
|
335
|
+
// feedback PRD we already wrote on a previous tick. Without this, every stale
|
|
336
|
+
// watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
|
|
337
|
+
const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
|
|
338
|
+
let maxNN = 0;
|
|
339
|
+
let prdFiles = [];
|
|
340
|
+
try {
|
|
341
|
+
prdFiles = fs.readdirSync(prdsDir);
|
|
342
|
+
} catch { /* prdsDir may not exist yet */ }
|
|
343
|
+
for (const f of prdFiles) {
|
|
344
|
+
if (prdDupRe.test(f)) {
|
|
345
|
+
return { emitted: false, reason: 'duplicate' };
|
|
346
|
+
}
|
|
347
|
+
const m = f.match(/^(\d+)-/);
|
|
348
|
+
if (m) {
|
|
349
|
+
const n = parseInt(m[1], 10);
|
|
350
|
+
if (n > maxNN) maxNN = n;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const nn = String(maxNN + 1).padStart(2, '0');
|
|
354
|
+
const slug = `${nn}-feedback-${project}`;
|
|
355
|
+
const prdPath = path.join(prdsDir, `${slug}.md`);
|
|
356
|
+
|
|
357
|
+
// Read and inline skill content.
|
|
358
|
+
let rawSkill = '';
|
|
359
|
+
if (skillPath) {
|
|
360
|
+
try {
|
|
361
|
+
rawSkill = fs.readFileSync(skillPath, 'utf8');
|
|
362
|
+
} catch { /* handled by the empty-body guard below */ }
|
|
363
|
+
}
|
|
364
|
+
const skillBody = stripFrontmatter(rawSkill).trim();
|
|
365
|
+
|
|
366
|
+
// Read and inline standards — strip H1 so our ## heading is the section anchor.
|
|
367
|
+
let rawStandards = '';
|
|
368
|
+
if (standardsPath) {
|
|
369
|
+
try {
|
|
370
|
+
rawStandards = fs.readFileSync(standardsPath, 'utf8');
|
|
371
|
+
} catch { /* handled by the empty-body guard below */ }
|
|
372
|
+
}
|
|
373
|
+
const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
|
|
374
|
+
|
|
375
|
+
// Hard-fail rather than silently ship a hollow PRD: if either inline is
|
|
376
|
+
// empty/unreadable, refuse to write anything and name every candidate tried
|
|
377
|
+
// so a future relocation shows up loudly in the watchdog log instead of
|
|
378
|
+
// queueing a template with no procedure and no standards in it.
|
|
379
|
+
if (!skillBody || !standardsBody) {
|
|
380
|
+
const missing = [];
|
|
381
|
+
if (!skillBody) {
|
|
382
|
+
const tried = skillPathExplicit ? [skillPath] : listSkillCandidates(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
|
|
383
|
+
missing.push(`process-feedback SKILL.md (tried: ${tried.join(', ')})`);
|
|
384
|
+
}
|
|
385
|
+
if (!standardsBody) {
|
|
386
|
+
const tried = standardsPathExplicit ? [standardsPath] : listSkillCandidates(cwd, 'develop', 'standards.md', pluginCacheRoot);
|
|
387
|
+
missing.push(`develop standards.md (tried: ${tried.join(', ')})`);
|
|
388
|
+
}
|
|
389
|
+
process.stderr.write(`[emitFeedbackPRD] refusing to emit for ${cwd}: missing inline(s) — ${missing.join('; ')}\n`);
|
|
390
|
+
return { emitted: false, reason: 'missing-inline' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const projectName = path.basename(cwd);
|
|
394
|
+
const body = [
|
|
395
|
+
'---',
|
|
396
|
+
`title: Process feedback for ${projectName}`,
|
|
397
|
+
`cwd: ${cwd}`,
|
|
398
|
+
'estimateMinutes: 15',
|
|
399
|
+
'---',
|
|
400
|
+
'',
|
|
401
|
+
'# Goal',
|
|
402
|
+
'',
|
|
403
|
+
`Process the inbound feedback folder for ${projectName}. The quick-exit (step 0 below)`,
|
|
404
|
+
'means this run bails in milliseconds if no open items exist — safe to execute even if',
|
|
405
|
+
'feedback was already cleared between scheduling and execution.',
|
|
406
|
+
'',
|
|
407
|
+
'# Acceptance criteria',
|
|
408
|
+
'',
|
|
409
|
+
'- [ ] All open feedback items evaluated and either queued via /develop, declined with RESOLUTION, or forwarded upstream.',
|
|
410
|
+
'- [ ] Processed items archived to session-manager-operations/feedback/processed/ (or, for repos not yet relocated, legacy feedback/processed/) with RESOLUTION notes.',
|
|
411
|
+
'- [ ] session-manager-operations/feedback/README.md (or legacy feedback/README.md) self-improved with lessons from this pass.',
|
|
412
|
+
'- [ ] timeout 60 git diff --exit-code runs clean (no uncommitted inline work).',
|
|
413
|
+
'',
|
|
414
|
+
'# Implementation notes',
|
|
415
|
+
'',
|
|
416
|
+
'Follow the inlined process-feedback procedure below exactly. No skills are loaded in',
|
|
417
|
+
'headless execution — the procedure is fully self-contained. Use /develop for any work',
|
|
418
|
+
'that belongs to this project; never implement feedback inline.',
|
|
419
|
+
'',
|
|
420
|
+
'# Out of scope',
|
|
421
|
+
'',
|
|
422
|
+
'- Implementing feedback items directly (that is /develop → scheduler).',
|
|
423
|
+
'- Cross-project feedback beyond filing upstream items.',
|
|
424
|
+
'',
|
|
425
|
+
skillBody,
|
|
426
|
+
'',
|
|
427
|
+
'## Engineering standards',
|
|
428
|
+
'',
|
|
429
|
+
standardsBody,
|
|
430
|
+
].join('\n');
|
|
431
|
+
|
|
432
|
+
// Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
|
|
433
|
+
const tmpPath = `${prdPath}.tmp-${process.pid}-${Date.now()}`;
|
|
434
|
+
try {
|
|
435
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
436
|
+
fs.writeFileSync(tmpPath, body + '\n', 'utf8');
|
|
437
|
+
fs.renameSync(tmpPath, prdPath);
|
|
438
|
+
} catch (e) {
|
|
439
|
+
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
440
|
+
throw e;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { emitted: true, slug, prdPath };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── app liveness + auto-relaunch ─────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// The watchdog's one supervision job: is session-manager itself running, and
|
|
449
|
+
// if not, start it. Reuses the existing scheduler heartbeat (written every
|
|
450
|
+
// 60 s by scheduler.cjs's heartbeatInterval regardless of pause state) as the
|
|
451
|
+
// liveness signal — no second heartbeat file.
|
|
452
|
+
|
|
453
|
+
const DEFAULT_RELAUNCH_STATE_PATH = path.join(
|
|
454
|
+
os.homedir(), '.claude', 'session-manager', 'watchdog-relaunch-state.json',
|
|
455
|
+
);
|
|
456
|
+
const DEFAULT_RELAUNCH_LOG_PATH = path.join(
|
|
457
|
+
os.homedir(), '.claude', 'logs', 'scheduler-watchdog-relaunch.log',
|
|
458
|
+
);
|
|
459
|
+
const DEFAULT_RELAUNCH_DEBOUNCE_MS = 90_000;
|
|
460
|
+
const DEFAULT_MAX_RELAUNCH_ATTEMPTS = 3;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* checkAppLiveness(opts?) → { alive: boolean, reason: string }
|
|
464
|
+
*
|
|
465
|
+
* Primary signal: heartbeatFresh() within the existing max-age window.
|
|
466
|
+
* Defense-in-depth: if the heartbeat is stale, but the pid it last recorded
|
|
467
|
+
* is still alive (app under heavy load, tick just missed), treat as alive
|
|
468
|
+
* rather than relaunching on top of a live process.
|
|
469
|
+
*/
|
|
470
|
+
function checkAppLiveness({
|
|
471
|
+
heartbeatPath = DEFAULT_HEARTBEAT_PATH,
|
|
472
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
473
|
+
} = {}) {
|
|
474
|
+
if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
|
|
475
|
+
return { alive: true, reason: 'heartbeat-fresh' };
|
|
476
|
+
}
|
|
477
|
+
const last = readLastHeartbeat(heartbeatPath);
|
|
478
|
+
if (last !== null && typeof last.pid === 'number' && isPidAlive(last.pid)) {
|
|
479
|
+
return { alive: true, reason: 'pid-alive-heartbeat-stale' };
|
|
480
|
+
}
|
|
481
|
+
return { alive: false, reason: 'stale-and-dead' };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Default { lastAttemptTs: null, attemptCount: 0 } on missing/unparseable state file. */
|
|
485
|
+
function readRelaunchState(statePath = DEFAULT_RELAUNCH_STATE_PATH) {
|
|
486
|
+
try {
|
|
487
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
488
|
+
return {
|
|
489
|
+
lastAttemptTs: typeof raw.lastAttemptTs === 'number' ? raw.lastAttemptTs : null,
|
|
490
|
+
attemptCount: typeof raw.attemptCount === 'number' ? raw.attemptCount : 0,
|
|
491
|
+
};
|
|
492
|
+
} catch {
|
|
493
|
+
return { lastAttemptTs: null, attemptCount: 0 };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
|
|
498
|
+
function writeRelaunchState(statePath, state) {
|
|
499
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
500
|
+
const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now()}`;
|
|
501
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
502
|
+
fs.renameSync(tmpPath, statePath);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function logRelaunchLine(logPath, message) {
|
|
506
|
+
try {
|
|
507
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
508
|
+
fs.appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
process.stderr.write(`[watchdog-relaunch] log write failed: ${e?.message}\n`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* defaultSpawnRelaunch(opts?) — launches a new session-manager instance the
|
|
516
|
+
* same way a user would: `npx claude-code-session-manager@latest` (the
|
|
517
|
+
* documented distribution method — CLAUDE.md's "Distribution" section), so
|
|
518
|
+
* this keeps working across npm publishes with no local path to maintain.
|
|
519
|
+
*
|
|
520
|
+
* Detached + stdio redirected to logPath + unref()'d so the watchdog's own
|
|
521
|
+
* (short-lived) process doesn't block on the launched app and the app
|
|
522
|
+
* survives the watchdog exiting.
|
|
523
|
+
*/
|
|
524
|
+
function defaultSpawnRelaunch({ logPath = DEFAULT_RELAUNCH_LOG_PATH } = {}) {
|
|
525
|
+
const { spawn } = require('node:child_process');
|
|
526
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
527
|
+
const fd = fs.openSync(logPath, 'a');
|
|
528
|
+
try {
|
|
529
|
+
const child = spawn('npx', ['claude-code-session-manager@latest'], {
|
|
530
|
+
detached: true,
|
|
531
|
+
stdio: ['ignore', fd, fd],
|
|
532
|
+
});
|
|
533
|
+
child.on('error', (e) => {
|
|
534
|
+
logRelaunchLine(logPath, `spawn error: ${e?.message}`);
|
|
535
|
+
});
|
|
536
|
+
child.unref();
|
|
537
|
+
} finally {
|
|
538
|
+
fs.closeSync(fd);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* maybeRelaunchApp(opts?) → { relaunched: boolean, reason: string, attemptCount: number }
|
|
544
|
+
*
|
|
545
|
+
* Debounced, capped auto-relaunch:
|
|
546
|
+
* - alive (heartbeat fresh or pid alive) → no-op, reset attempt state.
|
|
547
|
+
* - dead + attemptCount already at maxAttempts → skip, log diagnostic (give up).
|
|
548
|
+
* - dead + last attempt < debounceMs ago → skip (still booting).
|
|
549
|
+
* - dead + debounce elapsed + under cap → spawn, bump attemptCount, log.
|
|
550
|
+
*
|
|
551
|
+
* attemptCount only resets to 0 once a fresh heartbeat is observed (i.e. a
|
|
552
|
+
* relaunch actually succeeded), so 3 relaunch attempts that each fail to
|
|
553
|
+
* produce a fresh heartbeat permanently cap further attempts until a human
|
|
554
|
+
* intervenes or the app comes up some other way.
|
|
555
|
+
*/
|
|
556
|
+
function maybeRelaunchApp({
|
|
557
|
+
heartbeatPath = DEFAULT_HEARTBEAT_PATH,
|
|
558
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
559
|
+
statePath = DEFAULT_RELAUNCH_STATE_PATH,
|
|
560
|
+
logPath = DEFAULT_RELAUNCH_LOG_PATH,
|
|
561
|
+
debounceMs = DEFAULT_RELAUNCH_DEBOUNCE_MS,
|
|
562
|
+
maxAttempts = DEFAULT_MAX_RELAUNCH_ATTEMPTS,
|
|
563
|
+
now = Date.now(),
|
|
564
|
+
spawnFn = defaultSpawnRelaunch,
|
|
565
|
+
} = {}) {
|
|
566
|
+
const liveness = checkAppLiveness({ heartbeatPath, maxAgeMs });
|
|
567
|
+
const state = readRelaunchState(statePath);
|
|
568
|
+
|
|
569
|
+
if (liveness.alive) {
|
|
570
|
+
if (state.attemptCount !== 0 || state.lastAttemptTs !== null) {
|
|
571
|
+
writeRelaunchState(statePath, { lastAttemptTs: null, attemptCount: 0 });
|
|
572
|
+
}
|
|
573
|
+
return { relaunched: false, reason: 'alive', attemptCount: 0 };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (state.attemptCount >= maxAttempts) {
|
|
577
|
+
logRelaunchLine(
|
|
578
|
+
logPath,
|
|
579
|
+
`giving up: ${state.attemptCount} relaunch attempt(s) already made and heartbeat is still stale — manual intervention required`,
|
|
580
|
+
);
|
|
581
|
+
return { relaunched: false, reason: 'capped', attemptCount: state.attemptCount };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (state.lastAttemptTs !== null && (now - state.lastAttemptTs) < debounceMs) {
|
|
585
|
+
return { relaunched: false, reason: 'debounce', attemptCount: state.attemptCount };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const attemptCount = state.attemptCount + 1;
|
|
589
|
+
spawnFn({ logPath });
|
|
590
|
+
writeRelaunchState(statePath, { lastAttemptTs: now, attemptCount });
|
|
591
|
+
logRelaunchLine(
|
|
592
|
+
logPath,
|
|
593
|
+
`relaunch attempt ${attemptCount}/${maxAttempts}: spawning "npx claude-code-session-manager@latest" (heartbeat stale, last-known pid dead)`,
|
|
594
|
+
);
|
|
595
|
+
return { relaunched: true, reason: 'launched', attemptCount };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── sweep ─────────────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* sweep(opts?) → { scanned: number, emitted: number, skipped: number }
|
|
602
|
+
*
|
|
603
|
+
* For each active-session cwd (from activeProjectCwds), cheaply checks for open
|
|
604
|
+
* feedback. Projects without open feedback cost a readdir per folder name — no LLM.
|
|
605
|
+
* Projects with open feedback get a PRD emitted into prdsDir (de-duped against queue).
|
|
606
|
+
*
|
|
607
|
+
* Never mutates queue.json — only adds PRD files, so it is safe to run while the
|
|
608
|
+
* in-app scheduler is alive.
|
|
609
|
+
*
|
|
610
|
+
* Complexity: O(C × F) where C = active cwds (≤50) and F = root entries per
|
|
611
|
+
* feedback folder (typically small).
|
|
612
|
+
*
|
|
613
|
+
* opts:
|
|
614
|
+
* projectsDir — forwarded to activeProjectCwds for testing
|
|
615
|
+
* prdsDir, queuePath, skillPath, standardsPath — forwarded to emitFeedbackPRD
|
|
616
|
+
*/
|
|
617
|
+
function sweep({
|
|
618
|
+
projectsDir,
|
|
619
|
+
prdsDir = DEFAULT_PRDS_DIR,
|
|
620
|
+
queuePath = DEFAULT_QUEUE_PATH,
|
|
621
|
+
skillPath,
|
|
622
|
+
standardsPath,
|
|
623
|
+
} = {}) {
|
|
624
|
+
const activeOpts = {};
|
|
625
|
+
if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
|
|
626
|
+
|
|
627
|
+
const cwds = activeProjectCwds(90, activeOpts);
|
|
628
|
+
|
|
629
|
+
let scanned = 0;
|
|
630
|
+
let emitted = 0;
|
|
631
|
+
let skipped = 0;
|
|
632
|
+
|
|
633
|
+
for (const cwd of cwds) {
|
|
634
|
+
scanned++;
|
|
635
|
+
if (!hasOpenFeedback(cwd)) continue;
|
|
636
|
+
|
|
637
|
+
let result;
|
|
638
|
+
try {
|
|
639
|
+
result = emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
|
|
640
|
+
} catch (e) {
|
|
641
|
+
process.stderr.write(`[sweep] error emitting PRD for ${cwd}: ${e?.message}\n`);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (result.emitted) {
|
|
646
|
+
emitted++;
|
|
647
|
+
process.stderr.write(`[sweep] emitted ${result.slug} for ${cwd}\n`);
|
|
648
|
+
} else {
|
|
649
|
+
skipped++;
|
|
650
|
+
process.stderr.write(`[sweep] skipped ${cwd} (${result.reason})\n`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
process.stderr.write(`[sweep] scanned=${scanned} emitted=${emitted} skipped=${skipped}\n`);
|
|
655
|
+
return { scanned, emitted, skipped };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── maybeFinalizeHistory ─────────────────────────────────────────────────────
|
|
659
|
+
//
|
|
660
|
+
// Precomputes closed History-dashboard days outside Electron, so the app's
|
|
661
|
+
// first paint after a boot (possibly weeks after the app was last open) is
|
|
662
|
+
// instant, and old transcripts stay safe to age out without losing analytics.
|
|
663
|
+
// See src/main/historyAggregator.cjs's finalizeClosedDays() for the actual
|
|
664
|
+
// walk/persist logic — this is just the once-per-day scheduling + lock
|
|
665
|
+
// wrapper around it, tailored to being invoked repeatedly by a systemd timer.
|
|
666
|
+
|
|
667
|
+
const DEFAULT_STAMP_PATH = path.join(
|
|
668
|
+
os.homedir(), '.claude', 'session-manager', 'history-rollup.stamp',
|
|
669
|
+
);
|
|
670
|
+
const DEFAULT_LOCK_PATH = path.join(
|
|
671
|
+
os.homedir(), '.claude', 'session-manager', 'history-rollup.lock',
|
|
672
|
+
);
|
|
673
|
+
const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
|
|
674
|
+
const DEFAULT_FINALIZE_BUDGET_MS = 60_000;
|
|
675
|
+
|
|
676
|
+
function readStampDate(stampPath) {
|
|
677
|
+
try {
|
|
678
|
+
return fs.readFileSync(stampPath, 'utf8').trim();
|
|
679
|
+
} catch {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
|
|
685
|
+
function writeStampDate(stampPath, date) {
|
|
686
|
+
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
|
|
687
|
+
const tmpPath = `${stampPath}.tmp-${process.pid}-${Date.now()}`;
|
|
688
|
+
fs.writeFileSync(tmpPath, date, 'utf8');
|
|
689
|
+
fs.renameSync(tmpPath, stampPath);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* tryAcquireLock(lockPath, staleMs) → boolean
|
|
694
|
+
*
|
|
695
|
+
* O_EXCL ('wx') lock file so the in-app Electron boot pass and this cron
|
|
696
|
+
* pass never interleave writes to the rollup. A lock older than staleMs is
|
|
697
|
+
* assumed abandoned (a crashed holder) and reclaimed; otherwise the caller
|
|
698
|
+
* loses the race and must skip silently.
|
|
699
|
+
*/
|
|
700
|
+
function tryAcquireLock(lockPath, staleMs = DEFAULT_LOCK_STALE_MS) {
|
|
701
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
702
|
+
try {
|
|
703
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
704
|
+
return true;
|
|
705
|
+
} catch (e) {
|
|
706
|
+
if (e.code !== 'EEXIST') throw e;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
let st;
|
|
710
|
+
try {
|
|
711
|
+
st = fs.statSync(lockPath);
|
|
712
|
+
} catch {
|
|
713
|
+
st = null; // lock vanished between our failed create and this stat
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (st !== null && Date.now() - st.mtimeMs <= staleMs) {
|
|
717
|
+
return false; // held by a live (or at least recent) owner — loser skips
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Stale (or already gone) — reclaim. The unlink+wx pair isn't atomic, so
|
|
721
|
+
// two concurrent reclaimers could theoretically both win here — but that
|
|
722
|
+
// only happens when the prior holder already crashed/vanished, and the
|
|
723
|
+
// resulting "both run finalize" outcome is benign: appendRollupDays is
|
|
724
|
+
// append-only + last-write-wins-deduped, so a redundant concurrent pass
|
|
725
|
+
// costs extra work, not corrupted data.
|
|
726
|
+
try {
|
|
727
|
+
if (st !== null) fs.unlinkSync(lockPath);
|
|
728
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
729
|
+
return true;
|
|
730
|
+
} catch {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function releaseLock(lockPath) {
|
|
736
|
+
try { fs.unlinkSync(lockPath); } catch { /* already gone / never created */ }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Lazily require historyAggregator.cjs — deferred so a missing PRD-650
|
|
740
|
+
* dependency fails inside maybeFinalizeHistory's try/catch, not at require
|
|
741
|
+
* time for the whole watchdog script. */
|
|
742
|
+
function defaultFinalizeClosedDays(opts) {
|
|
743
|
+
const { finalizeClosedDays } = require('../../src/main/historyAggregator.cjs');
|
|
744
|
+
return finalizeClosedDays(opts);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* maybeFinalizeHistory(opts?) → Promise<{ ran, reason, date, finalizedDates? }>
|
|
749
|
+
*
|
|
750
|
+
* Runs at most once per local day (stamp-file gated, O(1) same-day skip).
|
|
751
|
+
* Guards against interleaving with the in-app Electron boot pass via an
|
|
752
|
+
* O_EXCL lock file. Bounded by opts.budgetMs so a huge transcript corpus
|
|
753
|
+
* can't make a single watchdog tick run long; a budget-partial pass does
|
|
754
|
+
* NOT stamp the day complete, so a later tick resumes/retries.
|
|
755
|
+
*
|
|
756
|
+
* opts:
|
|
757
|
+
* stampPath — override for testing (default ~/.claude/session-manager/history-rollup.stamp)
|
|
758
|
+
* lockPath — override for testing (default ~/.claude/session-manager/history-rollup.lock)
|
|
759
|
+
* staleLockMs — lock staleness threshold (default 10 min)
|
|
760
|
+
* budgetMs — cap forwarded to finalizeClosedDays (default 60_000)
|
|
761
|
+
* dryRun — compute without writing (default SM_WATCHDOG_DRYRUN === '1')
|
|
762
|
+
* finalizeFn — injectable finalizeClosedDays for testing
|
|
763
|
+
*/
|
|
764
|
+
async function maybeFinalizeHistory({
|
|
765
|
+
stampPath = DEFAULT_STAMP_PATH,
|
|
766
|
+
lockPath = DEFAULT_LOCK_PATH,
|
|
767
|
+
staleLockMs = DEFAULT_LOCK_STALE_MS,
|
|
768
|
+
budgetMs = DEFAULT_FINALIZE_BUDGET_MS,
|
|
769
|
+
dryRun = process.env.SM_WATCHDOG_DRYRUN === '1',
|
|
770
|
+
finalizeFn = defaultFinalizeClosedDays,
|
|
771
|
+
} = {}) {
|
|
772
|
+
const today = localDateStr();
|
|
773
|
+
|
|
774
|
+
if (readStampDate(stampPath) === today) {
|
|
775
|
+
return { ran: false, reason: 'already-finalized-today', date: today };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!tryAcquireLock(lockPath, staleLockMs)) {
|
|
779
|
+
return { ran: false, reason: 'lock-contended', date: today };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
const result = await finalizeFn({ budgetMs, dryRun });
|
|
784
|
+
|
|
785
|
+
if (dryRun) {
|
|
786
|
+
return { ran: false, reason: 'dry-run', date: today, finalizedDates: result.finalizedDates };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (!result.partial) {
|
|
790
|
+
writeStampDate(stampPath, today);
|
|
791
|
+
return { ran: true, reason: 'finalized', date: today, finalizedDates: result.finalizedDates };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return { ran: true, reason: 'partial', date: today, finalizedDates: result.finalizedDates };
|
|
795
|
+
} finally {
|
|
796
|
+
releaseLock(lockPath);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
module.exports = {
|
|
801
|
+
readLastHeartbeat,
|
|
802
|
+
readLastHeartbeatTs,
|
|
803
|
+
heartbeatFresh,
|
|
804
|
+
isPidAlive,
|
|
805
|
+
hasOpenFeedback,
|
|
806
|
+
resolveSkillFile,
|
|
807
|
+
listSkillCandidates,
|
|
808
|
+
emitFeedbackPRD,
|
|
809
|
+
sweep,
|
|
810
|
+
localDateStr,
|
|
811
|
+
maybeFinalizeHistory,
|
|
812
|
+
tryAcquireLock,
|
|
813
|
+
releaseLock,
|
|
814
|
+
checkAppLiveness,
|
|
815
|
+
readRelaunchState,
|
|
816
|
+
writeRelaunchState,
|
|
817
|
+
defaultSpawnRelaunch,
|
|
818
|
+
maybeRelaunchApp,
|
|
819
|
+
DEFAULT_HEARTBEAT_PATH,
|
|
820
|
+
DEFAULT_MAX_AGE_MS,
|
|
821
|
+
DEFAULT_STAMP_PATH,
|
|
822
|
+
DEFAULT_LOCK_PATH,
|
|
823
|
+
DEFAULT_LOCK_STALE_MS,
|
|
824
|
+
DEFAULT_FINALIZE_BUDGET_MS,
|
|
825
|
+
DEFAULT_RELAUNCH_STATE_PATH,
|
|
826
|
+
DEFAULT_RELAUNCH_LOG_PATH,
|
|
827
|
+
DEFAULT_RELAUNCH_DEBOUNCE_MS,
|
|
828
|
+
DEFAULT_MAX_RELAUNCH_ATTEMPTS,
|
|
829
|
+
};
|