claude-code-session-manager 0.39.4 → 0.40.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-BSRtwtPP.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DyLm7GM_.css → index-CR-WdMgm.css} +1 -1
- package/dist/assets/index-oMNYp73N.js +3194 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +14 -13
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/config.cjs +10 -0
- package/src/main/index.cjs +22 -6
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/epicMint.cjs +1 -1
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +28 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +94 -5
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +16 -3
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-XGsBHqYu.js +0 -3180
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-oMNYp73N.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-CR-WdMgm.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.
|
|
3
|
+
"version": "0.40.0",
|
|
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",
|
|
@@ -161,8 +161,9 @@ can't load skills.
|
|
|
161
161
|
- **Evolving chain (3-5 PRDs).** Use this shape when the request is naturally a sequence of
|
|
162
162
|
stages that build on each other rather than independent units — e.g. scaffold → core
|
|
163
163
|
behavior → edge-case/interaction hardening → integration wiring → UI validation pass. Each
|
|
164
|
-
PRD in the chain: gets
|
|
165
|
-
|
|
164
|
+
PRD in the chain: gets its own unique `NN` (numbers are strictly unique per project —
|
|
165
|
+
PRD 832, user decision 2026-07-31) plus a `dependsOn: [<previous-link-slug>]`
|
|
166
|
+
frontmatter line expressing the chain edge explicitly, and its
|
|
166
167
|
`# Implementation notes` states in one line what the *previous* link actually delivered
|
|
167
168
|
(file paths/functions it added, referencing its real landed state — not the plan for it,
|
|
168
169
|
since PRDs 1..k-1 may have adjusted scope during execution) and what this link is expected
|
|
@@ -184,11 +185,11 @@ can't load skills.
|
|
|
184
185
|
(`mcp__session-manager-scheduler__scheduler_create_prd`) over hand-writing the file. Its input
|
|
185
186
|
(`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
|
|
186
187
|
`outOfScope[]`) maps directly onto the sections below — pass them straight through. It
|
|
187
|
-
allocates
|
|
188
|
-
concurrent `/develop`/`/process-feedback` invocation
|
|
189
|
-
and embeds the standards pointer for you.
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
allocates a strictly-unique `NN` atomically (no read-then-write race against another
|
|
189
|
+
concurrent `/develop`/`/process-feedback` invocation, never reused across the project — PRD
|
|
190
|
+
832), derives and collision-checks the slug, and embeds the standards pointer for you.
|
|
191
|
+
`parallelGroup` is DEPRECATED and ignored — express ordering with the `dependsOn` input
|
|
192
|
+
(slugs that must complete first); independent PRDs simply omit it and may run in parallel.
|
|
192
193
|
|
|
193
194
|
**Fallback — only when the tool errors with "app not running" / admin API unreachable**
|
|
194
195
|
(the session-manager Electron app must be running for this MCP tool to work; if it isn't,
|
|
@@ -200,9 +201,9 @@ can't load skills.
|
|
|
200
201
|
ls <cwd>/session-manager-operations/scheduler/epics/*/prds/ <cwd>/session-manager-operations/scheduler/prds-archived/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
|
|
201
202
|
```
|
|
202
203
|
(`<cwd>` is the target repo's absolute path — the same one this PRD's `cwd` field will use.)
|
|
203
|
-
The last line is the current max within that project. Then:
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
The last line is the current max within that project. Then: **always next free `NN` =
|
|
205
|
+
max+1** — never reuse a sibling's number (unique-per-project rule, PRD 832); express
|
|
206
|
+
ordering with `dependsOn: [<slug>]` frontmatter instead. This manual path has a
|
|
206
207
|
small, accepted race (two concurrent authors could compute the same "next free" `NN`) —
|
|
207
208
|
cosmetic (two unrelated groups end up sharing a number) rather than destructive, and only
|
|
208
209
|
reachable when the atomic tool path above isn't available. Record each cross-PRD dependency
|
|
@@ -238,9 +239,9 @@ can't load skills.
|
|
|
238
239
|
without being executed. PRD *source* files are per-project and per-Epic, resolved at runtime
|
|
239
240
|
via `src/main/lib/prdLocations.cjs`.
|
|
240
241
|
|
|
241
|
-
**Filename rules.** `NN` is the
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
**Filename rules.** `NN` is the PRD's unique per-project number (always next free =
|
|
243
|
+
max+1 per the `ls` command above; ordering via `dependsOn` frontmatter, never via shared
|
|
244
|
+
numbers). `<kebab-slug>` is a short, descriptive kebab-case identifier
|
|
244
245
|
(e.g. `voice-commands-send-cancel`, `ticker-velocity-mcp`), kept under 60 chars. Verify your
|
|
245
246
|
chosen filename doesn't already exist before writing.
|
|
246
247
|
|
|
@@ -307,7 +307,7 @@ test('POST /admin/scheduler/create-prd rejects a title containing a newline (fro
|
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
-
test('
|
|
310
|
+
test('PRD 832: an explicit parallelGroup colliding with an existing file is ignored — a fresh unique NN is allocated and nothing is clobbered', async () => {
|
|
311
311
|
const prdsDir = await mkTmpPrdsDir();
|
|
312
312
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
313
313
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -317,17 +317,18 @@ test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that
|
|
|
317
317
|
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
318
318
|
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
319
319
|
});
|
|
320
|
-
expect(res.status).toBe(
|
|
321
|
-
|
|
320
|
+
expect(res.status).toBe(200);
|
|
321
|
+
// Allocator scans past the existing 777 — unique per project, never reused.
|
|
322
|
+
expect(res.json.nn).toBe(778);
|
|
323
|
+
expect(res.json.filename).toBe('778-my-explicit-slug.md');
|
|
322
324
|
const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
|
|
323
|
-
// existing PRD file must not be overwritten
|
|
324
325
|
expect(stillThere).toBe('ORIGINAL CONTENT\n');
|
|
325
326
|
} finally {
|
|
326
327
|
await admin.stop();
|
|
327
328
|
}
|
|
328
329
|
});
|
|
329
330
|
|
|
330
|
-
test('
|
|
331
|
+
test('PRD 832: explicit parallelGroup input is deprecated and ignored — NN always comes from the allocator', async () => {
|
|
331
332
|
const prdsDir = await mkTmpPrdsDir();
|
|
332
333
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
333
334
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -337,9 +338,9 @@ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup i
|
|
|
337
338
|
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
338
339
|
});
|
|
339
340
|
expect(res.status).toBe(200);
|
|
340
|
-
expect(res.json.nn).toBe(
|
|
341
|
-
expect(res.json.filename).toBe('
|
|
342
|
-
expect(fs.existsSync(path.join(prdsDir, '
|
|
341
|
+
expect(res.json.nn).toBe(1);
|
|
342
|
+
expect(res.json.filename).toBe('1-my-explicit-slug.md');
|
|
343
|
+
expect(fs.existsSync(path.join(prdsDir, '1-my-explicit-slug.md'))).toBeTruthy();
|
|
343
344
|
} finally {
|
|
344
345
|
await admin.stop();
|
|
345
346
|
}
|
|
@@ -385,17 +386,55 @@ test('createPrd with a valid payload allocates a group, writes through remote.wr
|
|
|
385
386
|
expect(written).toMatch(/# Goal/);
|
|
386
387
|
});
|
|
387
388
|
|
|
388
|
-
test('createPrd returns {ok:false, status:409} without clobbering
|
|
389
|
+
test('createPrd returns {ok:false, status:409} without clobbering when the destination already exists', async () => {
|
|
389
390
|
const prdsDir = await mkTmpPrdsDir();
|
|
391
|
+
// The existence guard survives PRD 832: whatever NN the allocator picks,
|
|
392
|
+
// a pre-existing file at that exact destination must 409, never clobber.
|
|
390
393
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
391
|
-
|
|
394
|
+
const realRead = remote.readPrd.bind(remote);
|
|
395
|
+
remote.readPrd = async (slug) => (slug.endsWith('my-explicit-slug') ? { ok: true, text: 'ORIGINAL' } : realRead(slug));
|
|
396
|
+
const writes = [];
|
|
397
|
+
const realWrite = remote.writePrd.bind(remote);
|
|
398
|
+
remote.writePrd = async (...a) => { writes.push(a[0]); return realWrite(...a); };
|
|
392
399
|
|
|
393
|
-
const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug'
|
|
400
|
+
const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug' }), remote);
|
|
394
401
|
|
|
395
402
|
expect(result.ok).toBe(false);
|
|
396
403
|
expect(result.status).toBe(409);
|
|
397
|
-
|
|
398
|
-
|
|
404
|
+
expect(writes).toEqual([]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ──────────────────────────────────────────── PRD 825: Epic PRD dir write grant + `~` cwd normalization
|
|
408
|
+
|
|
409
|
+
test('validateWrite allows writes under <root>/session-manager-operations/scheduler/epics/<id>/prds/', async () => {
|
|
410
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-scheduler-epics-'));
|
|
411
|
+
config.addAllowedRoot(root);
|
|
412
|
+
const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'epic-abc123', 'prds');
|
|
413
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
414
|
+
const filePath = path.join(prdsDir, '1-do-thing.md');
|
|
415
|
+
|
|
416
|
+
await config.writeTextAtomic(filePath, 'PRD body\n');
|
|
417
|
+
|
|
418
|
+
expect(fs.existsSync(filePath)).toBeTruthy();
|
|
419
|
+
expect(await fsp.readFile(filePath, 'utf8')).toBe('PRD body\n');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('createPrd normalizes a `~`-prefixed cwd via expandHome before calling remote', async () => {
|
|
423
|
+
const receivedCwds = [];
|
|
424
|
+
const remote = {
|
|
425
|
+
async allocateParallelGroup(cwd) { receivedCwds.push(cwd); return 42; },
|
|
426
|
+
async readPrd(slug, cwd) { receivedCwds.push(cwd); return { ok: false }; },
|
|
427
|
+
async writePrd(slug, body, cwd) { receivedCwds.push(cwd); return { ok: true, bytesWritten: body.length }; },
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const result = await createPrd(validCreateBody({ cwd: '~' }), remote);
|
|
431
|
+
|
|
432
|
+
expect(result.ok).toBe(true);
|
|
433
|
+
expect(receivedCwds.length).toBeGreaterThan(0);
|
|
434
|
+
for (const cwd of receivedCwds) {
|
|
435
|
+
expect(cwd.startsWith('~')).toBe(false);
|
|
436
|
+
expect(cwd).toBe(os.homedir());
|
|
437
|
+
}
|
|
399
438
|
});
|
|
400
439
|
|
|
401
440
|
test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
|
|
@@ -405,10 +444,10 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
|
|
|
405
444
|
try {
|
|
406
445
|
const res = await request(port, {
|
|
407
446
|
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
408
|
-
body: validCreateBody({ slug: 'from-a-ticket',
|
|
447
|
+
body: validCreateBody({ slug: 'from-a-ticket', sourcePromptId: 'ticket-xyz-789' }),
|
|
409
448
|
});
|
|
410
449
|
expect(res.status).toBe(200);
|
|
411
|
-
const written = fs.readFileSync(path.join(prdsDir,
|
|
450
|
+
const written = fs.readFileSync(path.join(prdsDir, `${res.json.nn}-from-a-ticket.md`), 'utf8');
|
|
412
451
|
expect(written).toMatch(/sourcePromptId: ticket-xyz-789/);
|
|
413
452
|
} finally {
|
|
414
453
|
await admin.stop();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdSourcePromptIdBackfill.test.cjs — PRD 830: a PRD file's location under
|
|
3
|
+
* `.../scheduler/epics/<epicId>/prds/<slug>.md` already IS its Epic
|
|
4
|
+
* membership, so ingest should stamp `sourcePromptId = <epicId>` whenever
|
|
5
|
+
* frontmatter doesn't supply one — instead of leaving it null forever.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* - deriveEpicIdFromPrdPath: pure path-shape extraction (prdLocations.cjs)
|
|
9
|
+
* - parsePrdRaw: dir-derived stamping + frontmatter-wins precedence
|
|
10
|
+
* - reconcileSourcePromptId: pending-only backfill (scheduler.cjs)
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdSourcePromptIdBackfill.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect } from 'vitest';
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { deriveEpicIdFromPrdPath, resolveEpicPrdWriteDir } = require('../lib/prdLocations.cjs');
|
|
23
|
+
const { parsePrdRaw, _resetCache } = require('../scheduler/prdParser.cjs');
|
|
24
|
+
const { reconcileSourcePromptId } = require('../scheduler.cjs');
|
|
25
|
+
|
|
26
|
+
async function mkProjectCwd() {
|
|
27
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-backfill-'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------- deriveEpicIdFromPrdPath ----------
|
|
31
|
+
|
|
32
|
+
test('deriveEpicIdFromPrdPath extracts the epic id from a well-formed epic PRD path', async () => {
|
|
33
|
+
const cwd = await mkProjectCwd();
|
|
34
|
+
const epicId = 'some-epic-abc123';
|
|
35
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
36
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
37
|
+
const filePath = path.join(dir, '900-example.md');
|
|
38
|
+
expect(deriveEpicIdFromPrdPath(filePath)).toBe(epicId);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('deriveEpicIdFromPrdPath returns null for the retired flat prds/ layout', () => {
|
|
42
|
+
const filePath = '/home/user/Projects/foo/session-manager-operations/scheduler/prds/900-example.md';
|
|
43
|
+
expect(deriveEpicIdFromPrdPath(filePath)).toBe(null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('deriveEpicIdFromPrdPath returns null for an unrelated path', () => {
|
|
47
|
+
expect(deriveEpicIdFromPrdPath('/tmp/whatever/900-example.md')).toBe(null);
|
|
48
|
+
expect(deriveEpicIdFromPrdPath(null)).toBe(null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------- parsePrdRaw: dir-derived stamping + frontmatter-wins ----------
|
|
52
|
+
|
|
53
|
+
test('parsePrdRaw derives sourcePromptId from the parent epic dir when frontmatter omits it', async () => {
|
|
54
|
+
_resetCache();
|
|
55
|
+
const cwd = await mkProjectCwd();
|
|
56
|
+
const epicId = 'derive-me-epic-def456';
|
|
57
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
58
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
59
|
+
const filePath = path.join(dir, '901-hand-authored.md');
|
|
60
|
+
await fsp.writeFile(filePath, [
|
|
61
|
+
'---',
|
|
62
|
+
'title: Hand-authored, no sourcePromptId',
|
|
63
|
+
`cwd: ${cwd}`,
|
|
64
|
+
'---',
|
|
65
|
+
'# Goal',
|
|
66
|
+
'',
|
|
67
|
+
'Do the thing.',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n'));
|
|
70
|
+
|
|
71
|
+
const parsed = await parsePrdRaw(filePath);
|
|
72
|
+
expect(parsed.sourcePromptId).toBe(epicId);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parsePrdRaw: explicit frontmatter sourcePromptId wins over the dir-derived epic id', async () => {
|
|
76
|
+
_resetCache();
|
|
77
|
+
const cwd = await mkProjectCwd();
|
|
78
|
+
const epicId = 'dir-epic-ghi789';
|
|
79
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
80
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
81
|
+
const filePath = path.join(dir, '902-explicit.md');
|
|
82
|
+
await fsp.writeFile(filePath, [
|
|
83
|
+
'---',
|
|
84
|
+
'title: Explicit sourcePromptId',
|
|
85
|
+
`cwd: ${cwd}`,
|
|
86
|
+
'sourcePromptId: ticket-explicit-999',
|
|
87
|
+
'---',
|
|
88
|
+
'# Goal',
|
|
89
|
+
'',
|
|
90
|
+
'Do the thing.',
|
|
91
|
+
'',
|
|
92
|
+
].join('\n'));
|
|
93
|
+
|
|
94
|
+
const parsed = await parsePrdRaw(filePath);
|
|
95
|
+
expect(parsed.sourcePromptId).toBe('ticket-explicit-999');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------- reconcileSourcePromptId: pending-only backfill ----------
|
|
99
|
+
|
|
100
|
+
test('reconcileSourcePromptId backfills a pending job whose sourcePromptId is null', () => {
|
|
101
|
+
const job = { slug: 'x', status: 'pending', sourcePromptId: null };
|
|
102
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe('epic-abc');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('reconcileSourcePromptId leaves a running job untouched even if its sourcePromptId is null', () => {
|
|
106
|
+
const job = { slug: 'x', status: 'running', sourcePromptId: null };
|
|
107
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('reconcileSourcePromptId leaves a completed job untouched even if its sourcePromptId is null', () => {
|
|
111
|
+
const job = { slug: 'x', status: 'completed', sourcePromptId: null };
|
|
112
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('reconcileSourcePromptId keeps a pending job\'s existing value if the fresh parse yields null', () => {
|
|
116
|
+
const job = { slug: 'x', status: 'pending', sourcePromptId: 'already-set' };
|
|
117
|
+
expect(reconcileSourcePromptId(job, null)).toBe('already-set');
|
|
118
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// PRD 832 — unique per-project PRD numbers + dependsOn eligibility.
|
|
2
|
+
// User decision 2026-07-31: duplicate NN must be impossible; ordering is
|
|
3
|
+
// expressed via dependsOn frontmatter, never via shared numbers.
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const prdParser = require('../scheduler/prdParser.cjs');
|
|
10
|
+
const { pickForProject } = require('../lib/schedulerBatch.cjs');
|
|
11
|
+
|
|
12
|
+
function tmpdir() {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const job = (slug, status, group, extra = {}) => ({
|
|
17
|
+
slug, status, parallelGroup: group, cwd: '/proj', ...extra,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('allocateParallelGroup extraFloor (PRD 832)', () => {
|
|
21
|
+
it('never allocates at or below the cross-dir max', async () => {
|
|
22
|
+
const dir = tmpdir();
|
|
23
|
+
// Bookkeeping dir itself is empty; another dir in the project already
|
|
24
|
+
// used numbers up to 830.
|
|
25
|
+
const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 830 });
|
|
26
|
+
expect(nn).toBe(831);
|
|
27
|
+
// And the high-water mark persists the raised floor for later calls.
|
|
28
|
+
const nn2 = await prdParser.allocateParallelGroup(dir);
|
|
29
|
+
expect(nn2).toBe(832);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('local scan still wins when higher than extraFloor', async () => {
|
|
33
|
+
const dir = tmpdir();
|
|
34
|
+
fs.writeFileSync(path.join(dir, '900-existing.md'), '---\ntitle: x\n---\n# Goal\n');
|
|
35
|
+
const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 10 });
|
|
36
|
+
expect(nn).toBe(901);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('parsePrdRaw dependsOn (PRD 832)', () => {
|
|
41
|
+
it('parses bracket and comma forms, rejects junk', async () => {
|
|
42
|
+
const dir = tmpdir();
|
|
43
|
+
const fp = path.join(dir, '840-x.md');
|
|
44
|
+
fs.writeFileSync(fp, '---\ntitle: x\ncwd: /proj\ndependsOn: [833-epics-review-fixes, 834-guard]\n---\n# Goal\nbody\n');
|
|
45
|
+
const p = await prdParser.parsePrdRaw(fp);
|
|
46
|
+
expect(p.dependsOn).toEqual(['833-epics-review-fixes', '834-guard']);
|
|
47
|
+
|
|
48
|
+
const fp2 = path.join(dir, '841-y.md');
|
|
49
|
+
fs.writeFileSync(fp2, '---\ntitle: y\ncwd: /proj\ndependsOn: 833-a, ../evil/path, 834-b\n---\n# Goal\nbody\n');
|
|
50
|
+
const p2 = await prdParser.parsePrdRaw(fp2);
|
|
51
|
+
expect(p2.dependsOn).toEqual(['833-a', '834-b']);
|
|
52
|
+
|
|
53
|
+
const fp3 = path.join(dir, '842-z.md');
|
|
54
|
+
fs.writeFileSync(fp3, '---\ntitle: z\ncwd: /proj\n---\n# Goal\nbody\n');
|
|
55
|
+
const p3 = await prdParser.parsePrdRaw(fp3);
|
|
56
|
+
expect(p3.dependsOn).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
61
|
+
it('holds a job while its dep row is pending/running; releases when completed', () => {
|
|
62
|
+
const dep = job('850-base', 'running', 850);
|
|
63
|
+
const dependent = job('851-next', 'pending', 851, { dependsOn: ['850-base'] });
|
|
64
|
+
|
|
65
|
+
// Dep running (tracked in queue rows, not runningSet) — not eligible.
|
|
66
|
+
let res = pickForProject([dep, dependent], new Set(), 3);
|
|
67
|
+
expect(res.batch.map((j) => j.slug)).not.toContain('851-next');
|
|
68
|
+
|
|
69
|
+
// Dep completed — eligible.
|
|
70
|
+
res = pickForProject([{ ...dep, status: 'completed' }, dependent], new Set(), 3);
|
|
71
|
+
expect(res.batch.map((j) => j.slug)).toContain('851-next');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('a dep slug with NO queue row counts as done (retired to history)', () => {
|
|
75
|
+
const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
|
|
76
|
+
const res = pickForProject([dependent], new Set(), 3);
|
|
77
|
+
expect(res.batch.map((j) => j.slug)).toContain('853-next');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
|
|
81
|
+
const dep = job('854-base', 'failed', 854);
|
|
82
|
+
const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
|
|
83
|
+
const res = pickForProject([dep, dependent], new Set(), 3);
|
|
84
|
+
expect(res.batch).toEqual([]);
|
|
85
|
+
expect(res.reason).toMatch(/depends-gate/);
|
|
86
|
+
expect(res.reason).toContain('855-next');
|
|
87
|
+
expect(res.reason).toContain('854-base');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('legacy same-NN pending jobs keep their wave order untouched', () => {
|
|
91
|
+
const a = job('827-one', 'pending', 827);
|
|
92
|
+
const b = job('827-two', 'pending', 827);
|
|
93
|
+
const later = job('828-three', 'pending', 828);
|
|
94
|
+
const res = pickForProject([a, b, later], new Set(), 3);
|
|
95
|
+
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('reconcile retires archived-twin pending rows (post-832 cleanup)', () => {
|
|
100
|
+
it('drops a pending row whose PRD lives in prds-archived, keeps a normal invisible pending row', async () => {
|
|
101
|
+
const scheduler = require('../scheduler.cjs');
|
|
102
|
+
const root = tmpdir();
|
|
103
|
+
const opsDir = path.join(root, 'session-manager-operations', 'scheduler');
|
|
104
|
+
fs.mkdirSync(path.join(opsDir, 'prds'), { recursive: true });
|
|
105
|
+
fs.mkdirSync(path.join(opsDir, 'prds-archived'), { recursive: true });
|
|
106
|
+
fs.writeFileSync(path.join(opsDir, 'prds-archived', '833-landed-inline.md'), '---\ntitle: x\n---\n# Goal\n');
|
|
107
|
+
|
|
108
|
+
const state = {
|
|
109
|
+
jobs: [
|
|
110
|
+
job('833-landed-inline', 'pending', 833, { cwd: root }),
|
|
111
|
+
job('900-still-hidden', 'pending', 900, { cwd: root }),
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
await scheduler.reconcile(state);
|
|
115
|
+
const slugs = state.jobs.map((j) => j.slug);
|
|
116
|
+
expect(slugs).not.toContain('833-landed-inline');
|
|
117
|
+
expect(slugs).toContain('900-still-hidden');
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/main/config.cjs
CHANGED
|
@@ -152,6 +152,16 @@ function validateWrite(realAbs) {
|
|
|
152
152
|
if (realAbs === promptSessionsSub || realAbs.startsWith(promptSessionsSub + path.sep)) {
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
|
+
// Scheduler PRD sources (PRD 825 — per-project Epic PRD dirs under
|
|
156
|
+
// scheduler/epics/<id>/prds/, plus the legacy flat scheduler/prds/ and
|
|
157
|
+
// scheduler/state/ shards): narrowly scoped to
|
|
158
|
+
// session-manager-operations/scheduler/, this repo's existing
|
|
159
|
+
// per-project artifact-store convention (scheduler.cjs's remote.writePrd
|
|
160
|
+
// writes here via config.writeTextAtomic).
|
|
161
|
+
const schedulerSub = path.join(realRoot, 'session-manager-operations', 'scheduler');
|
|
162
|
+
if (realAbs === schedulerSub || realAbs.startsWith(schedulerSub + path.sep)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
167
|
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|
package/src/main/index.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const os = require('node:os');
|
|
|
7
7
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
8
8
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
9
9
|
const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
|
|
10
|
+
const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
|
|
10
11
|
const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
|
|
11
12
|
const browserView = require('./browserView.cjs');
|
|
12
13
|
const browserCapture = require('./browserCapture.cjs');
|
|
@@ -1090,12 +1091,26 @@ app.whenReady().then(async () => {
|
|
|
1090
1091
|
webRemote.attachWindow(mainWindow);
|
|
1091
1092
|
chatRunner.attachWindow(mainWindow);
|
|
1092
1093
|
attachDocEditWindow(mainWindow);
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1094
|
+
// Scheduler ownership (PRD 834): exactly one instance machine-wide runs
|
|
1095
|
+
// scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
|
|
1096
|
+
// the admin API. Electron's own single-instance lock is keyed by userData
|
|
1097
|
+
// identity, which a dev/e2e/Playwright launch bypasses — live incident
|
|
1098
|
+
// 2026-07-31: a secondary instance's boot reconciliation SIGTERM'd the
|
|
1099
|
+
// owner's running job and overwrote admin-api.json. Losing instances run
|
|
1100
|
+
// scheduler-PASSIVE: read IPC still works (handlers registered above),
|
|
1101
|
+
// nothing mutates.
|
|
1102
|
+
const ownership = acquireSchedulerOwnership();
|
|
1103
|
+
if (ownership.owner) {
|
|
1104
|
+
scheduler.init().catch((e) => {
|
|
1105
|
+
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1106
|
+
});
|
|
1107
|
+
adminHttp.start().catch((e) => {
|
|
1108
|
+
logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1109
|
+
});
|
|
1110
|
+
} else {
|
|
1111
|
+
console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
|
|
1112
|
+
logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
|
|
1113
|
+
}
|
|
1099
1114
|
browserAgentServer.start().catch((e) => {
|
|
1100
1115
|
logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1101
1116
|
});
|
|
@@ -1199,6 +1214,7 @@ app.on('activate', () => {
|
|
|
1199
1214
|
});
|
|
1200
1215
|
|
|
1201
1216
|
app.on('before-quit', () => {
|
|
1217
|
+
releaseSchedulerOwnership();
|
|
1202
1218
|
supervisor.stopSupervisor();
|
|
1203
1219
|
ptyManager.killAll();
|
|
1204
1220
|
configMgr.closeAllWatchers();
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -20,6 +20,10 @@ const ptySpawn = z.object({
|
|
|
20
20
|
|
|
21
21
|
const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
|
|
22
22
|
|
|
23
|
+
// PRD 833 C1: Epics-workspace reconcile — which of these session keys still
|
|
24
|
+
// have a live PTY? Bounded so the renderer can't ask about unbounded lists.
|
|
25
|
+
const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
|
|
26
|
+
|
|
23
27
|
// v2 mobile: subscribe to a session's live state + summary. cwd is needed to
|
|
24
28
|
// locate the transcript JSONL (transcriptPath); validated against home-dir boundary
|
|
25
29
|
// in webRemote before any fs access.
|
|
@@ -266,7 +270,12 @@ const schedulerCreatePrd = z.object({
|
|
|
266
270
|
implementationNotes: z.string().min(1).max(20000),
|
|
267
271
|
outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
|
|
268
272
|
slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
|
|
273
|
+
// DEPRECATED (PRD 832): accepted for wire-compat but ignored — numbers are
|
|
274
|
+
// strictly unique per project; use dependsOn for ordering.
|
|
269
275
|
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
276
|
+
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
277
|
+
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
278
|
+
dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
|
|
270
279
|
// Originating PromptTicket.id (PRD 748) when this PRD was authored from a
|
|
271
280
|
// ticket classified 'develop' (PRD 749) — traces the PRD back to the
|
|
272
281
|
// prompt that spawned it. Same newline-injection guard as title/cwd since
|
|
@@ -726,6 +735,7 @@ module.exports = {
|
|
|
726
735
|
webRemoteAuditTail,
|
|
727
736
|
ptySpawn,
|
|
728
737
|
ptyTabId,
|
|
738
|
+
ptyAlive,
|
|
729
739
|
ptyWrite,
|
|
730
740
|
ptyResize,
|
|
731
741
|
sessionSubscribe,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// PRD 834 — machine-wide scheduler-ownership lock. Secondary instances must
|
|
2
|
+
// go scheduler-passive instead of running boot reconciliation / admin server
|
|
3
|
+
// against the owner's queue state (live incident 2026-07-31).
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
acquireSchedulerOwnership,
|
|
11
|
+
releaseSchedulerOwnership,
|
|
12
|
+
pidAlive,
|
|
13
|
+
lockPath,
|
|
14
|
+
} = require('../instanceLock.cjs');
|
|
15
|
+
|
|
16
|
+
let dir;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-instance-lock-'));
|
|
20
|
+
process.env.SM_SCHEDULER_LOCK_PATH = path.join(dir, 'scheduler-owner.lock');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
delete process.env.SM_SCHEDULER_LOCK_PATH;
|
|
25
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('instanceLock (PRD 834)', () => {
|
|
29
|
+
it('first acquirer becomes owner and writes its pid', () => {
|
|
30
|
+
const res = acquireSchedulerOwnership();
|
|
31
|
+
expect(res.owner).toBe(true);
|
|
32
|
+
const onDisk = JSON.parse(fs.readFileSync(lockPath(), 'utf8'));
|
|
33
|
+
expect(onDisk.pid).toBe(process.pid);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('a live foreign holder makes acquisition passive with the holder pid reported', () => {
|
|
37
|
+
// Use our own live pid as the "foreign" holder — pidAlive(process.pid) is
|
|
38
|
+
// true, and acquire treats pid !== process.pid as foreign, so fake one by
|
|
39
|
+
// writing pid 1 (init — always alive, never ours).
|
|
40
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
41
|
+
fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
|
|
42
|
+
const res = acquireSchedulerOwnership();
|
|
43
|
+
expect(res.owner).toBe(false);
|
|
44
|
+
expect(res.holderPid).toBe(1);
|
|
45
|
+
// The holder's lock is untouched.
|
|
46
|
+
expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('a stale lock (dead pid) is broken and ownership taken', () => {
|
|
50
|
+
// Find a pid that is certainly dead: spawn nothing — use a huge pid
|
|
51
|
+
// beyond pid_max defaults.
|
|
52
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
53
|
+
fs.writeFileSync(lockPath(), JSON.stringify({ pid: 2 ** 30, startedAt: 'x' }));
|
|
54
|
+
const res = acquireSchedulerOwnership();
|
|
55
|
+
expect(res.owner).toBe(true);
|
|
56
|
+
expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('re-acquiring our own lock stays owner (idempotent across re-init)', () => {
|
|
60
|
+
expect(acquireSchedulerOwnership().owner).toBe(true);
|
|
61
|
+
expect(acquireSchedulerOwnership().owner).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('release removes only our own lock', () => {
|
|
65
|
+
acquireSchedulerOwnership();
|
|
66
|
+
releaseSchedulerOwnership();
|
|
67
|
+
expect(fs.existsSync(lockPath())).toBe(false);
|
|
68
|
+
// A foreign lock is never removed by release.
|
|
69
|
+
fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
|
|
70
|
+
releaseSchedulerOwnership();
|
|
71
|
+
expect(fs.existsSync(lockPath())).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('a corrupt lock file is treated as stale, not fatal', () => {
|
|
75
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
76
|
+
fs.writeFileSync(lockPath(), 'not json {');
|
|
77
|
+
const res = acquireSchedulerOwnership();
|
|
78
|
+
expect(res.owner).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('pidAlive: own pid alive, absurd pid dead', () => {
|
|
82
|
+
expect(pidAlive(process.pid)).toBe(true);
|
|
83
|
+
expect(pidAlive(2 ** 30)).toBe(false);
|
|
84
|
+
expect(pidAlive(-5)).toBe(false);
|
|
85
|
+
expect(pidAlive(0)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|