claude-code-session-manager 0.3.2 → 0.5.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/{cssMode-DKcHzqs6.js → cssMode-DuceD2Ek.js} +1 -1
- package/dist/assets/{editor.main-CKATA8Es.js → editor.main-W7kZjY3Y.js} +3 -3
- package/dist/assets/{freemarker2-DW6HspbF.js → freemarker2-BrfVQxqM.js} +1 -1
- package/dist/assets/{handlebars-DrHBBsXD.js → handlebars-CEk4GZAW.js} +1 -1
- package/dist/assets/{html-sZnU1oHD.js → html-Dsr1hOJo.js} +1 -1
- package/dist/assets/{htmlMode-BqHVHLoz.js → htmlMode-DTyxWkAs.js} +1 -1
- package/dist/assets/index-DUYNLg5N.js +2973 -0
- package/dist/assets/index-QriiiRo1.css +32 -0
- package/dist/assets/{javascript-CoPK13FX.js → javascript-DDnXRxuX.js} +1 -1
- package/dist/assets/{jsonMode-DLl_bJXa.js → jsonMode-BFDUayfd.js} +1 -1
- package/dist/assets/{liquid-C-gTpqe2.js → liquid-BcvXX-ei.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BVR1hoTD.js → lspLanguageFeatures-D6rzws04.js} +1 -1
- package/dist/assets/{mdx-BDBDopEV.js → mdx-DnY5OLKT.js} +1 -1
- package/dist/assets/{python-BzWMTDid.js → python-BA4bdGM0.js} +1 -1
- package/dist/assets/{razor-CZXXc8Yy.js → razor-VjEf8dER.js} +1 -1
- package/dist/assets/{tsMode-DBVY2EZ_.js → tsMode-BzXie6uX.js} +1 -1
- package/dist/assets/{typescript-Ca_aDCJd.js → typescript-BEjKh90W.js} +1 -1
- package/dist/assets/{xml-C7eMpTwW.js → xml-C64Hq61M.js} +1 -1
- package/dist/assets/{yaml-BOPrlUSY.js → yaml-BvsE9PT3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +7 -1
- package/src/main/index.cjs +142 -1
- package/src/main/otel.cjs +248 -0
- package/src/main/otelSettings.cjs +119 -0
- package/src/main/scheduler.cjs +717 -0
- package/src/main/transcripts.cjs +10 -0
- package/src/main/watchers.cjs +154 -0
- package/src/preload/api.d.ts +167 -0
- package/src/preload/index.cjs +39 -0
- package/dist/assets/index-BzbwWnyF.css +0 -32
- package/dist/assets/index-FnbJBSnE.js +0 -2971
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler.cjs — runs queued PRDs as headless `claude -p` jobs around
|
|
3
|
+
* the next 5h token-window reset.
|
|
4
|
+
*
|
|
5
|
+
* Layout (under ~/.claude/session-manager/scheduled-plans/):
|
|
6
|
+
* prds/NN-slug.md → user/Claude-authored PRD files (source of truth)
|
|
7
|
+
* queue.json → scheduler state: schedule, runs, status per PRD
|
|
8
|
+
* runs/<ISO>/<slug>.log → captured stdout/stderr for one execution
|
|
9
|
+
* runs/<ISO>/<slug>.meta.json → per-job metadata (exit, duration, cwd)
|
|
10
|
+
*
|
|
11
|
+
* Time:
|
|
12
|
+
* - "Next reset" comes from /api/oauth/usage five_hour.resets_at
|
|
13
|
+
* (already exposed via billing.fetchUsage()).
|
|
14
|
+
* - Trigger fires at resets_at + offsetMinutes (default 15).
|
|
15
|
+
* - We schedule a single setTimeout for the next fire-time. On schedule
|
|
16
|
+
* change (queue updated, reset_at moves) we cancel + reschedule.
|
|
17
|
+
*
|
|
18
|
+
* Parallelism:
|
|
19
|
+
* - PRD filename `NN-slug.md` — `NN` is the parallel group. All PRDs in
|
|
20
|
+
* the same group launch simultaneously; the next group only starts
|
|
21
|
+
* after the previous group's jobs all settle.
|
|
22
|
+
* - User-set concurrency cap (default 5) is the within-group ceiling. If
|
|
23
|
+
* a group has more PRDs than the cap, the excess waits until a slot
|
|
24
|
+
* frees in that group.
|
|
25
|
+
*
|
|
26
|
+
* Execution:
|
|
27
|
+
* - `claude -p "<PRD body>" --dangerously-skip-permissions` per PRD.
|
|
28
|
+
* - Stdout/stderr → runs/<ts>/<slug>.log; meta json gets exit + duration.
|
|
29
|
+
* - PRD frontmatter `cwd` → child cwd. Default: PROJECT_CWD const below.
|
|
30
|
+
*
|
|
31
|
+
* Persistence:
|
|
32
|
+
* - queue.json is the system of record for scheduling. Edited atomically
|
|
33
|
+
* via fs.writeFileSync(tmp) + rename.
|
|
34
|
+
* - On startup, walk prds/, ensure every .md has a queue.json entry.
|
|
35
|
+
* Orphaned entries (.md gone) are pruned.
|
|
36
|
+
*
|
|
37
|
+
* Renderer events:
|
|
38
|
+
* - 'schedule:state' broadcasts the full state on any change. Keeps the
|
|
39
|
+
* panel UI dead simple — no diff machinery.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const fs = require('node:fs');
|
|
43
|
+
const fsp = require('node:fs/promises');
|
|
44
|
+
const path = require('node:path');
|
|
45
|
+
const os = require('node:os');
|
|
46
|
+
const { spawn } = require('node:child_process');
|
|
47
|
+
const { ipcMain } = require('electron');
|
|
48
|
+
const billing = require('./usage.cjs');
|
|
49
|
+
|
|
50
|
+
const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
|
|
51
|
+
const PRDS_DIR = path.join(ROOT, 'prds');
|
|
52
|
+
const RUNS_DIR = path.join(ROOT, 'runs');
|
|
53
|
+
const QUEUE_PATH = path.join(ROOT, 'queue.json');
|
|
54
|
+
const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
55
|
+
|
|
56
|
+
const DEFAULT_CONFIG = {
|
|
57
|
+
// Legacy on/off retained for backwards compat; v0.5+ uses firePolicy.
|
|
58
|
+
enabled: false,
|
|
59
|
+
offsetMinutes: 15,
|
|
60
|
+
concurrencyCap: 5,
|
|
61
|
+
defaultCwd: DEFAULT_PROJECT_CWD,
|
|
62
|
+
// 'when-available' = poll usage and fire whenever utilization < threshold.
|
|
63
|
+
// 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
|
|
64
|
+
// 'manual' = only fire on explicit Run-now click.
|
|
65
|
+
firePolicy: 'when-available',
|
|
66
|
+
// For 'when-available'. Fire only when five_hour utilization < this percent.
|
|
67
|
+
utilizationThreshold: 90,
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------- fs helpers ----------
|
|
72
|
+
|
|
73
|
+
function ensureDirs() {
|
|
74
|
+
fs.mkdirSync(PRDS_DIR, { recursive: true });
|
|
75
|
+
fs.mkdirSync(RUNS_DIR, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function atomicWriteJson(p, data) {
|
|
79
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
80
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
81
|
+
fs.renameSync(tmp, p);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readQueue() {
|
|
85
|
+
try {
|
|
86
|
+
const raw = fs.readFileSync(QUEUE_PATH, 'utf8');
|
|
87
|
+
const data = JSON.parse(raw);
|
|
88
|
+
return {
|
|
89
|
+
config: { ...DEFAULT_CONFIG, ...(data.config || {}) },
|
|
90
|
+
jobs: Array.isArray(data.jobs) ? data.jobs : [],
|
|
91
|
+
scheduledFor: data.scheduledFor ?? null,
|
|
92
|
+
lastRunAt: data.lastRunAt ?? null,
|
|
93
|
+
paused: data.paused ?? null,
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return { config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeQueue(state) {
|
|
101
|
+
ensureDirs();
|
|
102
|
+
atomicWriteJson(QUEUE_PATH, state);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------- PRD parsing ----------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse YAML-ish frontmatter — only the keys we use. We don't take a
|
|
109
|
+
* yaml dep; the schema is small (title, cwd, estimateMinutes, parallelGroup)
|
|
110
|
+
* and the format is documented in the user-facing README.
|
|
111
|
+
*/
|
|
112
|
+
function parsePrd(filePath) {
|
|
113
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
114
|
+
const meta = { title: null, cwd: null, estimateMinutes: null, parallelGroup: null };
|
|
115
|
+
let body = text;
|
|
116
|
+
|
|
117
|
+
if (text.startsWith('---\n')) {
|
|
118
|
+
const end = text.indexOf('\n---', 4);
|
|
119
|
+
if (end !== -1) {
|
|
120
|
+
const fm = text.slice(4, end);
|
|
121
|
+
body = text.slice(end + 4).replace(/^\n/, '');
|
|
122
|
+
for (const line of fm.split('\n')) {
|
|
123
|
+
const m = line.match(/^([a-zA-Z]+):\s*(.+?)\s*$/);
|
|
124
|
+
if (!m) continue;
|
|
125
|
+
const k = m[1];
|
|
126
|
+
let v = m[2];
|
|
127
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
128
|
+
v = v.slice(1, -1);
|
|
129
|
+
}
|
|
130
|
+
if (k === 'title') meta.title = v;
|
|
131
|
+
else if (k === 'cwd') meta.cwd = v;
|
|
132
|
+
else if (k === 'estimateMinutes') meta.estimateMinutes = Number(v) || null;
|
|
133
|
+
else if (k === 'parallelGroup') meta.parallelGroup = Number(v) || null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const base = path.basename(filePath, '.md');
|
|
139
|
+
const groupFromName = (() => {
|
|
140
|
+
const m = base.match(/^(\d+)-/);
|
|
141
|
+
return m ? Number(m[1]) : null;
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
slug: base,
|
|
146
|
+
path: filePath,
|
|
147
|
+
title: meta.title || base,
|
|
148
|
+
cwd: meta.cwd || null,
|
|
149
|
+
estimateMinutes: meta.estimateMinutes,
|
|
150
|
+
parallelGroup: meta.parallelGroup ?? groupFromName ?? 99,
|
|
151
|
+
body: body.trim(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function listPrdFiles() {
|
|
156
|
+
ensureDirs();
|
|
157
|
+
return fs.readdirSync(PRDS_DIR)
|
|
158
|
+
.filter((f) => f.endsWith('.md') && !f.startsWith('.'))
|
|
159
|
+
.map((f) => path.join(PRDS_DIR, f))
|
|
160
|
+
.sort();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- queue reconciliation ----------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Walk prds/, ensure every .md has a queue entry. Drop entries whose .md
|
|
167
|
+
* is gone. Refresh title/cwd/parallelGroup from disk every reconcile so
|
|
168
|
+
* editing the .md after queueing is honored.
|
|
169
|
+
*
|
|
170
|
+
* Status is preserved: pending stays pending, completed stays completed.
|
|
171
|
+
* Newly-discovered PRDs land as `pending`.
|
|
172
|
+
*/
|
|
173
|
+
function reconcile(state) {
|
|
174
|
+
const files = listPrdFiles();
|
|
175
|
+
const onDisk = new Map();
|
|
176
|
+
for (const f of files) {
|
|
177
|
+
try {
|
|
178
|
+
const p = parsePrd(f);
|
|
179
|
+
onDisk.set(p.slug, p);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.warn('[scheduler] failed to parse', f, e?.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const next = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const job of state.jobs) {
|
|
188
|
+
const p = onDisk.get(job.slug);
|
|
189
|
+
if (!p) continue;
|
|
190
|
+
seen.add(job.slug);
|
|
191
|
+
next.push({
|
|
192
|
+
...job,
|
|
193
|
+
title: p.title,
|
|
194
|
+
cwd: p.cwd,
|
|
195
|
+
parallelGroup: p.parallelGroup,
|
|
196
|
+
estimateMinutes: p.estimateMinutes,
|
|
197
|
+
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
for (const [slug, p] of onDisk) {
|
|
201
|
+
if (seen.has(slug)) continue;
|
|
202
|
+
next.push({
|
|
203
|
+
slug,
|
|
204
|
+
title: p.title,
|
|
205
|
+
cwd: p.cwd,
|
|
206
|
+
parallelGroup: p.parallelGroup,
|
|
207
|
+
estimateMinutes: p.estimateMinutes,
|
|
208
|
+
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
209
|
+
status: 'pending',
|
|
210
|
+
runId: null,
|
|
211
|
+
startedAt: null,
|
|
212
|
+
finishedAt: null,
|
|
213
|
+
exitCode: null,
|
|
214
|
+
error: null,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
state.jobs = next.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
218
|
+
return state;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- next-reset detection ----------
|
|
222
|
+
|
|
223
|
+
let cachedNextReset = { at: null, fetchedAt: 0 };
|
|
224
|
+
let cachedUtilization = null; // five_hour utilization %, 0–100, or null if unknown
|
|
225
|
+
|
|
226
|
+
async function refreshNextReset() {
|
|
227
|
+
try {
|
|
228
|
+
const r = await billing.fetchUsage();
|
|
229
|
+
const at = r?.usage?.five_hour?.resets_at ?? null;
|
|
230
|
+
cachedNextReset = { at, fetchedAt: Date.now() };
|
|
231
|
+
cachedUtilization = r?.usage?.five_hour?.utilization ?? cachedUtilization;
|
|
232
|
+
return at;
|
|
233
|
+
} catch {
|
|
234
|
+
return cachedNextReset.at;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getNextResetCached() {
|
|
239
|
+
return cachedNextReset.at;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------- timer ----------
|
|
243
|
+
|
|
244
|
+
let mainWindow = null;
|
|
245
|
+
let fireTimer = null;
|
|
246
|
+
let resumeTimer = null;
|
|
247
|
+
let pollTimer = null;
|
|
248
|
+
let isExecuting = false;
|
|
249
|
+
let cancelToken = { cancelled: false };
|
|
250
|
+
let claudeBinPathCached = null;
|
|
251
|
+
|
|
252
|
+
function attachWindow(w) { mainWindow = w; }
|
|
253
|
+
|
|
254
|
+
function broadcast() {
|
|
255
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
256
|
+
const state = readQueue();
|
|
257
|
+
reconcile(state);
|
|
258
|
+
writeQueue(state);
|
|
259
|
+
mainWindow.webContents.send('schedule:state', {
|
|
260
|
+
config: state.config,
|
|
261
|
+
jobs: state.jobs,
|
|
262
|
+
scheduledFor: state.scheduledFor,
|
|
263
|
+
lastRunAt: state.lastRunAt,
|
|
264
|
+
nextReset: getNextResetCached(),
|
|
265
|
+
paused: state.paused,
|
|
266
|
+
utilization: cachedUtilization,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function clearFireTimer() {
|
|
271
|
+
if (fireTimer) {
|
|
272
|
+
clearTimeout(fireTimer);
|
|
273
|
+
fireTimer = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function computeFireAt(state, nextResetIso) {
|
|
278
|
+
// Only the legacy 'on-reset' policy uses scheduled fire times. Other
|
|
279
|
+
// policies fire either immediately on demand ('manual') or via the
|
|
280
|
+
// when-available poll loop.
|
|
281
|
+
if (state.config.firePolicy !== 'on-reset') return null;
|
|
282
|
+
if (!nextResetIso) return null;
|
|
283
|
+
const reset = new Date(nextResetIso).getTime();
|
|
284
|
+
if (Number.isNaN(reset)) return null;
|
|
285
|
+
return reset + (state.config.offsetMinutes * 60_000);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function rescheduleTimer() {
|
|
289
|
+
clearFireTimer();
|
|
290
|
+
const state = readQueue();
|
|
291
|
+
reconcile(state);
|
|
292
|
+
const nextResetIso = await refreshNextReset();
|
|
293
|
+
const fireAt = computeFireAt(state, nextResetIso);
|
|
294
|
+
if (!fireAt) {
|
|
295
|
+
state.scheduledFor = null;
|
|
296
|
+
writeQueue(state);
|
|
297
|
+
broadcast();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
state.scheduledFor = new Date(fireAt).toISOString();
|
|
302
|
+
writeQueue(state);
|
|
303
|
+
broadcast();
|
|
304
|
+
|
|
305
|
+
const delay = Math.max(1000, fireAt - Date.now());
|
|
306
|
+
// setTimeout caps at int32 ms (~24.8 days) — well above our 5h horizon, so
|
|
307
|
+
// a single timer is fine. If reset_at is wildly in the future we'd still
|
|
308
|
+
// re-anchor on the next billing refresh.
|
|
309
|
+
fireTimer = setTimeout(() => { runDueJobs().catch(() => {}); }, delay);
|
|
310
|
+
console.log(`[scheduler] next fire in ${Math.round(delay / 1000)}s @ ${state.scheduledFor}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------- pause / resume ----------
|
|
314
|
+
|
|
315
|
+
function setPaused(reason, resumeAtIso) {
|
|
316
|
+
const s = readQueue();
|
|
317
|
+
if (s.paused && s.paused.reason === reason) {
|
|
318
|
+
// already paused for this reason; just refresh resumeAt if newer
|
|
319
|
+
if (resumeAtIso) s.paused.resumeAt = resumeAtIso;
|
|
320
|
+
} else {
|
|
321
|
+
s.paused = { reason, since: new Date().toISOString(), resumeAt: resumeAtIso || null };
|
|
322
|
+
}
|
|
323
|
+
writeQueue(s);
|
|
324
|
+
broadcast();
|
|
325
|
+
cancelToken.cancelled = true;
|
|
326
|
+
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
|
|
327
|
+
if (resumeAtIso) {
|
|
328
|
+
// Resume 30s after the reset to give the auth/billing endpoint time to flip.
|
|
329
|
+
const delay = Math.max(30_000, new Date(resumeAtIso).getTime() - Date.now() + 30_000);
|
|
330
|
+
if (delay <= 0x7fffffff) {
|
|
331
|
+
resumeTimer = setTimeout(() => {
|
|
332
|
+
clearPause('resume-timer');
|
|
333
|
+
runDueJobs().catch(() => {});
|
|
334
|
+
}, delay);
|
|
335
|
+
console.log(`[scheduler] paused (${reason}); auto-resume in ${Math.round(delay/1000)}s`);
|
|
336
|
+
} else {
|
|
337
|
+
console.warn(`[scheduler] paused (${reason}); resumeAt too far in future for setTimeout (${delay}ms)`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function clearPause(source) {
|
|
343
|
+
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
|
|
344
|
+
const s = readQueue();
|
|
345
|
+
if (s.paused) {
|
|
346
|
+
console.log(`[scheduler] clearPause (${source || 'manual'})`);
|
|
347
|
+
s.paused = null;
|
|
348
|
+
writeQueue(s);
|
|
349
|
+
broadcast();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Scan the tail of a job's log for the canonical rate-limit signal. We look
|
|
354
|
+
* at the last 16 KB — final result event always lands at the end. */
|
|
355
|
+
function detectRateLimitInLog(logPath) {
|
|
356
|
+
try {
|
|
357
|
+
const stat = fs.statSync(logPath);
|
|
358
|
+
const start = Math.max(0, stat.size - 16384);
|
|
359
|
+
const len = stat.size - start;
|
|
360
|
+
if (len <= 0) return false;
|
|
361
|
+
const fd = fs.openSync(logPath, 'r');
|
|
362
|
+
const buf = Buffer.alloc(len);
|
|
363
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
364
|
+
fs.closeSync(fd);
|
|
365
|
+
const text = buf.toString('utf8');
|
|
366
|
+
return /"rateLimitType":"five_hour"/.test(text)
|
|
367
|
+
|| /"api_error_status":429/.test(text)
|
|
368
|
+
|| /You'?ve hit your limit/.test(text);
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------- claude binary ----------
|
|
375
|
+
|
|
376
|
+
function resolveClaudeBin() {
|
|
377
|
+
if (claudeBinPathCached) return claudeBinPathCached;
|
|
378
|
+
const candidates = [
|
|
379
|
+
path.join(os.homedir(), '.claude', 'local', 'claude'),
|
|
380
|
+
'/usr/local/bin/claude',
|
|
381
|
+
'/opt/homebrew/bin/claude',
|
|
382
|
+
'/usr/bin/claude',
|
|
383
|
+
];
|
|
384
|
+
for (const c of candidates) {
|
|
385
|
+
try { fs.accessSync(c, fs.constants.X_OK); claudeBinPathCached = c; return c; } catch { /* */ }
|
|
386
|
+
}
|
|
387
|
+
// Last resort: rely on PATH lookup at spawn time.
|
|
388
|
+
claudeBinPathCached = 'claude';
|
|
389
|
+
return claudeBinPathCached;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------- execution ----------
|
|
393
|
+
|
|
394
|
+
function pickRunDir() {
|
|
395
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
396
|
+
const dir = path.join(RUNS_DIR, ts);
|
|
397
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
398
|
+
return { runId: ts, dir };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function executeJob(job, runDir, defaultCwd) {
|
|
402
|
+
const logPath = path.join(runDir, `${job.slug}.log`);
|
|
403
|
+
const metaPath = path.join(runDir, `${job.slug}.meta.json`);
|
|
404
|
+
const cwd = job.cwd || defaultCwd;
|
|
405
|
+
const startedAt = Date.now();
|
|
406
|
+
|
|
407
|
+
const fd = fs.openSync(logPath, 'a');
|
|
408
|
+
fs.writeSync(fd, `[scheduler] starting ${job.slug} at ${new Date().toISOString()}\n[scheduler] cwd=${cwd}\n\n`);
|
|
409
|
+
|
|
410
|
+
// Read full PRD body fresh from disk (queue stored only the preview).
|
|
411
|
+
let prompt;
|
|
412
|
+
try {
|
|
413
|
+
const parsed = parsePrd(path.join(PRDS_DIR, `${job.slug}.md`));
|
|
414
|
+
prompt = parsed.body;
|
|
415
|
+
} catch (e) {
|
|
416
|
+
fs.writeSync(fd, `[scheduler] failed to read PRD: ${e?.message}\n`);
|
|
417
|
+
fs.closeSync(fd);
|
|
418
|
+
return { exitCode: -1, durationMs: 0, error: e?.message };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return await new Promise((resolve) => {
|
|
422
|
+
const claudeBin = resolveClaudeBin();
|
|
423
|
+
const child = spawn(claudeBin, [
|
|
424
|
+
'-p', prompt,
|
|
425
|
+
'--dangerously-skip-permissions',
|
|
426
|
+
'--output-format', 'stream-json',
|
|
427
|
+
'--verbose',
|
|
428
|
+
], {
|
|
429
|
+
cwd,
|
|
430
|
+
env: process.env,
|
|
431
|
+
stdio: ['ignore', fd, fd],
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
fs.writeSync(fd, `[scheduler] spawned pid=${child.pid}\n\n`);
|
|
435
|
+
|
|
436
|
+
child.on('error', (err) => {
|
|
437
|
+
const durationMs = Date.now() - startedAt;
|
|
438
|
+
fs.writeSync(fd, `\n[scheduler] spawn error: ${err.message}\n`);
|
|
439
|
+
fs.closeSync(fd);
|
|
440
|
+
atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: -1, error: err.message, startedAt, finishedAt: Date.now(), durationMs });
|
|
441
|
+
resolve({ exitCode: -1, durationMs, error: err.message });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
child.on('exit', (code) => {
|
|
445
|
+
const durationMs = Date.now() - startedAt;
|
|
446
|
+
fs.writeSync(fd, `\n[scheduler] exit code=${code} duration=${Math.round(durationMs / 1000)}s\n`);
|
|
447
|
+
fs.closeSync(fd);
|
|
448
|
+
const rateLimited = code !== 0 && detectRateLimitInLog(logPath);
|
|
449
|
+
atomicWriteJson(metaPath, { slug: job.slug, cwd, exitCode: code, rateLimited, startedAt, finishedAt: Date.now(), durationMs });
|
|
450
|
+
resolve({ exitCode: code, durationMs, rateLimited });
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function runDueJobs() {
|
|
456
|
+
if (isExecuting) return;
|
|
457
|
+
isExecuting = true;
|
|
458
|
+
cancelToken = { cancelled: false };
|
|
459
|
+
try {
|
|
460
|
+
const state = readQueue();
|
|
461
|
+
if (state.paused) {
|
|
462
|
+
console.log('[scheduler] runDueJobs skipped: paused');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
reconcile(state);
|
|
466
|
+
const pending = state.jobs.filter((j) => j.status === 'pending');
|
|
467
|
+
if (pending.length === 0) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const { runId, dir: runDir } = pickRunDir();
|
|
471
|
+
state.lastRunAt = new Date().toISOString();
|
|
472
|
+
|
|
473
|
+
// Group by parallelGroup, ascending. Each group runs serially after the
|
|
474
|
+
// previous group completes.
|
|
475
|
+
const groups = new Map();
|
|
476
|
+
for (const j of pending) {
|
|
477
|
+
const g = j.parallelGroup ?? 99;
|
|
478
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
479
|
+
groups.get(g).push(j);
|
|
480
|
+
}
|
|
481
|
+
const groupKeys = Array.from(groups.keys()).sort((a, b) => a - b);
|
|
482
|
+
|
|
483
|
+
writeQueue(state);
|
|
484
|
+
broadcast();
|
|
485
|
+
|
|
486
|
+
for (const gk of groupKeys) {
|
|
487
|
+
if (cancelToken.cancelled) break;
|
|
488
|
+
const groupJobs = groups.get(gk);
|
|
489
|
+
// Within a group: cap concurrency and run waves until all done.
|
|
490
|
+
const cap = Math.max(1, Math.min(state.config.concurrencyCap, groupJobs.length));
|
|
491
|
+
const queue = [...groupJobs];
|
|
492
|
+
const inFlight = new Set();
|
|
493
|
+
|
|
494
|
+
const launch = (job) => {
|
|
495
|
+
const s = readQueue();
|
|
496
|
+
const idx = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
497
|
+
if (idx >= 0) {
|
|
498
|
+
s.jobs[idx].status = 'running';
|
|
499
|
+
s.jobs[idx].runId = runId;
|
|
500
|
+
s.jobs[idx].startedAt = new Date().toISOString();
|
|
501
|
+
writeQueue(s);
|
|
502
|
+
broadcast();
|
|
503
|
+
}
|
|
504
|
+
const promise = executeJob(job, runDir, state.config.defaultCwd).then(async (res) => {
|
|
505
|
+
// Rate-limit OR pause-already-set means we treat this job as
|
|
506
|
+
// unfinished — bounce it back to 'pending' so the next run
|
|
507
|
+
// (after token reset) picks it up.
|
|
508
|
+
if (res.rateLimited) {
|
|
509
|
+
const resetIso = await refreshNextReset();
|
|
510
|
+
setPaused('rate_limit', resetIso);
|
|
511
|
+
}
|
|
512
|
+
const sn = readQueue();
|
|
513
|
+
const i2 = sn.jobs.findIndex((x) => x.slug === job.slug);
|
|
514
|
+
if (i2 >= 0) {
|
|
515
|
+
const treatAsPending = res.rateLimited || (sn.paused && sn.paused.reason === 'rate_limit');
|
|
516
|
+
if (treatAsPending) {
|
|
517
|
+
sn.jobs[i2].status = 'pending';
|
|
518
|
+
sn.jobs[i2].runId = null;
|
|
519
|
+
sn.jobs[i2].startedAt = null;
|
|
520
|
+
sn.jobs[i2].finishedAt = null;
|
|
521
|
+
sn.jobs[i2].exitCode = null;
|
|
522
|
+
sn.jobs[i2].error = res.rateLimited ? 'paused: rate limit' : 'paused: queue halted';
|
|
523
|
+
} else {
|
|
524
|
+
sn.jobs[i2].status = res.exitCode === 0 ? 'completed' : 'failed';
|
|
525
|
+
sn.jobs[i2].finishedAt = new Date().toISOString();
|
|
526
|
+
sn.jobs[i2].exitCode = res.exitCode;
|
|
527
|
+
sn.jobs[i2].error = res.error || null;
|
|
528
|
+
}
|
|
529
|
+
writeQueue(sn);
|
|
530
|
+
broadcast();
|
|
531
|
+
}
|
|
532
|
+
inFlight.delete(promise);
|
|
533
|
+
});
|
|
534
|
+
inFlight.add(promise);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// Prime up to cap
|
|
538
|
+
while (queue.length && inFlight.size < cap && !cancelToken.cancelled) launch(queue.shift());
|
|
539
|
+
// Drain. If cancelled mid-group, stop launching new jobs but let
|
|
540
|
+
// already-launched ones settle (they're rate-limited too — short).
|
|
541
|
+
while (inFlight.size > 0) {
|
|
542
|
+
await Promise.race(inFlight);
|
|
543
|
+
if (cancelToken.cancelled) {
|
|
544
|
+
await Promise.allSettled([...inFlight]);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
while (queue.length && inFlight.size < cap) launch(queue.shift());
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
551
|
+
isExecuting = false;
|
|
552
|
+
// No longer auto-disable after a run. The firePolicy now governs whether
|
|
553
|
+
// the next batch fires automatically. Just clear the one-shot scheduledFor.
|
|
554
|
+
const s = readQueue();
|
|
555
|
+
s.scheduledFor = null;
|
|
556
|
+
writeQueue(s);
|
|
557
|
+
broadcast();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------- when-available poll loop ----------
|
|
562
|
+
|
|
563
|
+
async function pollWhenAvailable() {
|
|
564
|
+
try {
|
|
565
|
+
const state = readQueue();
|
|
566
|
+
if (state.config.firePolicy !== 'when-available') return;
|
|
567
|
+
if (state.paused) return;
|
|
568
|
+
if (isExecuting) return;
|
|
569
|
+
const pending = state.jobs.filter((j) => j.status === 'pending');
|
|
570
|
+
if (pending.length === 0) return;
|
|
571
|
+
|
|
572
|
+
// Refresh utilization. If the call fails, don't fire blindly — wait
|
|
573
|
+
// for the next tick.
|
|
574
|
+
let util;
|
|
575
|
+
try {
|
|
576
|
+
const r = await billing.fetchUsage();
|
|
577
|
+
util = r?.usage?.five_hour?.utilization ?? null;
|
|
578
|
+
cachedUtilization = util;
|
|
579
|
+
cachedNextReset = { at: r?.usage?.five_hour?.resets_at ?? cachedNextReset.at, fetchedAt: Date.now() };
|
|
580
|
+
} catch {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (util === null || util === undefined) return;
|
|
584
|
+
if (util >= state.config.utilizationThreshold) {
|
|
585
|
+
// Tokens too high — broadcast so the UI shows current util but don't fire.
|
|
586
|
+
broadcast();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
console.log(`[scheduler] when-available: util=${util}%, ${pending.length} pending — firing`);
|
|
590
|
+
runDueJobs().catch((e) => console.error('[scheduler] runDueJobs error', e));
|
|
591
|
+
} catch (e) {
|
|
592
|
+
console.error('[scheduler] poll error', e);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---------- IPC ----------
|
|
597
|
+
|
|
598
|
+
function registerScheduleHandlers() {
|
|
599
|
+
ensureDirs();
|
|
600
|
+
|
|
601
|
+
ipcMain.handle('schedule:state', async () => {
|
|
602
|
+
const state = readQueue();
|
|
603
|
+
reconcile(state);
|
|
604
|
+
writeQueue(state);
|
|
605
|
+
return {
|
|
606
|
+
config: state.config,
|
|
607
|
+
jobs: state.jobs,
|
|
608
|
+
scheduledFor: state.scheduledFor,
|
|
609
|
+
lastRunAt: state.lastRunAt,
|
|
610
|
+
nextReset: getNextResetCached(),
|
|
611
|
+
paused: state.paused,
|
|
612
|
+
utilization: cachedUtilization,
|
|
613
|
+
paths: { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH },
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
ipcMain.handle('schedule:set-config', async (_e, partial) => {
|
|
618
|
+
const state = readQueue();
|
|
619
|
+
state.config = { ...state.config, ...(partial || {}) };
|
|
620
|
+
if (typeof state.config.concurrencyCap === 'number') {
|
|
621
|
+
state.config.concurrencyCap = Math.max(1, Math.min(20, Math.floor(state.config.concurrencyCap)));
|
|
622
|
+
}
|
|
623
|
+
if (typeof state.config.offsetMinutes === 'number') {
|
|
624
|
+
state.config.offsetMinutes = Math.max(0, Math.min(180, Math.floor(state.config.offsetMinutes)));
|
|
625
|
+
}
|
|
626
|
+
writeQueue(state);
|
|
627
|
+
await rescheduleTimer();
|
|
628
|
+
return { ok: true, config: state.config };
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
ipcMain.handle('schedule:reset-job', async (_e, { slug }) => {
|
|
632
|
+
const state = readQueue();
|
|
633
|
+
const idx = state.jobs.findIndex((j) => j.slug === slug);
|
|
634
|
+
if (idx < 0) return { ok: false, error: 'not found' };
|
|
635
|
+
state.jobs[idx].status = 'pending';
|
|
636
|
+
state.jobs[idx].runId = null;
|
|
637
|
+
state.jobs[idx].startedAt = null;
|
|
638
|
+
state.jobs[idx].finishedAt = null;
|
|
639
|
+
state.jobs[idx].exitCode = null;
|
|
640
|
+
state.jobs[idx].error = null;
|
|
641
|
+
writeQueue(state);
|
|
642
|
+
broadcast();
|
|
643
|
+
return { ok: true };
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
ipcMain.handle('schedule:run-now', async () => {
|
|
647
|
+
// Manual run-now overrides any auto-pause. Clear it first.
|
|
648
|
+
clearPause('run-now');
|
|
649
|
+
runDueJobs().catch((e) => console.error('[scheduler] runDueJobs error', e));
|
|
650
|
+
return { ok: true };
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
ipcMain.handle('schedule:resume', async () => {
|
|
654
|
+
clearPause('manual');
|
|
655
|
+
return { ok: true };
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
ipcMain.handle('schedule:refresh-reset', async () => {
|
|
659
|
+
const at = await refreshNextReset();
|
|
660
|
+
await rescheduleTimer();
|
|
661
|
+
return { ok: true, nextReset: at };
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
ipcMain.handle('schedule:open-folder', async () => {
|
|
665
|
+
const { shell } = require('electron');
|
|
666
|
+
await shell.openPath(ROOT);
|
|
667
|
+
return { ok: true };
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
ipcMain.handle('schedule:read-prd', async (_e, { slug }) => {
|
|
671
|
+
try {
|
|
672
|
+
const text = await fsp.readFile(path.join(PRDS_DIR, `${slug}.md`), 'utf8');
|
|
673
|
+
return { ok: true, text };
|
|
674
|
+
} catch (e) {
|
|
675
|
+
return { ok: false, error: e?.message };
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
ipcMain.handle('schedule:read-log', async (_e, { runId, slug }) => {
|
|
680
|
+
try {
|
|
681
|
+
const p = path.join(RUNS_DIR, runId, `${slug}.log`);
|
|
682
|
+
const text = await fsp.readFile(p, 'utf8');
|
|
683
|
+
return { ok: true, text };
|
|
684
|
+
} catch (e) {
|
|
685
|
+
return { ok: false, error: e?.message };
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function init() {
|
|
691
|
+
ensureDirs();
|
|
692
|
+
// Ensure queue.json exists with defaults so the renderer can read it.
|
|
693
|
+
if (!fs.existsSync(QUEUE_PATH)) writeQueue({ config: { ...DEFAULT_CONFIG }, jobs: [], scheduledFor: null, lastRunAt: null, paused: null });
|
|
694
|
+
|
|
695
|
+
// If we boot up while paused with a resumeAt in the past, clear it. This
|
|
696
|
+
// happens when the app was closed across the reset window.
|
|
697
|
+
const boot = readQueue();
|
|
698
|
+
if (boot.paused && boot.paused.resumeAt && new Date(boot.paused.resumeAt).getTime() <= Date.now()) {
|
|
699
|
+
clearPause('boot-elapsed');
|
|
700
|
+
} else if (boot.paused && boot.paused.resumeAt) {
|
|
701
|
+
// Re-arm the resume timer (lost across restart).
|
|
702
|
+
setPaused(boot.paused.reason, boot.paused.resumeAt);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
await rescheduleTimer();
|
|
706
|
+
// Refresh next-reset every 10 minutes — billing window can shift if usage
|
|
707
|
+
// resets early or the auth token rotates.
|
|
708
|
+
setInterval(() => { rescheduleTimer().catch(() => {}); }, 10 * 60_000);
|
|
709
|
+
// when-available poll loop. Tick every 2 minutes; the function itself is
|
|
710
|
+
// a no-op when policy != 'when-available' or queue is empty/paused.
|
|
711
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
712
|
+
pollTimer = setInterval(() => { pollWhenAvailable().catch(() => {}); }, 2 * 60_000);
|
|
713
|
+
// First tick fires after a short delay so billing is warmed up.
|
|
714
|
+
setTimeout(() => { pollWhenAvailable().catch(() => {}); }, 15_000);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR };
|