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.
@@ -14,7 +14,7 @@ const fs = require('node:fs');
14
14
  const fsp = require('node:fs/promises');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
- const { ensureEpic, removeEpic, readActiveIndex } = require('../lib/epicMint.cjs');
17
+ const { ensureEpic, removeEpic, readActiveIndex, findJoinableEpic } = require('../lib/epicMint.cjs');
18
18
 
19
19
  const tmpDirs = [];
20
20
  afterEach(async () => {
@@ -83,3 +83,134 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
83
83
  expect(removed).toBe(false);
84
84
  expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
85
85
  });
86
+
87
+ // A plain object's bracket lookup with a prototype-chain key like
88
+ // "__proto__"/"constructor" returns a truthy Object.prototype member even
89
+ // when no such Epic was ever written — without an own-property check, this
90
+ // would make mintIfMissing:false's "join an existing Epic" gate falsely
91
+ // report a match, defeating the restriction that a PRD write may only join
92
+ // a real, already-approved Epic.
93
+ for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
94
+ test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}" (mintIfMissing:false)`, async () => {
95
+ const cwd = await mkCwd();
96
+
97
+ await expect(
98
+ ensureEpic(cwd, { epicId: pollutedKey, mintIfMissing: false }),
99
+ ).rejects.toThrow(/no existing Epic found/);
100
+
101
+ const index = readActiveIndex(cwd);
102
+ expect(Object.keys(index.sessions)).toHaveLength(0);
103
+ });
104
+ }
105
+
106
+ test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
107
+ const cwd = await mkCwd();
108
+ const removed = removeEpic(cwd, '__proto__');
109
+ expect(removed).toBe(false);
110
+ });
111
+
112
+ test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
113
+ const cwd = await mkCwd();
114
+ const source = { producer: 'rca-hook', prdSlug: 'x', runId: 'y' };
115
+
116
+ const minted = await ensureEpic(cwd, { goalText: 'auto-filed epic', source });
117
+
118
+ const index = readActiveIndex(cwd);
119
+ expect(index.sessions[minted.epicId].source).toEqual(source);
120
+ });
121
+
122
+ test('ensureEpic omits source from the written record when not passed', async () => {
123
+ const cwd = await mkCwd();
124
+
125
+ const minted = await ensureEpic(cwd, { goalText: 'human-filed epic' });
126
+
127
+ const index = readActiveIndex(cwd);
128
+ expect('source' in index.sessions[minted.epicId]).toBe(false);
129
+ });
130
+
131
+ test('ensureEpic joins an existing open Epic when goalText is a near-identical paraphrase (no preferEpicId)', async () => {
132
+ const cwd = await mkCwd();
133
+
134
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
135
+ expect(first.created).toBe(true);
136
+
137
+ const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue' });
138
+
139
+ expect(second.created).toBe(false);
140
+ expect(second.epicId).toBe(first.epicId);
141
+
142
+ const index = readActiveIndex(cwd);
143
+ expect(Object.keys(index.sessions)).toHaveLength(1);
144
+ });
145
+
146
+ test('ensureEpic mints a distinct Epic when goalText is genuinely unrelated', async () => {
147
+ const cwd = await mkCwd();
148
+
149
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
150
+ expect(first.created).toBe(true);
151
+
152
+ const second = await ensureEpic(cwd, { goalText: 'Add CSV export to History tab' });
153
+
154
+ expect(second.created).toBe(true);
155
+ expect(second.epicId).not.toBe(first.epicId);
156
+
157
+ const index = readActiveIndex(cwd);
158
+ expect(Object.keys(index.sessions)).toHaveLength(2);
159
+ });
160
+
161
+ test('findJoinableEpic joins the preferEpicId Epic even when goalText similarity is near-zero', async () => {
162
+ const cwd = await mkCwd();
163
+
164
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
165
+ expect(first.created).toBe(true);
166
+
167
+ const joined = findJoinableEpic(cwd, {
168
+ goalText: 'Completely unrelated topic about database migrations',
169
+ preferEpicId: first.epicId,
170
+ });
171
+
172
+ expect(joined).toEqual({ epicId: first.epicId, matchedBy: 'preferEpicId' });
173
+ });
174
+
175
+ test('findJoinableEpic falls through to the similarity check when preferEpicId points at a completed Epic', async () => {
176
+ const cwd = await mkCwd();
177
+
178
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
179
+ const index = readActiveIndex(cwd);
180
+ index.sessions[first.epicId].status = 'completed';
181
+ fs.writeFileSync(
182
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
183
+ JSON.stringify(index, null, 2),
184
+ );
185
+
186
+ // No other open Epic shares tokens with the completed one, so the
187
+ // fall-through similarity check finds nothing rather than joining the
188
+ // dead (completed) preferEpicId Epic.
189
+ const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: first.epicId });
190
+
191
+ expect(joined).toBeNull();
192
+ });
193
+
194
+ test('findJoinableEpic falls through to the similarity check when preferEpicId points at a nonexistent Epic, and can still find a match there', async () => {
195
+ const cwd = await mkCwd();
196
+ const sibling = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
197
+
198
+ const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: 'nonexistent-epic-id' });
199
+
200
+ expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
201
+ });
202
+
203
+ test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
204
+ const cwd = await mkCwd();
205
+
206
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
207
+ expect(first.created).toBe(true);
208
+
209
+ const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue', forceNewEpic: true });
210
+
211
+ expect(second.created).toBe(true);
212
+ expect(second.epicId).not.toBe(first.epicId);
213
+
214
+ const index = readActiveIndex(cwd);
215
+ expect(Object.keys(index.sessions)).toHaveLength(2);
216
+ });
@@ -147,6 +147,87 @@ test('fileRcaFeedback: files into the job\'s own project, no feedback dir requir
147
147
  expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
148
148
  });
