claude-code-session-manager 0.58.0 → 0.60.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/README.md +2 -2
- package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-BtVPGBaq.js} +1 -1
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +9 -2
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +26 -3
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +36 -0
- package/src/main/lib/epicMint.cjs +6 -1
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +16 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/scheduler.cjs +228 -49
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +75 -9
- package/src/preload/index.cjs +3 -0
|
@@ -170,3 +170,163 @@ test('migratePrds moves multiple PRDs to different projects independently', asyn
|
|
|
170
170
|
expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdA), '05-a.md'))).toBe(true);
|
|
171
171
|
expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdB), '06-b.md'))).toBe(true);
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------------ *
|
|
175
|
+
* PRD 992 — consolidateFlatPrds must not archive a PRD with a live queue job.
|
|
176
|
+
*
|
|
177
|
+
* The scheduler still scans the retired flat `scheduler/prds/` dir as a PRD
|
|
178
|
+
* *source* (prdLocations.cjs's resolvePrdsDirs), so a file there can have a
|
|
179
|
+
* pending/running job. Consolidation used to move every .md unconditionally,
|
|
180
|
+
* stranding that job with no resolvable source. Observed live 2026-08-02:
|
|
181
|
+
* 980-fix-chat-typed-event-renderers.md sat there with status `running`.
|
|
182
|
+
* ------------------------------------------------------------------------ */
|
|
183
|
+
|
|
184
|
+
const { consolidateFlatPrds, LIVE_JOB_STATUSES } = require('../lib/prdMigration.cjs');
|
|
185
|
+
|
|
186
|
+
function opsRoot(root) {
|
|
187
|
+
return path.join(root, 'session-manager-operations', 'scheduler');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function makeFlatProject(root, files) {
|
|
191
|
+
const flat = path.join(opsRoot(root), 'prds');
|
|
192
|
+
await fsp.mkdir(flat, { recursive: true });
|
|
193
|
+
for (const name of files) {
|
|
194
|
+
await fsp.writeFile(path.join(flat, name), `---\ntitle: ${name}\n---\n# Goal\n`, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
return flat;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function writeQueue(root, jobs) {
|
|
200
|
+
const stateDir = path.join(opsRoot(root), 'state');
|
|
201
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
202
|
+
await fsp.writeFile(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }), 'utf8');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function newRoot() {
|
|
206
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-consolidate-'));
|
|
207
|
+
tmpDirs.push(d);
|
|
208
|
+
return d;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const status of LIVE_JOB_STATUSES) {
|
|
212
|
+
test(`consolidateFlatPrds leaves a file in place when its job is ${status}`, async () => {
|
|
213
|
+
const root = newRoot();
|
|
214
|
+
const flat = await makeFlatProject(root, ['980-fix-chat-typed-event-renderers.md']);
|
|
215
|
+
await writeQueue(root, [{ slug: '980-fix-chat-typed-event-renderers', status }]);
|
|
216
|
+
|
|
217
|
+
const r = await consolidateFlatPrds(root);
|
|
218
|
+
|
|
219
|
+
expect(r.moved).toBe(0);
|
|
220
|
+
expect(r.skipped).toEqual([
|
|
221
|
+
{ file: '980-fix-chat-typed-event-renderers.md', reason: 'live queue job — source must survive' },
|
|
222
|
+
]);
|
|
223
|
+
expect(fs.existsSync(path.join(flat, '980-fix-chat-typed-event-renderers.md'))).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const status of ['completed', 'failed']) {
|
|
228
|
+
test(`consolidateFlatPrds archives a file whose job is ${status}`, async () => {
|
|
229
|
+
const root = newRoot();
|
|
230
|
+
const flat = await makeFlatProject(root, ['700-done.md']);
|
|
231
|
+
await writeQueue(root, [{ slug: '700-done', status }]);
|
|
232
|
+
|
|
233
|
+
const r = await consolidateFlatPrds(root);
|
|
234
|
+
|
|
235
|
+
expect(r.moved).toBe(1);
|
|
236
|
+
expect(r.skipped).toEqual([]);
|
|
237
|
+
expect(fs.existsSync(path.join(flat, '700-done.md'))).toBe(false);
|
|
238
|
+
expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '700-done.md'))).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('consolidateFlatPrds archives a file with no job at all', async () => {
|
|
243
|
+
const root = newRoot();
|
|
244
|
+
await makeFlatProject(root, ['701-orphan.md']);
|
|
245
|
+
await writeQueue(root, []);
|
|
246
|
+
|
|
247
|
+
const r = await consolidateFlatPrds(root);
|
|
248
|
+
|
|
249
|
+
expect(r.moved).toBe(1);
|
|
250
|
+
expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '701-orphan.md'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('consolidateFlatPrds mixed dir: only the live one survives', async () => {
|
|
254
|
+
const root = newRoot();
|
|
255
|
+
const flat = await makeFlatProject(root, ['800-live.md', '801-done.md', '802-none.md']);
|
|
256
|
+
await writeQueue(root, [
|
|
257
|
+
{ slug: '800-live', status: 'running' },
|
|
258
|
+
{ slug: '801-done', status: 'completed' },
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
const r = await consolidateFlatPrds(root);
|
|
262
|
+
|
|
263
|
+
expect(r.moved).toBe(2);
|
|
264
|
+
expect(r.skipped.map((s) => s.file)).toEqual(['800-live.md']);
|
|
265
|
+
expect(fs.existsSync(path.join(flat, '800-live.md'))).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('consolidateFlatPrds never touches dotfiles (NN allocation still uses them)', async () => {
|
|
269
|
+
const root = newRoot();
|
|
270
|
+
const flat = await makeFlatProject(root, ['900-x.md']);
|
|
271
|
+
await fsp.writeFile(path.join(flat, '.max-allocated-group'), '992', 'utf8');
|
|
272
|
+
|
|
273
|
+
await consolidateFlatPrds(root, { liveSlugs: new Set() });
|
|
274
|
+
|
|
275
|
+
expect(fs.existsSync(path.join(flat, '.max-allocated-group'))).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('consolidateFlatPrds is idempotent and creates no -legacy- duplicate', async () => {
|
|
279
|
+
const root = newRoot();
|
|
280
|
+
await makeFlatProject(root, ['910-a.md']);
|
|
281
|
+
await writeQueue(root, []);
|
|
282
|
+
|
|
283
|
+
const first = await consolidateFlatPrds(root);
|
|
284
|
+
const second = await consolidateFlatPrds(root);
|
|
285
|
+
|
|
286
|
+
expect(first.moved).toBe(1);
|
|
287
|
+
expect(second.moved).toBe(0);
|
|
288
|
+
expect(fs.readdirSync(path.join(opsRoot(root), 'prds-archived'))).toEqual(['910-a.md']);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('consolidateFlatPrds protects a live job across repeat runs, not just the first', async () => {
|
|
292
|
+
const root = newRoot();
|
|
293
|
+
await makeFlatProject(root, ['920-live.md']);
|
|
294
|
+
await writeQueue(root, [{ slug: '920-live', status: 'pending' }]);
|
|
295
|
+
|
|
296
|
+
await consolidateFlatPrds(root);
|
|
297
|
+
const second = await consolidateFlatPrds(root);
|
|
298
|
+
|
|
299
|
+
expect(second.moved).toBe(0);
|
|
300
|
+
expect(second.skipped).toHaveLength(1);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('consolidateFlatPrds treats an absent queue.json as "no jobs", not an error', async () => {
|
|
304
|
+
const root = newRoot();
|
|
305
|
+
await makeFlatProject(root, ['930-fresh.md']);
|
|
306
|
+
|
|
307
|
+
const r = await consolidateFlatPrds(root);
|
|
308
|
+
|
|
309
|
+
expect(r.moved).toBe(1);
|
|
310
|
+
expect(r.skipped).toEqual([]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('consolidateFlatPrds FAILS CLOSED on an unparseable queue.json', async () => {
|
|
314
|
+
const root = newRoot();
|
|
315
|
+
const flat = await makeFlatProject(root, ['940-a.md', '941-b.md']);
|
|
316
|
+
const stateDir = path.join(opsRoot(root), 'state');
|
|
317
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
318
|
+
await fsp.writeFile(path.join(stateDir, 'queue.json'), '{ not json', 'utf8');
|
|
319
|
+
|
|
320
|
+
const r = await consolidateFlatPrds(root);
|
|
321
|
+
|
|
322
|
+
expect(r.moved).toBe(0);
|
|
323
|
+
expect(r.skipped).toHaveLength(2);
|
|
324
|
+
expect(r.skipped[0].reason).toMatch(/queue state unreadable/);
|
|
325
|
+
expect(fs.existsSync(path.join(flat, '940-a.md'))).toBe(true);
|
|
326
|
+
expect(fs.existsSync(path.join(flat, '941-b.md'))).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('consolidateFlatPrds returns the full shape when the flat dir is missing', async () => {
|
|
330
|
+
const r = await consolidateFlatPrds(newRoot());
|
|
331
|
+
expect(r).toEqual({ moved: 0, failed: [], skipped: [] });
|
|
332
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectPages.test.cjs — unit tests for projectPages.cjs's read-only get(),
|
|
3
|
+
* including the regression this PRD's AC calls out explicitly: adding the
|
|
4
|
+
* 5th 'brief' lens must not make every pre-existing 4-lens project suddenly
|
|
5
|
+
* read as having no output at all — and the shipped-default fallback added
|
|
6
|
+
* by the "project-home-hosted-html-spec" PRD: a project with no generated
|
|
7
|
+
* output gets the build-time default home.html (isDefault: true) instead of
|
|
8
|
+
* the bare `{ output: null }` empty-state signal.
|
|
9
|
+
*
|
|
10
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/projectPages.test.cjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import { test, expect, afterEach } from 'vitest';
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const fsp = require('node:fs/promises');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const config = require('../config.cjs');
|
|
21
|
+
const { get, DEFAULT_HOME_PATH, readDefaultHomeHtml } = require('../projectPages.cjs');
|
|
22
|
+
|
|
23
|
+
const tmpDirs = [];
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
while (tmpDirs.length) {
|
|
26
|
+
const d = tmpDirs.pop();
|
|
27
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function mkTmpCwd() {
|
|
32
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-project-pages-'));
|
|
33
|
+
config.addAllowedRoot(dir);
|
|
34
|
+
tmpDirs.push(dir);
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function outputDir(cwd) {
|
|
39
|
+
return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeOutput(cwd, lenses, manifest = { generatedAt: '2026-08-02T00:00:00.000Z' }) {
|
|
43
|
+
const dir = outputDir(cwd);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest));
|
|
46
|
+
for (const [lens, html] of Object.entries(lenses)) {
|
|
47
|
+
fs.writeFileSync(path.join(dir, `${lens}.html`), html);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('the shipped default asset exists on disk at a fixed app-relative path', () => {
|
|
52
|
+
expect(fs.existsSync(DEFAULT_HOME_PATH)).toBe(true);
|
|
53
|
+
expect(path.isAbsolute(DEFAULT_HOME_PATH)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('(a) generated output present: get() returns it with isDefault false', async () => {
|
|
57
|
+
const cwd = await mkTmpCwd();
|
|
58
|
+
writeOutput(cwd, {
|
|
59
|
+
home: 'HOME',
|
|
60
|
+
marketing: 'MARKETING',
|
|
61
|
+
feature: 'FEATURE',
|
|
62
|
+
architecture: 'ARCHITECTURE',
|
|
63
|
+
brief: 'BRIEF',
|
|
64
|
+
});
|
|
65
|
+
const result = await get({ cwd });
|
|
66
|
+
expect(result.output).not.toBeNull();
|
|
67
|
+
expect(result.output.home).toBe('HOME');
|
|
68
|
+
expect(result.output.brief).toBe('BRIEF');
|
|
69
|
+
expect(result.output.generatedAt).toBe('2026-08-02T00:00:00.000Z');
|
|
70
|
+
expect(result.output.isDefault).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The regression this PRD's AC explicitly guards against: a project that
|
|
74
|
+
// generated its output BEFORE the 'brief' lens existed has the original 4
|
|
75
|
+
// files but no brief.html — that must still read as "has output", not the
|
|
76
|
+
// shipped-default fallback, and `brief` must simply be absent from the payload.
|
|
77
|
+
test('4 original lenses present but brief.html missing still returns generated output, with brief omitted', async () => {
|
|
78
|
+
const cwd = await mkTmpCwd();
|
|
79
|
+
writeOutput(cwd, {
|
|
80
|
+
home: 'HOME',
|
|
81
|
+
marketing: 'MARKETING',
|
|
82
|
+
feature: 'FEATURE',
|
|
83
|
+
architecture: 'ARCHITECTURE',
|
|
84
|
+
});
|
|
85
|
+
const result = await get({ cwd });
|
|
86
|
+
expect(result.output).not.toBeNull();
|
|
87
|
+
expect(result.output.home).toBe('HOME');
|
|
88
|
+
expect(result.output.marketing).toBe('MARKETING');
|
|
89
|
+
expect(result.output.feature).toBe('FEATURE');
|
|
90
|
+
expect(result.output.architecture).toBe('ARCHITECTURE');
|
|
91
|
+
expect(result.output.brief).toBeUndefined();
|
|
92
|
+
expect(result.output.isDefault).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// (b) output absent → returns the shipped default with isDefault true.
|
|
96
|
+
test('(b) no manifest at all: get() returns the shipped default with isDefault true', async () => {
|
|
97
|
+
const cwd = await mkTmpCwd();
|
|
98
|
+
const result = await get({ cwd });
|
|
99
|
+
expect(result.output).not.toBeNull();
|
|
100
|
+
expect(result.output.isDefault).toBe(true);
|
|
101
|
+
expect(result.output.generatedAt).toBeNull();
|
|
102
|
+
expect(result.output.home).toBe(readDefaultHomeHtml());
|
|
103
|
+
expect(result.output.marketing).toBeUndefined();
|
|
104
|
+
expect(result.output.feature).toBeUndefined();
|
|
105
|
+
expect(result.output.architecture).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('one of the required 4 lenses missing falls back to the shipped default, even if brief.html exists', async () => {
|
|
109
|
+
const cwd = await mkTmpCwd();
|
|
110
|
+
writeOutput(cwd, {
|
|
111
|
+
home: 'HOME',
|
|
112
|
+
marketing: 'MARKETING',
|
|
113
|
+
feature: 'FEATURE',
|
|
114
|
+
// architecture.html deliberately missing
|
|
115
|
+
brief: 'BRIEF',
|
|
116
|
+
});
|
|
117
|
+
const result = await get({ cwd });
|
|
118
|
+
expect(result.output).not.toBeNull();
|
|
119
|
+
expect(result.output.isDefault).toBe(true);
|
|
120
|
+
expect(result.output.home).toBe(readDefaultHomeHtml());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('manifest with no generatedAt string falls back to the shipped default', async () => {
|
|
124
|
+
const cwd = await mkTmpCwd();
|
|
125
|
+
writeOutput(
|
|
126
|
+
cwd,
|
|
127
|
+
{ home: 'HOME', marketing: 'MARKETING', feature: 'FEATURE', architecture: 'ARCHITECTURE', brief: 'BRIEF' },
|
|
128
|
+
{},
|
|
129
|
+
);
|
|
130
|
+
const result = await get({ cwd });
|
|
131
|
+
expect(result.output).not.toBeNull();
|
|
132
|
+
expect(result.output.isDefault).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// (c) the default path resolution does not depend on cwd.
|
|
136
|
+
test('(c) default fallback content is identical regardless of which project cwd requests it', async () => {
|
|
137
|
+
const cwdA = await mkTmpCwd();
|
|
138
|
+
const cwdB = await mkTmpCwd();
|
|
139
|
+
const resultA = await get({ cwd: cwdA });
|
|
140
|
+
const resultB = await get({ cwd: cwdB });
|
|
141
|
+
expect(resultA.output.isDefault).toBe(true);
|
|
142
|
+
expect(resultB.output.isDefault).toBe(true);
|
|
143
|
+
expect(resultA.output.home).toBe(resultB.output.home);
|
|
144
|
+
expect(resultA.output.home).toBe(readDefaultHomeHtml());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the shipped default is honest: no fabricated project-specific content, and prompts Generate My Project Home', () => {
|
|
148
|
+
const html = readDefaultHomeHtml();
|
|
149
|
+
expect(html).toContain('Generate My Project Home');
|
|
150
|
+
expect(html.toLowerCase()).not.toContain('lorem ipsum');
|
|
151
|
+
});
|
|
@@ -139,6 +139,80 @@ test('broadcasts EVENT_APPENDED_CHANNEL to the attached window on a successful a
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
test('appending with { prdSlug, outcome } persists both onto the event on disk', async () => {
|
|
143
|
+
await writeIndex({
|
|
144
|
+
sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
|
|
145
|
+
events: {
|
|
146
|
+
'psess-1': [
|
|
147
|
+
{ id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD 976-foo finished: completed.', {
|
|
153
|
+
prdSlug: '976-foo',
|
|
154
|
+
outcome: 'completed',
|
|
155
|
+
});
|
|
156
|
+
expect(routed).toBe(true);
|
|
157
|
+
|
|
158
|
+
const onDisk = JSON.parse(await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8'));
|
|
159
|
+
const event = onDisk.events['psess-1'][1];
|
|
160
|
+
expect(event.prdSlug).toBe('976-foo');
|
|
161
|
+
expect(event.outcome).toBe('completed');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// PRD 986: a scheduler check-in event is born 'unvalidated' — the stamp is
|
|
165
|
+
// persisted from meta, and events appended without it carry no validation
|
|
166
|
+
// key at all (pre-existing events keep rendering as they do today).
|
|
167
|
+
test('appending with { validation } persists the stamp; without it no validation key exists', async () => {
|
|
168
|
+
await writeIndex({
|
|
169
|
+
sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
|
|
170
|
+
events: {
|
|
171
|
+
'psess-1': [
|
|
172
|
+
{ id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD 986-foo finished: completed.', {
|
|
178
|
+
prdSlug: '986-foo',
|
|
179
|
+
outcome: 'completed',
|
|
180
|
+
validation: 'unvalidated',
|
|
181
|
+
});
|
|
182
|
+
expect(routed).toBe(true);
|
|
183
|
+
|
|
184
|
+
await appendResponseEventIfKnown(cwd, 'psess-1', 'plain response, no meta');
|
|
185
|
+
|
|
186
|
+
const raw = await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8');
|
|
187
|
+
const onDisk = JSON.parse(raw);
|
|
188
|
+
const [, checkin, plain] = onDisk.events['psess-1'];
|
|
189
|
+
expect(checkin.validation).toBe('unvalidated');
|
|
190
|
+
expect('validation' in plain).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('appending without { prdSlug, outcome } persists an event with neither key set', async () => {
|
|
194
|
+
await writeIndex({
|
|
195
|
+
sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
|
|
196
|
+
events: {
|
|
197
|
+
'psess-1': [
|
|
198
|
+
{ id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'no meta here');
|
|
204
|
+
expect(routed).toBe(true);
|
|
205
|
+
|
|
206
|
+
const raw = await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8');
|
|
207
|
+
const onDisk = JSON.parse(raw);
|
|
208
|
+
const event = onDisk.events['psess-1'][1];
|
|
209
|
+
expect('prdSlug' in event).toBe(false);
|
|
210
|
+
expect('outcome' in event).toBe(false);
|
|
211
|
+
// Also assert the raw JSON text has no undefined-valued keys serialized in.
|
|
212
|
+
expect(raw).not.toMatch(/"prdSlug"/);
|
|
213
|
+
expect(raw).not.toMatch(/"outcome"/);
|
|
214
|
+
});
|
|
215
|
+
|
|
142
216
|
test('does not throw broadcasting before a window is attached, or after it is destroyed', async () => {
|
|
143
217
|
await writeIndex({
|
|
144
218
|
sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* scheduler-effective-concurrency.test.cjs — unit tests for the
|
|
3
3
|
* effectiveConcurrency field on buildScheduleStatePayload.
|
|
4
4
|
*
|
|
5
|
+
* The scheduler's private `concurrencyCap` is retired: the machine-wide
|
|
6
|
+
* sessionSlots pool is the ONLY concurrency limit, so effectiveConcurrency
|
|
7
|
+
* now reports the pool (and its SM_SESSION_SLOTS override), not a config
|
|
8
|
+
* field. See lib/sessionSlots.cjs's charter — caps belong to Session-Manager,
|
|
9
|
+
* not to each consumer.
|
|
10
|
+
*
|
|
5
11
|
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-effective-concurrency.test.cjs
|
|
6
12
|
*/
|
|
7
13
|
|
|
@@ -9,23 +15,26 @@
|
|
|
9
15
|
|
|
10
16
|
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
17
|
const { buildScheduleStatePayload } = require('../scheduler.cjs');
|
|
18
|
+
const sessionSlots = require('../lib/sessionSlots.cjs');
|
|
12
19
|
|
|
13
|
-
const ENV_KEY = '
|
|
20
|
+
const ENV_KEY = 'SM_SESSION_SLOTS';
|
|
14
21
|
let savedEnv;
|
|
15
22
|
|
|
16
23
|
beforeEach(() => {
|
|
17
24
|
savedEnv = process.env[ENV_KEY];
|
|
18
25
|
delete process.env[ENV_KEY];
|
|
26
|
+
sessionSlots.__resetForTests();
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
afterEach(() => {
|
|
22
30
|
if (savedEnv === undefined) delete process.env[ENV_KEY];
|
|
23
31
|
else process.env[ENV_KEY] = savedEnv;
|
|
32
|
+
sessionSlots.__resetForTests();
|
|
24
33
|
});
|
|
25
34
|
|
|
26
|
-
function fakeState(
|
|
35
|
+
function fakeState() {
|
|
27
36
|
return {
|
|
28
|
-
config: {
|
|
37
|
+
config: {},
|
|
29
38
|
jobs: [],
|
|
30
39
|
scheduledFor: null,
|
|
31
40
|
lastRunAt: null,
|
|
@@ -33,19 +42,30 @@ function fakeState(concurrencyCap) {
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
test('effectiveConcurrency
|
|
37
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
38
|
-
expect(payload.effectiveConcurrency).
|
|
45
|
+
test('effectiveConcurrency reports the pool when no env override is set', () => {
|
|
46
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
47
|
+
expect(payload.effectiveConcurrency.source).toBe('pool');
|
|
48
|
+
expect(payload.effectiveConcurrency.cap).toBe(sessionSlots.totalSlots());
|
|
49
|
+
expect(payload.effectiveConcurrency.free).toBe(sessionSlots.available());
|
|
39
50
|
});
|
|
40
51
|
|
|
41
|
-
test('effectiveConcurrency is env-sourced
|
|
52
|
+
test('effectiveConcurrency is env-sourced when SM_SESSION_SLOTS is set', () => {
|
|
42
53
|
process.env[ENV_KEY] = '7';
|
|
43
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
44
|
-
expect(payload.effectiveConcurrency).toEqual({ cap: 7, source: 'env' });
|
|
54
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
55
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 7, free: 7, source: 'env' });
|
|
45
56
|
});
|
|
46
57
|
|
|
47
|
-
test('effectiveConcurrency clamps env var into [
|
|
58
|
+
test('effectiveConcurrency clamps the env var into the pool range [0, 10]', () => {
|
|
48
59
|
process.env[ENV_KEY] = '999';
|
|
49
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
50
|
-
expect(payload.effectiveConcurrency).toEqual({ cap:
|
|
60
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
61
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 10, free: 10, source: 'env' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('free is net of held slots — a chat run shrinks it without changing cap', () => {
|
|
65
|
+
process.env[ENV_KEY] = '5';
|
|
66
|
+
const token = sessionSlots.acquire('chat:test-tab');
|
|
67
|
+
expect(token).toBeTruthy();
|
|
68
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
69
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 5, free: 4, source: 'env' });
|
|
70
|
+
sessionSlots.release(token);
|
|
51
71
|
});
|
|
@@ -107,8 +107,15 @@ test('prepends the Epic context digest when job.epicId resolves to a known Epic'
|
|
|
107
107
|
|
|
108
108
|
const digestText = await buildContextDigest({ cwd: projectCwd, epicId });
|
|
109
109
|
expect(digestText.length).toBeGreaterThan(0);
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// The PRD body (plus FINISH_PROTOCOL) comes first; the digest is fenced
|
|
111
|
+
// as background afterward, and the prompt ends with the task restatement
|
|
112
|
+
// rather than the digest itself (composeExecutorPrompt, PRD 987 Part A).
|
|
113
|
+
expect(capturedPrompt.startsWith(bodyText + FINISH_PROTOCOL)).toBe(true);
|
|
114
|
+
const bodyIndex = capturedPrompt.indexOf(bodyText);
|
|
115
|
+
const digestIndex = capturedPrompt.indexOf(digestText);
|
|
116
|
+
expect(digestIndex).toBeGreaterThan(bodyIndex);
|
|
117
|
+
expect(capturedPrompt).toContain('BEGIN EPIC CONTEXT (background only');
|
|
118
|
+
expect(capturedPrompt.trim().endsWith('Your task is the PRD at the top of this prompt. Implement it now.')).toBe(true);
|
|
112
119
|
|
|
113
120
|
// The PRD source on disk must never be rewritten with the digest merged in.
|
|
114
121
|
const onDiskPrd = fs.readFileSync(path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds', `${slug}.md`), 'utf8');
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-heal-refusal.test.cjs — PRD 983.
|
|
3
|
+
*
|
|
4
|
+
* Guards the needs_review → completed self-heal in reverifyNeedsReview.
|
|
5
|
+
*
|
|
6
|
+
* Incident being regression-tested: job 972 (2026-08-03) ran 34 s, edited zero
|
|
7
|
+
* files, exited 0. verifyRun correctly raised `no_verdict_sentinel` /
|
|
8
|
+
* downgradeTo 'needs_review'. One minute later the self-heal pass promoted it
|
|
9
|
+
* to `completed` and the false green shipped. Cause: the rescan recomputes
|
|
10
|
+
* `committedDuringRun` from `committedInWindow`, a repo-wide
|
|
11
|
+
* `git log --all --since --until` with no author/message/slug filter — a
|
|
12
|
+
* CONCURRENT job's commit inside the window was credited to this job, which
|
|
13
|
+
* suppressed the no_verdict_sentinel check on re-verify and let the verdict
|
|
14
|
+
* conclude `clean`.
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-heal-refusal.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import { test, expect } from 'vitest';
|
|
22
|
+
const { healRefusalReason } = require('../scheduler.cjs');
|
|
23
|
+
|
|
24
|
+
const CLEAN = { verdict: 'clean', reason: 'no issues detected' };
|
|
25
|
+
|
|
26
|
+
test('REGRESSION (PRD 983): a no_verdict_sentinel job with no landedCommit is NOT healed, even on a clean re-verify', () => {
|
|
27
|
+
const job = { slug: '972-notify', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
|
|
28
|
+
const refusal = healRefusalReason(job, CLEAN, true);
|
|
29
|
+
expect(refusal).toBeTruthy();
|
|
30
|
+
expect(refusal).toContain('no job-attributable commit');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a no_verdict_sentinel job WITH its own landedCommit is allowed to heal', () => {
|
|
34
|
+
const job = { slug: '900-real', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'abc1234' };
|
|
35
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('other verdict classes are untouched — transcript_errors still heals on a clean re-verify', () => {
|
|
39
|
+
const job = { slug: '901-transcript', verifierVerdict: 'transcript_errors', landedCommit: null };
|
|
40
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('verify_unavailable (the other pre-sentinel-heal class) is untouched', () => {
|
|
44
|
+
const job = { slug: '902-unavailable', verifierVerdict: 'verify_unavailable', landedCommit: null };
|
|
45
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a non-completed-equivalent re-verify verdict is left to the existing path, not refused here', () => {
|
|
49
|
+
const job = { slug: '903-still-bad', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
|
|
50
|
+
expect(healRefusalReason(job, { verdict: 'transcript_errors', reason: 'x' }, false)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an honest no-op exemption verdict still heals when the job recorded its own commit', () => {
|
|
54
|
+
const job = { slug: '904-shipped', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'deadbee' };
|
|
55
|
+
expect(healRefusalReason(job, { verdict: 'pass_no_commit_already_shipped', reason: 'x' }, false)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('null/missing inputs never throw and never refuse', () => {
|
|
59
|
+
expect(healRefusalReason(null, CLEAN, true)).toBeNull();
|
|
60
|
+
expect(healRefusalReason({ slug: 'x' }, null, true)).toBeNull();
|
|
61
|
+
});
|
|
@@ -116,6 +116,7 @@ test('routes into the known PromptSession event chain and never falls back to a
|
|
|
116
116
|
'/some/cwd',
|
|
117
117
|
'psess-1',
|
|
118
118
|
expect.stringContaining('814-notify'),
|
|
119
|
+
{ prdSlug: '814-notify', outcome: 'completed', validation: 'unvalidated' },
|
|
119
120
|
);
|
|
120
121
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
121
122
|
});
|
|
@@ -186,3 +187,76 @@ test('falls back to the tab-external-ticket path when appendResponseEvent itself
|
|
|
186
187
|
|
|
187
188
|
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
188
189
|
});
|
|
190
|
+
|
|
191
|
+
// ─── PRD 985: real path resolution ───────────────────────────────────────────
|
|
192
|
+
// Every test above injects a stubbed `parsePrdRaw`, which is exactly why the
|
|
193
|
+
// bug PRD 985 fixes shipped green: notifyOriginatingTab resolved its PRD via
|
|
194
|
+
// prdPathForJob → the RETIRED flat `scheduler/prds/` dir (today only
|
|
195
|
+
// `.reserved-NNN` stubs, no PRDs), and archiveCompletedPrd renames the file
|
|
196
|
+
// into `prds-archived/` immediately BEFORE notify runs anyway. `prd` was
|
|
197
|
+
// therefore always null on the completed path and no response event was ever
|
|
198
|
+
// appended. These two tests use the REAL parsePrdRaw against a temp tree so
|
|
199
|
+
// the resolution logic itself is covered, not mocked away.
|
|
200
|
+
|
|
201
|
+
const fs = require('node:fs');
|
|
202
|
+
const os = require('node:os');
|
|
203
|
+
const nodePath = require('node:path');
|
|
204
|
+
|
|
205
|
+
function makeEpicTree({ epicId, slug, archived }) {
|
|
206
|
+
const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-'));
|
|
207
|
+
const dir = nodePath.join(
|
|
208
|
+
cwd, 'session-manager-operations', 'scheduler', 'epics', epicId,
|
|
209
|
+
archived ? 'prds-archived' : 'prds',
|
|
210
|
+
);
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
nodePath.join(dir, `${slug}.md`),
|
|
214
|
+
`---\ntitle: notify 985\ncwd: ${cwd}\nsourcePromptId: ${epicId}\n---\n\nbody\n`,
|
|
215
|
+
'utf8',
|
|
216
|
+
);
|
|
217
|
+
return cwd;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test('PRD 985: an ARCHIVED-only PRD still routes its check-in to the authoring Epic', async () => {
|
|
221
|
+
const epicId = 'psess-985-archived';
|
|
222
|
+
const slug = '985-archived-twin';
|
|
223
|
+
const cwd = makeEpicTree({ epicId, slug, archived: true });
|
|
224
|
+
|
|
225
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
226
|
+
const appendTranscriptTurn = vi.fn(async () => {});
|
|
227
|
+
const sendPrompt = vi.fn();
|
|
228
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
229
|
+
|
|
230
|
+
// NOTE: no parsePrdRaw override — the real one runs against the temp tree.
|
|
231
|
+
await notifyOriginatingTab(
|
|
232
|
+
{ slug, status: 'completed', cwd, epicId: null },
|
|
233
|
+
{ loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(appendResponseEvent).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(appendResponseEvent).toHaveBeenCalledWith(
|
|
238
|
+
cwd, epicId, expect.stringContaining(slug),
|
|
239
|
+
expect.objectContaining({ prdSlug: slug, outcome: 'completed' }),
|
|
240
|
+
);
|
|
241
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, epicId, expect.anything());
|
|
242
|
+
expect(sendPrompt).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('PRD 985: a PRD missing entirely still routes via the job row\'s own epicId', async () => {
|
|
246
|
+
const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-none-'));
|
|
247
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
248
|
+
const appendTranscriptTurn = vi.fn(async () => {});
|
|
249
|
+
const sendPrompt = vi.fn();
|
|
250
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
251
|
+
|
|
252
|
+
await notifyOriginatingTab(
|
|
253
|
+
{ slug: '985-no-prd-on-disk', status: 'failed', cwd, epicId: 'psess-985-from-queue-row' },
|
|
254
|
+
{ loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(appendResponseEvent).toHaveBeenCalledWith(
|
|
258
|
+
cwd, 'psess-985-from-queue-row', expect.stringContaining('985-no-prd-on-disk'),
|
|
259
|
+
expect.objectContaining({ prdSlug: '985-no-prd-on-disk', outcome: 'failed' }),
|
|
260
|
+
);
|
|
261
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, 'psess-985-from-queue-row', expect.anything());
|
|
262
|
+
});
|