claude-code-session-manager 0.39.2 → 0.39.4
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-MCzz_6Zm.js → TiptapBody-BSRtwtPP.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-DyLm7GM_.css} +1 -1
- package/dist/assets/{index-BPbLVT6E.js → index-XGsBHqYu.js} +563 -563
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +19 -13
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -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/chatRunner.cjs +11 -1
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +6 -3
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/prdLocations.cjs +54 -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/sessionSlots.cjs +85 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler.cjs +241 -49
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
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-XGsBHqYu.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-DyLm7GM_.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.
|
|
3
|
+
"version": "0.39.4",
|
|
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",
|
|
@@ -77,7 +77,7 @@ can't load skills.
|
|
|
77
77
|
do they actually share the same shape, or only look similar — confirm by reading, don't
|
|
78
78
|
assume: a wrong assumption here means an inaccurate PRD, discovered only after the executor
|
|
79
79
|
runs it), check existing tests for the area, and check whether a prior PRD already touched
|
|
80
|
-
this subsystem (`ls <cwd>/session-manager-operations/scheduler/prds/` for related slugs, in
|
|
80
|
+
this subsystem (`ls <cwd>/session-manager-operations/scheduler/epics/*/prds/` for related slugs, in
|
|
81
81
|
the target repo) — duplicating or contradicting a still-queued PRD is a real failure mode,
|
|
82
82
|
not a hypothetical one.
|
|
83
83
|
|
|
@@ -197,7 +197,7 @@ can't load skills.
|
|
|
197
197
|
and collides). PRDs are stored per-project, so `NN` allocation for a given PRD only needs
|
|
198
198
|
*that project's own* prds directory scanned — not every project's:
|
|
199
199
|
```bash
|
|
200
|
-
ls <cwd>/session-manager-operations/scheduler/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
|
|
200
|
+
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
201
|
```
|
|
202
202
|
(`<cwd>` is the target repo's absolute path — the same one this PRD's `cwd` field will use.)
|
|
203
203
|
The last line is the current max within that project. Then: same `NN` as a logically
|
|
@@ -218,19 +218,25 @@ can't load skills.
|
|
|
218
218
|
system that runs `claude -p <prd-body> --dangerously-skip-permissions` jobs around 5-hour
|
|
219
219
|
token-window resets, with auto-pause on rate-limit and auto-resume.
|
|
220
220
|
|
|
221
|
-
**Canonical location — non-negotiable.**
|
|
221
|
+
**Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
|
|
222
|
+
PRD domain model in the project CLAUDE.md). PRDs MUST be written to, inside the target repo:
|
|
222
223
|
```
|
|
223
|
-
<cwd>/session-manager-operations/scheduler/prds/<NN>-<kebab-slug>.md
|
|
224
|
+
<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
|
|
224
225
|
```
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
226
|
+
Get `<epic-id>`'s prds/ directory by minting (or joining) an Epic first:
|
|
227
|
+
```bash
|
|
228
|
+
node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<one-line goal for this work>" [feature|bug|discussion]
|
|
229
|
+
```
|
|
230
|
+
The last stdout line is the exact directory to write into. Reuse-by-goal makes it idempotent —
|
|
231
|
+
all PRDs of one /develop ask share one Epic. (The MCP `scheduler_create_prd` tool does all of
|
|
232
|
+
this automatically; the mint step is only for the manual-write fallback.)
|
|
233
|
+
|
|
234
|
+
**Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
|
|
235
|
+
old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
|
|
236
|
+
the scheduler; the legacy flat `session-manager-operations/scheduler/prds/` dir is RETIRED —
|
|
237
|
+
anything written there is auto-consolidated into `prds-archived/` at the next app boot
|
|
238
|
+
without being executed. PRD *source* files are per-project and per-Epic, resolved at runtime
|
|
239
|
+
via `src/main/lib/prdLocations.cjs`.
|
|
234
240
|
|
|
235
241
|
**Filename rules.** `NN` is the 2-digit zero-padded parallel group (picked per the `ls`
|
|
236
242
|
command above — same `NN` as an independent sibling, or next free `NN` = max+1 when this PRD
|
|
@@ -8,7 +8,9 @@ const os = require('node:os');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
|
|
10
10
|
const { activeProjectCwds } = require('./activeSessions.cjs');
|
|
11
|
-
const { resolvePrdWriteDir } = require('../../src/main/lib/prdLocations.cjs');
|
|
11
|
+
const { resolvePrdWriteDir, listEpicPrdDirs } = require('../../src/main/lib/prdLocations.cjs');
|
|
12
|
+
const { ensureEpic, appendPrdCreatedEvent } = require('../../src/main/lib/epicMint.cjs');
|
|
13
|
+
const { projectQueuePath } = require('../../src/main/lib/queueStore.cjs');
|
|
12
14
|
|
|
13
15
|
// Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
|
|
14
16
|
const DEFAULT_HEARTBEAT_PATH = path.join(
|
|
@@ -294,7 +296,7 @@ function hasOpenFeedback(cwd) {
|
|
|
294
296
|
*/
|
|
295
297
|
function emitFeedbackPRD(cwd, {
|
|
296
298
|
prdsDir,
|
|
297
|
-
queuePath
|
|
299
|
+
queuePath,
|
|
298
300
|
skillPath,
|
|
299
301
|
standardsPath,
|
|
300
302
|
pluginCacheRoot = PLUGIN_CACHE_ROOT,
|
|
@@ -306,8 +308,18 @@ function emitFeedbackPRD(cwd, {
|
|
|
306
308
|
// PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
|
|
307
309
|
// prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
|
|
308
310
|
// global dir into `<cwd>/session-manager-operations/scheduler/prds`).
|
|
311
|
+
let epicId = null;
|
|
309
312
|
if (prdsDir === undefined) {
|
|
310
|
-
|
|
313
|
+
// Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
|
|
314
|
+
// one standing "Inbound feedback" Epic per project (reuseByGoal) so
|
|
315
|
+
// recurring ticks chain into a single Epic rather than minting one each.
|
|
316
|
+
const epic = ensureEpic(cwd, {
|
|
317
|
+
goalText: 'Inbound feedback processing',
|
|
318
|
+
tag: 'discussion',
|
|
319
|
+
reuseByGoal: true,
|
|
320
|
+
});
|
|
321
|
+
epicId = epic.epicId;
|
|
322
|
+
prdsDir = epic.prdDir;
|
|
311
323
|
}
|
|
312
324
|
const skillPathExplicit = skillPath !== undefined;
|
|
313
325
|
const standardsPathExplicit = standardsPath !== undefined;
|
|
@@ -319,11 +331,20 @@ function emitFeedbackPRD(cwd, {
|
|
|
319
331
|
}
|
|
320
332
|
const project = slugify(path.basename(cwd));
|
|
321
333
|
|
|
322
|
-
// De-dup: check queue
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
334
|
+
// De-dup: check the queue for a pending/running feedback job for this
|
|
335
|
+
// project. Federated state (2026-07-31): the project's own shard is the
|
|
336
|
+
// system of record; the legacy global path is still consulted as a fallback
|
|
337
|
+
// during transition. An explicit queuePath override (tests) wins verbatim.
|
|
338
|
+
const queuePaths = queuePath !== undefined
|
|
339
|
+
? [queuePath]
|
|
340
|
+
: [projectQueuePath(cwd), DEFAULT_QUEUE_PATH];
|
|
341
|
+
const queueState = { jobs: [] };
|
|
342
|
+
for (const qp of queuePaths) {
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(fs.readFileSync(qp, 'utf8'));
|
|
345
|
+
queueState.jobs.push(...(Array.isArray(parsed.jobs) ? parsed.jobs : []));
|
|
346
|
+
} catch { /* file may not exist */ }
|
|
347
|
+
}
|
|
327
348
|
|
|
328
349
|
// project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
|
|
329
350
|
const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
|
|
@@ -341,18 +362,28 @@ function emitFeedbackPRD(cwd, {
|
|
|
341
362
|
// watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
|
|
342
363
|
const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
|
|
343
364
|
let maxNN = 0;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
365
|
+
// NN numbering and the on-disk dedup are project-global, so scan every PRD
|
|
366
|
+
// location the project has: the write dir, the legacy flat dir, and every
|
|
367
|
+
// Epic's prds/ dir. (An explicit prdsDir override — tests — scans only it.)
|
|
368
|
+
const scanDirs = new Set([prdsDir]);
|
|
369
|
+
if (epicId !== null) {
|
|
370
|
+
try { scanDirs.add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
|
|
371
|
+
for (const d of listEpicPrdDirs(cwd)) scanDirs.add(d);
|
|
372
|
+
}
|
|
373
|
+
for (const dir of scanDirs) {
|
|
374
|
+
let prdFiles = [];
|
|
375
|
+
try {
|
|
376
|
+
prdFiles = fs.readdirSync(dir);
|
|
377
|
+
} catch { continue; /* dir may not exist yet */ }
|
|
378
|
+
for (const f of prdFiles) {
|
|
379
|
+
if (prdDupRe.test(f)) {
|
|
380
|
+
return { emitted: false, reason: 'duplicate' };
|
|
381
|
+
}
|
|
382
|
+
const m = f.match(/^(\d+)-/);
|
|
383
|
+
if (m) {
|
|
384
|
+
const n = parseInt(m[1], 10);
|
|
385
|
+
if (n > maxNN) maxNN = n;
|
|
386
|
+
}
|
|
356
387
|
}
|
|
357
388
|
}
|
|
358
389
|
const nn = String(maxNN + 1).padStart(2, '0');
|
|
@@ -440,6 +471,11 @@ function emitFeedbackPRD(cwd, {
|
|
|
440
471
|
fs.mkdirSync(prdsDir, { recursive: true });
|
|
441
472
|
fs.writeFileSync(tmpPath, body + '\n', 'utf8');
|
|
442
473
|
fs.renameSync(tmpPath, prdPath);
|
|
474
|
+
if (epicId !== null) {
|
|
475
|
+
// Trace the dispatch on the Epic's event chain (prompt → prd_created →
|
|
476
|
+
// ...) so the Epic conversation shows the PRD it spawned.
|
|
477
|
+
try { appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
|
|
478
|
+
}
|
|
443
479
|
} catch (e) {
|
|
444
480
|
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
445
481
|
throw e;
|
|
@@ -622,7 +658,7 @@ function maybeRelaunchApp({
|
|
|
622
658
|
function sweep({
|
|
623
659
|
projectsDir,
|
|
624
660
|
prdsDir,
|
|
625
|
-
queuePath
|
|
661
|
+
queuePath,
|
|
626
662
|
skillPath,
|
|
627
663
|
standardsPath,
|
|
628
664
|
} = {}) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-prd-migration.test.cjs — stranded-PRD-migration health signal
|
|
3
|
+
* (behind two real 2026-07-31 incidents: a single stranded PRD causing an
|
|
4
|
+
* instant ENOENT job failure, and 223 stranded legacy PRDs + a missing
|
|
5
|
+
* history.jsonl resurrecting 189 completed jobs). Exercises
|
|
6
|
+
* evaluatePrdMigrationHealth() directly since it's pure — no need to spawn
|
|
7
|
+
* the full check() (which shells out to npm run typecheck) to cover this.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/health-prd-migration.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect } from 'vitest';
|
|
15
|
+
const { evaluatePrdMigrationHealth } = require('../health.cjs');
|
|
16
|
+
|
|
17
|
+
const LEGACY_DIR = '/home/user/.claude/session-manager/scheduled-plans/prds';
|
|
18
|
+
|
|
19
|
+
test('reports ok when nothing is stranded', () => {
|
|
20
|
+
const result = evaluatePrdMigrationHealth({ moved: 3, skipped: 0, unresolved: [] }, LEGACY_DIR);
|
|
21
|
+
expect(result.ok).toBe(true);
|
|
22
|
+
expect(result.strandedCount).toBe(0);
|
|
23
|
+
expect(result.legacyDir).toBe(LEGACY_DIR);
|
|
24
|
+
expect(result.unresolved).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('reports RED with count and legacy dir path when files are stranded', () => {
|
|
28
|
+
const unresolved = [
|
|
29
|
+
{ file: '01-stuck.md', reason: 'no cwd in frontmatter' },
|
|
30
|
+
{ file: '02-stuck.md', reason: 'cwd does not exist on disk' },
|
|
31
|
+
];
|
|
32
|
+
const result = evaluatePrdMigrationHealth({ moved: 0, skipped: 0, unresolved }, LEGACY_DIR);
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
expect(result.strandedCount).toBe(2);
|
|
35
|
+
expect(result.legacyDir).toBe(LEGACY_DIR);
|
|
36
|
+
expect(result.unresolved).toEqual(unresolved);
|
|
37
|
+
});
|
|
@@ -146,11 +146,13 @@ test('readHistory respects limit and returns [] when file is absent', async () =
|
|
|
146
146
|
const empty = await queueHistory.readHistory({ limit: 5 });
|
|
147
147
|
expect(empty).toEqual([]);
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// Realistic append order: oldest finishedAt first (append-only file grows
|
|
150
|
+
// forward in time), so s7 is both last-appended and newest.
|
|
151
|
+
const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - (7 - i) * 60_000).toISOString() }));
|
|
150
152
|
await queueHistory.appendHistory(entries);
|
|
151
153
|
const read = await queueHistory.readHistory({ limit: 3 });
|
|
152
154
|
expect(read.length).toBe(3);
|
|
153
|
-
//
|
|
155
|
+
// Federated readHistory (2026-07-31) orders by finishedAt newest-first.
|
|
154
156
|
expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
|
|
155
157
|
});
|
|
156
158
|
|
|
@@ -165,8 +167,11 @@ test('readHistory tail-reads correctly when the file is larger than one chunk',
|
|
|
165
167
|
}));
|
|
166
168
|
await queueHistory.appendHistory(bigEntries);
|
|
167
169
|
const read = await queueHistory.readHistory({ limit: 5 });
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
+
// The tail read only touches the file's end; whatever was read comes back
|
|
171
|
+
// ordered by finishedAt newest-first.
|
|
172
|
+
expect(read.length).toBe(5);
|
|
173
|
+
const t = (j) => Date.parse(j.finishedAt);
|
|
174
|
+
for (let i = 1; i < read.length; i++) expect(t(read[i - 1])).toBeGreaterThanOrEqual(t(read[i]));
|
|
170
175
|
});
|
|
171
176
|
|
|
172
177
|
// ---------- selectHistoryJobs merge (scheduler.cjs) ----------
|
|
@@ -140,3 +140,15 @@ test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async ()
|
|
|
140
140
|
const result = await autoArchiveCompleted({}, { nowMs: NOW });
|
|
141
141
|
expect(result.archived).toBe(0);
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
// ---------- archiving never strands a runnable queue job (scheduler PRD:
|
|
145
|
+
// archived-prd-skip — job 822-epics-nav-rename fired ENOENT after its PRD
|
|
146
|
+
// was archived out from under a still-queued job) ----------
|
|
147
|
+
|
|
148
|
+
test('selectAutoArchivable never selects a slug whose job is still pending or running, so auto-archive can never leave a runnable job pointing at an archived PRD', () => {
|
|
149
|
+
const pending = old({ slug: '13-pending', status: 'pending', finishedAt: null });
|
|
150
|
+
const running = old({ slug: '14-running', status: 'running', finishedAt: null });
|
|
151
|
+
const completed = old({ slug: '15-done', status: 'completed' });
|
|
152
|
+
const slugs = selectAutoArchivable([pending, running, completed], { nowMs: NOW });
|
|
153
|
+
expect(slugs).toEqual(['15-done']);
|
|
154
|
+
});
|
|
@@ -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)', () => {
|
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/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
|
@@ -450,9 +450,12 @@ ipcMain.handle('app:home-self-check', () => bootHomeSelfCheck);
|
|
|
450
450
|
ipcMain.handle('app:pick-directory', async () => {
|
|
451
451
|
console.log('[main] pick-directory invoked');
|
|
452
452
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
453
|
-
|
|
453
|
+
// createDirectory enables the picker's "New Folder" affordance (macOS;
|
|
454
|
+
// the GTK chooser has its own) — this is the "Start Project" path:
|
|
455
|
+
// create a fresh folder in place, open it as the project's TAB.
|
|
456
|
+
properties: ['openDirectory', 'createDirectory'],
|
|
454
457
|
defaultPath: os.homedir(),
|
|
455
|
-
title: '
|
|
458
|
+
title: 'Open a project folder — or create a new one to start a project',
|
|
456
459
|
});
|
|
457
460
|
console.log('[main] pick-directory result:', result);
|
|
458
461
|
if (result.canceled || result.filePaths.length === 0) return null;
|
|
@@ -760,7 +763,7 @@ voiceWizard.registerWizardHandlers();
|
|
|
760
763
|
scheduler.registerScheduleHandlers();
|
|
761
764
|
watchers.registerWatcherHandlers();
|
|
762
765
|
teams.registerTeamsHandlers();
|
|
763
|
-
queueOps.registerQueueOpsHandlers();
|
|
766
|
+
queueOps.registerQueueOpsHandlers({ retireCompletedSlugs: scheduler.retireCompletedSlugs });
|
|
764
767
|
registerHistoryAggregatorHandlers();
|
|
765
768
|
registerHistoryDashboardHandlers();
|
|
766
769
|
pluginInstall.registerPluginInstallHandlers();
|
|
@@ -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
|
+
});
|