149
149
 
150
+ // ─── epic targeting (job.epicId → preferred join target) ───────────────────
151
+
152
+ function writeActiveIndex(cwd, sessions) {
153
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
154
+ fs.mkdirSync(dir, { recursive: true });
155
+ const events = {};
156
+ for (const id of Object.keys(sessions)) {
157
+ events[id] = [{ id: `${id}-e0`, promptSessionId: id, kind: 'prompt', causedByEventId: null, at: new Date(0).toISOString(), text: sessions[id].goalText }];
158
+ }
159
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events }));
160
+ }
161
+
162
+ test('fileRcaFeedback: joins the job\'s known origin Epic when it is still open, and tags source', async () => {
163
+ const cwd = makeProjectWithInbox();
164
+ const runDir = path.join(tmpHome, 'run1');
165
+ writeRun(runDir, 'testslug');
166
+ writePrd(cwd, 'testslug');
167
+
168
+ writeActiveIndex(cwd, {
169
+ 'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'proposed', createdAt: new Date(0).toISOString(), completedAt: null },
170
+ });
171
+
172
+ const res = await rcaFeedbackHook.fileRcaFeedback({
173
+ job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
174
+ });
175
+
176
+ expect(res.filed).toBe(true);
177
+ expect(res.epicId).toBe('origin-epic-1');
178
+ expect(res.created).toBe(false);
179
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
180
+ });
181
+
182
+ test('fileRcaFeedback: does not join a completed origin Epic, falls through to mint', async () => {
183
+ const cwd = makeProjectWithInbox();
184
+ const runDir = path.join(tmpHome, 'run1');
185
+ writeRun(runDir, 'testslug');
186
+ writePrd(cwd, 'testslug');
187
+
188
+ writeActiveIndex(cwd, {
189
+ 'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'completed', createdAt: new Date(0).toISOString(), completedAt: new Date(0).toISOString() },
190
+ });
191
+
192
+ const res = await rcaFeedbackHook.fileRcaFeedback({
193
+ job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
194
+ });
195
+
196
+ expect(res.filed).toBe(true);
197
+ expect(res.epicId).not.toBe('origin-epic-1');
198
+ expect(res.created).toBe(true);
199
+ const sessions = readSessions(cwd);
200
+ expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
201
+ });
202
+
203
+ test('fileRcaFeedback: a nonexistent job.epicId falls through to mint without erroring', async () => {
204
+ const cwd = makeProjectWithInbox();
205
+ const runDir = path.join(tmpHome, 'run1');
206
+ writeRun(runDir, 'testslug');
207
+ writePrd(cwd, 'testslug');
208
+
209
+ const res = await rcaFeedbackHook.fileRcaFeedback({
210
+ job: baseJob({ cwd, epicId: 'does-not-exist' }), runDir, verdict: 'transcript_errors',
211
+ });
212
+
213
+ expect(res.filed).toBe(true);
214
+ expect(res.epicId).not.toBe('does-not-exist');
215
+ expect(res.created).toBe(true);
216
+ });
217
+
218
+ test('fileRcaFeedback: no job.epicId preserves current mint-or-reuseByGoal behavior', async () => {
219
+ const cwd = makeProjectWithInbox();
220
+ const runDir = path.join(tmpHome, 'run1');
221
+ writeRun(runDir, 'testslug');
222
+ writePrd(cwd, 'testslug');
223
+
224
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
225
+ expect(res.filed).toBe(true);
226
+ expect(res.created).toBe(true);
227
+ const sessions = readSessions(cwd);
228
+ expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
229
+ });
230
+
150
231
  test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
