claude-code-session-manager 0.39.4 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-BSRtwtPP.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DyLm7GM_.css → index-CR-WdMgm.css} +1 -1
- package/dist/assets/index-oMNYp73N.js +3194 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +14 -13
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/config.cjs +10 -0
- package/src/main/index.cjs +22 -6
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/epicMint.cjs +1 -1
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +28 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +94 -5
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +16 -3
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-XGsBHqYu.js +0 -3180
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instanceLock.cjs — machine-wide scheduler-ownership lock (PRD 834).
|
|
3
|
+
*
|
|
4
|
+
* Electron's app.requestSingleInstanceLock() is keyed by userData path, so an
|
|
5
|
+
* instance launched with a different identity bypasses it entirely — live
|
|
6
|
+
* incident 2026-07-31: a Playwright `_electron.launch(src/main/index.cjs)`
|
|
7
|
+
* runs as the "Electron" default app (userData ~/.config/Electron), won its
|
|
8
|
+
* own Electron lock, ran full scheduler boot reconciliation against the same
|
|
9
|
+
* per-project queue state as the production instance, SIGTERM'd the
|
|
10
|
+
* production instance's live job as an "orphan", and overwrote
|
|
11
|
+
* admin-api.json. Dev/e2e launches skip the Electron lock on purpose, with
|
|
12
|
+
* the same effect.
|
|
13
|
+
*
|
|
14
|
+
* This lock is identity-independent: a pid file at a FIXED path under
|
|
15
|
+
* ~/.claude/session-manager/. Exactly one process — whichever wins the
|
|
16
|
+
* atomic 'wx' create — owns scheduler mutation (boot reconciliation, queue
|
|
17
|
+
* ticking, feedback sweep, supervisor, heartbeat) and the admin API. Every
|
|
18
|
+
* other instance runs scheduler-passive: UI reads still work (IPC read
|
|
19
|
+
* handlers register unconditionally), but nothing that mutates queue state
|
|
20
|
+
* or kills processes runs.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately plain sync fs (no config.cjs writeJson): this runs before
|
|
23
|
+
* app.whenReady and must not await; the file is tiny and single-writer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
// Env override is for unit tests only (isolates the lock from the real
|
|
33
|
+
// ~/.claude of the machine running the suite).
|
|
34
|
+
function lockPath() {
|
|
35
|
+
return process.env.SM_SCHEDULER_LOCK_PATH
|
|
36
|
+
|| path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-owner.lock');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pidAlive(pid) {
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// EPERM = alive but not ours; ESRCH = dead.
|
|
46
|
+
return e && e.code === 'EPERM';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readLock() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(lockPath(), 'utf8');
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
return Number.isInteger(parsed?.pid) ? parsed : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeLockExclusive() {
|
|
61
|
+
const body = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
62
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
63
|
+
// 'wx' = atomic create-or-fail — two racing instances cannot both win.
|
|
64
|
+
fs.writeFileSync(lockPath(), body, { flag: 'wx', mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Try to become the machine's scheduler owner.
|
|
69
|
+
* Returns { owner: true } or { owner: false, holderPid } — never throws.
|
|
70
|
+
*/
|
|
71
|
+
function acquireSchedulerOwnership() {
|
|
72
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
73
|
+
const existing = readLock();
|
|
74
|
+
if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
|
|
75
|
+
return { owner: false, holderPid: existing.pid };
|
|
76
|
+
}
|
|
77
|
+
// Missing, unreadable, our own, or stale (holder pid dead) — take it.
|
|
78
|
+
try {
|
|
79
|
+
if (existing || fs.existsSync(lockPath())) fs.unlinkSync(lockPath());
|
|
80
|
+
} catch { /* raced with another breaker — retry loop below settles it */ }
|
|
81
|
+
try {
|
|
82
|
+
writeLockExclusive();
|
|
83
|
+
return { owner: true };
|
|
84
|
+
} catch {
|
|
85
|
+
// Lost the create race — loop once more to report the winner's pid.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const winner = readLock();
|
|
89
|
+
return { owner: false, holderPid: winner?.pid };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Best-effort release on clean shutdown; stale-pid detection covers crashes. */
|
|
93
|
+
function releaseSchedulerOwnership() {
|
|
94
|
+
try {
|
|
95
|
+
const existing = readLock();
|
|
96
|
+
if (existing && existing.pid === process.pid) fs.unlinkSync(lockPath());
|
|
97
|
+
} catch { /* nothing to release */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { acquireSchedulerOwnership, releaseSchedulerOwnership, pidAlive, lockPath };
|
|
@@ -48,7 +48,7 @@ function deriveSlugFromTitle(title) {
|
|
|
48
48
|
function buildPrdBody(input) {
|
|
49
49
|
const {
|
|
50
50
|
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
51
|
-
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag,
|
|
51
|
+
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, dependsOn,
|
|
52
52
|
} = input;
|
|
53
53
|
|
|
54
54
|
// No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
|
|
@@ -64,6 +64,8 @@ function buildPrdBody(input) {
|
|
|
64
64
|
// Optional, additive: the user-selected Feature/Bug tag (PRD 774) carried
|
|
65
65
|
// through from the originating PromptTicket — deterministic, never LLM-classified.
|
|
66
66
|
if (tag) fmLines.push(`tag: ${tag}`);
|
|
67
|
+
// Explicit ordering (PRD 832): replaces the retired shared-NN convention.
|
|
68
|
+
if (dependsOn && dependsOn.length) fmLines.push(`dependsOn: [${dependsOn.join(', ')}]`);
|
|
67
69
|
fmLines.push('---', '');
|
|
68
70
|
|
|
69
71
|
const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
|
|
@@ -108,21 +110,32 @@ async function createPrd(input, remote) {
|
|
|
108
110
|
// through config.cjs's validatePath (allowedRoots = home dir) —
|
|
109
111
|
// same boundary every other fs-touching IPC handler uses — never a
|
|
110
112
|
// bespoke check here.
|
|
113
|
+
// Normalize a `~`-prefixed cwd BEFORE it reaches allocateParallelGroup/
|
|
114
|
+
// readPrd/writePrd — those pass cwd through to safeSlugPathIn-adjacent path
|
|
115
|
+
// joins that treat it as a literal path segment, so an unexpanded `~/...`
|
|
116
|
+
// fails downstream as a bare "invalid slug" instead of a clear cwd error.
|
|
117
|
+
const cwd = expandHome(input.cwd);
|
|
111
118
|
try {
|
|
112
|
-
config.validatePath(
|
|
119
|
+
config.validatePath(cwd);
|
|
113
120
|
} catch (e) {
|
|
114
121
|
return { ok: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
|
|
115
122
|
}
|
|
123
|
+
input = { ...input, cwd };
|
|
116
124
|
|
|
117
125
|
const slug = input.slug || deriveSlugFromTitle(input.title);
|
|
118
126
|
if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
|
|
119
127
|
return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
130
|
+
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via the
|
|
131
|
+
// injected remote — never re-derived here. `parallelGroup` input is
|
|
132
|
+
// DEPRECATED (PRD 832, user decision 2026-07-31): numbers are strictly
|
|
133
|
+
// unique per project; ordering is expressed via `dependsOn` frontmatter,
|
|
134
|
+
// never by sharing a number. An explicit parallelGroup is ignored.
|
|
135
|
+
if (input.parallelGroup != null) {
|
|
136
|
+
console.warn(`[prdCreate] parallelGroup input is deprecated and ignored (got ${input.parallelGroup}) — numbers are unique per project; use dependsOn for ordering`);
|
|
137
|
+
}
|
|
138
|
+
const nn = await remote.allocateParallelGroup(input.cwd);
|
|
126
139
|
const filenameSlug = `${nn}-${slug}`;
|
|
127
140
|
|
|
128
141
|
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
@@ -125,12 +125,40 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
125
125
|
return dirs;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* deriveEpicIdFromPrdPath(filePath) → epicId | null
|
|
130
|
+
*
|
|
131
|
+
* A PRD's file location already IS its Epic membership (epic id == parent
|
|
132
|
+
* dir name, 1:1 by design). Given an absolute PRD file path, walk back up
|
|
133
|
+
* `EPICS_SUBPATH.length` segments from the epics root implied by the path's
|
|
134
|
+
* own `.../prds/<slug>.md` shape and confirm it round-trips through
|
|
135
|
+
* `resolveEpicsRoot` — i.e. the path really is
|
|
136
|
+
* `<projectCwd>/session-manager-operations/scheduler/epics/<epicId>/prds/<slug>.md`,
|
|
137
|
+
* not some other `prds/` dir (e.g. the retired flat layout). Returns null for
|
|
138
|
+
* any path that doesn't match this shape.
|
|
139
|
+
*/
|
|
140
|
+
function deriveEpicIdFromPrdPath(filePath) {
|
|
141
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
142
|
+
const prdsDir = path.dirname(filePath);
|
|
143
|
+
if (path.basename(prdsDir) !== 'prds') return null;
|
|
144
|
+
const epicDir = path.dirname(prdsDir);
|
|
145
|
+
const epicId = path.basename(epicDir);
|
|
146
|
+
if (!epicId || epicId === '.' || epicId === path.sep) return null;
|
|
147
|
+
let projectCwd = path.dirname(epicDir); // epicsRoot (.../scheduler/epics)
|
|
148
|
+
for (let i = 0; i < EPICS_SUBPATH.length; i++) projectCwd = path.dirname(projectCwd);
|
|
149
|
+
let epicsRoot;
|
|
150
|
+
try { epicsRoot = resolveEpicsRoot(projectCwd); } catch { return null; }
|
|
151
|
+
if (path.join(epicsRoot, epicId, 'prds') !== prdsDir) return null;
|
|
152
|
+
return epicId;
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
module.exports = {
|
|
129
156
|
resolvePrdWriteDir,
|
|
130
157
|
resolvePrdsDirs,
|
|
131
158
|
resolveEpicsRoot,
|
|
132
159
|
resolveEpicPrdWriteDir,
|
|
133
160
|
listEpicPrdDirs,
|
|
161
|
+
deriveEpicIdFromPrdPath,
|
|
134
162
|
PRD_SUBPATH,
|
|
135
163
|
EPICS_SUBPATH,
|
|
136
164
|
};
|
|
@@ -38,12 +38,44 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
|
|
|
38
38
|
* human-readable reason text that would otherwise only reach console.log.
|
|
39
39
|
*/
|
|
40
40
|
function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
41
|
-
const
|
|
41
|
+
const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
|
|
42
|
+
|
|
43
|
+
// Explicit dependsOn eligibility (PRD 832). A dep slug is BLOCKING while a
|
|
44
|
+
// queue row for it exists in a non-completed state; a slug with no row is
|
|
45
|
+
// treated as already done (completed rows are retired to history shards,
|
|
46
|
+
// so absence is the normal end-state of a finished dep). A FAILED dep
|
|
47
|
+
// holds the dependent with an explicit reason, mirroring the failure gate.
|
|
48
|
+
// Legacy jobs without dependsOn keep the shared-NN group semantics below
|
|
49
|
+
// unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
|
|
50
|
+
// its order without migration.
|
|
51
|
+
const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
52
|
+
const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => {
|
|
53
|
+
const dep = rowBySlug.get(slug);
|
|
54
|
+
return dep && dep.status !== 'completed';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const allPending = projectJobs.filter(
|
|
42
58
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
43
59
|
);
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const
|
|
60
|
+
if (allPending.length === 0) return { batch: [], reason: null };
|
|
61
|
+
|
|
62
|
+
const pending = [];
|
|
63
|
+
const heldByFailedDep = [];
|
|
64
|
+
for (const j of allPending) {
|
|
65
|
+
const dep = blockingDep(j);
|
|
66
|
+
if (!dep) { pending.push(j); continue; }
|
|
67
|
+
if (rowBySlug.get(dep)?.status === 'failed') heldByFailedDep.push({ job: j, dep });
|
|
68
|
+
// running/pending/needs_review dep — simply not eligible this tick.
|
|
69
|
+
}
|
|
70
|
+
if (pending.length === 0) {
|
|
71
|
+
if (heldByFailedDep.length > 0) {
|
|
72
|
+
const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
|
|
73
|
+
const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
|
|
74
|
+
console.log(reason);
|
|
75
|
+
return { batch: [], reason };
|
|
76
|
+
}
|
|
77
|
+
return { batch: [], reason: null };
|
|
78
|
+
}
|
|
47
79
|
|
|
48
80
|
// Lowest pending group (computed up-front for the failure-gate check).
|
|
49
81
|
const lowestPendingGroup = pending.reduce(
|
package/src/main/pty.cjs
CHANGED
|
@@ -231,6 +231,14 @@ class PtyManager {
|
|
|
231
231
|
killAll() {
|
|
232
232
|
for (const tabId of [...this.sessions.keys()]) this.kill(tabId);
|
|
233
233
|
}
|
|
234
|
+
|
|
235
|
+
/** Subset of `ids` that currently have a live PTY in the sessions map.
|
|
236
|
+
* Lets the Epics workspace reconcile Terminal-mode attachment after a
|
|
237
|
+
* renderer reload (the PTY survives; the renderer's in-memory attachment
|
|
238
|
+
* record does not — PRD 833 C1). */
|
|
239
|
+
aliveOf(ids) {
|
|
240
|
+
return ids.filter((id) => this.sessions.has(id));
|
|
241
|
+
}
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
const manager = new PtyManager();
|
|
@@ -244,6 +252,7 @@ function registerPtyHandlers() {
|
|
|
244
252
|
if (typeof tabId !== 'string') return;
|
|
245
253
|
manager.kill(tabId);
|
|
246
254
|
});
|
|
255
|
+
ipcMain.handle('pty:alive', v(s.ptyAlive, ({ tabIds }) => manager.aliveOf(tabIds)));
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
module.exports = { manager, registerPtyHandlers };
|
|
@@ -18,6 +18,7 @@ const fsp = require('node:fs/promises');
|
|
|
18
18
|
const os = require('node:os');
|
|
19
19
|
const path = require('node:path');
|
|
20
20
|
const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
|
|
21
|
+
const { deriveEpicIdFromPrdPath } = require('../lib/prdLocations.cjs');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Expand a PRD `cwd` value to an absolute path.
|
|
@@ -66,17 +67,37 @@ async function parsePrdRaw(filePath) {
|
|
|
66
67
|
parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
|
|
67
68
|
// Optional traceability back to the PromptTicket.id (PRD 748) that was
|
|
68
69
|
// classified 'develop' and spawned this PRD (PRD 749). Additive — absent
|
|
69
|
-
// on every PRD authored before this field existed.
|
|
70
|
-
|
|
70
|
+
// on every PRD authored before this field existed. When frontmatter
|
|
71
|
+
// omits it, fall back to the owning Epic dir name (PRD 830) — a PRD's
|
|
72
|
+
// file location already IS its Epic membership, so hand-authored PRDs
|
|
73
|
+
// dropped straight into an Epic's prds/ dir still get real linkage
|
|
74
|
+
// instead of null.
|
|
75
|
+
sourcePromptId: fm.sourcePromptId || deriveEpicIdFromPrdPath(filePath) || null,
|
|
71
76
|
// Optional traceability back to the chat tab that queued this PRD (PRD
|
|
72
77
|
// 761) — read back at job completion to route a status prompt via
|
|
73
78
|
// enqueueExternalPrompt (PRD 753). Additive — absent on every PRD
|
|
74
79
|
// authored before this field existed.
|
|
75
80
|
sourceTabId: fm.sourceTabId || null,
|
|
81
|
+
// Explicit cross-PRD ordering (PRD 832): `dependsOn: [<slug>, <slug>]`
|
|
82
|
+
// or a comma-separated string. Replaces the retired shared-NN-means-
|
|
83
|
+
// parallel convention — a job is eligible only once every listed slug's
|
|
84
|
+
// queue row is completed (a slug with no row is treated as already
|
|
85
|
+
// done/archived, matching retireCompletedSlugs semantics).
|
|
86
|
+
dependsOn: parseDependsOn(fm.dependsOn),
|
|
76
87
|
body: body.trim(),
|
|
77
88
|
};
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
/** `[a, b]` / `a, b` / `a` → ['a','b']; anything else → []. */
|
|
92
|
+
function parseDependsOn(raw) {
|
|
93
|
+
if (!raw || typeof raw !== 'string') return [];
|
|
94
|
+
const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
|
|
95
|
+
return inner
|
|
96
|
+
.split(',')
|
|
97
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
98
|
+
.filter((s) => /^[A-Za-z0-9][\w.-]*$/.test(s));
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/**
|
|
81
102
|
* List `.md` PRD files under the given dir. Caches the result keyed by the
|
|
82
103
|
* directory mtime so repeated reconcile() calls don't re-stat every entry.
|
|
@@ -257,11 +278,14 @@ async function maxParallelGroupInUse(prdsDir) {
|
|
|
257
278
|
* number) and never wedges future callers, since each attempt only needs
|
|
258
279
|
* the marker for its OWN candidate to not already exist.
|
|
259
280
|
*/
|
|
260
|
-
async function allocateParallelGroup(prdsDir) {
|
|
281
|
+
async function allocateParallelGroup(prdsDir, { extraFloor = 0 } = {}) {
|
|
261
282
|
await fsp.mkdir(prdsDir, { recursive: true });
|
|
262
283
|
const scanMax = await maxParallelGroupInUse(prdsDir);
|
|
263
284
|
const highWater = await readHighWaterMark(prdsDir);
|
|
264
|
-
|
|
285
|
+
// extraFloor (PRD 832): the caller's max across OTHER dirs sharing the
|
|
286
|
+
// project's number space (epic prds/ dirs, prds-archived) — numbers are
|
|
287
|
+
// unique per project, never merely per directory.
|
|
288
|
+
const floor = Math.max(scanMax, highWater, extraFloor);
|
|
265
289
|
let candidate = floor + 1;
|
|
266
290
|
for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
|
|
267
291
|
const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -86,7 +86,25 @@ const queueOps = require('./queueOps.cjs');
|
|
|
86
86
|
// home-dir layout.
|
|
87
87
|
const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
|
|
88
88
|
const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs } = require('./lib/prdLocations.cjs');
|
|
89
|
-
const { ensureEpic, appendPrdCreatedEvent } = require('./lib/epicMint.cjs');
|
|
89
|
+
const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
|
|
90
|
+
|
|
91
|
+
// ---------- origin session resolution (PRD 832) ----------
|
|
92
|
+
// An Epic IS a tagged claude session — job rows carry the originating
|
|
93
|
+
// claudeSessionId alongside sourcePromptId so every PRD stays traceable to
|
|
94
|
+
// the session that spawned it. active-index.json is tiny; a short TTL cache
|
|
95
|
+
// keeps reconcile (every 60s, N jobs) at one read per project per pass.
|
|
96
|
+
const originIndexCache = new Map(); // cwd -> { at, sessions }
|
|
97
|
+
const ORIGIN_CACHE_TTL_MS = 30_000;
|
|
98
|
+
function resolveOriginSessionId(cwd, epicId) {
|
|
99
|
+
if (!cwd || !epicId) return null;
|
|
100
|
+
let entry = originIndexCache.get(cwd);
|
|
101
|
+
if (!entry || Date.now() - entry.at > ORIGIN_CACHE_TTL_MS) {
|
|
102
|
+
entry = { at: Date.now(), sessions: readActiveIndex(cwd).sessions };
|
|
103
|
+
originIndexCache.set(cwd, entry);
|
|
104
|
+
}
|
|
105
|
+
const session = entry.sessions[epicId];
|
|
106
|
+
return session && typeof session.claudeSessionId === 'string' ? session.claudeSessionId : null;
|
|
107
|
+
}
|
|
90
108
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
91
109
|
const queueStore = require('./lib/queueStore.cjs');
|
|
92
110
|
const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
|
|
@@ -911,7 +929,23 @@ async function listPrdFiles() {
|
|
|
911
929
|
async function allocateParallelGroup(cwd) {
|
|
912
930
|
const dir = prdDirForCwd(cwd);
|
|
913
931
|
await fsp.mkdir(dir, { recursive: true });
|
|
914
|
-
|
|
932
|
+
// PRD 832: numbers are unique across the WHOLE project, not just the
|
|
933
|
+
// allocator's bookkeeping dir — scan every Epic prds/ dir plus the
|
|
934
|
+
// archive so a number used anywhere (even by a hand-authored or archived
|
|
935
|
+
// PRD) is never reissued. The reservation markers + high-water sidecar
|
|
936
|
+
// stay in `dir`; the cross-dir max only raises the floor.
|
|
937
|
+
const targetCwd = cwd || DEFAULT_PROJECT_CWD;
|
|
938
|
+
const extraDirs = [
|
|
939
|
+
...listEpicPrdDirs(targetCwd),
|
|
940
|
+
path.join(prdDirForCwd(targetCwd), '..', 'prds-archived'),
|
|
941
|
+
];
|
|
942
|
+
let extraFloor = 0;
|
|
943
|
+
for (const d of extraDirs) {
|
|
944
|
+
try {
|
|
945
|
+
extraFloor = Math.max(extraFloor, await prdParser.maxParallelGroupInUse(d));
|
|
946
|
+
} catch { /* missing dir — nothing allocated there */ }
|
|
947
|
+
}
|
|
948
|
+
return prdParser.allocateParallelGroup(dir, { extraFloor });
|
|
915
949
|
}
|
|
916
950
|
|
|
917
951
|
/**
|
|
@@ -991,6 +1025,20 @@ function validatePromptForSpawn(body, srcLabel) {
|
|
|
991
1025
|
|
|
992
1026
|
// ---------- queue reconciliation ----------
|
|
993
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* sourcePromptId backfill (PRD 830) is pending-only: a pending row always
|
|
1030
|
+
* takes the freshly-parsed value (explicit frontmatter, or the dir-derived
|
|
1031
|
+
* epic id parsePrd falls back to when frontmatter has none) since it hasn't
|
|
1032
|
+
* started executing yet. A running/completed row's sourcePromptId is left
|
|
1033
|
+
* exactly as it was minted at dispatch time — reconcile must not rewrite
|
|
1034
|
+
* linkage on work already in flight or finished.
|
|
1035
|
+
*/
|
|
1036
|
+
function reconcileSourcePromptId(job, parsedSourcePromptId) {
|
|
1037
|
+
return job.status === 'pending'
|
|
1038
|
+
? (parsedSourcePromptId ?? job.sourcePromptId ?? null)
|
|
1039
|
+
: job.sourcePromptId;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
994
1042
|
/**
|
|
995
1043
|
* Walk prds/, ensure every .md has a queue entry. Drop entries whose .md
|
|
996
1044
|
* is gone. Refresh title/cwd/parallelGroup from disk every reconcile so
|
|
@@ -1033,6 +1081,16 @@ async function reconcile(state) {
|
|
|
1033
1081
|
// or mid-move. "I can't see it" is not "the user deleted it", so the
|
|
1034
1082
|
// row survives — worst case it re-resolves on the next pass.
|
|
1035
1083
|
if (job.status === 'pending' || job.status === 'running') {
|
|
1084
|
+
// Exception: a PENDING row whose PRD has an archived twin was
|
|
1085
|
+
// retired on purpose (work landed by other means — e.g. implemented
|
|
1086
|
+
// inline — and the source .md moved to prds-archived/). Keeping it
|
|
1087
|
+
// would show a phantom "scheduled" job forever; firing it would just
|
|
1088
|
+
// hit executeJob's archived-twin skip anyway. Running rows are left
|
|
1089
|
+
// alone — the reaper owns their lifecycle.
|
|
1090
|
+
if (job.status === 'pending' && (await archivedTwinExists(job))) {
|
|
1091
|
+
console.log(`[scheduler] reconcile: retiring pending job ${job.slug} — PRD already archived (work landed elsewhere)`);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1036
1094
|
seen.add(job.slug);
|
|
1037
1095
|
next.push({ ...job });
|
|
1038
1096
|
console.warn(`[scheduler] reconcile: keeping ${job.status} job ${job.slug} — PRD source not visible in any candidate dir`);
|
|
@@ -1046,8 +1104,11 @@ async function reconcile(state) {
|
|
|
1046
1104
|
cwd: p.cwd,
|
|
1047
1105
|
parallelGroup: p.parallelGroup,
|
|
1048
1106
|
estimateMinutes: p.estimateMinutes,
|
|
1049
|
-
sourcePromptId: p.sourcePromptId,
|
|
1107
|
+
sourcePromptId: reconcileSourcePromptId(job, p.sourcePromptId),
|
|
1050
1108
|
sourceTabId: p.sourceTabId,
|
|
1109
|
+
dependsOn: p.dependsOn,
|
|
1110
|
+
originSessionId: job.originSessionId
|
|
1111
|
+
?? resolveOriginSessionId(p.cwd, reconcileSourcePromptId(job, p.sourcePromptId)),
|
|
1051
1112
|
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
1052
1113
|
});
|
|
1053
1114
|
}
|
|
@@ -1106,6 +1167,8 @@ async function reconcile(state) {
|
|
|
1106
1167
|
estimateMinutes: p.estimateMinutes,
|
|
1107
1168
|
sourcePromptId: p.sourcePromptId,
|
|
1108
1169
|
sourceTabId: p.sourceTabId,
|
|
1170
|
+
dependsOn: p.dependsOn,
|
|
1171
|
+
originSessionId: resolveOriginSessionId(p.cwd, p.sourcePromptId),
|
|
1109
1172
|
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
1110
1173
|
status: 'pending',
|
|
1111
1174
|
runId: null,
|
|
@@ -4026,6 +4089,8 @@ const remote = {
|
|
|
4026
4089
|
async writePrd(slug, body, cwd) {
|
|
4027
4090
|
let dir;
|
|
4028
4091
|
let epicTrace = null;
|
|
4092
|
+
let epicCreated = false;
|
|
4093
|
+
let epicId = null;
|
|
4029
4094
|
if (cwd) {
|
|
4030
4095
|
// Edit-in-place if this slug already lives anywhere under this project
|
|
4031
4096
|
// (legacy flat dir or any Epic's prds/); otherwise this is a CREATE,
|
|
@@ -4047,6 +4112,8 @@ const remote = {
|
|
|
4047
4112
|
});
|
|
4048
4113
|
dir = epic.prdDir;
|
|
4049
4114
|
epicTrace = epic.epicId;
|
|
4115
|
+
epicCreated = epic.created === true;
|
|
4116
|
+
epicId = epic.epicId;
|
|
4050
4117
|
} catch (e) {
|
|
4051
4118
|
// Epic mint must never block a PRD write — fall back to the
|
|
4052
4119
|
// legacy flat dir and log loudly.
|
|
@@ -4059,8 +4126,27 @@ const remote = {
|
|
|
4059
4126
|
dir = (await findPrdDir(slug)) ?? PRDS_DIR;
|
|
4060
4127
|
if (dir === PRDS_DIR) ensureDirs();
|
|
4061
4128
|
}
|
|
4129
|
+
|
|
4130
|
+
// PRD 825: if this call minted a brand-new Epic (ensureEpic's `created`)
|
|
4131
|
+
// and the write below never lands, don't strand an empty Epic dir —
|
|
4132
|
+
// best-effort remove `<epic>/prds` then `<epic>` itself, only when empty.
|
|
4133
|
+
const cleanupEmptyMintedEpic = async () => {
|
|
4134
|
+
if (!epicCreated || !epicId) return;
|
|
4135
|
+
try {
|
|
4136
|
+
const entries = await fsp.readdir(dir);
|
|
4137
|
+
if (entries.length > 0) return;
|
|
4138
|
+
await fsp.rmdir(dir);
|
|
4139
|
+
const epicRootDir = path.dirname(dir);
|
|
4140
|
+
const epicRootEntries = await fsp.readdir(epicRootDir);
|
|
4141
|
+
if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
|
|
4142
|
+
} catch { /* best-effort only */ }
|
|
4143
|
+
};
|
|
4144
|
+
|
|
4062
4145
|
const resolved = safeSlugPathIn(dir, slug);
|
|
4063
|
-
if (!resolved)
|
|
4146
|
+
if (!resolved) {
|
|
4147
|
+
await cleanupEmptyMintedEpic();
|
|
4148
|
+
return { ok: false, error: 'invalid slug' };
|
|
4149
|
+
}
|
|
4064
4150
|
try {
|
|
4065
4151
|
// Symlink defense, matching readPrd/readLog: safeSlugPathIn is lexical
|
|
4066
4152
|
// and does NOT resolve symlinks, so a rogue job could plant a PRDs-dir
|
|
@@ -4070,10 +4156,12 @@ const remote = {
|
|
|
4070
4156
|
// symlink.
|
|
4071
4157
|
const realParent = await fsp.realpath(path.dirname(resolved));
|
|
4072
4158
|
if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
|
|
4159
|
+
await cleanupEmptyMintedEpic();
|
|
4073
4160
|
return { ok: false, error: 'invalid slug' };
|
|
4074
4161
|
}
|
|
4075
4162
|
const existing = await fsp.lstat(resolved).catch(() => null);
|
|
4076
4163
|
if (existing && existing.isSymbolicLink()) {
|
|
4164
|
+
await cleanupEmptyMintedEpic();
|
|
4077
4165
|
return { ok: false, error: 'invalid slug' };
|
|
4078
4166
|
}
|
|
4079
4167
|
await config.writeTextAtomic(resolved, body);
|
|
@@ -4084,6 +4172,7 @@ const remote = {
|
|
|
4084
4172
|
}
|
|
4085
4173
|
return { ok: true, bytesWritten: stat.size };
|
|
4086
4174
|
} catch (e) {
|
|
4175
|
+
await cleanupEmptyMintedEpic();
|
|
4087
4176
|
return { ok: false, error: e?.message ?? 'write failed' };
|
|
4088
4177
|
}
|
|
4089
4178
|
},
|
|
@@ -4178,4 +4267,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4178
4267
|
});
|
|
4179
4268
|
}
|
|
4180
4269
|
|
|
4181
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
|
|
4270
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
|
|
@@ -392,17 +392,21 @@ the user may or may not have open.
|
|
|
392
392
|
|
|
393
393
|
### Fallback: writing the PRD file directly
|
|
394
394
|
|
|
395
|
-
When the app is not running, write `<NN>-<slug>.md` by hand into
|
|
396
|
-
`<cwd>/session-manager-operations/scheduler/prds/` following the frontmatter rules in §6 and the
|
|
395
|
+
When the app is not running, first mint (or join) an Epic — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<goal>" [feature|bug|discussion]`; its last stdout line is the prds dir — then write `<NN>-<slug>.md` by hand into that
|
|
396
|
+
`<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/` dir (the flat `scheduler/prds/` is RETIRED and auto-archived unexecuted at boot), add `sourcePromptId: <epic-id>` to the frontmatter so the job keeps its Epic linkage, following the frontmatter rules in §6 and the
|
|
397
397
|
body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
|
|
398
398
|
`# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
|
|
399
399
|
for the exact shape). **Trade-off:** this path has no atomic `NN` allocation. The tool's
|
|
400
400
|
`allocateParallelGroup()` (PRD 548) exists specifically to close a race where two writers pick
|
|
401
401
|
the same `NN` at once; a hand-written file bypasses that reservation entirely, so if another
|
|
402
402
|
writer (a human, `/develop`, or another automation) picks the same `NN` around the same time, one
|
|
403
|
-
file silently shadows or is shadowed by the other's
|
|
404
|
-
|
|
405
|
-
possible, not merely theoretical.
|
|
403
|
+
file silently shadows or is shadowed by the other's number. Pick an `NN` by scanning ALL of the
|
|
404
|
+
project's prds dirs (`scheduler/epics/*/prds/` and `prds-archived/`) for the current max and
|
|
405
|
+
incrementing, and treat a collision as possible, not merely theoretical. NN is strictly unique
|
|
406
|
+
per project (PRD 832) — NEVER reuse an existing number to signal "runs in parallel"; that
|
|
407
|
+
convention is retired. Express ordering with `dependsOn: [<slug>, ...]` frontmatter (the job is
|
|
408
|
+
eligible once every listed slug's queue row is completed); independent PRDs omit it and the
|
|
409
|
+
scheduler may run them concurrently.
|
|
406
410
|
|
|
407
411
|
### Ownership boundary
|
|
408
412
|
|
package/src/preload/api.d.ts
CHANGED
|
@@ -398,16 +398,22 @@ export interface ScheduleJob {
|
|
|
398
398
|
*/
|
|
399
399
|
verifierVerdict?: string;
|
|
400
400
|
/** Per-job values carried in queue.json for dependency checking. */
|
|
401
|
+
/** Explicit cross-PRD ordering (PRD 832): slugs that must complete before
|
|
402
|
+
* this job is eligible. Replaces the retired shared-NN-parallel convention. */
|
|
401
403
|
dependsOn?: string[];
|
|
404
|
+
/** The originating claude session — the Epic's claudeSessionId, resolved
|
|
405
|
+
* from active-index.json at ingest (PRD 832). An Epic IS a tagged session. */
|
|
406
|
+
originSessionId?: string | null;
|
|
402
407
|
/** Originating tab id (PRD frontmatter `sourceTabId`), refreshed from the
|
|
403
408
|
* PRD file on every reconcile. When the PRD was authored from inside a
|
|
404
409
|
* PromptSessionConversation, this equals that PromptSession's own id
|
|
405
410
|
* (its chat key) — lets the Scheduler UI trace a job back to the
|
|
406
411
|
* PromptSession that spawned it. */
|
|
407
412
|
sourceTabId?: string | null;
|
|
408
|
-
/** Originating prompt id (PRD frontmatter `sourcePromptId`) — the
|
|
409
|
-
*
|
|
410
|
-
* for referential tracing even when the tab/session no
|
|
413
|
+
/** Originating prompt id (PRD frontmatter `sourcePromptId`) — the
|
|
414
|
+
* PromptSession (Epic) id this PRD was dispatched from. Kept alongside
|
|
415
|
+
* `sourceTabId` for referential tracing even when the tab/session no
|
|
416
|
+
* longer resolves. */
|
|
411
417
|
sourcePromptId?: string | null;
|
|
412
418
|
}
|
|
413
419
|
|
|
@@ -450,6 +456,9 @@ export interface PrdListItem {
|
|
|
450
456
|
cwd: string;
|
|
451
457
|
estimateMinutes: number | null;
|
|
452
458
|
mtimeMs: number;
|
|
459
|
+
/** PRD frontmatter `sourcePromptId` — the PromptSession (Epic) id this PRD
|
|
460
|
+
* was dispatched from, if any. */
|
|
461
|
+
sourcePromptId?: string | null;
|
|
453
462
|
}
|
|
454
463
|
|
|
455
464
|
export interface SupervisorConfig {
|
|
@@ -1092,6 +1101,10 @@ export interface SessionManagerAPI {
|
|
|
1092
1101
|
write: (payload: { tabId: string; data: string }) => void;
|
|
1093
1102
|
resize: (payload: { tabId: string; cols: number; rows: number }) => void;
|
|
1094
1103
|
kill: (tabId: string) => void;
|
|
1104
|
+
/** Subset of `tabIds` (session keys) that currently have a live PTY —
|
|
1105
|
+
* used by the Epics workspace to reconcile Terminal-mode attachment
|
|
1106
|
+
* after a renderer reload (PRD 833 C1). */
|
|
1107
|
+
alive: (tabIds: string[]) => Promise<string[]>;
|
|
1095
1108
|
onData: (tabId: string, handler: (data: string) => void) => () => void;
|
|
1096
1109
|
onExit: (tabId: string, handler: (info: PtyExit) => void) => () => void;
|
|
1097
1110
|
onWriteError: (handler: (ev: WriteErrorEvent) => void) => () => void;
|
package/src/preload/index.cjs
CHANGED
|
@@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
36
36
|
write: (payload) => ipcRenderer.send('pty:write', payload),
|
|
37
37
|
resize: (payload) => ipcRenderer.send('pty:resize', payload),
|
|
38
38
|
kill: (tabId) => ipcRenderer.send('pty:kill', tabId),
|
|
39
|
+
alive: (tabIds) => ipcRenderer.invoke('pty:alive', { tabIds }),
|
|
39
40
|
onData: (tabId, handler) => {
|
|
40
41
|
const channel = `pty:data:${tabId}`;
|
|
41
42
|
const listener = (_e, data) => handler(data);
|