claude-code-session-manager 0.43.0 → 0.45.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-rWzd7CPW.js → TiptapBody-Pv4sVeQ8.js} +1 -1
- package/dist/assets/{index-6w1gIeyB.js → index-CSUJZAO6.js} +466 -460
- package/dist/assets/{index-CxGY-V5G.css → index-l5y1xr1v.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +32 -10
- package/scripts/lib/watchdogHelpers.cjs +1 -0
- package/scripts/mint-epic.cjs +26 -13
- package/scripts/propose-epic.cjs +1 -0
- package/src/main/__tests__/chat-dead-channels.test.cjs +63 -0
- package/src/main/__tests__/epicMint.test.cjs +132 -1
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +81 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +41 -14
- package/src/main/ipcSchemas.cjs +15 -7
- package/src/main/lib/__tests__/sessionSlots.test.cjs +59 -15
- package/src/main/lib/epicMint.cjs +139 -12
- package/src/main/lib/rcaFeedbackHook.cjs +23 -2
- package/src/main/lib/sessionSlots.cjs +74 -9
- package/src/main/scheduler.cjs +33 -88
- package/src/main/templates/PRD_AUTHORING.md +5 -1
- package/src/main/webRemote.cjs +0 -32
- package/src/preload/api.d.ts +10 -2
- package/src/preload/index.cjs +1 -0
|
@@ -51,6 +51,17 @@ function writeActiveIndex(cwd, index) {
|
|
|
51
51
|
fs.renameSync(tmp, file);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// index.sessions/index.events are plain objects parsed from JSON — bracket
|
|
55
|
+
// lookup with an attacker/agent-supplied key like "__proto__" or
|
|
56
|
+
// "constructor" resolves through the prototype chain to a truthy
|
|
57
|
+
// Object.prototype member even though no such Epic was ever written,
|
|
58
|
+
// bypassing every "does this Epic exist" gate below (including the
|
|
59
|
+
// mintIfMissing:false join-only check that PRD-authoring paths rely on).
|
|
60
|
+
// Always use this instead of `obj[key]`/`!obj[key]` for existence checks.
|
|
61
|
+
function hasOwn(obj, key) {
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
function slugify(text) {
|
|
55
66
|
return String(text || 'epic')
|
|
56
67
|
.toLowerCase()
|
|
@@ -59,6 +70,85 @@ function slugify(text) {
|
|
|
59
70
|
.slice(0, 48) || 'epic';
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
const STOPWORDS = new Set([
|
|
74
|
+
'the', 'a', 'an', 'is', 'of', 'to', 'for', 'and', 'in', 'on', 'at', 'this', 'that',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Lowercase, strip punctuation, split on whitespace, drop stopwords — shared
|
|
78
|
+
// by findJoinableEpic's similarity check. Kept standalone so its behavior is
|
|
79
|
+
// independently testable rather than inlined into the Jaccard computation.
|
|
80
|
+
function tokenize(text) {
|
|
81
|
+
return String(text || '')
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
84
|
+
.split(/\s+/)
|
|
85
|
+
.filter((token) => token && !STOPWORDS.has(token));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function jaccardSimilarity(tokensA, tokensB) {
|
|
89
|
+
const setA = new Set(tokensA);
|
|
90
|
+
const setB = new Set(tokensB);
|
|
91
|
+
if (setA.size === 0 && setB.size === 0) return 0;
|
|
92
|
+
let intersection = 0;
|
|
93
|
+
for (const token of setA) {
|
|
94
|
+
if (setB.has(token)) intersection += 1;
|
|
95
|
+
}
|
|
96
|
+
const union = setA.size + setB.size - intersection;
|
|
97
|
+
return union === 0 ? 0 : intersection / union;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const JOIN_SIMILARITY_THRESHOLD = 0.35;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* findJoinableEpicInIndex(index, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
|
|
104
|
+
*
|
|
105
|
+
* Same contract as findJoinableEpic() but takes an already-loaded index —
|
|
106
|
+
* lets ensureEpic() consult this without a second readActiveIndex() call
|
|
107
|
+
* inside its own withPathLock critical section. Two strategies, in order:
|
|
108
|
+
* 1. `preferEpicId` — an explicitly-known origin Epic the caller already has
|
|
109
|
+
* in hand. Joined immediately (no similarity check) as long as it exists
|
|
110
|
+
* and is still open ('proposed' or 'active') — a 'completed' or unknown
|
|
111
|
+
* preferEpicId falls through to strategy 2 rather than joining a dead Epic.
|
|
112
|
+
* 2. Keyword-similarity — Jaccard token-set overlap between `goalText` and
|
|
113
|
+
* every other OPEN Epic's goalText in the same cwd (open = 'proposed' or
|
|
114
|
+
* 'active', regardless of the specific requested `status` — a proposed
|
|
115
|
+
* RCA report and an active one about the same topic are still the same
|
|
116
|
+
* underlying issue). Highest score wins if it clears
|
|
117
|
+
* JOIN_SIMILARITY_THRESHOLD; otherwise no join. `status` is accepted for
|
|
118
|
+
* signature symmetry with ensureEpic()/reuseByGoal but only participates
|
|
119
|
+
* in strategy 1's preferEpicId open-check, not the similarity filter.
|
|
120
|
+
*/
|
|
121
|
+
function findJoinableEpicInIndex(index, { goalText, preferEpicId = null, status: _status = 'proposed' } = {}) {
|
|
122
|
+
if (preferEpicId && hasOwn(index.sessions, preferEpicId)) {
|
|
123
|
+
const preferred = index.sessions[preferEpicId];
|
|
124
|
+
if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
|
|
125
|
+
return { epicId: preferEpicId, matchedBy: 'preferEpicId' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const candidateTokens = tokenize(goalText);
|
|
130
|
+
let best = null;
|
|
131
|
+
for (const s of Object.values(index.sessions)) {
|
|
132
|
+
if (!s || (s.status !== 'proposed' && s.status !== 'active')) continue;
|
|
133
|
+
const score = jaccardSimilarity(candidateTokens, tokenize(s.goalText));
|
|
134
|
+
if (score >= JOIN_SIMILARITY_THRESHOLD && (!best || score > best.score)) {
|
|
135
|
+
best = { epicId: s.id, matchedBy: 'similarity', score };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* findJoinableEpic(cwd, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
|
|
143
|
+
*
|
|
144
|
+
* Public entry point for callers outside ensureEpic()'s own critical section
|
|
145
|
+
* (tests, future PRD 899/900 wiring) — loads the index itself. See
|
|
146
|
+
* findJoinableEpicInIndex() for the matching logic.
|
|
147
|
+
*/
|
|
148
|
+
function findJoinableEpic(cwd, opts = {}) {
|
|
149
|
+
return findJoinableEpicInIndex(readActiveIndex(cwd), opts);
|
|
150
|
+
}
|
|
151
|
+
|
|
62
152
|
// Serializes read-modify-write cycles per active-index.json path, mirroring
|
|
63
153
|
// promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
|
|
64
154
|
// read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
|
|
@@ -83,26 +173,43 @@ function withPathLock(lockPath, task) {
|
|
|
83
173
|
}
|
|
84
174
|
|
|
85
175
|
/**
|
|
86
|
-
* ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status? }) → Promise<{ epicId, prdDir, created }>
|
|
176
|
+
* ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status?, openingPrompt?, mintIfMissing?, source?, forceNewEpic? }) → Promise<{ epicId, prdDir, created }>
|
|
177
|
+
*
|
|
178
|
+
* Before minting brand-new, the mint branch consults findJoinableEpic() —
|
|
179
|
+
* minting is the LAST resort, not the default, for automated callers. Pass
|
|
180
|
+
* `forceNewEpic: true` to skip that check and always mint (the one
|
|
181
|
+
* legitimate case being explicit human-authored creation).
|
|
87
182
|
*
|
|
88
|
-
* `status` defaults to '
|
|
89
|
-
* for human approval
|
|
183
|
+
* `status` defaults to 'proposed' — a fail-safe default so any caller that
|
|
184
|
+
* forgets to pass it files an Epic that waits for human approval rather than
|
|
185
|
+
* one that starts running immediately. Pass 'active' explicitly only for the
|
|
186
|
+
* one legitimate immediate-start path (the New Epic UI's own proposed→active
|
|
187
|
+
* transition, `promptSessions.ts`'s `approveProposed`).
|
|
90
188
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
189
|
+
* `mintIfMissing` defaults to true for the small set of callers that are
|
|
190
|
+
* themselves the human-intent gate (propose-epic, the RCA hook, the feedback
|
|
191
|
+
* sweep — all of which pass `status: 'proposed'`, so "minting" here still
|
|
192
|
+
* never starts anything without a human's Approve & start). Callers that are
|
|
193
|
+
* NOT themselves a human-intent gate (an automated PRD-write path acting on
|
|
194
|
+
* a session's behalf) must pass `mintIfMissing: false` — that path may only
|
|
195
|
+
* JOIN an Epic that already exists; it throws instead of silently creating a
|
|
196
|
+
* new one, so no PRD-authoring surface can conjure Epics on its own.
|
|
197
|
+
*
|
|
198
|
+
* Mints a new Epic — or, with `reuseByGoal`, joins the existing Epic (of the
|
|
199
|
+
* same `status`) whose goalText matches (used by the recurring feedback sweep
|
|
200
|
+
* so successive sweeps chain into one Epic instead of minting one per tick).
|
|
94
201
|
*
|
|
95
202
|
* The Epic's id doubles as its directory name under scheduler/epics/, so the
|
|
96
203
|
* PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
|
|
97
204
|
*/
|
|
98
|
-
function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = '
|
|
205
|
+
function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, mintIfMissing = true, source = null, forceNewEpic = false } = {}) {
|
|
99
206
|
if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
|
|
100
207
|
return withPathLock(activeIndexPath(cwd), () => {
|
|
101
208
|
const index = readActiveIndex(cwd);
|
|
102
209
|
|
|
103
210
|
// A dispatch that already knows its Epic (sourcePromptId frontmatter from
|
|
104
211
|
// an Epic-conversation dispatch) joins it rather than minting a sibling.
|
|
105
|
-
if (explicitEpicId && index.sessions
|
|
212
|
+
if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
|
|
106
213
|
const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
|
|
107
214
|
fs.mkdirSync(prdDir, { recursive: true });
|
|
108
215
|
return { epicId: explicitEpicId, prdDir, created: false };
|
|
@@ -133,6 +240,23 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
133
240
|
}
|
|
134
241
|
}
|
|
135
242
|
|
|
243
|
+
if (!mintIfMissing) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`ensureEpic: no existing Epic found (epicId=${explicitEpicId ?? 'none'}) and mintIfMissing is false — `
|
|
246
|
+
+ 'a new Epic can only be created by explicit human intent (New Epic UI, or /propose-epic + Approve & start), '
|
|
247
|
+
+ 'never implicitly by a PRD-authoring path',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!forceNewEpic) {
|
|
252
|
+
const joinable = findJoinableEpicInIndex(index, { goalText, preferEpicId: explicitEpicId, status });
|
|
253
|
+
if (joinable) {
|
|
254
|
+
const prdDir = resolveEpicPrdWriteDir(cwd, joinable.epicId);
|
|
255
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
256
|
+
return { epicId: joinable.epicId, prdDir, created: false };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
136
260
|
const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
137
261
|
const now = new Date().toISOString();
|
|
138
262
|
const session = {
|
|
@@ -152,6 +276,9 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
152
276
|
// Full body for a proposal whose goalText is only a one-line title;
|
|
153
277
|
// sent verbatim as the first prompt when a human approves it.
|
|
154
278
|
...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
|
|
279
|
+
// Structured trace of which automated producer minted this Epic — see
|
|
280
|
+
// EpicSource in state/promptSessions.ts.
|
|
281
|
+
...(source ? { source } : {}),
|
|
155
282
|
};
|
|
156
283
|
const firstEvent = {
|
|
157
284
|
id: crypto.randomUUID(),
|
|
@@ -168,7 +295,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
168
295
|
// Every mint is logged, whether the Epic starts 'proposed' (human gate
|
|
169
296
|
// ahead) or 'active' (started immediately) — this is the trace-back point
|
|
170
297
|
// for "who/what created this Epic" (see auditLog.cjs).
|
|
171
|
-
appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
|
|
298
|
+
appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText, source: source ?? null });
|
|
172
299
|
|
|
173
300
|
const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
174
301
|
fs.mkdirSync(prdDir, { recursive: true });
|
|
@@ -185,7 +312,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
185
312
|
function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
|
|
186
313
|
return withPathLock(activeIndexPath(cwd), () => {
|
|
187
314
|
const index = readActiveIndex(cwd);
|
|
188
|
-
if (!index.sessions
|
|
315
|
+
if (!hasOwn(index.sessions, epicId)) return false;
|
|
189
316
|
const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
|
|
190
317
|
const tail = chain.length ? chain[chain.length - 1] : null;
|
|
191
318
|
chain.push({
|
|
@@ -214,11 +341,11 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
|
|
|
214
341
|
function removeEpic(cwd, epicId) {
|
|
215
342
|
if (!cwd || !epicId) return false;
|
|
216
343
|
const index = readActiveIndex(cwd);
|
|
217
|
-
if (!index.sessions
|
|
344
|
+
if (!hasOwn(index.sessions, epicId)) return false;
|
|
218
345
|
delete index.sessions[epicId];
|
|
219
346
|
delete index.events[epicId];
|
|
220
347
|
writeActiveIndex(cwd, index);
|
|
221
348
|
return true;
|
|
222
349
|
}
|
|
223
350
|
|
|
224
|
-
module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex };
|
|
351
|
+
module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex, findJoinableEpic, tokenize };
|
|
@@ -28,7 +28,7 @@ const fs = require('node:fs');
|
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const path = require('node:path');
|
|
30
30
|
const config = require('../config.cjs');
|
|
31
|
-
const { ensureEpic } = require('./epicMint.cjs');
|
|
31
|
+
const { ensureEpic, findJoinableEpic } = require('./epicMint.cjs');
|
|
32
32
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
33
33
|
const { readTail } = require('./fileTail.cjs');
|
|
34
34
|
const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
@@ -294,7 +294,7 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
|
|
|
294
294
|
* duplicate. A different runId (new run of the same slug) files a new RCA.
|
|
295
295
|
*
|
|
296
296
|
* @param {object} opts
|
|
297
|
-
* @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string}} opts.job
|
|
297
|
+
* @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string, epicId?: string}} opts.job
|
|
298
298
|
* @param {string} [opts.runDir] Run directory containing <slug>.log / <slug>.meta.json.
|
|
299
299
|
* @param {string} opts.verdict Verifier verdict string (e.g. 'uncommitted_changes').
|
|
300
300
|
* @param {Array} [opts.annotations] Non-blocking verifier annotations (currently unused
|
|
@@ -351,12 +351,33 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
|
|
|
351
351
|
// the old live/processed existence checks were for.
|
|
352
352
|
config.addAllowedRoot(dest.allowlistRoot);
|
|
353
353
|
const title = rcaProposalTitle(job, verdict);
|
|
354
|
+
|
|
355
|
+
// job.epicId (present on jobs authored after Epic-gating landed; absent
|
|
356
|
+
// on legacy PRDs) is the known origin Epic this failing job belonged to.
|
|
357
|
+
// Passing it straight through as ensureEpic's `epicId` would hit its
|
|
358
|
+
// explicitEpicId join-only branch, which joins unconditionally — even a
|
|
359
|
+
// 'completed' Epic. findJoinableEpic's preferEpicId strategy is the one
|
|
360
|
+
// that only joins when the Epic is still open ('proposed'/'active'), so
|
|
361
|
+
// resolve it here and only forward it when that strategy actually fires
|
|
362
|
+
// — otherwise fall through to the existing reuseByGoal/mint behavior.
|
|
363
|
+
// Once forwarded, ensureEpic joins it unconditionally by id (no second
|
|
364
|
+
// status check) — the status gate lives entirely in this resolve step.
|
|
365
|
+
let preferredEpicId;
|
|
366
|
+
if (typeof job.epicId === 'string' && job.epicId) {
|
|
367
|
+
const joinable = findJoinableEpic(dest.cwd, { goalText: title, preferEpicId: job.epicId, status: 'proposed' });
|
|
368
|
+
if (joinable && joinable.matchedBy === 'preferEpicId') {
|
|
369
|
+
preferredEpicId = joinable.epicId;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
354
373
|
const { epicId, created } = await ensureEpic(dest.cwd, {
|
|
355
374
|
goalText: title,
|
|
356
375
|
tag: 'bug',
|
|
357
376
|
status: 'proposed',
|
|
358
377
|
reuseByGoal: true,
|
|
359
378
|
openingPrompt: markdown,
|
|
379
|
+
epicId: preferredEpicId,
|
|
380
|
+
source: { producer: 'rca-hook', prdSlug: job.slug, runId: job.runId },
|
|
360
381
|
});
|
|
361
382
|
|
|
362
383
|
console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
|
|
3
|
-
* concurrency pool (2026-07-31 domain-model decision
|
|
3
|
+
* concurrency pool (2026-07-31 domain-model decision; made user-configurable
|
|
4
|
+
* 2026-08-01).
|
|
4
5
|
*
|
|
5
6
|
* Caps and limits belong to Session-Manager, not to any one consumer: the
|
|
6
7
|
* scheduler and chatRunner previously each enforced a private cap (3 and 2),
|
|
@@ -8,22 +9,68 @@
|
|
|
8
9
|
* the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
|
|
9
10
|
* wants to launch a `claude -p` process REQUESTS a slot here first and
|
|
10
11
|
* releases it when the process settles. There is one pool, sized to the
|
|
11
|
-
* machine (default
|
|
12
|
-
*
|
|
12
|
+
* machine (default 5, user-adjustable [0, 10] from the Home tab; 0 pauses new
|
|
13
|
+
* launches without touching already-running processes — SM_SESSION_SLOTS
|
|
14
|
+
* still overrides everything for scripted/CI use, clamped to the same
|
|
15
|
+
* [0, 10] range).
|
|
13
16
|
*
|
|
14
17
|
* Consumers keep their own scheduling policy (FIFO lanes, batch picking,
|
|
15
18
|
* memory gates); this module only answers "may one more process start right
|
|
16
|
-
* now?". Plain Node, no Electron deps, process-local state
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* now?". Plain Node, no Electron deps, process-local state (the persisted cap
|
|
20
|
+
* is the one exception — a tiny standalone JSON file, not routed through any
|
|
21
|
+
* other module's config store) — all consumers live in the one Electron main
|
|
22
|
+
* process, which is exactly why it can be the arbiter.
|
|
19
23
|
*/
|
|
20
24
|
'use strict';
|
|
21
25
|
|
|
22
26
|
const crypto = require('node:crypto');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const MIN_SLOTS = 0;
|
|
32
|
+
const MAX_SLOTS = 10;
|
|
33
|
+
const DEFAULT_SLOTS = 5;
|
|
34
|
+
|
|
35
|
+
const CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'session-slots-config.json');
|
|
36
|
+
|
|
37
|
+
function clamp(n) {
|
|
38
|
+
return Math.min(MAX_SLOTS, Math.max(MIN_SLOTS, n));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readPersistedCap() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
const cap = Number(parsed.cap);
|
|
46
|
+
return Number.isFinite(cap) ? clamp(Math.trunc(cap)) : DEFAULT_SLOTS;
|
|
47
|
+
} catch {
|
|
48
|
+
return DEFAULT_SLOTS;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Persist a new cap to disk (tmp + rename). Throws on an out-of-range value. */
|
|
53
|
+
function setCap(cap) {
|
|
54
|
+
const n = Number(cap);
|
|
55
|
+
if (!Number.isFinite(n) || Math.trunc(n) !== n || n < MIN_SLOTS || n > MAX_SLOTS) {
|
|
56
|
+
throw new Error(`sessionSlots.setCap: cap must be an integer in [${MIN_SLOTS}, ${MAX_SLOTS}]`);
|
|
57
|
+
}
|
|
58
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
59
|
+
const tmp = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify({ cap: n }, null, 2));
|
|
61
|
+
fs.renameSync(tmp, CONFIG_PATH);
|
|
62
|
+
for (const fn of listeners) {
|
|
63
|
+
try { fn(); } catch { /* a consumer's pump error is its own problem */ }
|
|
64
|
+
}
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
23
67
|
|
|
24
68
|
function totalSlots() {
|
|
25
|
-
|
|
26
|
-
|
|
69
|
+
if (process.env.SM_SESSION_SLOTS !== undefined) {
|
|
70
|
+
const parsed = parseInt(process.env.SM_SESSION_SLOTS, 10);
|
|
71
|
+
return Number.isFinite(parsed) ? clamp(parsed) : DEFAULT_SLOTS;
|
|
72
|
+
}
|
|
73
|
+
return readPersistedCap();
|
|
27
74
|
}
|
|
28
75
|
|
|
29
76
|
// token → { owner, at }
|
|
@@ -51,6 +98,7 @@ function acquire(owner) {
|
|
|
51
98
|
|
|
52
99
|
// Release listeners: each consumer registers its own "a slot freed — try to
|
|
53
100
|
// start work" pump so a scheduler release wakes the chat lane and vice versa.
|
|
101
|
+
// Also fired when the cap itself increases (setCap), for the same reason.
|
|
54
102
|
const listeners = new Set();
|
|
55
103
|
function subscribe(fn) {
|
|
56
104
|
listeners.add(fn);
|
|
@@ -74,6 +122,10 @@ function snapshot() {
|
|
|
74
122
|
total: totalSlots(),
|
|
75
123
|
inUse: holders.size,
|
|
76
124
|
holders: [...holders.values()],
|
|
125
|
+
min: MIN_SLOTS,
|
|
126
|
+
max: MAX_SLOTS,
|
|
127
|
+
default: DEFAULT_SLOTS,
|
|
128
|
+
envOverride: process.env.SM_SESSION_SLOTS !== undefined,
|
|
77
129
|
};
|
|
78
130
|
}
|
|
79
131
|
|
|
@@ -82,4 +134,17 @@ function __resetForTests() {
|
|
|
82
134
|
holders.clear();
|
|
83
135
|
}
|
|
84
136
|
|
|
85
|
-
module.exports = {
|
|
137
|
+
module.exports = {
|
|
138
|
+
MIN_SLOTS,
|
|
139
|
+
MAX_SLOTS,
|
|
140
|
+
DEFAULT_SLOTS,
|
|
141
|
+
totalSlots,
|
|
142
|
+
setCap,
|
|
143
|
+
inUse,
|
|
144
|
+
available,
|
|
145
|
+
acquire,
|
|
146
|
+
release,
|
|
147
|
+
subscribe,
|
|
148
|
+
snapshot,
|
|
149
|
+
__resetForTests,
|
|
150
|
+
};
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -86,7 +86,7 @@ const queueOps = require('./queueOps.cjs');
|
|
|
86
86
|
// match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
|
|
87
87
|
// home-dir layout.
|
|
88
88
|
const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
|
|
89
|
-
const { ensureEpic, appendPrdCreatedEvent,
|
|
89
|
+
const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
|
|
90
90
|
|
|
91
91
|
// ---------- origin session resolution (PRD 832) ----------
|
|
92
92
|
// An Epic IS a tagged claude session — job rows carry the originating
|
|
@@ -3710,6 +3710,15 @@ function registerScheduleHandlers() {
|
|
|
3710
3710
|
// configuration tab.
|
|
3711
3711
|
ipcMain.handle('schedule:session-slots', () => sessionSlots.snapshot());
|
|
3712
3712
|
|
|
3713
|
+
// Home-tab control for the same pool: user-set cap in [0, 10], default 5.
|
|
3714
|
+
// 0 pauses new claude -p launches machine-wide without killing anything
|
|
3715
|
+
// already running. SM_SESSION_SLOTS (if set) still overrides this at read
|
|
3716
|
+
// time — sessionSlots.snapshot().envOverride tells the UI to disable itself.
|
|
3717
|
+
ipcMain.handle('schedule:set-session-slots', validated(schemas.setSessionSlotsSchema, async (data) => {
|
|
3718
|
+
sessionSlots.setCap(data.cap);
|
|
3719
|
+
return sessionSlots.snapshot();
|
|
3720
|
+
}));
|
|
3721
|
+
|
|
3713
3722
|
ipcMain.handle('schedule:health', async () => {
|
|
3714
3723
|
const state = await readQueue();
|
|
3715
3724
|
const runningJobs = [];
|
|
@@ -4178,13 +4187,6 @@ async function init() {
|
|
|
4178
4187
|
|
|
4179
4188
|
// remote — callable from webRemote.cjs without going through IPC.
|
|
4180
4189
|
const remote = {
|
|
4181
|
-
async getState() {
|
|
4182
|
-
const state = await readQueue();
|
|
4183
|
-
await reconcile(state);
|
|
4184
|
-
await writeQueue(state);
|
|
4185
|
-
return buildScheduleStatePayload(state, { withPaths: true });
|
|
4186
|
-
},
|
|
4187
|
-
|
|
4188
4190
|
// `cwd` is optional: prdCreate.cjs's create flow passes the target
|
|
4189
4191
|
// project's cwd explicitly (the file may not exist yet, so there's
|
|
4190
4192
|
// nothing for findPrdDir to search for); the renderer's slug-only IPC
|
|
@@ -4220,33 +4222,12 @@ const remote = {
|
|
|
4220
4222
|
}
|
|
4221
4223
|
},
|
|
4222
4224
|
|
|
4223
|
-
async readLog(slug, runId) {
|
|
4224
|
-
const logPath = path.resolve(path.join(RUNS_DIR, runId, `${slug}.log`));
|
|
4225
|
-
if (!logPath.startsWith(RUNS_DIR + path.sep)) {
|
|
4226
|
-
return { ok: false, error: 'invalid slug or runId' };
|
|
4227
|
-
}
|
|
4228
|
-
try {
|
|
4229
|
-
// realpath resolves symlinks; re-check boundary to block a rogue agent job
|
|
4230
|
-
// that places a symlink inside RUNS_DIR pointing outside the safe root.
|
|
4231
|
-
const real = await fsp.realpath(logPath);
|
|
4232
|
-
if (!real.startsWith(RUNS_DIR + path.sep)) {
|
|
4233
|
-
return { ok: false, error: 'invalid slug or runId' };
|
|
4234
|
-
}
|
|
4235
|
-
const text = await fsp.readFile(real, 'utf8');
|
|
4236
|
-
return { ok: true, text };
|
|
4237
|
-
} catch (e) {
|
|
4238
|
-
return { ok: false, error: e?.message };
|
|
4239
|
-
}
|
|
4240
|
-
},
|
|
4241
|
-
|
|
4242
4225
|
// `cwd` optional — see readPrd's comment above; prdCreate.cjs's create
|
|
4243
4226
|
// flow supplies it (the destination project dir for a brand-new file that
|
|
4244
4227
|
// doesn't exist yet, so findPrdDir would return nothing to write into).
|
|
4245
4228
|
async writePrd(slug, body, cwd) {
|
|
4246
4229
|
let dir;
|
|
4247
4230
|
let epicTrace = null;
|
|
4248
|
-
let epicCreated = false;
|
|
4249
|
-
let epicId = null;
|
|
4250
4231
|
if (cwd) {
|
|
4251
4232
|
// Edit-in-place if this slug already lives anywhere under this project
|
|
4252
4233
|
// (legacy flat dir or any Epic's prds/); otherwise this is a CREATE,
|
|
@@ -4258,29 +4239,37 @@ const remote = {
|
|
|
4258
4239
|
if (candidate && fs.existsSync(candidate)) { dir = d; break; }
|
|
4259
4240
|
}
|
|
4260
4241
|
if (!dir) {
|
|
4242
|
+
// Every PRD must join an EXISTING, already-human-approved Epic —
|
|
4243
|
+
// mintIfMissing:false means this never conjures a new one. Only
|
|
4244
|
+
// Epics born from explicit human intent (New Epic UI, or
|
|
4245
|
+
// /propose-epic + Approve & start) may write PRDs; this write path
|
|
4246
|
+
// is not itself a human-intent gate, so it must not become one by
|
|
4247
|
+
// accident.
|
|
4248
|
+
const { fm } = splitFrontmatter(body);
|
|
4261
4249
|
try {
|
|
4262
|
-
const { fm } = splitFrontmatter(body);
|
|
4263
4250
|
const epic = await ensureEpic(cwd, {
|
|
4264
|
-
goalText: fm.title || slug,
|
|
4265
|
-
tag: fm.tag,
|
|
4266
|
-
// An Epic-conversation dispatch already has its Epic — join it.
|
|
4267
4251
|
// fm.sourcePromptId must be an existing Epic's promptSessionId
|
|
4268
4252
|
// (== its active-index.json sessions key), NOT a
|
|
4269
4253
|
// PromptTicket.id — epicMint.cjs's ensureEpic looks it up via
|
|
4270
4254
|
// `index.sessions[explicitEpicId]` (see epicMint.cjs ~line 73),
|
|
4271
4255
|
// a literal-equality join. Any other id (e.g. a PromptTicket.id)
|
|
4272
|
-
// simply won't match and
|
|
4256
|
+
// simply won't match and this call throws below.
|
|
4273
4257
|
epicId: fm.sourcePromptId,
|
|
4258
|
+
mintIfMissing: false,
|
|
4259
|
+
// Join-only call (mintIfMissing:false, explicit epicId): the
|
|
4260
|
+
// explicitEpicId branch in ensureEpic returns before `source` is
|
|
4261
|
+
// ever read, so this is a no-op today — kept for symmetry/audit
|
|
4262
|
+
// trail if that join path ever grows a source-touch (PRD 902/905).
|
|
4263
|
+
source: { producer: 'scheduler-dispatch', prdSlug: slug },
|
|
4274
4264
|
});
|
|
4275
4265
|
dir = epic.prdDir;
|
|
4276
4266
|
epicTrace = epic.epicId;
|
|
4277
|
-
epicCreated = epic.created === true;
|
|
4278
|
-
epicId = epic.epicId;
|
|
4279
4267
|
} catch (e) {
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4268
|
+
return {
|
|
4269
|
+
ok: false,
|
|
4270
|
+
error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
|
|
4271
|
+
+ 'Create the Epic first via the New Epic UI or /propose-epic + Approve & start, then pass its id as sourcePromptId.',
|
|
4272
|
+
};
|
|
4284
4273
|
}
|
|
4285
4274
|
}
|
|
4286
4275
|
await fsp.mkdir(dir, { recursive: true });
|
|
@@ -4289,30 +4278,11 @@ const remote = {
|
|
|
4289
4278
|
if (dir === PRDS_DIR) ensureDirs();
|
|
4290
4279
|
}
|
|
4291
4280
|
|
|
4292
|
-
//
|
|
4293
|
-
//
|
|
4294
|
-
//
|
|
4295
|
-
// PRD 851: also drop the Epic's active-index.json entry (sessions/events)
|
|
4296
|
-
// so an orphaned seed-prompt-only Epic doesn't linger in the Epics nav.
|
|
4297
|
-
// Gated on epicCreated (never fires when ensureEpic joined an existing
|
|
4298
|
-
// Epic) so a pre-existing Epic's history is never touched.
|
|
4299
|
-
const cleanupEmptyMintedEpic = async () => {
|
|
4300
|
-
if (!epicCreated || !epicId) return;
|
|
4301
|
-
try {
|
|
4302
|
-
const entries = await fsp.readdir(dir);
|
|
4303
|
-
if (entries.length === 0) {
|
|
4304
|
-
await fsp.rmdir(dir);
|
|
4305
|
-
const epicRootDir = path.dirname(dir);
|
|
4306
|
-
const epicRootEntries = await fsp.readdir(epicRootDir);
|
|
4307
|
-
if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
|
|
4308
|
-
}
|
|
4309
|
-
} catch { /* best-effort only */ }
|
|
4310
|
-
try { removeEpic(cwd, epicId); } catch { /* best-effort only */ }
|
|
4311
|
-
};
|
|
4312
|
-
|
|
4281
|
+
// writePrd only ever JOINS an existing Epic now (mintIfMissing:false
|
|
4282
|
+
// above) — it can never mint one, so there is no orphaned-mint case left
|
|
4283
|
+
// to roll back here (contrast the old PRD 825/851 cleanup, removed).
|
|
4313
4284
|
const resolved = safeSlugPathIn(dir, slug);
|
|
4314
4285
|
if (!resolved) {
|
|
4315
|
-
await cleanupEmptyMintedEpic();
|
|
4316
4286
|
return { ok: false, error: 'invalid slug' };
|
|
4317
4287
|
}
|
|
4318
4288
|
try {
|
|
@@ -4324,23 +4294,20 @@ const remote = {
|
|
|
4324
4294
|
// symlink.
|
|
4325
4295
|
const realParent = await fsp.realpath(path.dirname(resolved));
|
|
4326
4296
|
if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
|
|
4327
|
-
await cleanupEmptyMintedEpic();
|
|
4328
4297
|
return { ok: false, error: 'invalid slug' };
|
|
4329
4298
|
}
|
|
4330
4299
|
const existing = await fsp.lstat(resolved).catch(() => null);
|
|
4331
4300
|
if (existing && existing.isSymbolicLink()) {
|
|
4332
|
-
await cleanupEmptyMintedEpic();
|
|
4333
4301
|
return { ok: false, error: 'invalid slug' };
|
|
4334
4302
|
}
|
|
4335
4303
|
await config.writeTextAtomic(resolved, body, { writer: 'scheduler' });
|
|
4336
4304
|
const stat = await fsp.stat(resolved);
|
|
4337
4305
|
if (epicTrace) {
|
|
4338
|
-
// Best-effort: record the dispatch on the
|
|
4306
|
+
// Best-effort: record the dispatch on the Epic's event chain.
|
|
4339
4307
|
try { await appendPrdCreatedEvent(cwd, epicTrace, slug); } catch { /* trace only */ }
|
|
4340
4308
|
}
|
|
4341
4309
|
return { ok: true, bytesWritten: stat.size };
|
|
4342
4310
|
} catch (e) {
|
|
4343
|
-
await cleanupEmptyMintedEpic();
|
|
4344
4311
|
return { ok: false, error: e?.message ?? 'write failed' };
|
|
4345
4312
|
}
|
|
4346
4313
|
},
|
|
@@ -4378,28 +4345,6 @@ const remote = {
|
|
|
4378
4345
|
// reuses the same allocator the file-based /develop authoring path relies
|
|
4379
4346
|
// on implicitly, rather than re-deriving NN here.
|
|
4380
4347
|
allocateParallelGroup,
|
|
4381
|
-
|
|
4382
|
-
async runNow() {
|
|
4383
|
-
await clearPause('run-now');
|
|
4384
|
-
runDueJobs().catch((e) => logs.writeLine({
|
|
4385
|
-
level: 'error', scope: 'scheduler',
|
|
4386
|
-
message: 'runDueJobs error (remote:run-now)', meta: { error: e?.message },
|
|
4387
|
-
}));
|
|
4388
|
-
return { ok: true };
|
|
4389
|
-
},
|
|
4390
|
-
|
|
4391
|
-
async setConfig(partial) {
|
|
4392
|
-
const cfg = await mutate((state) => {
|
|
4393
|
-
const { supervisor: supPartial, ...rest } = partial;
|
|
4394
|
-
state.config = { ...state.config, ...rest };
|
|
4395
|
-
if (supPartial !== undefined) {
|
|
4396
|
-
state.config.supervisor = { ...(state.config.supervisor ?? {}), ...supPartial };
|
|
4397
|
-
}
|
|
4398
|
-
return state.config;
|
|
4399
|
-
});
|
|
4400
|
-
await rescheduleTimer();
|
|
4401
|
-
return { ok: true, config: cfg };
|
|
4402
|
-
},
|
|
4403
4348
|
};
|
|
4404
4349
|
|
|
4405
4350
|
// Registers the two job-management admin HTTP routes (PRD 689 — moved
|
|
@@ -392,7 +392,11 @@ the user may or may not have open.
|
|
|
392
392
|
|
|
393
393
|
### Fallback: writing the PRD file directly
|
|
394
394
|
|
|
395
|
-
When the app is not running, first
|
|
395
|
+
When the app is not running, first join the EXISTING, already-human-approved Epic you're already
|
|
396
|
+
working inside — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> <epic-id>`; its last
|
|
397
|
+
stdout line is the prds dir. This only joins; it never creates an Epic, and errors out if
|
|
398
|
+
`<epic-id>` doesn't already exist — get a human to create/approve the Epic first (New Epic UI, or
|
|
399
|
+
`/propose-epic` + Approve & start) if it doesn't. Then write `<NN>-<slug>.md` by hand into that
|
|
396
400
|
`<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/` dir (the flat `scheduler/prds/` is RETIRED and auto-archived unexecuted at boot), add `sourcePromptId: <epic-id>` to the frontmatter so the job keeps its Epic linkage, following the frontmatter rules in §6 and the
|
|
397
401
|
body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
|
|
398
402
|
`# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -1054,7 +1054,6 @@ function getDispatchMap() {
|
|
|
1054
1054
|
|
|
1055
1055
|
const { manager: ptyManager } = require('./pty.cjs');
|
|
1056
1056
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
1057
|
-
const scheduler = require('./scheduler.cjs');
|
|
1058
1057
|
const { remote: histRemote } = require('./historyAggregator.cjs');
|
|
1059
1058
|
const chatRunner = require('./chatRunner.cjs');
|
|
1060
1059
|
|
|
@@ -1095,37 +1094,6 @@ function getDispatchMap() {
|
|
|
1095
1094
|
return { ok: true };
|
|
1096
1095
|
},
|
|
1097
1096
|
|
|
1098
|
-
'cmd:schedule:state': async () =>
|
|
1099
|
-
scheduler.remote.getState(),
|
|
1100
|
-
|
|
1101
|
-
'cmd:schedule:read-prd': async (payload) => {
|
|
1102
|
-
const parsed = schemas.scheduleSlug.parse(payload);
|
|
1103
|
-
return scheduler.remote.readPrd(parsed.slug);
|
|
1104
|
-
},
|
|
1105
|
-
|
|
1106
|
-
'cmd:schedule:read-log': async (payload) => {
|
|
1107
|
-
const parsed = schemas.scheduleReadLog.parse(payload);
|
|
1108
|
-
return scheduler.remote.readLog(parsed.slug, parsed.runId);
|
|
1109
|
-
},
|
|
1110
|
-
|
|
1111
|
-
'cmd:schedule:write-prd': async (payload) => {
|
|
1112
|
-
const parsed = schemas.scheduleWritePrd.parse(payload);
|
|
1113
|
-
return scheduler.remote.writePrd(parsed.slug, parsed.body);
|
|
1114
|
-
},
|
|
1115
|
-
|
|
1116
|
-
'cmd:schedule:reset-job': async (payload) => {
|
|
1117
|
-
const parsed = schemas.scheduleSlug.parse(payload);
|
|
1118
|
-
return scheduler.remote.resetJob(parsed.slug);
|
|
1119
|
-
},
|
|
1120
|
-
|
|
1121
|
-
'cmd:schedule:run-now': async () =>
|
|
1122
|
-
scheduler.remote.runNow(),
|
|
1123
|
-
|
|
1124
|
-
'cmd:schedule:set-config': async (payload) => {
|
|
1125
|
-
const parsed = schemas.setConfigSchema.default({}).parse(payload ?? {});
|
|
1126
|
-
return scheduler.remote.setConfig(parsed);
|
|
1127
|
-
},
|
|
1128
|
-
|
|
1129
1097
|
'cmd:history:aggregate': async (payload) => {
|
|
1130
1098
|
const parsed = schemas.historyAggregate.parse(payload);
|
|
1131
1099
|
return histRemote.aggregate(parsed);
|