claude-code-session-manager 0.39.3 → 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-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.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 +34 -27
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/config.cjs +10 -0
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +28 -9
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +82 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/pty.cjs +9 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +333 -52
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +18 -3
- package/src/preload/index.cjs +2 -0
- package/dist/assets/index-BUuhV6vT.js +0 -3180
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-archived-twin-guard.test.cjs — unit tests for archivedTwinExists
|
|
3
|
+
* (PRD 822-promptsession-prd-trace-events recurrence fix): a queue row whose
|
|
4
|
+
* PRD `.md` has already been moved to the sibling `prds-archived/` dir (work
|
|
5
|
+
* shipped out-of-band) must be recognized as stale, not a genuine missing-PRD
|
|
6
|
+
* failure.
|
|
7
|
+
*
|
|
8
|
+
* Uses a temp cwd fixture (`prdDirForCwd` is a pure path join off `cwd`) so
|
|
9
|
+
* this never touches the real `~/.claude/session-manager/` tree.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-archived-twin-guard.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { archivedTwinExists, archivedPrdPathForJob, prdDirForCwd } = require('../scheduler.cjs');
|
|
21
|
+
|
|
22
|
+
const tmpDirs = [];
|
|
23
|
+
|
|
24
|
+
function makeFixtureCwd() {
|
|
25
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-archived-twin-'));
|
|
26
|
+
tmpDirs.push(cwd);
|
|
27
|
+
return cwd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
while (tmpDirs.length) {
|
|
32
|
+
const dir = tmpDirs.pop();
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('archivedTwinExists returns true when the archived twin exists and the live PRD does not', async () => {
|
|
38
|
+
const cwd = makeFixtureCwd();
|
|
39
|
+
const slug = 'test-shipped-and-archived';
|
|
40
|
+
const prdsDir = prdDirForCwd(cwd);
|
|
41
|
+
const archivedDir = path.join(prdsDir, '..', 'prds-archived');
|
|
42
|
+
fs.mkdirSync(archivedDir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(archivedDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
|
|
44
|
+
|
|
45
|
+
await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('archivedTwinExists returns false when neither the live PRD nor an archived twin exists', async () => {
|
|
49
|
+
const cwd = makeFixtureCwd();
|
|
50
|
+
const slug = 'test-genuinely-missing';
|
|
51
|
+
fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
|
|
52
|
+
|
|
53
|
+
await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('archivedTwinExists returns false for a slug whose archived twin belongs to a different cwd', async () => {
|
|
57
|
+
const cwd = makeFixtureCwd();
|
|
58
|
+
const otherCwd = makeFixtureCwd();
|
|
59
|
+
const slug = 'test-cross-cwd-twin';
|
|
60
|
+
const otherArchivedDir = path.join(prdDirForCwd(otherCwd), '..', 'prds-archived');
|
|
61
|
+
fs.mkdirSync(otherArchivedDir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(path.join(otherArchivedDir, `${slug}.md`), '# Goal\n\nother project\n', 'utf8');
|
|
63
|
+
fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
|
|
64
|
+
|
|
65
|
+
await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('archivedTwinExists returns false when the PRD is still live in prds/ (never short-circuits a normal run)', async () => {
|
|
69
|
+
const cwd = makeFixtureCwd();
|
|
70
|
+
const slug = 'test-still-live';
|
|
71
|
+
const prdsDir = prdDirForCwd(cwd);
|
|
72
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nlive\n', 'utf8');
|
|
74
|
+
|
|
75
|
+
await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('archivedPrdPathForJob resolves to the sibling prds-archived/<slug>.md for a job cwd', () => {
|
|
79
|
+
const cwd = makeFixtureCwd();
|
|
80
|
+
const job = { cwd, slug: 'my-slug' };
|
|
81
|
+
const prdsDir = prdDirForCwd(cwd);
|
|
82
|
+
const expected = path.join(prdsDir, '..', 'prds-archived', 'my-slug.md');
|
|
83
|
+
expect(archivedPrdPathForJob(job)).toBe(expected);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('archivedPrdPathForJob does not escape prds-archived/ for a well-formed slug', () => {
|
|
87
|
+
const cwd = makeFixtureCwd();
|
|
88
|
+
const job = { cwd, slug: 'well-formed-slug' };
|
|
89
|
+
const resolved = path.resolve(archivedPrdPathForJob(job));
|
|
90
|
+
const archiveDir = path.resolve(path.join(prdDirForCwd(cwd), '..', 'prds-archived'));
|
|
91
|
+
expect(resolved.startsWith(archiveDir + path.sep)).toBe(true);
|
|
92
|
+
});
|
|
@@ -32,7 +32,7 @@ const unreadableState = () => ({
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test('writeQueue refuses to persist a state derived from an unreadable read', async () => {
|
|
35
|
-
await expect(writeQueue(unreadableState())).rejects.toThrow(/refusing to write queue
|
|
35
|
+
await expect(writeQueue(unreadableState())).rejects.toThrow(/refusing to write queue/i);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test('writeQueue still persists a normal state (guard is not a blanket block)', () => {
|
|
@@ -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/chatRunner.cjs
CHANGED
|
@@ -54,6 +54,7 @@ const { recordExchange } = require('./exchanges.cjs');
|
|
|
54
54
|
const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
|
|
55
55
|
const { extractJson } = require('./lib/extractJson.cjs');
|
|
56
56
|
const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
57
|
+
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
57
58
|
|
|
58
59
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
59
60
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -387,6 +388,12 @@ function run(opts) {
|
|
|
387
388
|
// the remainder. O(n) over the waiting list (bounded by open tabs).
|
|
388
389
|
function pump() {
|
|
389
390
|
while (activeCount < getConcurrencyCap() && waiting.length > 0) {
|
|
391
|
+
// The chat lane cap is a POLICY bound; actual capacity comes from the
|
|
392
|
+
// Session-Manager-wide slot pool shared with the scheduler
|
|
393
|
+
// (lib/sessionSlots.cjs). No slot → everyone keeps waiting; the next
|
|
394
|
+
// settle() in either subsystem re-pumps.
|
|
395
|
+
const slotToken = sessionSlots.acquire(`chat:${waiting[0].tabId}`);
|
|
396
|
+
if (!slotToken) break;
|
|
390
397
|
const job = waiting.shift();
|
|
391
398
|
activeCount += 1;
|
|
392
399
|
Promise.resolve()
|
|
@@ -397,7 +404,7 @@ function pump() {
|
|
|
397
404
|
return donePromise;
|
|
398
405
|
})
|
|
399
406
|
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
400
|
-
.finally(() => { activeCount -= 1; pump(); });
|
|
407
|
+
.finally(() => { sessionSlots.release(slotToken); activeCount -= 1; pump(); });
|
|
401
408
|
}
|
|
402
409
|
// Anyone still waiting gets a 1-based position update. Silent (automated
|
|
403
410
|
// probe) runs must stay invisible to the renderer, same as every other
|
|
@@ -409,6 +416,9 @@ function pump() {
|
|
|
409
416
|
});
|
|
410
417
|
}
|
|
411
418
|
|
|
419
|
+
// A slot freed anywhere (e.g. a scheduler job settled) may unblock our lane.
|
|
420
|
+
sessionSlots.subscribe(() => { try { pump(); } catch { /* defensive */ } });
|
|
421
|
+
|
|
412
422
|
// ─── Core runner ──────────────────────────────────────────────────────────
|
|
413
423
|
|
|
414
424
|
/**
|
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/health.cjs
CHANGED
|
@@ -12,6 +12,8 @@ const { execFileSync } = require('node:child_process');
|
|
|
12
12
|
const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
|
|
13
13
|
const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
|
|
14
14
|
const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
|
|
15
|
+
const { migratePrds } = require('./lib/prdMigration.cjs');
|
|
16
|
+
const queueStore = require('./lib/queueStore.cjs');
|
|
15
17
|
|
|
16
18
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
17
19
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
@@ -119,6 +121,19 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// Pure evaluator over migratePrds()'s { moved, skipped, unresolved } result —
|
|
125
|
+
// kept separate from the fs-touching check() call site so it's directly
|
|
126
|
+
// unit-testable, matching evaluateTickLiveness's pattern.
|
|
127
|
+
function evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir) {
|
|
128
|
+
const strandedCount = migrationResult.unresolved.length;
|
|
129
|
+
return {
|
|
130
|
+
ok: strandedCount === 0,
|
|
131
|
+
legacyDir: legacyPrdsDir,
|
|
132
|
+
strandedCount,
|
|
133
|
+
...(strandedCount > 0 ? { unresolved: migrationResult.unresolved } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
async function check() {
|
|
123
138
|
const start = Date.now();
|
|
124
139
|
const status = {
|
|
@@ -183,16 +198,14 @@ async function check() {
|
|
|
183
198
|
status.ok = false;
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
// 3. Check scheduler
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
-
const queuePath = path.join(schedulerBaseDir, 'queue.json');
|
|
201
|
+
// 3. Check scheduler state (federated, 2026-07-31): machine runtime file +
|
|
202
|
+
// per-project job shards merged via queueStore — the retired global
|
|
203
|
+
// queue.json is no longer consulted.
|
|
204
|
+
const queuePath = queueStore.MACHINE_STATE_PATH;
|
|
192
205
|
let queueState = null;
|
|
193
206
|
try {
|
|
194
|
-
|
|
195
|
-
queueState
|
|
207
|
+
queueState = queueStore.readMergedSync();
|
|
208
|
+
if (queueState.unreadable) throw new Error(queueState.unreadable);
|
|
196
209
|
const runningCount = Object.values(queueState.jobs || {}).filter(
|
|
197
210
|
(j) => j.status === 'running'
|
|
198
211
|
).length;
|
|
@@ -266,6 +279,29 @@ async function check() {
|
|
|
266
279
|
...(prdsDirs.length === 0 || anyPrdsDirAccessible ? {} : { exists: false }),
|
|
267
280
|
};
|
|
268
281
|
|
|
282
|
+
// 4.5. Check for stranded legacy-dir PRDs left behind by runPrdMigration()
|
|
283
|
+
// (scheduler.cjs). A stranded PRD is the cheapest generic leading indicator
|
|
284
|
+
// of a stale installed build vs. the git repo — it fires regardless of
|
|
285
|
+
// *why* the build is stale (the 2026-07-31 burrow-project ENOENT + the
|
|
286
|
+
// 223-file/189-resurrected-job incident both trace back to this).
|
|
287
|
+
const legacyPrdsDir = path.join(
|
|
288
|
+
os.homedir(),
|
|
289
|
+
'.claude/session-manager/scheduled-plans/prds'
|
|
290
|
+
);
|
|
291
|
+
try {
|
|
292
|
+
const migrationResult = await migratePrds(legacyPrdsDir);
|
|
293
|
+
status.components.prd_migration = evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
status.components.prd_migration = { ok: false, legacyDir: legacyPrdsDir, error: e.message };
|
|
296
|
+
}
|
|
297
|
+
if (!status.components.prd_migration.ok) {
|
|
298
|
+
status.issues.push(
|
|
299
|
+
status.components.prd_migration.error
|
|
300
|
+
? `PRD migration check failed: ${status.components.prd_migration.error}`
|
|
301
|
+
: `PRD migration: ${status.components.prd_migration.strandedCount} file(s) stranded in legacy dir ${legacyPrdsDir} — could not be resolved into a per-project dir`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
269
305
|
// 5. Check transcripts directory (where live session logs are tailed).
|
|
270
306
|
const projectsDir = path.join(os.homedir(), '.claude/projects');
|
|
271
307
|
try {
|
|
@@ -329,7 +365,7 @@ async function check() {
|
|
|
329
365
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
330
366
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
331
367
|
// Informational: app log age (shows if app is running, but not blocking).
|
|
332
|
-
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
|
|
368
|
+
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration'];
|
|
333
369
|
status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
|
|
334
370
|
|
|
335
371
|
status.elapsedMs = Date.now() - start;
|
|
@@ -345,4 +381,4 @@ if (require.main === module) {
|
|
|
345
381
|
})();
|
|
346
382
|
}
|
|
347
383
|
|
|
348
|
-
module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
|
|
384
|
+
module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, evaluatePrdMigrationHealth, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
|
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');
|
|
@@ -450,9 +451,12 @@ ipcMain.handle('app:home-self-check', () => bootHomeSelfCheck);
|
|
|
450
451
|
ipcMain.handle('app:pick-directory', async () => {
|
|
451
452
|
console.log('[main] pick-directory invoked');
|
|
452
453
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
453
|
-
|
|
454
|
+
// createDirectory enables the picker's "New Folder" affordance (macOS;
|
|
455
|
+
// the GTK chooser has its own) — this is the "Start Project" path:
|
|
456
|
+
// create a fresh folder in place, open it as the project's TAB.
|
|
457
|
+
properties: ['openDirectory', 'createDirectory'],
|
|
454
458
|
defaultPath: os.homedir(),
|
|
455
|
-
title: '
|
|
459
|
+
title: 'Open a project folder — or create a new one to start a project',
|
|
456
460
|
});
|
|
457
461
|
console.log('[main] pick-directory result:', result);
|
|
458
462
|
if (result.canceled || result.filePaths.length === 0) return null;
|
|
@@ -760,7 +764,7 @@ voiceWizard.registerWizardHandlers();
|
|
|
760
764
|
scheduler.registerScheduleHandlers();
|
|
761
765
|
watchers.registerWatcherHandlers();
|
|
762
766
|
teams.registerTeamsHandlers();
|
|
763
|
-
queueOps.registerQueueOpsHandlers();
|
|
767
|
+
queueOps.registerQueueOpsHandlers({ retireCompletedSlugs: scheduler.retireCompletedSlugs });
|
|
764
768
|
registerHistoryAggregatorHandlers();
|
|
765
769
|
registerHistoryDashboardHandlers();
|
|
766
770
|
pluginInstall.registerPluginInstallHandlers();
|
|
@@ -1087,12 +1091,26 @@ app.whenReady().then(async () => {
|
|
|
1087
1091
|
webRemote.attachWindow(mainWindow);
|
|
1088
1092
|
chatRunner.attachWindow(mainWindow);
|
|
1089
1093
|
attachDocEditWindow(mainWindow);
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
+
}
|
|
1096
1114
|
browserAgentServer.start().catch((e) => {
|
|
1097
1115
|
logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1098
1116
|
});
|
|
@@ -1196,6 +1214,7 @@ app.on('activate', () => {
|
|
|
1196
1214
|
});
|
|
1197
1215
|
|
|
1198
1216
|
app.on('before-quit', () => {
|
|
1217
|
+
releaseSchedulerOwnership();
|
|
1199
1218
|
supervisor.stopSupervisor();
|
|
1200
1219
|
ptyManager.killAll();
|
|
1201
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
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// sessionSlots.cjs — the Session-Manager-owned machine-wide claude -p pool.
|
|
2
|
+
// vitest globals (test/beforeEach/afterEach) — same convention as the other .cjs tests.
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const slots = require('../sessionSlots.cjs');
|
|
5
|
+
|
|
6
|
+
beforeEach(() => slots.__resetForTests());
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
slots.__resetForTests();
|
|
9
|
+
delete process.env.SM_SESSION_SLOTS;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('pool defaults to 3 slots and exhausts', () => {
|
|
13
|
+
assert.equal(slots.totalSlots(), 3);
|
|
14
|
+
const t1 = slots.acquire('a');
|
|
15
|
+
const t2 = slots.acquire('b');
|
|
16
|
+
const t3 = slots.acquire('c');
|
|
17
|
+
assert.ok(t1 && t2 && t3);
|
|
18
|
+
assert.equal(slots.available(), 0);
|
|
19
|
+
assert.equal(slots.acquire('d'), null, 'fourth acquire must be refused');
|
|
20
|
+
slots.release(t2);
|
|
21
|
+
assert.equal(slots.available(), 1);
|
|
22
|
+
assert.ok(slots.acquire('d'));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('SM_SESSION_SLOTS overrides, clamped to [1,3]', () => {
|
|
26
|
+
process.env.SM_SESSION_SLOTS = '1';
|
|
27
|
+
assert.equal(slots.totalSlots(), 1);
|
|
28
|
+
process.env.SM_SESSION_SLOTS = '9';
|
|
29
|
+
assert.equal(slots.totalSlots(), 3, 'clamped high');
|
|
30
|
+
process.env.SM_SESSION_SLOTS = '0';
|
|
31
|
+
assert.equal(slots.totalSlots(), 1, 'clamped low');
|
|
32
|
+
process.env.SM_SESSION_SLOTS = 'junk';
|
|
33
|
+
assert.equal(slots.totalSlots(), 3, 'non-numeric falls back to default');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('release is idempotent and notifies subscribers exactly on real releases', () => {
|
|
37
|
+
let notified = 0;
|
|
38
|
+
const unsub = slots.subscribe(() => { notified += 1; });
|
|
39
|
+
const t = slots.acquire('a');
|
|
40
|
+
assert.equal(slots.release(t), true);
|
|
41
|
+
assert.equal(slots.release(t), false, 'double release is a no-op');
|
|
42
|
+
assert.equal(notified, 1, 'no notification for the no-op release');
|
|
43
|
+
unsub();
|
|
44
|
+
const t2 = slots.acquire('b');
|
|
45
|
+
slots.release(t2);
|
|
46
|
+
assert.equal(notified, 1, 'unsubscribed listener stays quiet');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('snapshot names holders for attribution', () => {
|
|
50
|
+
slots.acquire('scheduler:42-fix-thing');
|
|
51
|
+
slots.acquire('chat:tab-1');
|
|
52
|
+
const snap = slots.snapshot();
|
|
53
|
+
assert.equal(snap.inUse, 2);
|
|
54
|
+
assert.deepEqual(snap.holders.map((h) => h.owner).sort(), ['chat:tab-1', 'scheduler:42-fix-thing']);
|
|
55
|
+
});
|