151
232
  const cwd = makeProjectWithInbox();
152
233
  const runDir = path.join(tmpHome, 'run1');
@@ -1,14 +1,13 @@
1
1
  /**
2
- * scheduler-writeprd-epic-rollback.test.cjs — PRD 851: when remote.writePrd
3
- * mints a brand-new Epic via ensureEpic (epicMint.cjs) but the PRD file write
4
- * itself then fails, cleanupEmptyMintedEpic must roll back BOTH the on-disk
5
- * empty epic dir AND the Epic's active-index.json entry (sessions/events) —
6
- * otherwise an orphaned seed-prompt-only Epic lingers forever in the Epics
7
- * nav with no PRDs and no cleanup path.
2
+ * scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
3
+ * an existing, already-human-approved Epic (via `sourcePromptId`); it must
4
+ * never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
5
+ * A write with no resolvable Epic fails outright with zero residual
6
+ * active-index.json entries — no orphaned Epic can ever be left behind by
7
+ * this path because none is ever created by it.
8
8
  *
9
- * The rollback must only fire for a call that minted a FRESH Epic
10
- * (ensureEpic's `created: true`); a PRD write that joins an existing Epic
11
- * and then fails must leave that Epic's history untouched.
9
+ * A PRD write that joins a pre-existing Epic and then fails (e.g. an invalid
10
+ * slug) must leave that Epic's history untouched.
12
11
  *
13
12
  * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
14
13
  */
@@ -21,6 +20,7 @@ const os = require('node:os');
21
20
  const path = require('node:path');
22
21
  const { remote } = require('../scheduler.cjs');
23
22
  const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
23
+ const config = require('../config.cjs');
24
24
 
25
25
  const tmpDirs = [];
26
26
 
@@ -38,18 +38,20 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  // A slug that escapes safeSlugPathIn's containment check, tripping the
41
- // "invalid slug" branch of writePrd (scheduler.cjs ~4188-4191) after
42
- // ensureEpic has already minted+persisted the Epic.
41
+ // "invalid slug" branch of writePrd (scheduler.cjs's writePrd). Only the
42
+ // second test below (joining a pre-existing Epic) actually reaches this
43
+ // check — the first test fails earlier, at Epic resolution, before the slug
44
+ // is ever considered.
43
45
  const ESCAPING_SLUG = '../escape-attempt';
44
46
 
45
- test('writePrd: invalid-slug failure after a fresh Epic mint leaves zero residual active-index.json entries', async () => {
47
+ test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints nothing', async () => {
46
48
  const cwd = makeFixtureCwd();
47
- const body = '---\ntitle: Freshly minted epic goal\n---\n\nbody text\n';
49
+ const body = '---\ntitle: No epic given\n---\n\nbody text\n';
48
50
 
49
51
  const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
50
52
 
51
53
  expect(result.ok).toBe(false);
52
- expect(result.error).toBe('invalid slug');
54
+ expect(result.error).toMatch(/no existing Epic to join/);
53
55
 
54
56
  const index = readActiveIndex(cwd);
55
57
  expect(Object.keys(index.sessions)).toHaveLength(0);
@@ -76,3 +78,28 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
76
78
  expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
77
79
  expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
78
80
  });
