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/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-XGsBHqYu.js"></script>
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-DyLm7GM_.css">
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.39.4",
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 the **next sequential `NN`** (never the same `NN` as the PRD it
165
- depends on — same-`NN` means "can run in parallel," which a chain link cannot), and its
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 the parallel-group `NN` atomically (no read-then-write race against another
188
- concurrent `/develop`/`/process-feedback` invocation), derives and collision-checks the slug,
189
- and embeds the standards pointer for you. Set `parallelGroup` explicitly only when this PRD
190
- must share an existing sibling's `NN` (a logically independent PRD that can run in parallel
191
- with one already queued); omit it to get the next free `NN` atomically.
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: same `NN` as a logically
204
- independent sibling that can run in parallel; **next free `NN` = max+1** when this PRD
205
- hard-depends on prior work or is unrelated to every existing group. This manual path has a
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 2-digit zero-padded parallel group (picked per the `ls`
242
- command above — same `NN` as an independent sibling, or next free `NN` = max+1 when this PRD
243
- hard-depends on prior work). `<kebab-slug>` is a short, descriptive kebab-case identifier
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('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
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(409);
321
- expect(res.json.ok).toBe(false);
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('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
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(777);
341
- expect(res.json.filename).toBe('777-my-explicit-slug.md');
342
- expect(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md'))).toBeTruthy();
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 an existing file at the same destination', async () => {
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
- await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
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', parallelGroup: 777 }), remote);
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
- const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
398
- expect(stillThere).toBe('ORIGINAL CONTENT\n');
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', parallelGroup: 888, sourcePromptId: 'ticket-xyz-789' }),
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, '888-from-a-ticket.md'), 'utf8');
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
+ });
@@ -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}`);
@@ -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
- scheduler.init().catch((e) => {
1094
- logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1095
- });
1096
- adminHttp.start().catch((e) => {
1097
- logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
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();
@@ -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
+ });
@@ -141,4 +141,4 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
141
141
  return true;
142
142
  }
143
143
 
144
- module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath };
144
+ module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath, readActiveIndex };