claude-code-session-manager 0.35.15 → 0.35.17
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-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/assets/index-DuoC6oCy.js +3559 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +2 -1
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +13 -18
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-B39Qiq0n.css +0 -32
- package/dist/assets/index-DRSHENEg.js +0 -3559
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-DuoC6oCy.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DX2w2YhJ.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.17",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"@vitejs/plugin-react": "^4.3.3",
|
|
121
121
|
"autoprefixer": "^10.4.20",
|
|
122
122
|
"concurrently": "^9.1.0",
|
|
123
|
+
"jsdom": "^29.1.1",
|
|
123
124
|
"postcss": "^8.4.49",
|
|
124
125
|
"react": "^18.3.1",
|
|
125
126
|
"react-dom": "^18.3.1",
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
const { test } = require('node:test');
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
11
11
|
const http = require('node:http');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
12
16
|
const { createAdminServer } = require('../adminServer.cjs');
|
|
17
|
+
const config = require('../config.cjs');
|
|
13
18
|
|
|
14
19
|
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
15
20
|
return new Promise((resolve, reject) => {
|
|
@@ -173,3 +178,203 @@ test('server binds only to 127.0.0.1', async () => {
|
|
|
173
178
|
await admin.stop();
|
|
174
179
|
}
|
|
175
180
|
});
|
|
181
|
+
|
|
182
|
+
// ──────────────────────────────────────────── create-prd
|
|
183
|
+
|
|
184
|
+
const prdParser = require('../scheduler/prdParser.cjs');
|
|
185
|
+
|
|
186
|
+
// config.cjs's validateWrite only allows writes under a registered non-home
|
|
187
|
+
// allowedRoot's `.claude/` subtree (mirrors the real ROOT layout, which is
|
|
188
|
+
// $HOME/.claude/session-manager/...) — so the temp prdsDir must live under
|
|
189
|
+
// `<root>/.claude/...`, not directly under the temp root itself.
|
|
190
|
+
async function mkTmpPrdsDir() {
|
|
191
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-admin-create-prd-'));
|
|
192
|
+
config.addAllowedRoot(root);
|
|
193
|
+
const prdsDir = path.join(root, '.claude', 'prds');
|
|
194
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
195
|
+
return prdsDir;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Mirrors scheduler.cjs's real `remote.allocateParallelGroup` / `remote.writePrd`
|
|
199
|
+
// (same underlying prdParser.allocateParallelGroup + config.writeTextAtomic
|
|
200
|
+
// calls) but pointed at a throwaway temp dir instead of the user's real
|
|
201
|
+
// scheduled-plans/prds — so these tests never touch $HOME/.claude.
|
|
202
|
+
function makeFakeRemoteWithPrdsDir(prdsDir) {
|
|
203
|
+
return {
|
|
204
|
+
async allocateParallelGroup() {
|
|
205
|
+
return prdParser.allocateParallelGroup(prdsDir);
|
|
206
|
+
},
|
|
207
|
+
async readPrd(slug) {
|
|
208
|
+
try {
|
|
209
|
+
const text = await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8');
|
|
210
|
+
return { ok: true, text };
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return { ok: false, error: e?.message };
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
async writePrd(slug, body) {
|
|
216
|
+
try {
|
|
217
|
+
const filePath = path.join(prdsDir, `${slug}.md`);
|
|
218
|
+
await config.writeTextAtomic(filePath, body);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return { ok: false, error: e?.message };
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validCreateBody(overrides = {}) {
|
|
228
|
+
return {
|
|
229
|
+
title: 'Add widget frobnication',
|
|
230
|
+
cwd: os.homedir(),
|
|
231
|
+
estimateMinutes: 15,
|
|
232
|
+
goal: 'Add frobnication to the widget subsystem.',
|
|
233
|
+
acceptanceCriteria: ['widget frobnicates on click', 'timeout 300 npm run typecheck passes'],
|
|
234
|
+
implementationNotes: 'See src/widget.cjs:10.',
|
|
235
|
+
outOfScope: ['not touching gadgets'],
|
|
236
|
+
...overrides,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test('POST /admin/scheduler/create-prd without token returns 401', async () => {
|
|
241
|
+
const remote = makeFakeRemote();
|
|
242
|
+
const admin = createAdminServer(remote);
|
|
243
|
+
const { port } = await admin.start();
|
|
244
|
+
try {
|
|
245
|
+
const res = await request(port, {
|
|
246
|
+
method: 'POST', path: '/admin/scheduler/create-prd', body: validCreateBody(),
|
|
247
|
+
});
|
|
248
|
+
assert.strictEqual(res.status, 401);
|
|
249
|
+
} finally {
|
|
250
|
+
await admin.stop();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
|
|
255
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
256
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
257
|
+
const admin = createAdminServer(remote);
|
|
258
|
+
const { port, token } = await admin.start();
|
|
259
|
+
try {
|
|
260
|
+
const res = await request(port, {
|
|
261
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
|
|
262
|
+
});
|
|
263
|
+
assert.strictEqual(res.status, 200);
|
|
264
|
+
assert.strictEqual(res.json.status, 'queued');
|
|
265
|
+
assert.strictEqual(typeof res.json.nn, 'number');
|
|
266
|
+
assert.match(res.json.filename, /^\d+-add-widget-frobnication\.md$/);
|
|
267
|
+
|
|
268
|
+
const written = await fsp.readFile(path.join(prdsDir, res.json.filename), 'utf8');
|
|
269
|
+
assert.match(written, /^---\n/);
|
|
270
|
+
assert.match(written, /title: Add widget frobnication/);
|
|
271
|
+
assert.match(written, /cwd: .+/);
|
|
272
|
+
assert.match(written, /estimateMinutes: 15/);
|
|
273
|
+
assert.match(written, /# Goal/);
|
|
274
|
+
assert.match(written, /# Acceptance criteria/);
|
|
275
|
+
assert.match(written, /- \[ \] widget frobnicates on click/);
|
|
276
|
+
assert.match(written, /# Implementation notes/);
|
|
277
|
+
assert.match(written, /# Out of scope/);
|
|
278
|
+
assert.match(written, /## Engineering standards/);
|
|
279
|
+
assert.ok(written.includes('Execution discipline'), 'must inline standards.md verbatim');
|
|
280
|
+
} finally {
|
|
281
|
+
await admin.stop();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('POST /admin/scheduler/create-prd with malformed frontmatter (missing acceptanceCriteria) is rejected and writes no file', async () => {
|
|
286
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
287
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
288
|
+
const admin = createAdminServer(remote);
|
|
289
|
+
const { port, token } = await admin.start();
|
|
290
|
+
try {
|
|
291
|
+
const body = validCreateBody();
|
|
292
|
+
delete body.acceptanceCriteria;
|
|
293
|
+
const res = await request(port, {
|
|
294
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token, body,
|
|
295
|
+
});
|
|
296
|
+
assert.strictEqual(res.status, 400);
|
|
297
|
+
assert.strictEqual(res.json.ok, false);
|
|
298
|
+
const entries = await fsp.readdir(prdsDir);
|
|
299
|
+
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
300
|
+
} finally {
|
|
301
|
+
await admin.stop();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('POST /admin/scheduler/create-prd with cwd outside allowed roots is rejected and writes no file', async () => {
|
|
306
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
307
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
308
|
+
const admin = createAdminServer(remote);
|
|
309
|
+
const { port, token } = await admin.start();
|
|
310
|
+
try {
|
|
311
|
+
const res = await request(port, {
|
|
312
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
313
|
+
body: validCreateBody({ cwd: '/etc/passwd-adjacent-outside-home' }),
|
|
314
|
+
});
|
|
315
|
+
assert.strictEqual(res.status, 400);
|
|
316
|
+
assert.strictEqual(res.json.ok, false);
|
|
317
|
+
const entries = await fsp.readdir(prdsDir);
|
|
318
|
+
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
319
|
+
} finally {
|
|
320
|
+
await admin.stop();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('POST /admin/scheduler/create-prd rejects a title containing a newline (frontmatter-injection guard) and writes no file', async () => {
|
|
325
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
326
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
327
|
+
const admin = createAdminServer(remote);
|
|
328
|
+
const { port, token } = await admin.start();
|
|
329
|
+
try {
|
|
330
|
+
const res = await request(port, {
|
|
331
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
332
|
+
body: validCreateBody({ title: 'Legit title\n---\nEvil: injected' }),
|
|
333
|
+
});
|
|
334
|
+
assert.strictEqual(res.status, 400);
|
|
335
|
+
assert.strictEqual(res.json.ok, false);
|
|
336
|
+
const entries = await fsp.readdir(prdsDir);
|
|
337
|
+
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
338
|
+
} finally {
|
|
339
|
+
await admin.stop();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
|
|
344
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
345
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
346
|
+
const admin = createAdminServer(remote);
|
|
347
|
+
const { port, token } = await admin.start();
|
|
348
|
+
try {
|
|
349
|
+
await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
|
|
350
|
+
const res = await request(port, {
|
|
351
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
352
|
+
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
353
|
+
});
|
|
354
|
+
assert.strictEqual(res.status, 409);
|
|
355
|
+
assert.strictEqual(res.json.ok, false);
|
|
356
|
+
const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
|
|
357
|
+
assert.strictEqual(stillThere, 'ORIGINAL CONTENT\n', 'existing PRD file must not be overwritten');
|
|
358
|
+
} finally {
|
|
359
|
+
await admin.stop();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
|
|
364
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
365
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
366
|
+
const admin = createAdminServer(remote);
|
|
367
|
+
const { port, token } = await admin.start();
|
|
368
|
+
try {
|
|
369
|
+
const res = await request(port, {
|
|
370
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
371
|
+
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
372
|
+
});
|
|
373
|
+
assert.strictEqual(res.status, 200);
|
|
374
|
+
assert.strictEqual(res.json.nn, 777);
|
|
375
|
+
assert.strictEqual(res.json.filename, '777-my-explicit-slug.md');
|
|
376
|
+
assert.ok(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md')));
|
|
377
|
+
} finally {
|
|
378
|
+
await admin.stop();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
@@ -68,3 +68,30 @@ test('cancelling a queued run drops it without executing', async () => {
|
|
|
68
68
|
|
|
69
69
|
cr.__setExecutor(null);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
test('a queued silent run never broadcasts chat:run:queued (silent runs stay invisible to the renderer)', async () => {
|
|
73
|
+
const sent = [];
|
|
74
|
+
cr.attachWindow({
|
|
75
|
+
isDestroyed: () => false,
|
|
76
|
+
webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) },
|
|
77
|
+
});
|
|
78
|
+
cr.__setExecutor((j) => new Promise((res) => setImmediate(res)));
|
|
79
|
+
|
|
80
|
+
// P and Q take the two lanes under the default cap; R queues behind them —
|
|
81
|
+
// this is the only path that ever populates chatRunner's `waiting` list,
|
|
82
|
+
// and every job on it is silent (manual runs bypass the queue entirely).
|
|
83
|
+
cr.run(job('P'));
|
|
84
|
+
cr.run(job('Q'));
|
|
85
|
+
cr.run(job('R'));
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < 8; i++) await tick();
|
|
88
|
+
|
|
89
|
+
assert.equal(
|
|
90
|
+
sent.filter((s) => s.channel === 'chat:run:queued').length,
|
|
91
|
+
0,
|
|
92
|
+
'no chat:run:queued broadcast for a silent (automated probe) run',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
cr.__setExecutor(null);
|
|
96
|
+
cr.attachWindow(null);
|
|
97
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-tick-liveness.test.cjs — tick-liveness assertion for the
|
|
3
|
+
* scheduler_queue health check (PRD: health stayed GREEN through the
|
|
4
|
+
* 2026-07-14 16.5h scheduler-tick stall while PRD 544 sat pending with free
|
|
5
|
+
* capacity). Exercises evaluateTickLiveness()/readFreshHeartbeat() directly
|
|
6
|
+
* since they're pure/fs-isolated — no need to mock os.homedir() through the
|
|
7
|
+
* full check() to cover the liveness logic.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 node --test src/main/__tests__/health-tick-liveness.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { test } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const {
|
|
20
|
+
evaluateTickLiveness,
|
|
21
|
+
readFreshHeartbeat,
|
|
22
|
+
TICK_STALL_THRESHOLD_MS,
|
|
23
|
+
} = require('../health.cjs');
|
|
24
|
+
|
|
25
|
+
// Real incident fixture, verified at triage (PRD notes): queue.json had one
|
|
26
|
+
// job pending, running:0, concurrencyCap:3, paused:null, enabled:true, and
|
|
27
|
+
// lastRunAt frozen at the moment the tick died.
|
|
28
|
+
const STALL_LAST_RUN_AT = '2026-07-14T14:40:39.274Z';
|
|
29
|
+
const NOW = Date.parse(STALL_LAST_RUN_AT) + 16.5 * 60 * 60_000; // 16.5h later
|
|
30
|
+
|
|
31
|
+
function baseQueue(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
config: {
|
|
34
|
+
concurrencyCap: 3,
|
|
35
|
+
firePolicy: 'when-available',
|
|
36
|
+
utilizationThreshold: 90,
|
|
37
|
+
enabled: true,
|
|
38
|
+
},
|
|
39
|
+
jobs: [
|
|
40
|
+
{ slug: '544-stuck-job', status: 'pending' },
|
|
41
|
+
],
|
|
42
|
+
lastRunAt: STALL_LAST_RUN_AT,
|
|
43
|
+
paused: null,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('reports stalled: pending job, free capacity, not paused, enabled, tick silent for 16.5h (heartbeat confirms utilization is not the explanation)', () => {
|
|
49
|
+
const heartbeat = { ts: NOW - 30_000, utilization: 0 };
|
|
50
|
+
const result = evaluateTickLiveness(baseQueue(), heartbeat, NOW);
|
|
51
|
+
assert.strictEqual(result.stalled, true);
|
|
52
|
+
assert.strictEqual(result.oldestPendingSlug, '544-stuck-job');
|
|
53
|
+
assert.ok(result.tickAgeMs > TICK_STALL_THRESHOLD_MS);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('no false positive: queue is paused (incl. rate-limit auto-pause)', () => {
|
|
57
|
+
const result = evaluateTickLiveness(
|
|
58
|
+
baseQueue({ paused: { reason: 'rate-limit', resumeAt: '2099-01-01T00:00:00.000Z' } }),
|
|
59
|
+
null,
|
|
60
|
+
NOW
|
|
61
|
+
);
|
|
62
|
+
assert.strictEqual(result.stalled, false);
|
|
63
|
+
assert.strictEqual(result.reason, 'paused');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('no false positive: running is already at concurrencyCap', () => {
|
|
67
|
+
const q = baseQueue({
|
|
68
|
+
jobs: [
|
|
69
|
+
{ slug: 'running-1', status: 'running' },
|
|
70
|
+
{ slug: 'running-2', status: 'running' },
|
|
71
|
+
{ slug: 'running-3', status: 'running' },
|
|
72
|
+
{ slug: '544-stuck-job', status: 'pending' },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const result = evaluateTickLiveness(q, null, NOW);
|
|
76
|
+
assert.strictEqual(result.stalled, false);
|
|
77
|
+
assert.strictEqual(result.reason, 'at-capacity');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('no false positive: scheduler disabled', () => {
|
|
81
|
+
const q = baseQueue({ config: { ...baseQueue().config, enabled: false } });
|
|
82
|
+
const result = evaluateTickLiveness(q, null, NOW);
|
|
83
|
+
assert.strictEqual(result.stalled, false);
|
|
84
|
+
assert.strictEqual(result.reason, 'disabled');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('no false positive: billing utilization at/above utilizationThreshold (when-available is designed to hold)', () => {
|
|
88
|
+
const heartbeat = { ts: NOW - 30_000, utilization: 95 };
|
|
89
|
+
const result = evaluateTickLiveness(baseQueue(), heartbeat, NOW);
|
|
90
|
+
assert.strictEqual(result.stalled, false);
|
|
91
|
+
assert.strictEqual(result.reason, 'utilization-at-threshold');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no false positive: no pending jobs at all', () => {
|
|
95
|
+
const q = baseQueue({ jobs: [] });
|
|
96
|
+
const result = evaluateTickLiveness(q, null, NOW);
|
|
97
|
+
assert.strictEqual(result.stalled, false);
|
|
98
|
+
assert.strictEqual(result.reason, 'no-pending-jobs');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('degrades honestly: heartbeat missing/stale means utilization cannot be ruled out — caveat, not a confident stall', () => {
|
|
102
|
+
const result = evaluateTickLiveness(baseQueue(), null, NOW);
|
|
103
|
+
assert.strictEqual(result.stalled, false);
|
|
104
|
+
assert.strictEqual(result.caveat, true);
|
|
105
|
+
assert.strictEqual(result.reason, 'cannot-verify-utilization');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('does not caveat when firePolicy is not when-available (utilization is irrelevant, so no heartbeat needed)', () => {
|
|
109
|
+
const q = baseQueue({ config: { ...baseQueue().config, firePolicy: 'manual' } });
|
|
110
|
+
const result = evaluateTickLiveness(q, null, NOW);
|
|
111
|
+
assert.strictEqual(result.stalled, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('readFreshHeartbeat: returns null for missing file', () => {
|
|
115
|
+
const result = readFreshHeartbeat(path.join(os.tmpdir(), 'sm-health-test-nonexistent.log'));
|
|
116
|
+
assert.strictEqual(result, null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('readFreshHeartbeat: returns null for a stale (old) heartbeat line', () => {
|
|
120
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-health-heartbeat-'));
|
|
121
|
+
const p = path.join(dir, 'heartbeat.log');
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(p, `${JSON.stringify({ ts: Date.now() - 60 * 60_000, utilization: 10 })}\n`);
|
|
124
|
+
assert.strictEqual(readFreshHeartbeat(p), null);
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('readFreshHeartbeat: returns the parsed last line when fresh', () => {
|
|
131
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-health-heartbeat-'));
|
|
132
|
+
const p = path.join(dir, 'heartbeat.log');
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
p,
|
|
136
|
+
`${JSON.stringify({ ts: Date.now() - 5 * 60_000, utilization: 10 })}\n${JSON.stringify({ ts: Date.now(), utilization: 42 })}\n`
|
|
137
|
+
);
|
|
138
|
+
const entry = readFreshHeartbeat(p);
|
|
139
|
+
assert.strictEqual(entry.utilization, 42);
|
|
140
|
+
} finally {
|
|
141
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prd-group-allocator.test.cjs — regression test for gh-issue-4 / PRD
|
|
3
|
+
* (allocateParallelGroup): two projects authoring PRDs concurrently under
|
|
4
|
+
* the shared prds/ directory both computed the same max-NN and allocated
|
|
5
|
+
* the same new parallel group (05-11 vs 06-09 on 2026-07-14, then 545 vs
|
|
6
|
+
* 545 again on 2026-07-15 between session-manager and sigma). NN is both
|
|
7
|
+
* the filename counter and the parallel-group key the scheduler parses,
|
|
8
|
+
* so a collision silently merges two unrelated projects' PRDs into one
|
|
9
|
+
* group.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 node --test src/main/__tests__/prd-group-allocator.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { test } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const fsp = require('node:fs/promises');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const {
|
|
23
|
+
allocateParallelGroup,
|
|
24
|
+
maxParallelGroupInUse,
|
|
25
|
+
parsePrdRaw,
|
|
26
|
+
_resetCache,
|
|
27
|
+
} = require('../scheduler/prdParser.cjs');
|
|
28
|
+
|
|
29
|
+
function mkTmpPrdsDir() {
|
|
30
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-alloc-'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('allocateParallelGroup returns max+1 on an empty directory', async () => {
|
|
34
|
+
const dir = await mkTmpPrdsDir();
|
|
35
|
+
const nn = await allocateParallelGroup(dir);
|
|
36
|
+
assert.strictEqual(nn, 1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('allocateParallelGroup scans the FULL directory, not a narrowed glob — reproduces the 577-PRD/max-544 live directory shape', async () => {
|
|
40
|
+
const dir = await mkTmpPrdsDir();
|
|
41
|
+
// Live directory (2026-07-18) holds an unpadded legacy name and a max of 544.
|
|
42
|
+
await fsp.writeFile(path.join(dir, '54-scheduler-memory-guard-cap3.md'), '# x\n');
|
|
43
|
+
await fsp.writeFile(path.join(dir, '110-some-slug.md'), '# x\n'); // would be missed by '^10[0-9]'
|
|
44
|
+
await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
|
|
45
|
+
await fsp.writeFile(path.join(dir, '9-old-single-digit.md'), '# x\n');
|
|
46
|
+
|
|
47
|
+
const max = await maxParallelGroupInUse(dir);
|
|
48
|
+
assert.strictEqual(max, 544, 'must see 544 as the max even though 110 sorts before it lexically and 54/9 are unpadded');
|
|
49
|
+
|
|
50
|
+
const nn = await allocateParallelGroup(dir);
|
|
51
|
+
assert.strictEqual(nn, 545);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('two concurrent allocateParallelGroup callers never receive the same new NN (the core race from the incident)', async () => {
|
|
55
|
+
const dir = await mkTmpPrdsDir();
|
|
56
|
+
await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
|
|
57
|
+
|
|
58
|
+
const CONCURRENCY = 12;
|
|
59
|
+
const results = await Promise.all(
|
|
60
|
+
Array.from({ length: CONCURRENCY }, () => allocateParallelGroup(dir)),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const distinct = new Set(results);
|
|
64
|
+
assert.strictEqual(distinct.size, CONCURRENCY, `expected ${CONCURRENCY} distinct NNs, got collisions: ${results.join(',')}`);
|
|
65
|
+
for (const nn of results) {
|
|
66
|
+
assert.ok(nn > 544, `allocated NN ${nn} must not collide with existing on-disk NN 544`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('reservation markers are counted toward max-NN so a crash mid-allocation cannot be re-issued by a later caller', async () => {
|
|
71
|
+
const dir = await mkTmpPrdsDir();
|
|
72
|
+
await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
|
|
73
|
+
|
|
74
|
+
const first = await allocateParallelGroup(dir); // 545, leaves .reserved-545 on disk
|
|
75
|
+
assert.strictEqual(first, 545);
|
|
76
|
+
|
|
77
|
+
// Simulate the caller never authoring 545-slug.md (crash between reserve
|
|
78
|
+
// and author). A later allocation must still move past 545, not reissue it.
|
|
79
|
+
const second = await allocateParallelGroup(dir);
|
|
80
|
+
assert.strictEqual(second, 546);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('deliberate parallel siblings sharing one existing NN are unaffected by the allocator (main regression risk)', async () => {
|
|
84
|
+
const dir = await mkTmpPrdsDir();
|
|
85
|
+
// Three independent PRDs deliberately opting into the SAME group, exactly
|
|
86
|
+
// as /develop's SKILL.md contract allows — authored directly by filename,
|
|
87
|
+
// never through allocateParallelGroup.
|
|
88
|
+
await fsp.writeFile(path.join(dir, '545-scheduler-tick-stall-after-job-failure.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody A\n');
|
|
89
|
+
await fsp.writeFile(path.join(dir, '545-filetree-show-hidden-default-and-persist.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody B\n');
|
|
90
|
+
await fsp.writeFile(path.join(dir, '545-editor-tab-close-shortcut-and-close-right.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody C\n');
|
|
91
|
+
|
|
92
|
+
const files = await fsp.readdir(dir);
|
|
93
|
+
const parsed = await Promise.all(
|
|
94
|
+
files.filter((f) => f.endsWith('.md')).map((f) => parsePrdRaw(path.join(dir, f))),
|
|
95
|
+
);
|
|
96
|
+
assert.strictEqual(parsed.length, 3);
|
|
97
|
+
for (const p of parsed) {
|
|
98
|
+
assert.strictEqual(p.parallelGroup, 545, 'siblings must keep sharing group 545 — the allocator must not renumber existing files');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A brand-new PRD (not a deliberate sibling) allocated afterward must NOT
|
|
102
|
+
// land in 545 — it gets the next free group instead.
|
|
103
|
+
const nn = await allocateParallelGroup(dir);
|
|
104
|
+
assert.strictEqual(nn, 546);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('module-level PRD dir-mtime cache in prdParser is unaffected by reservation markers (hidden files stay invisible to listPrdFiles)', async () => {
|
|
108
|
+
const dir = await mkTmpPrdsDir();
|
|
109
|
+
_resetCache();
|
|
110
|
+
await fsp.writeFile(path.join(dir, '1-a.md'), '# x\n');
|
|
111
|
+
const { listPrdFiles } = require('../scheduler/prdParser.cjs');
|
|
112
|
+
const before = await listPrdFiles(dir);
|
|
113
|
+
assert.strictEqual(before.length, 1);
|
|
114
|
+
|
|
115
|
+
await allocateParallelGroup(dir); // writes a .reserved-2 marker
|
|
116
|
+
_resetCache();
|
|
117
|
+
const after = await listPrdFiles(dir);
|
|
118
|
+
assert.strictEqual(after.length, 1, 'reservation markers must not appear as PRD files');
|
|
119
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdCreate.test.cjs — unit tests for the create-PRD body builder (PRD 549,
|
|
3
|
+
* gh-issue-6). Pure-function tests only; the HTTP route + auth/cwd-boundary
|
|
4
|
+
* behavior is covered by adminServer.test.cjs.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 node --test src/main/__tests__/prdCreate.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { test } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const {
|
|
15
|
+
deriveSlugFromTitle,
|
|
16
|
+
buildPrdBody,
|
|
17
|
+
readStandards,
|
|
18
|
+
PRD_CREATE_SLUG_RE,
|
|
19
|
+
} = require('../lib/prdCreate.cjs');
|
|
20
|
+
|
|
21
|
+
test('deriveSlugFromTitle lowercases, kebab-cases, and strips non-alnum runs', () => {
|
|
22
|
+
assert.strictEqual(deriveSlugFromTitle('Add Foo Bar!! Baz'), 'add-foo-bar-baz');
|
|
23
|
+
assert.strictEqual(deriveSlugFromTitle(' leading/trailing '), 'leading-trailing');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('deriveSlugFromTitle output always satisfies PRD_CREATE_SLUG_RE', () => {
|
|
27
|
+
const slug = deriveSlugFromTitle('Some Title 123');
|
|
28
|
+
assert.ok(PRD_CREATE_SLUG_RE.test(slug));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('readStandards reads the real standards.md and returns non-empty text', async () => {
|
|
32
|
+
const text = await readStandards();
|
|
33
|
+
assert.ok(text.includes('Execution discipline'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('buildPrdBody emits required frontmatter keys and body sections in order', async () => {
|
|
37
|
+
const standards = await readStandards();
|
|
38
|
+
const body = buildPrdBody({
|
|
39
|
+
title: 'Do the thing',
|
|
40
|
+
cwd: '~/Projects/session-manager',
|
|
41
|
+
estimateMinutes: 15,
|
|
42
|
+
goal: 'Build the thing.',
|
|
43
|
+
acceptanceCriteria: ['thing exists', 'tests pass'],
|
|
44
|
+
implementationNotes: 'See file.cjs:10.',
|
|
45
|
+
outOfScope: ['not this'],
|
|
46
|
+
}, standards);
|
|
47
|
+
|
|
48
|
+
assert.ok(body.startsWith('---\n'));
|
|
49
|
+
assert.match(body, /title: Do the thing/);
|
|
50
|
+
assert.match(body, /cwd: ~\/Projects\/session-manager/);
|
|
51
|
+
assert.match(body, /estimateMinutes: 15/);
|
|
52
|
+
|
|
53
|
+
const goalIdx = body.indexOf('# Goal');
|
|
54
|
+
const acIdx = body.indexOf('# Acceptance criteria');
|
|
55
|
+
const implIdx = body.indexOf('# Implementation notes');
|
|
56
|
+
const oosIdx = body.indexOf('# Out of scope');
|
|
57
|
+
const standardsIdx = body.indexOf('## Engineering standards');
|
|
58
|
+
assert.ok(goalIdx > 0 && goalIdx < acIdx && acIdx < implIdx && implIdx < oosIdx && oosIdx < standardsIdx,
|
|
59
|
+
'sections must appear in Goal -> AC -> Implementation notes -> Out of scope -> Engineering standards order');
|
|
60
|
+
|
|
61
|
+
assert.match(body, /- \[ \] thing exists/);
|
|
62
|
+
assert.match(body, /- \[ \] tests pass/);
|
|
63
|
+
assert.match(body, /- not this/);
|
|
64
|
+
assert.ok(body.includes('Execution discipline'), 'must inline the standards.md content verbatim');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () => {
|
|
68
|
+
const body = buildPrdBody({
|
|
69
|
+
title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
|
|
70
|
+
acceptanceCriteria: ['a'], implementationNotes: 'n',
|
|
71
|
+
}, 'standards text');
|
|
72
|
+
assert.ok(!/parallelGroup:/.test(body));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('readStandards result is byte-identical to the on-disk file (single source of truth)', async () => {
|
|
76
|
+
const path = require('node:path');
|
|
77
|
+
const onDisk = fs.readFileSync(
|
|
78
|
+
path.join(__dirname, '..', '..', '..', 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md'),
|
|
79
|
+
'utf8',
|
|
80
|
+
);
|
|
81
|
+
assert.strictEqual(await readStandards(), onDisk);
|
|
82
|
+
});
|