81
+
82
+ // Provenance (PRD 905): writePrd's ensureEpic call passes
83
+ // source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
84
+ // join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
85
+ // explicitEpicId branch returns before ever reading `source`, so a
86
+ // successful join must NOT write/alter the joined Epic's `source` field.
87
+ test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
88
+ // Must live under the home dir — config.cjs's writeTextAtomic validates
89
+ // paths against allowedRoots=home dir, unlike the failure-path tests above
90
+ // (which never reach the write) os.tmpdir() is not sufficient here.
91
+ const cwd = fs.mkdtempSync(path.join(os.homedir(), '.sm-writeprd-epic-source-'));
92
+ tmpDirs.push(cwd);
93
+ config.addAllowedRoot(cwd);
94
+
95
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
96
+ expect(preexisting.created).toBe(true);
97
+
98
+ const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
99
+ const result = await remote.writePrd('valid-slug', body, cwd);
100
+
101
+ expect(result.ok).toBe(true);
102
+
103
+ const index = readActiveIndex(cwd);
104
+ expect('source' in index.sessions[preexisting.epicId]).toBe(false);
105
+ });
@@ -377,6 +377,14 @@ const setConfigSchema = z.object({
377
377
  }).optional(),
378
378
  }).strict();
379
379
 
380
+ // Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
381
+ // schema/channel rather than folded into setConfigSchema: it governs the shared
382
+ // process pool, not any one project's scheduler config, and its range (0-10, 0
383
+ // pauses new launches) differs from concurrencyCap's (1-20).
384
+ const setSessionSlotsSchema = z.object({
385
+ cap: z.number().int().min(0).max(10),
386
+ }).strict();
387
+
380
388
  // ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
381
389
  // Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
382
390
  // Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
@@ -754,9 +762,6 @@ const READ_COMMANDS = new Set([
754
762
  // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
755
763
  const SAS_GATED_READS = new Set([
756
764
  'cmd:sessions:load',
757
- 'cmd:schedule:state',
758
- 'cmd:schedule:read-prd',
759
- 'cmd:schedule:read-log',
760
765
  'cmd:history:aggregate',
761
766
  // subscribe initiates a live stream of session state/summary — sensitive.
762
767
  'cmd:session:subscribe',
@@ -764,6 +769,12 @@ const SAS_GATED_READS = new Set([
764
769
  // no dispatch handler for it, so an allowlist entry would only fail closed with
765
770
  // an opaque reject. Re-add here together with the handler when remote exchanges
766
771
  // are wired, so the allowlist always mirrors an actual capability.
772
+ //
773
+ // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
774
+ // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
775
+ // internals at all; a future Remote rebuild will talk to Scheduler/Epics
776
+ // through whatever higher-level surface that redesign lands on, not this
777
+ // per-command allowlist.
767
778
  ]);
768
779
 
769
780
  const MUTATE_COMMANDS = new Set([
@@ -776,10 +787,6 @@ const MUTATE_COMMANDS = new Set([
776
787
  // mobile mirror has no business killing or resizing the desktop's session.
777
788
  'cmd:pty:kill',
778
789
  'cmd:pty:resize',
779
- 'cmd:schedule:write-prd',
780
- 'cmd:schedule:reset-job',
781
- 'cmd:schedule:run-now',
782
- 'cmd:schedule:set-config',
783
790
  // Pushes a prompt into an open tab's chat queue — a real mutation of that
784
791
  // tab's session, same tier as pty:write.
785
792
  'cmd:chat:send',
@@ -839,6 +846,7 @@ module.exports = {
839
846
  scheduleArchivePrd,
840
847
  scheduleRetagPrd,
841
848
  setConfigSchema,
849
+ setSessionSlotsSchema,
842
850
  shellOpen,
843
851
  archiveProject,
844
852
  historyAggregate,
@@ -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
- beforeEach(() => slots.__resetForTests());
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 3 slots and exhausts', () => {
13
- assert.equal(slots.totalSlots(), 3);
14
- const t1 = slots.acquire('a');
15
- const t2 = slots.acquire('b');
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('d'), null, 'fourth acquire must be refused');
20
- slots.release(t2);
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('d'));
33
+ assert.ok(slots.acquire('f'));
23
34
  });
24
35
 
25
- test('SM_SESSION_SLOTS overrides, clamped to [1,3]', () => {
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 = '9';
29
- assert.equal(slots.totalSlots(), 3, 'clamped high');
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(), 1, 'clamped low');
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(), 3, 'non-numeric falls back to default');
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', () => {