claude-code-session-manager 0.39.3 → 0.40.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/{TiptapBody-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-CR-WdMgm.css} +1 -1
- package/dist/assets/index-oMNYp73N.js +3194 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +34 -27
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/config.cjs +10 -0
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +28 -9
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +82 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/pty.cjs +9 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +333 -52
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +18 -3
- package/src/preload/index.cjs +2 -0
- package/dist/assets/index-BUuhV6vT.js +0 -3180
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueStore.cjs — federated scheduler state (2026-07-31 domain-model
|
|
3
|
+
* decision: "retire the global-aware scheduler").
|
|
4
|
+
*
|
|
5
|
+
* The old system of record was one global
|
|
6
|
+
* `~/.claude/session-manager/scheduled-plans/queue.json`. It is retired.
|
|
7
|
+
* State now lives where it belongs in the TAB → EPIC → PRD hierarchy:
|
|
8
|
+
*
|
|
9
|
+
* - Per-project job rows: `<cwd>/session-manager-operations/scheduler/state/queue.json`
|
|
10
|
+
* ({ jobs: [...] } — only that project's jobs)
|
|
11
|
+
* - Per-project history: `<cwd>/session-manager-operations/scheduler/state/history.jsonl`
|
|
12
|
+
* (owned by queueHistory.cjs, path resolved here)
|
|
13
|
+
* - Machine runtime state: `~/.claude/session-manager/scheduler-machine.json`
|
|
14
|
+
* (config, paused/rate-limit, scheduledFor, lastRunAt — these are
|
|
15
|
+
* Session-Manager runtime concerns, like the sessionSlots pool, not any
|
|
16
|
+
* one project's data. Run logs under scheduled-plans/runs/ stay
|
|
17
|
+
* machine-local for the same reason: they're execution artifacts of this
|
|
18
|
+
* machine's runner.)
|
|
19
|
+
*
|
|
20
|
+
* scheduler.cjs's 4k lines keep operating on ONE merged in-memory state
|
|
21
|
+
* object (jobs across all projects + machine fields); this module is the
|
|
22
|
+
* read-merge / write-split shim underneath readQueue/writeQueue. Jobs are
|
|
23
|
+
* split by `job.cwd` (fallback: the provided defaultCwd).
|
|
24
|
+
*
|
|
25
|
+
* Plain Node (no Electron deps) so watchdog scripts can require it; atomic
|
|
26
|
+
* writes are tmp+rename here rather than config.cjs's writeJson because
|
|
27
|
+
* config.cjs requires electron/chokidar and this must load outside the app.
|
|
28
|
+
*/
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const fsp = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const os = require('node:os');
|
|
35
|
+
const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
36
|
+
|
|
37
|
+
const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
|
|
38
|
+
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
39
|
+
const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
|
|
40
|
+
|
|
41
|
+
function projectStateDir(cwd) {
|
|
42
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
|
|
43
|
+
return path.join(cwd, ...STATE_SUBPATH);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function projectQueuePath(cwd) {
|
|
47
|
+
return path.join(projectStateDir(cwd), 'queue.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function projectHistoryPath(cwd) {
|
|
51
|
+
return path.join(projectStateDir(cwd), 'history.jsonl');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeJsonAtomicSync(file, value) {
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeJsonAtomic(file, value) {
|
|
62
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
64
|
+
await fsp.writeFile(tmp, JSON.stringify(value, null, 2));
|
|
65
|
+
await fsp.rename(tmp, file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- project-cwd enumeration (cached) ----------
|
|
69
|
+
|
|
70
|
+
// allProjectCwds scans ~/.claude/projects; on the readQueue hot path (every
|
|
71
|
+
// IPC status call) that's too much stat traffic, so cache the resolved cwd
|
|
72
|
+
// list briefly. Correctness fallback: a brand-new project appears at worst
|
|
73
|
+
// CACHE_MS late, and its first write goes through writeSplit which busts the
|
|
74
|
+
// cache.
|
|
75
|
+
const CACHE_MS = 30_000;
|
|
76
|
+
let cwdCache = { at: 0, cwds: [] };
|
|
77
|
+
|
|
78
|
+
function stateCwds(opts) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (!opts && now - cwdCache.at < CACHE_MS) return cwdCache.cwds;
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const cwds = [];
|
|
83
|
+
const add = (cwd) => { if (cwd && !seen.has(cwd)) { seen.add(cwd); cwds.push(cwd); } };
|
|
84
|
+
// Projects that already have a state file are authoritative sources...
|
|
85
|
+
for (const cwd of allProjectCwds(opts)) {
|
|
86
|
+
try { if (fs.existsSync(projectQueuePath(cwd))) add(cwd); } catch { /* skip */ }
|
|
87
|
+
}
|
|
88
|
+
// ...and active projects are included even before their first write.
|
|
89
|
+
for (const cwd of activeProjectCwds(undefined, opts)) add(cwd);
|
|
90
|
+
if (!opts) cwdCache = { at: now, cwds };
|
|
91
|
+
return cwds;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bustCwdCache() {
|
|
95
|
+
cwdCache = { at: 0, cwds: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- merged read ----------
|
|
99
|
+
|
|
100
|
+
function shapeMachine(raw) {
|
|
101
|
+
const data = raw ? JSON.parse(raw) : {};
|
|
102
|
+
return {
|
|
103
|
+
config: data.config || {},
|
|
104
|
+
scheduledFor: data.scheduledFor ?? null,
|
|
105
|
+
lastRunAt: data.lastRunAt ?? null,
|
|
106
|
+
paused: data.paused ?? null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shapeJobs(raw) {
|
|
111
|
+
const data = JSON.parse(raw);
|
|
112
|
+
return Array.isArray(data.jobs) ? data.jobs : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* readMergedSync(opts?) → { config, jobs, scheduledFor, lastRunAt, paused,
|
|
117
|
+
* unreadable?, unreadablePath?, sourceCwds }.
|
|
118
|
+
*
|
|
119
|
+
* `unreadable` mirrors the old single-file semantics: ANY source file that
|
|
120
|
+
* exists but fails to parse halts scheduling (never treat a project's queue
|
|
121
|
+
* as empty because it read corrupt). `sourceCwds` records every project file
|
|
122
|
+
* consulted so writeSplit can persist "this project now has zero jobs".
|
|
123
|
+
*/
|
|
124
|
+
function readMergedSync(opts) {
|
|
125
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
126
|
+
const sourceCwds = [];
|
|
127
|
+
try {
|
|
128
|
+
Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e?.code !== 'ENOENT') {
|
|
131
|
+
out.unreadable = `machine state unreadable: ${e?.message}`;
|
|
132
|
+
out.unreadablePath = MACHINE_STATE_PATH;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const cwd of stateCwds(opts)) {
|
|
136
|
+
const file = projectQueuePath(cwd);
|
|
137
|
+
try {
|
|
138
|
+
out.jobs.push(...shapeJobs(fs.readFileSync(file, 'utf8')));
|
|
139
|
+
sourceCwds.push(cwd);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
142
|
+
out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
|
|
143
|
+
out.unreadablePath = out.unreadablePath || file;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
defineSources(out, sourceCwds);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Async twin of readMergedSync for IPC hot paths. */
|
|
151
|
+
async function readMerged(opts) {
|
|
152
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
153
|
+
const sourceCwds = [];
|
|
154
|
+
try {
|
|
155
|
+
Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e?.code !== 'ENOENT') {
|
|
158
|
+
out.unreadable = `machine state unreadable: ${e?.message}`;
|
|
159
|
+
out.unreadablePath = MACHINE_STATE_PATH;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const cwd of stateCwds(opts)) {
|
|
163
|
+
const file = projectQueuePath(cwd);
|
|
164
|
+
try {
|
|
165
|
+
out.jobs.push(...shapeJobs(await fsp.readFile(file, 'utf8')));
|
|
166
|
+
sourceCwds.push(cwd);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
169
|
+
out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
|
|
170
|
+
out.unreadablePath = out.unreadablePath || file;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
defineSources(out, sourceCwds);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Non-enumerable so broadcast/JSON payloads of the state never carry it.
|
|
178
|
+
function defineSources(state, sourceCwds) {
|
|
179
|
+
Object.defineProperty(state, 'sourceCwds', {
|
|
180
|
+
value: sourceCwds, enumerable: false, configurable: true, writable: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- split write ----------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* writeSplit(state, defaultCwd) — persist a merged state back to its shards:
|
|
188
|
+
* machine fields → MACHINE_STATE_PATH; jobs grouped by job.cwd (fallback
|
|
189
|
+
* defaultCwd) → each project's state/queue.json. Every cwd the read consulted
|
|
190
|
+
* (state.sourceCwds) is written even when it now holds zero jobs, so
|
|
191
|
+
* deletions stick.
|
|
192
|
+
*/
|
|
193
|
+
async function writeSplit(state, defaultCwd) {
|
|
194
|
+
await writeJsonAtomic(MACHINE_STATE_PATH, {
|
|
195
|
+
config: state.config,
|
|
196
|
+
scheduledFor: state.scheduledFor ?? null,
|
|
197
|
+
lastRunAt: state.lastRunAt ?? null,
|
|
198
|
+
paused: state.paused ?? null,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const byCwd = new Map();
|
|
202
|
+
for (const cwd of state.sourceCwds ?? []) byCwd.set(cwd, []);
|
|
203
|
+
for (const job of state.jobs ?? []) {
|
|
204
|
+
const cwd = job.cwd || defaultCwd;
|
|
205
|
+
if (!cwd) continue; // nowhere to put it; job is dropped from persistence rather than crashing
|
|
206
|
+
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
207
|
+
byCwd.get(cwd).push(job);
|
|
208
|
+
}
|
|
209
|
+
for (const [cwd, jobs] of byCwd) {
|
|
210
|
+
try {
|
|
211
|
+
await writeJsonAtomic(projectQueuePath(cwd), { jobs });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// A single unwritable project (deleted repo dir, permissions) must not
|
|
214
|
+
// lose every other project's write.
|
|
215
|
+
console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
bustCwdCache();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- legacy migration ----------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* migrateLegacyGlobalQueue(defaultCwd) — one-time boot split of the retired
|
|
225
|
+
* global queue.json into per-project shards. Shard rows win over legacy rows
|
|
226
|
+
* with the same slug (the shard is newer by construction). The legacy file is
|
|
227
|
+
* renamed to `queue.json.retired-<epoch>` so a rollback can recover it but no
|
|
228
|
+
* reader ever consults it again. Machine fields (config/paused/...) migrate
|
|
229
|
+
* only when no machine file exists yet. Idempotent: no legacy file → no-op.
|
|
230
|
+
*/
|
|
231
|
+
async function migrateLegacyGlobalQueue(defaultCwd) {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = await fsp.readFile(LEGACY_QUEUE_PATH, 'utf8');
|
|
235
|
+
} catch {
|
|
236
|
+
return { migrated: false };
|
|
237
|
+
}
|
|
238
|
+
let legacy;
|
|
239
|
+
try {
|
|
240
|
+
legacy = JSON.parse(raw);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(`[queueStore] legacy queue.json unparseable — leaving in place: ${e?.message}`);
|
|
243
|
+
return { migrated: false, error: e?.message };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!fs.existsSync(MACHINE_STATE_PATH)) {
|
|
247
|
+
await writeJsonAtomic(MACHINE_STATE_PATH, {
|
|
248
|
+
config: legacy.config || {},
|
|
249
|
+
scheduledFor: legacy.scheduledFor ?? null,
|
|
250
|
+
lastRunAt: legacy.lastRunAt ?? null,
|
|
251
|
+
paused: legacy.paused ?? null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const legacyJobs = Array.isArray(legacy.jobs) ? legacy.jobs : [];
|
|
256
|
+
const byCwd = new Map();
|
|
257
|
+
for (const job of legacyJobs) {
|
|
258
|
+
const cwd = job.cwd || defaultCwd;
|
|
259
|
+
if (!cwd) continue;
|
|
260
|
+
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
261
|
+
byCwd.get(cwd).push(job);
|
|
262
|
+
}
|
|
263
|
+
let moved = 0;
|
|
264
|
+
for (const [cwd, jobs] of byCwd) {
|
|
265
|
+
const file = projectQueuePath(cwd);
|
|
266
|
+
let existing = [];
|
|
267
|
+
try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
|
|
268
|
+
const have = new Set(existing.map((j) => j.slug));
|
|
269
|
+
const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
|
|
270
|
+
try {
|
|
271
|
+
await writeJsonAtomic(file, { jobs: merged });
|
|
272
|
+
moved += merged.length - existing.length;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error(`[queueStore] legacy split: failed to write ${file}: ${e?.message}`);
|
|
275
|
+
return { migrated: false, error: e?.message };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await fsp.rename(LEGACY_QUEUE_PATH, `${LEGACY_QUEUE_PATH}.retired-${Date.now()}`);
|
|
280
|
+
bustCwdCache();
|
|
281
|
+
return { migrated: true, moved, projects: byCwd.size };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
MACHINE_STATE_PATH,
|
|
286
|
+
LEGACY_QUEUE_PATH,
|
|
287
|
+
STATE_SUBPATH,
|
|
288
|
+
projectStateDir,
|
|
289
|
+
projectQueuePath,
|
|
290
|
+
projectHistoryPath,
|
|
291
|
+
stateCwds,
|
|
292
|
+
bustCwdCache,
|
|
293
|
+
readMerged,
|
|
294
|
+
readMergedSync,
|
|
295
|
+
writeSplit,
|
|
296
|
+
migrateLegacyGlobalQueue,
|
|
297
|
+
writeJsonAtomic,
|
|
298
|
+
writeJsonAtomicSync,
|
|
299
|
+
};
|
|
@@ -38,12 +38,44 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
|
|
|
38
38
|
* human-readable reason text that would otherwise only reach console.log.
|
|
39
39
|
*/
|
|
40
40
|
function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
41
|
-
const
|
|
41
|
+
const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
|
|
42
|
+
|
|
43
|
+
// Explicit dependsOn eligibility (PRD 832). A dep slug is BLOCKING while a
|
|
44
|
+
// queue row for it exists in a non-completed state; a slug with no row is
|
|
45
|
+
// treated as already done (completed rows are retired to history shards,
|
|
46
|
+
// so absence is the normal end-state of a finished dep). A FAILED dep
|
|
47
|
+
// holds the dependent with an explicit reason, mirroring the failure gate.
|
|
48
|
+
// Legacy jobs without dependsOn keep the shared-NN group semantics below
|
|
49
|
+
// unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
|
|
50
|
+
// its order without migration.
|
|
51
|
+
const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
52
|
+
const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => {
|
|
53
|
+
const dep = rowBySlug.get(slug);
|
|
54
|
+
return dep && dep.status !== 'completed';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const allPending = projectJobs.filter(
|
|
42
58
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
43
59
|
);
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const
|
|
60
|
+
if (allPending.length === 0) return { batch: [], reason: null };
|
|
61
|
+
|
|
62
|
+
const pending = [];
|
|
63
|
+
const heldByFailedDep = [];
|
|
64
|
+
for (const j of allPending) {
|
|
65
|
+
const dep = blockingDep(j);
|
|
66
|
+
if (!dep) { pending.push(j); continue; }
|
|
67
|
+
if (rowBySlug.get(dep)?.status === 'failed') heldByFailedDep.push({ job: j, dep });
|
|
68
|
+
// running/pending/needs_review dep — simply not eligible this tick.
|
|
69
|
+
}
|
|
70
|
+
if (pending.length === 0) {
|
|
71
|
+
if (heldByFailedDep.length > 0) {
|
|
72
|
+
const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
73
|
+
const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
|
|
74
|
+
console.log(reason);
|
|
75
|
+
return { batch: [], reason };
|
|
76
|
+
}
|
|
77
|
+
return { batch: [], reason: null };
|
|
78
|
+
}
|
|
47
79
|
|
|
48
80
|
// Lowest pending group (computed up-front for the failure-gate check).
|
|
49
81
|
const lowestPendingGroup = pending.reduce(
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
|
|
3
|
+
* concurrency pool (2026-07-31 domain-model decision).
|
|
4
|
+
*
|
|
5
|
+
* Caps and limits belong to Session-Manager, not to any one consumer: the
|
|
6
|
+
* scheduler and chatRunner previously each enforced a private cap (3 and 2),
|
|
7
|
+
* which combined could exceed the machine's real budget — the exact shape of
|
|
8
|
+
* the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
|
|
9
|
+
* wants to launch a `claude -p` process REQUESTS a slot here first and
|
|
10
|
+
* releases it when the process settles. There is one pool, sized to the
|
|
11
|
+
* machine (default 3 — CLAUDE.md "Avoid" cap; SM_SESSION_SLOTS overrides,
|
|
12
|
+
* clamped to [1, 3]).
|
|
13
|
+
*
|
|
14
|
+
* Consumers keep their own scheduling policy (FIFO lanes, batch picking,
|
|
15
|
+
* memory gates); this module only answers "may one more process start right
|
|
16
|
+
* now?". Plain Node, no Electron deps, process-local state — all consumers
|
|
17
|
+
* live in the one Electron main process, which is exactly why it can be the
|
|
18
|
+
* arbiter.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
|
|
24
|
+
function totalSlots() {
|
|
25
|
+
const parsed = parseInt(process.env.SM_SESSION_SLOTS || '3', 10);
|
|
26
|
+
return Math.min(3, Math.max(1, Number.isFinite(parsed) ? parsed : 3));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// token → { owner, at }
|
|
30
|
+
const holders = new Map();
|
|
31
|
+
|
|
32
|
+
function inUse() {
|
|
33
|
+
return holders.size;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function available() {
|
|
37
|
+
return Math.max(0, totalSlots() - holders.size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* acquire(owner) → token string, or null when the pool is exhausted.
|
|
42
|
+
* `owner` is a diagnostic label ("scheduler:<slug>", "chat:<tabId>") shown in
|
|
43
|
+
* snapshot() so a stuck holder is attributable.
|
|
44
|
+
*/
|
|
45
|
+
function acquire(owner) {
|
|
46
|
+
if (holders.size >= totalSlots()) return null;
|
|
47
|
+
const token = crypto.randomUUID();
|
|
48
|
+
holders.set(token, { owner: String(owner || 'unknown'), at: new Date().toISOString() });
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Release listeners: each consumer registers its own "a slot freed — try to
|
|
53
|
+
// start work" pump so a scheduler release wakes the chat lane and vice versa.
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
function subscribe(fn) {
|
|
56
|
+
listeners.add(fn);
|
|
57
|
+
return () => listeners.delete(fn);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** release(token) — idempotent; releasing an unknown/already-released token is a no-op. */
|
|
61
|
+
function release(token) {
|
|
62
|
+
const had = holders.delete(token);
|
|
63
|
+
if (had) {
|
|
64
|
+
for (const fn of listeners) {
|
|
65
|
+
try { fn(); } catch { /* a consumer's pump error is its own problem */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return had;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Diagnostic view for status surfaces and tests. */
|
|
72
|
+
function snapshot() {
|
|
73
|
+
return {
|
|
74
|
+
total: totalSlots(),
|
|
75
|
+
inUse: holders.size,
|
|
76
|
+
holders: [...holders.values()],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Test hook: drop all held slots. */
|
|
81
|
+
function __resetForTests() {
|
|
82
|
+
holders.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
|
package/src/main/pty.cjs
CHANGED
|
@@ -231,6 +231,14 @@ class PtyManager {
|
|
|
231
231
|
killAll() {
|
|
232
232
|
for (const tabId of [...this.sessions.keys()]) this.kill(tabId);
|
|
233
233
|
}
|
|
234
|
+
|
|
235
|
+
/** Subset of `ids` that currently have a live PTY in the sessions map.
|
|
236
|
+
* Lets the Epics workspace reconcile Terminal-mode attachment after a
|
|
237
|
+
* renderer reload (the PTY survives; the renderer's in-memory attachment
|
|
238
|
+
* record does not — PRD 833 C1). */
|
|
239
|
+
aliveOf(ids) {
|
|
240
|
+
return ids.filter((id) => this.sessions.has(id));
|
|
241
|
+
}
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
const manager = new PtyManager();
|
|
@@ -244,6 +252,7 @@ function registerPtyHandlers() {
|
|
|
244
252
|
if (typeof tabId !== 'string') return;
|
|
245
253
|
manager.kill(tabId);
|
|
246
254
|
});
|
|
255
|
+
ipcMain.handle('pty:alive', v(s.ptyAlive, ({ tabIds }) => manager.aliveOf(tabIds)));
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
module.exports = { manager, registerPtyHandlers };
|
package/src/main/queueOps.cjs
CHANGED
|
@@ -307,6 +307,16 @@ async function archiveOne(slug, archiveDir) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Injected by index.cjs at registration time (see registerQueueOpsHandlers)
|
|
312
|
+
* so a manual archive can retire any still-runnable queue job for the same
|
|
313
|
+
* slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
|
|
314
|
+
* already requires queueOps.cjs). No-op until set; the auto-archive path
|
|
315
|
+
* never needs it (selectAutoArchivable only ever selects already-completed
|
|
316
|
+
* jobs).
|
|
317
|
+
*/
|
|
318
|
+
let retireCompletedSlugsFn = async () => {};
|
|
319
|
+
|
|
310
320
|
async function archiveMany(slugs) {
|
|
311
321
|
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
312
322
|
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
@@ -324,6 +334,12 @@ async function archiveMany(slugs) {
|
|
|
324
334
|
results.push(await archiveOne(slug, archiveDir));
|
|
325
335
|
}
|
|
326
336
|
const archived = results.filter((r) => r.ok).length;
|
|
337
|
+
const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
|
338
|
+
if (archivedSlugs.length > 0) {
|
|
339
|
+
await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
|
|
340
|
+
logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
|
|
341
|
+
});
|
|
342
|
+
}
|
|
327
343
|
return { ok: true, archived, archivedTo: archiveDir, results };
|
|
328
344
|
}
|
|
329
345
|
|
|
@@ -547,7 +563,10 @@ async function retagMany(items) {
|
|
|
547
563
|
|
|
548
564
|
// ────────────────────────────────────────────── IPC registration
|
|
549
565
|
|
|
550
|
-
function registerQueueOpsHandlers() {
|
|
566
|
+
function registerQueueOpsHandlers({ retireCompletedSlugs } = {}) {
|
|
567
|
+
if (typeof retireCompletedSlugs === 'function') {
|
|
568
|
+
retireCompletedSlugsFn = retireCompletedSlugs;
|
|
569
|
+
}
|
|
551
570
|
ipcMain.handle('schedule:lint-queue', async () => {
|
|
552
571
|
return lintAll();
|
|
553
572
|
});
|
|
@@ -18,6 +18,7 @@ const fsp = require('node:fs/promises');
|
|
|
18
18
|
const os = require('node:os');
|
|
19
19
|
const path = require('node:path');
|
|
20
20
|
const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
|
|
21
|
+
const { deriveEpicIdFromPrdPath } = require('../lib/prdLocations.cjs');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Expand a PRD `cwd` value to an absolute path.
|
|
@@ -66,17 +67,37 @@ async function parsePrdRaw(filePath) {
|
|
|
66
67
|
parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
|
|
67
68
|
// Optional traceability back to the PromptTicket.id (PRD 748) that was
|
|
68
69
|
// classified 'develop' and spawned this PRD (PRD 749). Additive — absent
|
|
69
|
-
// on every PRD authored before this field existed.
|
|
70
|
-
|
|
70
|
+
// on every PRD authored before this field existed. When frontmatter
|
|
71
|
+
// omits it, fall back to the owning Epic dir name (PRD 830) — a PRD's
|
|
72
|
+
// file location already IS its Epic membership, so hand-authored PRDs
|
|
73
|
+
// dropped straight into an Epic's prds/ dir still get real linkage
|
|
74
|
+
// instead of null.
|
|
75
|
+
sourcePromptId: fm.sourcePromptId || deriveEpicIdFromPrdPath(filePath) || null,
|
|
71
76
|
// Optional traceability back to the chat tab that queued this PRD (PRD
|
|
72
77
|
// 761) — read back at job completion to route a status prompt via
|
|
73
78
|
// enqueueExternalPrompt (PRD 753). Additive — absent on every PRD
|
|
74
79
|
// authored before this field existed.
|
|
75
80
|
sourceTabId: fm.sourceTabId || null,
|
|
81
|
+
// Explicit cross-PRD ordering (PRD 832): `dependsOn: [<slug>, <slug>]`
|
|
82
|
+
// or a comma-separated string. Replaces the retired shared-NN-means-
|
|
83
|
+
// parallel convention — a job is eligible only once every listed slug's
|
|
84
|
+
// queue row is completed (a slug with no row is treated as already
|
|
85
|
+
// done/archived, matching retireCompletedSlugs semantics).
|
|
86
|
+
dependsOn: parseDependsOn(fm.dependsOn),
|
|
76
87
|
body: body.trim(),
|
|
77
88
|
};
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
/** `[a, b]` / `a, b` / `a` → ['a','b']; anything else → []. */
|
|
92
|
+
function parseDependsOn(raw) {
|
|
93
|
+
if (!raw || typeof raw !== 'string') return [];
|
|
94
|
+
const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
|
|
95
|
+
return inner
|
|
96
|
+
.split(',')
|
|
97
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
98
|
+
.filter((s) => /^[A-Za-z0-9][\w.-]*$/.test(s));
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/**
|
|
81
102
|
* List `.md` PRD files under the given dir. Caches the result keyed by the
|
|
82
103
|
* directory mtime so repeated reconcile() calls don't re-stat every entry.
|
|
@@ -257,11 +278,14 @@ async function maxParallelGroupInUse(prdsDir) {
|
|
|
257
278
|
* number) and never wedges future callers, since each attempt only needs
|
|
258
279
|
* the marker for its OWN candidate to not already exist.
|
|
259
280
|
*/
|
|
260
|
-
async function allocateParallelGroup(prdsDir) {
|
|
281
|
+
async function allocateParallelGroup(prdsDir, { extraFloor = 0 } = {}) {
|
|
261
282
|
await fsp.mkdir(prdsDir, { recursive: true });
|
|
262
283
|
const scanMax = await maxParallelGroupInUse(prdsDir);
|
|
263
284
|
const highWater = await readHighWaterMark(prdsDir);
|
|
264
|
-
|
|
285
|
+
// extraFloor (PRD 832): the caller's max across OTHER dirs sharing the
|
|
286
|
+
// project's number space (epic prds/ dirs, prds-archived) — numbers are
|
|
287
|
+
// unique per project, never merely per directory.
|
|
288
|
+
const floor = Math.max(scanMax, highWater, extraFloor);
|
|
265
289
|
let candidate = floor + 1;
|
|
266
290
|
for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
|
|
267
291
|
const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
|