claude-code-session-manager 0.43.0 → 0.44.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-CmDMR4ip.js} +1 -1
- package/dist/assets/{index-6w1gIeyB.js → index-BGEZVIeo.js} +461 -457
- package/dist/index.html +1 -1
- 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
|
@@ -1,36 +1,80 @@
|
|
|
1
1
|
// sessionSlots.cjs — the Session-Manager-owned machine-wide claude -p pool.
|
|
2
2
|
// vitest globals (test/beforeEach/afterEach) — same convention as the other .cjs tests.
|
|
3
3
|
const assert = require('node:assert');
|
|
4
|
+
const fs = require('node:fs');
|
|
4
5
|
const slots = require('../sessionSlots.cjs');
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// Same real-homedir convention as queueStore.cjs's own tests — no path override exists
|
|
8
|
+
// for this module, so keep the persisted file removed except where a test deliberately
|
|
9
|
+
// wants it, and restore it to unset afterward.
|
|
10
|
+
const CONFIG_PATH = require('node:path').join(require('node:os').homedir(), '.claude', 'session-manager', 'session-slots-config.json');
|
|
11
|
+
function removeConfig() {
|
|
12
|
+
try { fs.unlinkSync(CONFIG_PATH); } catch { /* already absent */ }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
slots.__resetForTests();
|
|
17
|
+
removeConfig();
|
|
18
|
+
});
|
|
7
19
|
afterEach(() => {
|
|
8
20
|
slots.__resetForTests();
|
|
9
21
|
delete process.env.SM_SESSION_SLOTS;
|
|
22
|
+
removeConfig();
|
|
10
23
|
});
|
|
11
24
|
|
|
12
|
-
test('pool defaults to
|
|
13
|
-
assert.equal(slots.totalSlots(),
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const t3 = slots.acquire('c');
|
|
17
|
-
assert.ok(t1 && t2 && t3);
|
|
25
|
+
test('pool defaults to 5 slots and exhausts', () => {
|
|
26
|
+
assert.equal(slots.totalSlots(), 5);
|
|
27
|
+
const tokens = ['a', 'b', 'c', 'd', 'e'].map((o) => slots.acquire(o));
|
|
28
|
+
assert.ok(tokens.every(Boolean));
|
|
18
29
|
assert.equal(slots.available(), 0);
|
|
19
|
-
assert.equal(slots.acquire('
|
|
20
|
-
slots.release(
|
|
30
|
+
assert.equal(slots.acquire('f'), null, 'sixth acquire must be refused');
|
|
31
|
+
slots.release(tokens[1]);
|
|
21
32
|
assert.equal(slots.available(), 1);
|
|
22
|
-
assert.ok(slots.acquire('
|
|
33
|
+
assert.ok(slots.acquire('f'));
|
|
23
34
|
});
|
|
24
35
|
|
|
25
|
-
test('SM_SESSION_SLOTS overrides, clamped to [
|
|
36
|
+
test('SM_SESSION_SLOTS overrides, clamped to [0, 10]', () => {
|
|
26
37
|
process.env.SM_SESSION_SLOTS = '1';
|
|
27
38
|
assert.equal(slots.totalSlots(), 1);
|
|
28
|
-
process.env.SM_SESSION_SLOTS = '
|
|
29
|
-
assert.equal(slots.totalSlots(),
|
|
39
|
+
process.env.SM_SESSION_SLOTS = '20';
|
|
40
|
+
assert.equal(slots.totalSlots(), 10, 'clamped high');
|
|
30
41
|
process.env.SM_SESSION_SLOTS = '0';
|
|
31
|
-
assert.equal(slots.totalSlots(),
|
|
42
|
+
assert.equal(slots.totalSlots(), 0, '0 is a legal explicit value (pauses new launches)');
|
|
43
|
+
process.env.SM_SESSION_SLOTS = '-5';
|
|
44
|
+
assert.equal(slots.totalSlots(), 0, 'clamped low');
|
|
32
45
|
process.env.SM_SESSION_SLOTS = 'junk';
|
|
33
|
-
assert.equal(slots.totalSlots(),
|
|
46
|
+
assert.equal(slots.totalSlots(), 5, 'non-numeric falls back to default');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('cap of 0 pauses all new acquisitions without touching existing holders', () => {
|
|
50
|
+
const t = slots.acquire('a');
|
|
51
|
+
assert.ok(t);
|
|
52
|
+
process.env.SM_SESSION_SLOTS = '0';
|
|
53
|
+
assert.equal(slots.acquire('b'), null);
|
|
54
|
+
assert.equal(slots.inUse(), 1, 'existing holder is untouched by the pause');
|
|
55
|
+
slots.release(t);
|
|
56
|
+
assert.equal(slots.inUse(), 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('setCap persists a value that totalSlots() picks up without an env override', () => {
|
|
60
|
+
slots.setCap(8);
|
|
61
|
+
assert.equal(slots.totalSlots(), 8);
|
|
62
|
+
slots.setCap(0);
|
|
63
|
+
assert.equal(slots.totalSlots(), 0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('setCap rejects out-of-range or non-integer values', () => {
|
|
67
|
+
assert.throws(() => slots.setCap(11));
|
|
68
|
+
assert.throws(() => slots.setCap(-1));
|
|
69
|
+
assert.throws(() => slots.setCap(2.5));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('setCap notifies subscribers so a raised cap can wake waiting consumers', () => {
|
|
73
|
+
let notified = 0;
|
|
74
|
+
const unsub = slots.subscribe(() => { notified += 1; });
|
|
75
|
+
slots.setCap(7);
|
|
76
|
+
assert.equal(notified, 1);
|
|
77
|
+
unsub();
|
|
34
78
|
});
|
|
35
79
|
|
|
36
80
|
test('release is idempotent and notifies subscribers exactly on real releases', () => {
|
|
@@ -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
|
+
};
|