claude-code-session-manager 0.36.0 → 0.37.1
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/{TiptapBody-D5b7nejd.js → TiptapBody-DXQDiOUx.js} +58 -58
- package/dist/assets/index--a8m5_ET.js +3496 -0
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +699 -0
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
|
@@ -0,0 +1,699 @@
|
|
|
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
|
+
// Relative path into src/main/lib/ — the watchdog is external to the app, so
|
|
13
|
+
// we re-use the helpers without importing any Electron code.
|
|
14
|
+
// (Same claudePidAlive + classifyRunOutcome used by scheduler.cjs boot reconciliation.)
|
|
15
|
+
const { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP } = require('../../src/main/lib/reaperHelpers.cjs');
|
|
16
|
+
|
|
17
|
+
// Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
|
|
18
|
+
const DEFAULT_HEARTBEAT_PATH = path.join(
|
|
19
|
+
os.homedir(), '.claude', 'session-manager', 'scheduler-heartbeat.log',
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// The in-app heartbeat ticks every 60 s; 3 missed ticks = stale.
|
|
23
|
+
const DEFAULT_MAX_AGE_MS = 180_000;
|
|
24
|
+
|
|
25
|
+
/** Local-TZ 'YYYY-MM-DD' for `d` (default now). Single source of truth for
|
|
26
|
+
* the watchdog's notion of "today" — used for both the log filename and the
|
|
27
|
+
* history-rollup once-per-day gate. */
|
|
28
|
+
function localDateStr(d = new Date()) {
|
|
29
|
+
const y = d.getFullYear();
|
|
30
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
31
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
32
|
+
return `${y}-${m}-${day}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Tail bytes to read — enough to hold several JSON heartbeat lines without
|
|
36
|
+
// loading a potentially 1 MB file. O(1) in file size.
|
|
37
|
+
const TAIL_BYTES = 4096;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* readLastHeartbeat(heartbeatPath?) → object | null
|
|
41
|
+
*
|
|
42
|
+
* Reads the last ~4 KB of the heartbeat log, reverse-scans for the last
|
|
43
|
+
* non-empty line, and returns the full parsed JSON object.
|
|
44
|
+
* Returns null on missing / empty / unparseable file.
|
|
45
|
+
*
|
|
46
|
+
* Single source of truth for the file-read + reverse-scan logic; used by
|
|
47
|
+
* readLastHeartbeatTs(), heartbeatFresh(), and reconcileQueueOffline().
|
|
48
|
+
*/
|
|
49
|
+
function readLastHeartbeat(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
|
|
50
|
+
let buf;
|
|
51
|
+
try {
|
|
52
|
+
const stat = fs.statSync(heartbeatPath);
|
|
53
|
+
const size = stat.size;
|
|
54
|
+
if (size === 0) return null;
|
|
55
|
+
|
|
56
|
+
const readSize = Math.min(TAIL_BYTES, size);
|
|
57
|
+
const fd = fs.openSync(heartbeatPath, 'r');
|
|
58
|
+
try {
|
|
59
|
+
buf = Buffer.alloc(readSize);
|
|
60
|
+
fs.readSync(fd, buf, 0, readSize, size - readSize);
|
|
61
|
+
} finally {
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = buf.toString('utf8').split('\n');
|
|
69
|
+
// Walk lines in reverse to find last non-empty parseable one.
|
|
70
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
71
|
+
const line = lines[i].trim();
|
|
72
|
+
if (!line) continue;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(line);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* readLastHeartbeatTs(heartbeatPath?) → number | null
|
|
84
|
+
*
|
|
85
|
+
* Returns the `ts` field (epoch ms) from the last heartbeat entry.
|
|
86
|
+
* Returns null on missing / empty / unparseable file or missing ts field.
|
|
87
|
+
*/
|
|
88
|
+
function readLastHeartbeatTs(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
|
|
89
|
+
const entry = readLastHeartbeat(heartbeatPath);
|
|
90
|
+
return (entry !== null && typeof entry.ts === 'number') ? entry.ts : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* heartbeatFresh(heartbeatPath?, maxAgeMs?) → boolean
|
|
95
|
+
*
|
|
96
|
+
* Returns true iff the last heartbeat ts is within maxAgeMs of `now`.
|
|
97
|
+
* Missing / empty / unparseable file → false.
|
|
98
|
+
*/
|
|
99
|
+
function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
100
|
+
const ts = readLastHeartbeatTs(heartbeatPath);
|
|
101
|
+
if (ts === null) return false;
|
|
102
|
+
return (Date.now() - ts) < maxAgeMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Default paths — callers can override via opts for testing.
|
|
106
|
+
const DEFAULT_QUEUE_PATH = path.join(
|
|
107
|
+
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
|
|
108
|
+
);
|
|
109
|
+
const DEFAULT_RUNS_DIR = path.join(
|
|
110
|
+
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'runs',
|
|
111
|
+
);
|
|
112
|
+
const DEFAULT_PRDS_DIR = path.join(
|
|
113
|
+
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
|
|
114
|
+
);
|
|
115
|
+
const DEFAULT_SKILL_PATH = path.join(
|
|
116
|
+
os.homedir(), '.claude', 'skills', 'process-feedback', 'SKILL.md',
|
|
117
|
+
);
|
|
118
|
+
const DEFAULT_STANDARDS_PATH = path.join(
|
|
119
|
+
os.homedir(), '.claude', 'skills', 'develop', 'standards.md',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* reconcileQueueOffline(opts?) → { reconciled: boolean, reapedCount: number, errors: string[] }
|
|
124
|
+
*
|
|
125
|
+
* Safe offline reconciliation of queue.json when the in-app scheduler is down.
|
|
126
|
+
*
|
|
127
|
+
* Guard: re-checks heartbeatFresh() at the top and returns a no-op result if the
|
|
128
|
+
* app is alive — the app owns the mutate lock and concurrent writes corrupt the queue.
|
|
129
|
+
*
|
|
130
|
+
* When stale: for each running job whose PID is dead (or whose app is dead):
|
|
131
|
+
* success → completed
|
|
132
|
+
* failed → failed
|
|
133
|
+
* no_result / unknown → re-queue to pending (bounded by ORPHAN_REQUEUE_CAP)
|
|
134
|
+
*
|
|
135
|
+
* If the PID is still alive but the app is dead, SIGTERM it before classifying.
|
|
136
|
+
*
|
|
137
|
+
* Atomic write: queue.json.tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
|
|
138
|
+
*
|
|
139
|
+
* O(n) in number of running jobs; classifyRunOutcome tails up to 64 KB per job.
|
|
140
|
+
*/
|
|
141
|
+
function reconcileQueueOffline({
|
|
142
|
+
heartbeatPath = DEFAULT_HEARTBEAT_PATH,
|
|
143
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
144
|
+
queuePath = DEFAULT_QUEUE_PATH,
|
|
145
|
+
runsDir = DEFAULT_RUNS_DIR,
|
|
146
|
+
} = {}) {
|
|
147
|
+
// Safety guard: never touch queue.json while the app is alive.
|
|
148
|
+
if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
|
|
149
|
+
return { reconciled: false, reapedCount: 0, errors: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Second guard: stale heartbeat may just mean the app is under load.
|
|
153
|
+
// If the heartbeat carries a pid and that process is still alive, the app
|
|
154
|
+
// is running — do not reap. Backward-compat: if no pid field, fall through
|
|
155
|
+
// to the classic age-only behavior so pre-existing orphan cleanup still works.
|
|
156
|
+
const lastHeartbeat = readLastHeartbeat(heartbeatPath);
|
|
157
|
+
if (lastHeartbeat !== null && typeof lastHeartbeat.pid === 'number') {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(lastHeartbeat.pid, 0); // liveness check only — no signal
|
|
160
|
+
// pid is alive → app is running; abort reap.
|
|
161
|
+
return { reconciled: false, reapedCount: 0, errors: [] };
|
|
162
|
+
} catch {
|
|
163
|
+
// ESRCH → process is gone; proceed with reap.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let state;
|
|
168
|
+
try {
|
|
169
|
+
state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { reconciled: false, reapedCount: 0, errors: [`read queue.json: ${e?.message}`] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const errors = [];
|
|
175
|
+
let reapedCount = 0;
|
|
176
|
+
let changed = false;
|
|
177
|
+
// Stamp once so all jobs reapedQueue in the same watchdog run share a finishedAt.
|
|
178
|
+
const reconciledAt = new Date().toISOString();
|
|
179
|
+
|
|
180
|
+
for (const j of (state.jobs ?? [])) {
|
|
181
|
+
if (j.status !== 'running') continue;
|
|
182
|
+
|
|
183
|
+
const logPath = j.runId ? path.join(runsDir, j.runId, `${j.slug}.log`) : null;
|
|
184
|
+
const pid = j.runtime?.pid;
|
|
185
|
+
|
|
186
|
+
// If the orphan is still alive we must NOT classify its log this tick — the
|
|
187
|
+
// process is still running/flushing, so a partial or in-flight log would be
|
|
188
|
+
// misread (e.g. a job about to emit result:success gets read as no_result and
|
|
189
|
+
// re-queued, double-running the same PRD while the original still edits the
|
|
190
|
+
// repo). Instead escalate across ticks (SIGTERM → SIGKILL), matching the
|
|
191
|
+
// scheduler's kill discipline (process group first, then the pid).
|
|
192
|
+
//
|
|
193
|
+
// Bounded so a job can't wedge in 'running' forever: after we've sent BOTH
|
|
194
|
+
// SIGTERM and SIGKILL on prior ticks, the real claude process is gone. A pid
|
|
195
|
+
// still reported "alive" here is an unkillable defunct — or, on macOS (no
|
|
196
|
+
// /proc, so claudePidAlive can't verify identity), a REUSED pid we must stop
|
|
197
|
+
// signalling. Either way we fall through to finalize from the now-settled log.
|
|
198
|
+
if (pid && claudePidAlive(pid)) {
|
|
199
|
+
if (!j.runtime.watchdogSigtermAt) {
|
|
200
|
+
try { process.kill(-pid, 'SIGTERM'); }
|
|
201
|
+
catch { try { process.kill(pid, 'SIGTERM'); } catch { /* ESRCH — died between checks */ } }
|
|
202
|
+
j.runtime.watchdogSigtermAt = reconciledAt;
|
|
203
|
+
changed = true;
|
|
204
|
+
continue; // leave status 'running'; revisit next tick
|
|
205
|
+
}
|
|
206
|
+
if (!j.runtime.watchdogSigkillAt) {
|
|
207
|
+
try { process.kill(-pid, 'SIGKILL'); }
|
|
208
|
+
catch { try { process.kill(pid, 'SIGKILL'); } catch { /* ESRCH */ } }
|
|
209
|
+
j.runtime.watchdogSigkillAt = reconciledAt;
|
|
210
|
+
changed = true;
|
|
211
|
+
continue; // give SIGKILL a tick to land before finalizing
|
|
212
|
+
}
|
|
213
|
+
// SIGTERM + SIGKILL already sent on earlier ticks → stop deferring; the
|
|
214
|
+
// original process is dead. Fall through to classify + finalize.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
|
|
218
|
+
|
|
219
|
+
if (outcome === 'success') {
|
|
220
|
+
j.status = 'completed';
|
|
221
|
+
j.exitCode = 0;
|
|
222
|
+
j.error = null;
|
|
223
|
+
j.finishedAt = reconciledAt;
|
|
224
|
+
delete j.runtime;
|
|
225
|
+
delete j.verifierVerdict;
|
|
226
|
+
} else if (outcome === 'failed') {
|
|
227
|
+
j.status = 'failed';
|
|
228
|
+
j.exitCode = j.exitCode ?? 1;
|
|
229
|
+
j.error = 'orphaned: watchdog found failed result while app was down';
|
|
230
|
+
j.finishedAt = reconciledAt;
|
|
231
|
+
delete j.runtime;
|
|
232
|
+
delete j.verifierVerdict;
|
|
233
|
+
} else {
|
|
234
|
+
// no_result / unknown: interrupted with no evidence of merit failure — re-queue bounded.
|
|
235
|
+
const tries = j.orphanRetries ?? 0;
|
|
236
|
+
if (tries < ORPHAN_REQUEUE_CAP) {
|
|
237
|
+
j.status = 'pending';
|
|
238
|
+
j.runId = null;
|
|
239
|
+
j.startedAt = null;
|
|
240
|
+
j.finishedAt = null;
|
|
241
|
+
j.exitCode = null;
|
|
242
|
+
j.error = `orphaned: watchdog re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})`;
|
|
243
|
+
j.orphanRetries = tries + 1;
|
|
244
|
+
delete j.runtime;
|
|
245
|
+
delete j.verifierVerdict;
|
|
246
|
+
} else {
|
|
247
|
+
j.status = 'failed';
|
|
248
|
+
j.exitCode = j.exitCode ?? 1;
|
|
249
|
+
j.error = `orphaned: watchdog exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts`;
|
|
250
|
+
j.finishedAt = reconciledAt;
|
|
251
|
+
delete j.runtime;
|
|
252
|
+
delete j.verifierVerdict;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
reapedCount++;
|
|
257
|
+
changed = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!changed) {
|
|
261
|
+
return { reconciled: false, reapedCount: 0, errors };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
|
|
265
|
+
const tmpPath = `${queuePath}.tmp-${process.pid}-${Date.now()}`;
|
|
266
|
+
try {
|
|
267
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
268
|
+
fs.renameSync(tmpPath, queuePath);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
errors.push(`write queue.json: ${e?.message}`);
|
|
271
|
+
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
272
|
+
return { reconciled: false, reapedCount: 0, errors };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { reconciled: true, reapedCount, errors };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── slugify ──────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
|
|
281
|
+
function slugify(name) {
|
|
282
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── YAML frontmatter stripper ─────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Strip leading YAML frontmatter block (---\n…\n---) from skill/standards files
|
|
289
|
+
* so they can be inlined cleanly into a PRD body.
|
|
290
|
+
*/
|
|
291
|
+
function stripFrontmatter(content) {
|
|
292
|
+
if (!content.startsWith('---')) return content;
|
|
293
|
+
const end = content.indexOf('\n---', 3);
|
|
294
|
+
if (end === -1) return content;
|
|
295
|
+
return content.slice(end + 4).replace(/^\n/, '');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Strip a leading H1 line (`# …\n`) — used to drop the `# Engineering standards`
|
|
300
|
+
* heading from standards.md before adding our own `## Engineering standards` heading.
|
|
301
|
+
*/
|
|
302
|
+
function stripLeadingH1(content) {
|
|
303
|
+
return content.replace(/^# [^\n]*\n/, '');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── hasOpenFeedback ───────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* hasOpenFeedback(cwd) → boolean
|
|
310
|
+
*
|
|
311
|
+
* Returns true iff `<cwd>/session-manager-operations/feedback/` (the PRD-462
|
|
312
|
+
* convention) or, as a transitional fallback for repos not yet relocated,
|
|
313
|
+
* the legacy `<cwd>/feedback/` exists AND contains at least one *.md file
|
|
314
|
+
* directly in its root (i.e. NOT inside `processed/` or any subdirectory).
|
|
315
|
+
* Pure filesystem check — no LLM call.
|
|
316
|
+
*
|
|
317
|
+
* Mirrors process-feedback skill step 0 (cheap quick-exit signal).
|
|
318
|
+
* Complexity: O(F) where F ≤ entries in the feedback folder root.
|
|
319
|
+
*/
|
|
320
|
+
function hasOpenFeedback(cwd) {
|
|
321
|
+
for (const folderName of [path.join('session-manager-operations', 'feedback'), 'feedback']) {
|
|
322
|
+
const folderPath = path.join(cwd, folderName);
|
|
323
|
+
let entries;
|
|
324
|
+
try {
|
|
325
|
+
entries = fs.readdirSync(folderPath);
|
|
326
|
+
} catch {
|
|
327
|
+
continue; // folder does not exist
|
|
328
|
+
}
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
if (!entry.endsWith('.md')) continue;
|
|
331
|
+
// README.md is the folder's convention doc, not an open feedback item —
|
|
332
|
+
// counting it makes a folder with only a README look perpetually "pending".
|
|
333
|
+
if (entry.toLowerCase() === 'readme.md') continue;
|
|
334
|
+
try {
|
|
335
|
+
const st = fs.statSync(path.join(folderPath, entry));
|
|
336
|
+
if (st.isFile()) return true;
|
|
337
|
+
} catch { continue; }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── emitFeedbackPRD ──────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* emitFeedbackPRD(cwd, opts?) → { emitted: boolean, slug?: string, prdPath?: string, reason?: string }
|
|
347
|
+
*
|
|
348
|
+
* De-dups against queue.json (pending/running jobs whose slug matches
|
|
349
|
+
* `^\d+-feedback-<project>$`). If a match exists, returns { emitted: false, reason: 'duplicate' }.
|
|
350
|
+
*
|
|
351
|
+
* Otherwise picks the next free NN by scanning prdsDir, writes a self-contained
|
|
352
|
+
* PRD file with the inlined process-feedback procedure + engineering standards,
|
|
353
|
+
* and returns { emitted: true, slug, prdPath }.
|
|
354
|
+
*
|
|
355
|
+
* Never writes to queue.json — the app's reconcile picks up new PRD files.
|
|
356
|
+
* Atomic write: tmp → rename (mirrors config.cjs writeJsonSync pattern).
|
|
357
|
+
*
|
|
358
|
+
* Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
|
|
359
|
+
*/
|
|
360
|
+
function emitFeedbackPRD(cwd, {
|
|
361
|
+
prdsDir = DEFAULT_PRDS_DIR,
|
|
362
|
+
queuePath = DEFAULT_QUEUE_PATH,
|
|
363
|
+
skillPath = DEFAULT_SKILL_PATH,
|
|
364
|
+
standardsPath = DEFAULT_STANDARDS_PATH,
|
|
365
|
+
} = {}) {
|
|
366
|
+
const project = slugify(path.basename(cwd));
|
|
367
|
+
|
|
368
|
+
// De-dup: check queue.json for pending/running feedback job for this project.
|
|
369
|
+
let queueState = { jobs: [] };
|
|
370
|
+
try {
|
|
371
|
+
queueState = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
372
|
+
} catch { /* queue.json may not exist */ }
|
|
373
|
+
|
|
374
|
+
// project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
|
|
375
|
+
const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
|
|
376
|
+
const isDuplicate = (queueState.jobs ?? []).some(
|
|
377
|
+
(j) => (j.status === 'pending' || j.status === 'running') && dupRe.test(j.slug),
|
|
378
|
+
);
|
|
379
|
+
if (isDuplicate) {
|
|
380
|
+
return { emitted: false, reason: 'duplicate' };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Pick next NN: max existing NN prefix + 1. O(P) scan.
|
|
384
|
+
// We ALSO dedup against the prds dir here: while the app is down it never
|
|
385
|
+
// ingests new PRD files into queue.json, so the queue check above can't see a
|
|
386
|
+
// feedback PRD we already wrote on a previous tick. Without this, every stale
|
|
387
|
+
// watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
|
|
388
|
+
const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
|
|
389
|
+
let maxNN = 0;
|
|
390
|
+
let prdFiles = [];
|
|
391
|
+
try {
|
|
392
|
+
prdFiles = fs.readdirSync(prdsDir);
|
|
393
|
+
} catch { /* prdsDir may not exist yet */ }
|
|
394
|
+
for (const f of prdFiles) {
|
|
395
|
+
if (prdDupRe.test(f)) {
|
|
396
|
+
return { emitted: false, reason: 'duplicate' };
|
|
397
|
+
}
|
|
398
|
+
const m = f.match(/^(\d+)-/);
|
|
399
|
+
if (m) {
|
|
400
|
+
const n = parseInt(m[1], 10);
|
|
401
|
+
if (n > maxNN) maxNN = n;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const nn = String(maxNN + 1).padStart(2, '0');
|
|
405
|
+
const slug = `${nn}-feedback-${project}`;
|
|
406
|
+
const prdPath = path.join(prdsDir, `${slug}.md`);
|
|
407
|
+
|
|
408
|
+
// Read and inline skill content.
|
|
409
|
+
let rawSkill = '';
|
|
410
|
+
try {
|
|
411
|
+
rawSkill = fs.readFileSync(skillPath, 'utf8');
|
|
412
|
+
} catch {
|
|
413
|
+
process.stderr.write(`[emitFeedbackPRD] warning: skill file not readable: ${skillPath}\n`);
|
|
414
|
+
}
|
|
415
|
+
const skillBody = stripFrontmatter(rawSkill).trim();
|
|
416
|
+
|
|
417
|
+
// Read and inline standards — strip H1 so our ## heading is the section anchor.
|
|
418
|
+
let rawStandards = '';
|
|
419
|
+
try {
|
|
420
|
+
rawStandards = fs.readFileSync(standardsPath, 'utf8');
|
|
421
|
+
} catch {
|
|
422
|
+
process.stderr.write(`[emitFeedbackPRD] warning: standards file not readable: ${standardsPath}\n`);
|
|
423
|
+
}
|
|
424
|
+
const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
|
|
425
|
+
|
|
426
|
+
const projectName = path.basename(cwd);
|
|
427
|
+
const body = [
|
|
428
|
+
'---',
|
|
429
|
+
`title: Process feedback for ${projectName}`,
|
|
430
|
+
`cwd: ${cwd}`,
|
|
431
|
+
'estimateMinutes: 15',
|
|
432
|
+
'---',
|
|
433
|
+
'',
|
|
434
|
+
'# Goal',
|
|
435
|
+
'',
|
|
436
|
+
`Process the inbound feedback folder for ${projectName}. The quick-exit (step 0 below)`,
|
|
437
|
+
'means this run bails in milliseconds if no open items exist — safe to execute even if',
|
|
438
|
+
'feedback was already cleared between scheduling and execution.',
|
|
439
|
+
'',
|
|
440
|
+
'# Acceptance criteria',
|
|
441
|
+
'',
|
|
442
|
+
'- [ ] All open feedback items evaluated and either queued via /develop, declined with RESOLUTION, or forwarded upstream.',
|
|
443
|
+
'- [ ] Processed items archived to session-manager-operations/feedback/processed/ (or, for repos not yet relocated, legacy feedback/processed/) with RESOLUTION notes.',
|
|
444
|
+
'- [ ] session-manager-operations/feedback/README.md (or legacy feedback/README.md) self-improved with lessons from this pass.',
|
|
445
|
+
'- [ ] timeout 60 git diff --exit-code runs clean (no uncommitted inline work).',
|
|
446
|
+
'',
|
|
447
|
+
'# Implementation notes',
|
|
448
|
+
'',
|
|
449
|
+
'Follow the inlined process-feedback procedure below exactly. No skills are loaded in',
|
|
450
|
+
'headless execution — the procedure is fully self-contained. Use /develop for any work',
|
|
451
|
+
'that belongs to this project; never implement feedback inline.',
|
|
452
|
+
'',
|
|
453
|
+
'# Out of scope',
|
|
454
|
+
'',
|
|
455
|
+
'- Implementing feedback items directly (that is /develop → scheduler).',
|
|
456
|
+
'- Cross-project feedback beyond filing upstream items.',
|
|
457
|
+
'',
|
|
458
|
+
skillBody,
|
|
459
|
+
'',
|
|
460
|
+
'## Engineering standards',
|
|
461
|
+
'',
|
|
462
|
+
standardsBody,
|
|
463
|
+
].join('\n');
|
|
464
|
+
|
|
465
|
+
// Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
|
|
466
|
+
const tmpPath = `${prdPath}.tmp-${process.pid}-${Date.now()}`;
|
|
467
|
+
try {
|
|
468
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
469
|
+
fs.writeFileSync(tmpPath, body + '\n', 'utf8');
|
|
470
|
+
fs.renameSync(tmpPath, prdPath);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { emitted: true, slug, prdPath };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── sweep ─────────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* sweep(opts?) → { scanned: number, emitted: number, skipped: number }
|
|
483
|
+
*
|
|
484
|
+
* For each active-session cwd (from activeProjectCwds), cheaply checks for open
|
|
485
|
+
* feedback. Projects without open feedback cost a readdir per folder name — no LLM.
|
|
486
|
+
* Projects with open feedback get a PRD emitted into prdsDir (de-duped against queue).
|
|
487
|
+
*
|
|
488
|
+
* Never mutates queue.json — only adds PRD files, so it is safe to run while the
|
|
489
|
+
* in-app scheduler is alive.
|
|
490
|
+
*
|
|
491
|
+
* Complexity: O(C × F) where C = active cwds (≤50) and F = root entries per
|
|
492
|
+
* feedback folder (typically small).
|
|
493
|
+
*
|
|
494
|
+
* opts:
|
|
495
|
+
* projectsDir — forwarded to activeProjectCwds for testing
|
|
496
|
+
* prdsDir, queuePath, skillPath, standardsPath — forwarded to emitFeedbackPRD
|
|
497
|
+
*/
|
|
498
|
+
function sweep({
|
|
499
|
+
projectsDir,
|
|
500
|
+
prdsDir = DEFAULT_PRDS_DIR,
|
|
501
|
+
queuePath = DEFAULT_QUEUE_PATH,
|
|
502
|
+
skillPath = DEFAULT_SKILL_PATH,
|
|
503
|
+
standardsPath = DEFAULT_STANDARDS_PATH,
|
|
504
|
+
} = {}) {
|
|
505
|
+
const activeOpts = {};
|
|
506
|
+
if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
|
|
507
|
+
|
|
508
|
+
const cwds = activeProjectCwds(90, activeOpts);
|
|
509
|
+
|
|
510
|
+
let scanned = 0;
|
|
511
|
+
let emitted = 0;
|
|
512
|
+
let skipped = 0;
|
|
513
|
+
|
|
514
|
+
for (const cwd of cwds) {
|
|
515
|
+
scanned++;
|
|
516
|
+
if (!hasOpenFeedback(cwd)) continue;
|
|
517
|
+
|
|
518
|
+
let result;
|
|
519
|
+
try {
|
|
520
|
+
result = emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
|
|
521
|
+
} catch (e) {
|
|
522
|
+
process.stderr.write(`[sweep] error emitting PRD for ${cwd}: ${e?.message}\n`);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (result.emitted) {
|
|
527
|
+
emitted++;
|
|
528
|
+
process.stderr.write(`[sweep] emitted ${result.slug} for ${cwd}\n`);
|
|
529
|
+
} else {
|
|
530
|
+
skipped++;
|
|
531
|
+
process.stderr.write(`[sweep] skipped ${cwd} (${result.reason})\n`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
process.stderr.write(`[sweep] scanned=${scanned} emitted=${emitted} skipped=${skipped}\n`);
|
|
536
|
+
return { scanned, emitted, skipped };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── maybeFinalizeHistory ─────────────────────────────────────────────────────
|
|
540
|
+
//
|
|
541
|
+
// Precomputes closed History-dashboard days outside Electron, so the app's
|
|
542
|
+
// first paint after a boot (possibly weeks after the app was last open) is
|
|
543
|
+
// instant, and old transcripts stay safe to age out without losing analytics.
|
|
544
|
+
// See src/main/historyAggregator.cjs's finalizeClosedDays() for the actual
|
|
545
|
+
// walk/persist logic — this is just the once-per-day scheduling + lock
|
|
546
|
+
// wrapper around it, tailored to being invoked repeatedly by a systemd timer.
|
|
547
|
+
|
|
548
|
+
const DEFAULT_STAMP_PATH = path.join(
|
|
549
|
+
os.homedir(), '.claude', 'session-manager', 'history-rollup.stamp',
|
|
550
|
+
);
|
|
551
|
+
const DEFAULT_LOCK_PATH = path.join(
|
|
552
|
+
os.homedir(), '.claude', 'session-manager', 'history-rollup.lock',
|
|
553
|
+
);
|
|
554
|
+
const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
|
|
555
|
+
const DEFAULT_FINALIZE_BUDGET_MS = 60_000;
|
|
556
|
+
|
|
557
|
+
function readStampDate(stampPath) {
|
|
558
|
+
try {
|
|
559
|
+
return fs.readFileSync(stampPath, 'utf8').trim();
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
|
|
566
|
+
function writeStampDate(stampPath, date) {
|
|
567
|
+
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
|
|
568
|
+
const tmpPath = `${stampPath}.tmp-${process.pid}-${Date.now()}`;
|
|
569
|
+
fs.writeFileSync(tmpPath, date, 'utf8');
|
|
570
|
+
fs.renameSync(tmpPath, stampPath);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* tryAcquireLock(lockPath, staleMs) → boolean
|
|
575
|
+
*
|
|
576
|
+
* O_EXCL ('wx') lock file so the in-app Electron boot pass and this cron
|
|
577
|
+
* pass never interleave writes to the rollup. A lock older than staleMs is
|
|
578
|
+
* assumed abandoned (a crashed holder) and reclaimed; otherwise the caller
|
|
579
|
+
* loses the race and must skip silently.
|
|
580
|
+
*/
|
|
581
|
+
function tryAcquireLock(lockPath, staleMs = DEFAULT_LOCK_STALE_MS) {
|
|
582
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
583
|
+
try {
|
|
584
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
585
|
+
return true;
|
|
586
|
+
} catch (e) {
|
|
587
|
+
if (e.code !== 'EEXIST') throw e;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let st;
|
|
591
|
+
try {
|
|
592
|
+
st = fs.statSync(lockPath);
|
|
593
|
+
} catch {
|
|
594
|
+
st = null; // lock vanished between our failed create and this stat
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (st !== null && Date.now() - st.mtimeMs <= staleMs) {
|
|
598
|
+
return false; // held by a live (or at least recent) owner — loser skips
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Stale (or already gone) — reclaim. The unlink+wx pair isn't atomic, so
|
|
602
|
+
// two concurrent reclaimers could theoretically both win here — but that
|
|
603
|
+
// only happens when the prior holder already crashed/vanished, and the
|
|
604
|
+
// resulting "both run finalize" outcome is benign: appendRollupDays is
|
|
605
|
+
// append-only + last-write-wins-deduped, so a redundant concurrent pass
|
|
606
|
+
// costs extra work, not corrupted data.
|
|
607
|
+
try {
|
|
608
|
+
if (st !== null) fs.unlinkSync(lockPath);
|
|
609
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
610
|
+
return true;
|
|
611
|
+
} catch {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function releaseLock(lockPath) {
|
|
617
|
+
try { fs.unlinkSync(lockPath); } catch { /* already gone / never created */ }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Lazily require historyAggregator.cjs — deferred so a missing PRD-650
|
|
621
|
+
* dependency fails inside maybeFinalizeHistory's try/catch, not at require
|
|
622
|
+
* time for the whole watchdog script. */
|
|
623
|
+
function defaultFinalizeClosedDays(opts) {
|
|
624
|
+
const { finalizeClosedDays } = require('../../src/main/historyAggregator.cjs');
|
|
625
|
+
return finalizeClosedDays(opts);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* maybeFinalizeHistory(opts?) → Promise<{ ran, reason, date, finalizedDates? }>
|
|
630
|
+
*
|
|
631
|
+
* Runs at most once per local day (stamp-file gated, O(1) same-day skip).
|
|
632
|
+
* Guards against interleaving with the in-app Electron boot pass via an
|
|
633
|
+
* O_EXCL lock file. Bounded by opts.budgetMs so a huge transcript corpus
|
|
634
|
+
* can't make a single watchdog tick run long; a budget-partial pass does
|
|
635
|
+
* NOT stamp the day complete, so a later tick resumes/retries.
|
|
636
|
+
*
|
|
637
|
+
* opts:
|
|
638
|
+
* stampPath — override for testing (default ~/.claude/session-manager/history-rollup.stamp)
|
|
639
|
+
* lockPath — override for testing (default ~/.claude/session-manager/history-rollup.lock)
|
|
640
|
+
* staleLockMs — lock staleness threshold (default 10 min)
|
|
641
|
+
* budgetMs — cap forwarded to finalizeClosedDays (default 60_000)
|
|
642
|
+
* dryRun — compute without writing (default SM_WATCHDOG_DRYRUN === '1')
|
|
643
|
+
* finalizeFn — injectable finalizeClosedDays for testing
|
|
644
|
+
*/
|
|
645
|
+
async function maybeFinalizeHistory({
|
|
646
|
+
stampPath = DEFAULT_STAMP_PATH,
|
|
647
|
+
lockPath = DEFAULT_LOCK_PATH,
|
|
648
|
+
staleLockMs = DEFAULT_LOCK_STALE_MS,
|
|
649
|
+
budgetMs = DEFAULT_FINALIZE_BUDGET_MS,
|
|
650
|
+
dryRun = process.env.SM_WATCHDOG_DRYRUN === '1',
|
|
651
|
+
finalizeFn = defaultFinalizeClosedDays,
|
|
652
|
+
} = {}) {
|
|
653
|
+
const today = localDateStr();
|
|
654
|
+
|
|
655
|
+
if (readStampDate(stampPath) === today) {
|
|
656
|
+
return { ran: false, reason: 'already-finalized-today', date: today };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!tryAcquireLock(lockPath, staleLockMs)) {
|
|
660
|
+
return { ran: false, reason: 'lock-contended', date: today };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const result = await finalizeFn({ budgetMs, dryRun });
|
|
665
|
+
|
|
666
|
+
if (dryRun) {
|
|
667
|
+
return { ran: false, reason: 'dry-run', date: today, finalizedDates: result.finalizedDates };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!result.partial) {
|
|
671
|
+
writeStampDate(stampPath, today);
|
|
672
|
+
return { ran: true, reason: 'finalized', date: today, finalizedDates: result.finalizedDates };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return { ran: true, reason: 'partial', date: today, finalizedDates: result.finalizedDates };
|
|
676
|
+
} finally {
|
|
677
|
+
releaseLock(lockPath);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
module.exports = {
|
|
682
|
+
readLastHeartbeat,
|
|
683
|
+
readLastHeartbeatTs,
|
|
684
|
+
heartbeatFresh,
|
|
685
|
+
reconcileQueueOffline,
|
|
686
|
+
hasOpenFeedback,
|
|
687
|
+
emitFeedbackPRD,
|
|
688
|
+
sweep,
|
|
689
|
+
localDateStr,
|
|
690
|
+
maybeFinalizeHistory,
|
|
691
|
+
tryAcquireLock,
|
|
692
|
+
releaseLock,
|
|
693
|
+
DEFAULT_HEARTBEAT_PATH,
|
|
694
|
+
DEFAULT_MAX_AGE_MS,
|
|
695
|
+
DEFAULT_STAMP_PATH,
|
|
696
|
+
DEFAULT_LOCK_PATH,
|
|
697
|
+
DEFAULT_LOCK_STALE_MS,
|
|
698
|
+
DEFAULT_FINALIZE_BUDGET_MS,
|
|
699
|
+
};
|