claude-code-session-manager 0.75.1 → 0.75.3

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.
Files changed (50) hide show
  1. package/dist/assets/{AgentLibrary-CGplUKxY.js → AgentLibrary-CzQqcObq.js} +1 -1
  2. package/dist/assets/{DataModel-DQ15KxVb.js → DataModel-Bj_WlLz8.js} +1 -1
  3. package/dist/assets/{History-v2TSC_6A.js → History-DnSi_OHm.js} +1 -1
  4. package/dist/assets/{Hooks-Co7s6BNB.js → Hooks-0BB0dp3S.js} +1 -1
  5. package/dist/assets/{HostBilko-DJj8FWPK.js → HostBilko-DHpwwsLQ.js} +1 -1
  6. package/dist/assets/{Library-DQaC9VQ1.js → Library-CaJVqVvi.js} +1 -1
  7. package/dist/assets/{ListDetail-BwFRfAZm.js → ListDetail-C1W2HmC2.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-C155gNpc.js → MarkdownEditor-5Ob9FW3z.js} +1 -1
  9. package/dist/assets/{McpServers-BmLgE_gS.js → McpServers-JxCSfm1S.js} +1 -1
  10. package/dist/assets/{Memory-ISwNcS5j.js → Memory-BDeqlqwH.js} +1 -1
  11. package/dist/assets/{Panel-1adjEpj-.js → Panel-Dh9ZHuEj.js} +1 -1
  12. package/dist/assets/{Permissions-FrCz5cGa.js → Permissions-DXy-CbEY.js} +1 -1
  13. package/dist/assets/{Plugins-zUssKp_b.js → Plugins-_n1Iuc8T.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CAXqA_HX.js → ProvenanceBadge-BP_evfxE.js} +1 -1
  15. package/dist/assets/{SaveBar-D9cqms3Y.js → SaveBar-D-gCUx4n.js} +1 -1
  16. package/dist/assets/{Scheduler-dNNc9S11.js → Scheduler-Bpd4OGju.js} +3 -3
  17. package/dist/assets/{ScopeSwitcher-1wBTne7S.js → ScopeSwitcher-CAWzM6RI.js} +1 -1
  18. package/dist/assets/{Settings-BT_expIg.js → Settings-DRRozLyT.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CVccq4Cu.js → SkillReferenceGraph-DGHDWlz4.js} +1 -1
  20. package/dist/assets/{Skills-xFpRY0ld.js → Skills-D8L66eiX.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CEk_71th.js → SystemPrompt-CYtUsonD.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2zhihQG.js → TagLibrary-E5CLeuVk.js} +1 -1
  23. package/dist/assets/{TiptapBody-CQKcHKaD.js → TiptapBody-B2hRgbPE.js} +1 -1
  24. package/dist/assets/{Toggle-CQ-IBb8m.js → Toggle-BTwsbxam.js} +1 -1
  25. package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
  26. package/dist/assets/{index-BgbV3sKX.js → index-DijufvkJ.js} +4 -4
  27. package/dist/assets/{settingsSchema-D6ccSKqm.js → settingsSchema-D6wzxAi6.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +35 -3
  31. package/scripts/scheduler-mcp-server.cjs +16 -1
  32. package/src/main/__tests__/prdAdminRoutes.test.cjs +100 -0
  33. package/src/main/__tests__/queueHistory.test.cjs +7 -0
  34. package/src/main/__tests__/scheduleJobSchema.test.cjs +1 -1
  35. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +1 -1
  36. package/src/main/__tests__/scheduleJobTransitions.test.cjs +24 -0
  37. package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +134 -0
  38. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +5 -1
  39. package/src/main/ipcSchemas.cjs +10 -0
  40. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +51 -0
  41. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
  42. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +26 -0
  43. package/src/main/lib/prdAdminRoutes.cjs +12 -2
  44. package/src/main/lib/queueHistory.cjs +6 -4
  45. package/src/main/lib/queueStore.cjs +42 -6
  46. package/src/main/lib/scheduleJobSchema.cjs +1 -1
  47. package/src/main/lib/scheduleJobTransitions.cjs +19 -6
  48. package/src/main/lib/schedulerBatch.cjs +17 -1
  49. package/src/main/scheduler.cjs +89 -16
  50. package/src/preload/api.d.ts +1 -1
@@ -0,0 +1,134 @@
1
+ /**
2
+ * scheduler-clear-queue-history.test.cjs — schedule:clear-queue must never
3
+ * drop a job row with zero audit trail. Before this PRD, the handler did
4
+ * `s.jobs = s.jobs.filter(...)` straight over every non-running victim with
5
+ * no history.jsonl write at all — a queue row (e.g. the 2026-08-30 sigma
6
+ * `788-pr-sweep-final-gate` incident) could vanish leaving no trace anywhere.
7
+ *
8
+ * Exercises `applyClearQueueVictims` (the core mutation extracted from the
9
+ * `schedule:clear-queue` IPC handler so it's testable without ipcMain/
10
+ * electron) directly against a synthetic state, then feeds its output
11
+ * through the REAL `queueHistory.appendHistory`. Job fixtures carry a real
12
+ * absolute `cwd` under a per-test mkdtemp dir (never the live operations
13
+ * root) because appendHistory routes each entry to its OWN project's
14
+ * `<cwd>/session-manager-operations/scheduler/state/history.jsonl` shard
15
+ * whenever the entry has a `cwd` — SM_HISTORY_PATH_OVERRIDE only affects
16
+ * cwd-less entries, and the AC here requires every entry to carry a cwd.
17
+ *
18
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-clear-queue-history.test.cjs
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ import { test, expect, beforeEach, afterEach } from 'vitest';
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ const queueHistory = require('../lib/queueHistory.cjs');
29
+ const { projectHistoryPath } = require('../lib/queueStore.cjs');
30
+ const scheduler = require('../scheduler.cjs');
31
+
32
+ let tmpCwd;
33
+
34
+ beforeEach(() => {
35
+ tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-clear-queue-history-'));
36
+ });
37
+
38
+ afterEach(() => {
39
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
40
+ });
41
+
42
+ function pendingJob(slug, overrides = {}) {
43
+ return { slug, cwd: tmpCwd, status: 'pending', runId: null, finishedAt: null, ...overrides };
44
+ }
45
+
46
+ function historyPath() {
47
+ return projectHistoryPath(tmpCwd);
48
+ }
49
+
50
+ function readHistoryRows() {
51
+ const text = fs.readFileSync(historyPath(), 'utf8').trim();
52
+ return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
53
+ }
54
+
55
+ test('applyClearQueueVictims transitions N pending jobs to skipped, drops them from state.jobs, and appendHistory writes N rows with source clear-queue', async () => {
56
+ const jobs = [pendingJob('101-a'), pendingJob('102-b'), pendingJob('103-c')];
57
+ const running = { slug: '104-running', cwd: tmpCwd, status: 'running', runId: 'run-104', finishedAt: null };
58
+ const state = { jobs: [...jobs, running] };
59
+ const victimSlugs = new Set(jobs.map((j) => j.slug));
60
+ const archiveNoteBySlug = new Map(jobs.map((j) => [j.slug, 'PRD file archived to /fake/archive/dir']));
61
+
62
+ const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, archiveNoteBySlug, '/fake/archive/dir');
63
+
64
+ // Running job is untouched, exempt from clear-queue, and got no history row.
65
+ expect(state.jobs.map((j) => j.slug)).toEqual(['104-running']);
66
+ expect(historyEntries.map((e) => e.slug).sort()).toEqual(['101-a', '102-b', '103-c']);
67
+ for (const entry of historyEntries) {
68
+ expect(entry.status).toBe('skipped'); // NEVER 'completed' — a manually cleared job did not ship
69
+ expect(entry.cwd).toBe(tmpCwd);
70
+ expect(entry.finishedAt).toBeTruthy();
71
+ const last = entry.statusHistory[entry.statusHistory.length - 1];
72
+ expect(last.source).toBe('clear-queue');
73
+ expect(last.reason).toContain('/fake/archive/dir');
74
+ }
75
+
76
+ const { appended } = await queueHistory.appendHistory(historyEntries);
77
+ expect(appended).toBe(3);
78
+ const onDisk = readHistoryRows();
79
+ expect(onDisk.length).toBe(3);
80
+ expect(onDisk.every((e) => e.status === 'skipped')).toBe(true);
81
+ expect(onDisk.every((e) => e.statusHistory.at(-1).source === 'clear-queue')).toBe(true);
82
+
83
+ // queue.json (state.jobs, what writeQueue serializes) no longer lists them.
84
+ expect(state.jobs.some((j) => victimSlugs.has(j.slug))).toBe(false);
85
+ });
86
+
87
+ test('running jobs are exempt from clear-queue and never appear in the returned history entries', () => {
88
+ const running = { slug: '201-running', cwd: tmpCwd, status: 'running', runId: 'run-201', finishedAt: null };
89
+ const state = { jobs: [running] };
90
+ const victimSlugs = new Set(); // schedule:clear-queue never includes running jobs as victims
91
+
92
+ const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
93
+
94
+ expect(historyEntries).toEqual([]);
95
+ expect(state.jobs).toEqual([running]);
96
+ });
97
+
98
+ test('a victim already terminal (completed) keeps its real status, not skipped, but still gets a clear-queue history row', () => {
99
+ const completed = { slug: '301-done', cwd: tmpCwd, status: 'completed', runId: 'run-301', finishedAt: '2026-08-29T00:00:00.000Z', statusHistory: [] };
100
+ const state = { jobs: [completed] };
101
+ const victimSlugs = new Set(['301-done']);
102
+
103
+ const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
104
+
105
+ expect(state.jobs).toEqual([]);
106
+ expect(historyEntries.length).toBe(1);
107
+ expect(historyEntries[0].status).toBe('completed'); // real outcome preserved, not overwritten to skipped
108
+ const last = historyEntries[0].statusHistory.at(-1);
109
+ expect(last.source).toBe('clear-queue');
110
+ expect(last.from).toBe('completed');
111
+ expect(last.to).toBe('completed');
112
+ });
113
+
114
+ test('appendHistory dedupe: clearing an already-cleared queue twice does not double-write rows', async () => {
115
+ const jobs = [pendingJob('401-x'), pendingJob('402-y')];
116
+ const state = { jobs: [...jobs] };
117
+ const victimSlugs = new Set(jobs.map((j) => j.slug));
118
+
119
+ const historyEntries = scheduler.applyClearQueueVictims(state, victimSlugs, new Map(), '/fake/archive/dir');
120
+ expect(state.jobs).toEqual([]);
121
+
122
+ const r1 = await queueHistory.appendHistory(historyEntries);
123
+ expect(r1.appended).toBe(2);
124
+
125
+ // A crash-replay of the SAME batch through appendHistory (the dedupe
126
+ // queueHistory itself guards against) must not double-write — this is
127
+ // what protects a second clear-queue pass, or a retry after a crash
128
+ // between the mutate() and appendHistory calls, from duplicating rows.
129
+ const r2 = await queueHistory.appendHistory(historyEntries);
130
+ expect(r2.appended).toBe(0);
131
+
132
+ const onDisk = readHistoryRows();
133
+ expect(onDisk.length).toBe(2);
134
+ });
@@ -126,7 +126,11 @@ test("spawnJob's post-run handler retires a prd-missing skip as a plain completi
126
126
  const state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
127
127
  const row = state.jobs.find((j) => j.slug === slug);
128
128
  expect(row).toBeDefined();
129
- expect(row.status).toBe('completed');
129
+ // A 'prd-missing' skip means no executor ever ran — it must land on the
130
+ // distinct 'skipped' status, never 'completed' (the status a genuine
131
+ // successful run produces), so unrun work can never masquerade as done.
132
+ expect(row.status).toBe('skipped');
133
+ expect(row.status).not.toBe('completed');
130
134
  expect(row.exitCode).toBe(0);
131
135
  expect(row.error).toBeNull();
132
136
  // No verify pass ran and no RCA feedback fired: a verified run always
@@ -369,6 +369,16 @@ const adminListPrdsQuery = z.object({
369
369
  cwd: z.string().min(1).max(4096).optional(),
370
370
  epicId: z.string().min(1).max(200).optional(),
371
371
  status: z.string().min(1).max(40).optional(),
372
+ // Page size — default 100, hard max 500 so an agent can't accidentally
373
+ // re-request the full unbounded list (PRD: the 353KB/120s-timeout
374
+ // incident this pagination exists to fix). Query strings arrive as text,
375
+ // so these coerce before the int/range checks run.
376
+ limit: z.coerce.number().int().min(1).max(500).optional().default(100),
377
+ offset: z.coerce.number().int().min(0).optional().default(0),
378
+ // 'compact' (default) omits the secondary detail fields (parallelGroup,
379
+ // estimateMinutes, sourcePromptId, epicId, archivedStatus) that aren't
380
+ // needed to answer "what PRDs are there"; 'full' restores them.
381
+ fields: z.enum(['compact', 'full']).optional().default('compact'),
372
382
  });
373
383
 
374
384
  const adminGetPrdQuery = z.object({
@@ -124,3 +124,54 @@ test('the guards compose: no relative cwd from a transcript can reach an ops-roo
124
124
  // And even if one somehow did, the write path itself refuses it.
125
125
  expect(() => queueStore.projectStateDir('src/renderer/state')).toThrow(/absolute path/);
126
126
  });
127
+
128
+ // ─── the second incident: an ABSOLUTE cwd INSIDE an ops root ────────────────
129
+ //
130
+ // Reported from starry-night-ships on 2026-08-30: 14 stray
131
+ // `.../session-manager-operations/scheduler/state/queue.json` stubs, each
132
+ // nested under another ops directory (`scheduler/prds/`, `prompt-sessions/`,
133
+ // `scheduler/epics/<id>/prds/`), every one holding `{"jobs": []}` and mtimes
134
+ // spanning 8 days. The 2026-08-13 absolute-path guard above could not catch
135
+ // them: these cwds ARE absolute and DO exist. They came from transcripts
136
+ // where an agent had `cd`'d into the artifact directory it was writing, so
137
+ // the last transcript row recorded that subdirectory as the session cwd.
138
+
139
+ test('a transcript cwd inside an ops root is normalized to the project root, not taken literally', async () => {
140
+ const project = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-nested-'));
141
+ tmpDirs.push(project);
142
+ const prdDir = path.join(project, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
143
+ fs.mkdirSync(prdDir, { recursive: true });
144
+ const projectsDir = await mkProjectsDir({ nested: prdDir });
145
+
146
+ const cwds = activeProjectCwds(Infinity, { projectsDir });
147
+ expect(cwds).toContain(project);
148
+ expect(cwds).not.toContain(prdDir);
149
+ });
150
+
151
+ test('normalization keeps the active-project signal rather than dropping the row', () => {
152
+ const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
153
+ expect(projectRootOf('/p/session-manager-operations/prompt-sessions')).toBe('/p');
154
+ expect(projectRootOf('/p/session-manager-operations')).toBe('/p');
155
+ expect(projectRootOf('/p/session-manager-operations/scheduler/state')).toBe('/p');
156
+ // Already-nested strays truncate at the FIRST segment — the inner one is
157
+ // itself the bug, never a project.
158
+ expect(projectRootOf('/p/session-manager-operations/x/session-manager-operations/y')).toBe('/p');
159
+ // A plain project cwd is untouched.
160
+ expect(projectRootOf('/p/src/main')).toBe('/p/src/main');
161
+ // A directory that merely CONTAINS the name as a substring is not a segment.
162
+ expect(projectRootOf('/p/session-manager-operations-archive')).toBe('/p/session-manager-operations-archive');
163
+ });
164
+
165
+ test('projectStateDir fails closed on an ops-internal cwd, naming the offending value', () => {
166
+ expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
167
+ .toThrow(/must be a project root/);
168
+ expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
169
+ .toThrow(/scheduler\/prds/);
170
+ // The guard is segment-exact, not a substring match.
171
+ expect(queueStore.projectStateDir('/p/session-manager-operations-archive'))
172
+ .toBe(path.join('/p/session-manager-operations-archive', 'session-manager-operations', 'scheduler', 'state'));
173
+ });
174
+
175
+ // The writeSplit half of this guard lives in opsRootNestedWrite.test.cjs —
176
+ // it must override HOME before requiring queueStore (writeSplit persists
177
+ // machine state to a homedir path), which cannot be done from this file.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * opsRootNestedWrite.test.cjs — writeSplit must never materialize a second
3
+ * ops root beneath the first.
4
+ *
5
+ * Companion to opsRootAbsoluteCwd.test.cjs; separate file because HOME must
6
+ * be overridden BEFORE requiring queueStore — MACHINE_STATE_PATH is baked
7
+ * into a top-level const from os.homedir() at require time, and writeSplit
8
+ * writes it on every call. (Learned the hard way: running this assertion in
9
+ * the shared file overwrote the real ~/.claude/session-manager/
10
+ * scheduler-machine.json, dropping the user's firePolicy and concurrencyCap.)
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/opsRootNestedWrite.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, afterEach } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const fsp = require('node:fs/promises');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opsroot-home-'));
24
+ process.env.HOME = tmpHome;
25
+
26
+ const queueStore = require('../queueStore.cjs');
27
+
28
+ const tmpDirs = [tmpHome];
29
+ afterEach(async () => {
30
+ while (tmpDirs.length > 1) {
31
+ await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
32
+ }
33
+ });
34
+
35
+ test('one ops-internal cwd in a write batch is skipped without losing the other projects', async () => {
36
+ const good = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-good-'));
37
+ tmpDirs.push(good);
38
+ const bad = path.join(good, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
39
+ fs.mkdirSync(bad, { recursive: true });
40
+
41
+ await queueStore.writeSplit({
42
+ config: {},
43
+ sourceCwds: [bad, good],
44
+ jobs: [{ slug: 'keeper', cwd: good }, { slug: 'stray', cwd: bad }],
45
+ });
46
+
47
+ const written = JSON.parse(fs.readFileSync(queueStore.projectQueuePath(good), 'utf8'));
48
+ expect(written.jobs.map((j) => j.slug)).toEqual(['keeper']);
49
+ // The doubled-suffix stub the starry-night-ships report was about.
50
+ expect(fs.existsSync(path.join(bad, 'session-manager-operations'))).toBe(false);
51
+ });
@@ -93,6 +93,18 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
93
93
  assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
94
94
  });
95
95
 
96
+ test('a SKIPPED (never-ran) bare-named dep holds the dependent and reports an explicit reason', () => {
97
+ const jobs = [
98
+ job('873-leftnav-two-face-framework', 'skipped'),
99
+ job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
100
+ ];
101
+ const { batch, reason } = pick(jobs);
102
+ assert.deepEqual(batch, []);
103
+ assert.match(reason, /depends-gate/);
104
+ assert.match(reason, /never-ran dependencies/);
105
+ assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
106
+ });
107
+
96
108
  // ---------------------------------------------------------------------------
97
109
  // Parallelism regression coverage.
98
110
  //
@@ -209,6 +221,20 @@ test('hold record carries a failed dep status alongside the depends-gate reason'
209
221
  ]);
210
222
  });
211
223
 
224
+ test('hold record carries a skipped dep status alongside the depends-gate reason', () => {
225
+ const jobs = [
226
+ job('985-foundation', 'skipped'),
227
+ job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
228
+ ];
229
+ const { batch, reason, holds } = pick(jobs, new Set(), 5);
230
+ assert.deepEqual(batch, []);
231
+ assert.match(reason, /depends-gate/);
232
+ assert.match(reason, /never-ran dependencies/);
233
+ assert.deepEqual(holds, [
234
+ { slug: '986-dependent', dep: 'foundation', depStatus: 'skipped' },
235
+ ]);
236
+ });
237
+
212
238
  test('no holds when nothing is dependency-blocked', () => {
213
239
  const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
214
240
  const { batch, holds } = pick(jobs, new Set(), 5);
@@ -45,6 +45,16 @@ function parseJsonBody(raw) {
45
45
  * scheduler.cjs's own registerAdminRoute(s) pattern.
46
46
  */
47
47
  function registerAdminRoute(adminHttp, remote) {
48
+ // GET /admin/scheduler/prds — paginated PRD listing (PRD: 353KB/120s
49
+ // fix). Sort order is stable and explicit: slug ascending via
50
+ // String.localeCompare(..., { numeric: true }) (listPrdsInternal in
51
+ // scheduler.cjs), so offset paging can't skip or duplicate an entry
52
+ // between calls as long as the underlying PRD set is unchanged.
53
+ // `limit`/`offset` default to 100/0 (hard max limit 500); `total` is the
54
+ // filtered count BEFORE the page slice, and `hasMore` is derived from
55
+ // `total`, not from whether the page came back full. `fields=full` opts
56
+ // into the secondary detail fields (parallelGroup, estimateMinutes,
57
+ // sourcePromptId, epicId, archivedStatus) omitted by default.
48
58
  adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
49
59
  let input;
50
60
  try {
@@ -53,8 +63,8 @@ function registerAdminRoute(adminHttp, remote) {
53
63
  sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
54
64
  return;
55
65
  }
56
- const prds = await remote.listPrds(input);
57
- sendJson(res, 200, { ok: true, prds });
66
+ const { prds, total, limit, offset, hasMore } = await remote.listPrds(input);
67
+ sendJson(res, 200, { ok: true, prds, total, limit, offset, hasMore });
58
68
  });
59
69
 
60
70
  adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
@@ -62,9 +62,11 @@ function jobKey(job) {
62
62
 
63
63
  /**
64
64
  * O(n) over `jobs`. Splits terminal jobs into `hot` (stays in queue.json)
65
- * and `toArchive` (moves to history.jsonl). A completed/failed job archives
66
- * only when ALL of:
67
- * - status is 'completed' or 'failed'
65
+ * and `toArchive` (moves to history.jsonl). A completed/failed/skipped job
66
+ * archives only when ALL of:
67
+ * - status is 'completed', 'failed', or 'skipped' (skipped: a job whose
68
+ * PRD source vanished before dispatch — never ran, but still terminal,
69
+ * and must age out of the hot queue the same as any other finished row)
68
70
  * - finishedAt parses and is older than opts.retentionMs (default
69
71
  * HISTORY_RETENTION_MS)
70
72
  * - it is not the healTargetForFix() parent of a pending/running fix-plan
@@ -90,7 +92,7 @@ function partitionJobs(jobs, nowMs, opts = {}) {
90
92
  if (!j) continue;
91
93
  const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
92
94
  const archivable =
93
- (j.status === 'completed' || j.status === 'failed') &&
95
+ (j.status === 'completed' || j.status === 'failed' || j.status === 'skipped') &&
94
96
  Number.isFinite(finishedMs) &&
95
97
  (nowMs - finishedMs) > retentionMs &&
96
98
  !protectedSlugs.has(j.slug);
@@ -38,7 +38,8 @@ const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
38
38
 
39
39
  const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
40
40
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
41
- const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
41
+ const OPS_DIRNAME = 'session-manager-operations';
42
+ const STATE_SUBPATH = [OPS_DIRNAME, 'scheduler', 'state'];
42
43
 
43
44
  /**
44
45
  * A project's `session-manager-operations/scheduler/state/` directory.
@@ -60,6 +61,20 @@ function projectStateDir(cwd) {
60
61
  if (!path.isAbsolute(cwd)) {
61
62
  throw new Error(`projectStateDir: cwd must be an absolute path, got "${cwd}"`);
62
63
  }
64
+ // ...and it must not itself sit INSIDE an ops root. Absolute-and-existing
65
+ // was not enough: a transcript records an agent's `cd` into a PRD folder,
66
+ // activeSessions hands that subdirectory over as a project, and this join
67
+ // materializes a second ops root under the first — the doubled
68
+ // `.../prds/session-manager-operations/scheduler/state/queue.json` stubs
69
+ // found in starry-night-ships on 2026-08-30 (14 of them, 8 days' worth).
70
+ // activeSessions.projectRootOf normalizes such a cwd away upstream; this is
71
+ // the fail-closed backstop for every other caller. A queue file below an
72
+ // ops root is always a bug, never a project.
73
+ if (cwd.split(path.sep).includes(OPS_DIRNAME)) {
74
+ throw new Error(
75
+ `projectStateDir: cwd must be a project root, not a path inside ${OPS_DIRNAME}/, got "${cwd}"`,
76
+ );
77
+ }
63
78
  return path.join(cwd, ...STATE_SUBPATH);
64
79
  }
65
80
 
@@ -71,6 +86,22 @@ function projectHistoryPath(cwd) {
71
86
  return path.join(projectStateDir(cwd), 'history.jsonl');
72
87
  }
73
88
 
89
+ /**
90
+ * queuePathOrSkip(cwd, context) → the shard path, or null when `cwd` is not a
91
+ * usable project root. projectStateDir fails closed on a relative or
92
+ * ops-internal cwd; a single such job row (or a stale cached cwd) must not
93
+ * abort a whole read or write of every OTHER project's queue, so callers that
94
+ * loop over many cwds log-and-skip instead of throwing.
95
+ */
96
+ function queuePathOrSkip(cwd, context) {
97
+ try {
98
+ return projectQueuePath(cwd);
99
+ } catch (e) {
100
+ console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
101
+ return null;
102
+ }
103
+ }
104
+
74
105
  function writeJsonAtomicSync(file, value) {
75
106
  assertOpsWrite(file, 'scheduler');
76
107
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -183,7 +214,8 @@ function readMergedSync(opts) {
183
214
  }
184
215
  }
185
216
  for (const cwd of stateCwds(opts)) {
186
- const file = projectQueuePath(cwd);
217
+ const file = queuePathOrSkip(cwd, 'read');
218
+ if (!file) continue;
187
219
  try {
188
220
  const { jobs, invalid } = shapeJobs(fs.readFileSync(file, 'utf8'), file);
189
221
  out.jobs.push(...jobs);
@@ -212,7 +244,8 @@ async function readMerged(opts) {
212
244
  }
213
245
  }
214
246
  for (const cwd of stateCwds(opts)) {
215
- const file = projectQueuePath(cwd);
247
+ const file = queuePathOrSkip(cwd, 'read');
248
+ if (!file) continue;
216
249
  try {
217
250
  const { jobs, invalid } = shapeJobs(await fsp.readFile(file, 'utf8'), file);
218
251
  out.jobs.push(...jobs);
@@ -261,12 +294,14 @@ async function writeSplit(state, defaultCwd) {
261
294
  byCwd.get(cwd).push(job);
262
295
  }
263
296
  for (const [cwd, jobs] of byCwd) {
297
+ const file = queuePathOrSkip(cwd, 'writeSplit');
298
+ if (!file) continue;
264
299
  try {
265
- await writeJsonAtomic(projectQueuePath(cwd), { jobs });
300
+ await writeJsonAtomic(file, { jobs });
266
301
  } catch (e) {
267
302
  // A single unwritable project (deleted repo dir, permissions) must not
268
303
  // lose every other project's write.
269
- console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
304
+ console.error(`[queueStore] failed to write ${file}: ${e?.message}`);
270
305
  }
271
306
  }
272
307
  bustCwdCache();
@@ -316,7 +351,8 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
316
351
  }
317
352
  let moved = 0;
318
353
  for (const [cwd, jobs] of byCwd) {
319
- const file = projectQueuePath(cwd);
354
+ const file = queuePathOrSkip(cwd, 'legacy split');
355
+ if (!file) continue;
320
356
  let existing = [];
321
357
  try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
322
358
  const have = new Set(existing.map((j) => j.slug));
@@ -24,7 +24,7 @@
24
24
 
25
25
  const { z } = require('zod');
26
26
 
27
- const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined'];
27
+ const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'skipped', 'failed', 'needs_review', 'quarantined'];
28
28
 
29
29
  const ScheduleJobStatusSchema = z.enum(JOB_STATUSES);
30
30
 
@@ -44,7 +44,10 @@ const STATUS_HISTORY_CAP = 20;
44
44
  * manual archive of an already-shipped PRD), pending->failed (admin
45
45
  * cancelJob on a not-yet-started job)
46
46
  * - running->completed|failed|needs_review (normal run outcomes, reaper),
47
- * running->pending (halt/rate-limit reset, transient-failure retry)
47
+ * running->skipped (spawnJob:skip-archived's 'prd-missing' case — no
48
+ * executor ever ran; kept distinct from 'completed' so unrun work can't
49
+ * read as shipped), running->pending (halt/rate-limit reset,
50
+ * transient-failure retry)
48
51
  * - investigating->failed|needs_review (restore prior status once the
49
52
  * investigation probe exits), investigating->completed (defensive: the
50
53
  * restored prior status could in principle be 'completed' if a caller
@@ -58,20 +61,30 @@ const STATUS_HISTORY_CAP = 20;
58
61
  * - completed->pending (force-only reset, gated separately by
59
62
  * resetJobFields' own guard — this table only says the edge is
60
63
  * structurally legal, not that every caller may take it unconditionally)
64
+ * - skipped->pending (force-only reset, same resetJobFields guard as
65
+ * completed->pending — 'skipped' is otherwise terminal: no further
66
+ * transitions out)
61
67
  * - quarantined->pending (reconcile()'s adopt path, PRD-authoring lockdown:
62
68
  * a PRD discovered with no `createdVia` provenance stamp is queued
63
69
  * 'quarantined' instead of 'pending'; the ONLY way out is the PRD being
64
70
  * stamped via the update-prd API — reconcile() detects the stamp on its
65
71
  * next pass and promotes the row)
72
+ * - pending|investigating|needs_review|quarantined -> skipped
73
+ * (schedule:clear-queue: a manual queue clear force-terminates any
74
+ * non-running victim row so it can leave a durable history.jsonl record
75
+ * instead of vanishing — same 'never ran, don't call it completed'
76
+ * rationale as running->skipped, just reachable from every pre-dispatch
77
+ * or unresolved status a queued row can be sitting in)
66
78
  */
67
79
  const LEGAL_TRANSITIONS = {
68
- pending: ['running', 'completed', 'failed'],
69
- running: ['completed', 'failed', 'needs_review', 'pending'],
70
- investigating: ['failed', 'needs_review', 'completed', 'pending'],
80
+ pending: ['running', 'completed', 'failed', 'skipped'],
81
+ running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
82
+ investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
71
83
  failed: ['investigating', 'pending', 'completed'],
72
- needs_review: ['investigating', 'pending', 'completed'],
84
+ needs_review: ['investigating', 'pending', 'completed', 'skipped'],
73
85
  completed: ['pending'],
74
- quarantined: ['pending'],
86
+ skipped: ['pending'],
87
+ quarantined: ['pending', 'skipped'],
75
88
  };
76
89
 
77
90
  let refusedTransitionCount = 0;
@@ -97,6 +97,12 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
97
97
 
98
98
  const pending = [];
99
99
  const heldByFailedDep = [];
100
+ // A dep that never ran (its PRD source vanished before dispatch — see
101
+ // scheduleJobSchema.cjs's 'skipped' status) is just as blocking as a
102
+ // failed one and deserves the same explicit reason, not a silent fall into
103
+ // the generic `holds` bucket — resetting won't help here (the source file
104
+ // is gone), so the guidance differs from the failed-dep case.
105
+ const heldBySkippedDep = [];
100
106
  // Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
101
107
  // dep is holding it instead of leaving the reason in console.log.
102
108
  const holds = [];
@@ -105,7 +111,9 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
105
111
  if (!dep) { pending.push(j); continue; }
106
112
  const depRows = rowsForDep(dep);
107
113
  const failed = depRows.some((d) => d.status === 'failed');
114
+ const skipped = depRows.some((d) => d.status === 'skipped');
108
115
  if (failed) heldByFailedDep.push({ job: j, dep });
116
+ else if (skipped) heldBySkippedDep.push({ job: j, dep });
109
117
  holds.push({
110
118
  slug: j.slug,
111
119
  dep,
@@ -114,9 +122,17 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
114
122
  // running/pending/needs_review dep — simply not eligible this tick.
115
123
  }
116
124
  if (pending.length === 0) {
125
+ const reasons = [];
117
126
  if (heldByFailedDep.length > 0) {
118
127
  const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
119
- const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
128
+ reasons.push(`holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`);
129
+ }
130
+ if (heldBySkippedDep.length > 0) {
131
+ const detail = heldBySkippedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
132
+ reasons.push(`holding ${heldBySkippedDep.length} job(s) behind never-ran dependencies [${detail}]. The dep's PRD source is gone — author a fresh PRD for that work to unblock.`);
133
+ }
134
+ if (reasons.length > 0) {
135
+ const reason = `[scheduler] depends-gate [${projectCwd}]: ${reasons.join(' ')}`;
120
136
  console.log(reason);
121
137
  return { batch: [], reason, holds };
122
138
  }