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.
@@ -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(expandHome(input.cwd));
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
- // the injected remote — never re-derived here — unless the caller
124
- // opted into an existing group explicitly.
125
- const nn = input.parallelGroup ?? await remote.allocateParallelGroup(input.cwd);
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 pending = projectJobs.filter(
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 (pending.length === 0) return { batch: [], reason: null };
45
-
46
- const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
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
- sourcePromptId: fm.sourcePromptId || null,
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
- const floor = Math.max(scanMax, highWater);
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}`);
@@ -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
- return prdParser.allocateParallelGroup(dir);
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) return { ok: false, error: 'invalid slug' };
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 the target repo's own
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 parallel-group slot. Pick an `NN` by scanning
404
- the existing prds directory for the current max and incrementing, and treat a collision as
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
 
@@ -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 chain-root
409
- * PromptTicket id this PRD was dispatched from. Kept alongside `sourceTabId`
410
- * for referential tracing even when the tab/session no longer resolves. */
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;
@@ -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);