claude-code-session-manager 0.75.0 → 0.75.2

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 (49) hide show
  1. package/dist/assets/{AgentLibrary-SpzptNyq.js → AgentLibrary-s1jRGOji.js} +1 -1
  2. package/dist/assets/{DataModel-C4M9xVLC.js → DataModel-DNefcWbk.js} +1 -1
  3. package/dist/assets/{History-92FYrZKS.js → History-4sMk80yI.js} +1 -1
  4. package/dist/assets/{Hooks-DlnavO1l.js → Hooks-CNUVwH5P.js} +1 -1
  5. package/dist/assets/{HostBilko-CKI7jt1z.js → HostBilko-BhkyvIhw.js} +1 -1
  6. package/dist/assets/{Library-Brhxe7xa.js → Library-BpockanA.js} +1 -1
  7. package/dist/assets/{ListDetail-DmIweYTN.js → ListDetail-Cre7h3U0.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DA4xqWWi.js → MarkdownEditor-x-FzvaXm.js} +1 -1
  9. package/dist/assets/{McpServers-DWXQGzVc.js → McpServers-CXAcQLMB.js} +1 -1
  10. package/dist/assets/{Memory-HCx9Arao.js → Memory-CGBv1Rco.js} +1 -1
  11. package/dist/assets/{Panel-B20C6kN8.js → Panel-czEs21N2.js} +1 -1
  12. package/dist/assets/{Permissions-C4sPgLpA.js → Permissions-rGkLOpqI.js} +1 -1
  13. package/dist/assets/{Plugins-CaRenCBg.js → Plugins-BTmB_vgU.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DfrXzTeh.js → ProvenanceBadge-dmCivDq5.js} +1 -1
  15. package/dist/assets/{SaveBar-BmpUx2Yh.js → SaveBar-D80TNkAx.js} +1 -1
  16. package/dist/assets/{Scheduler-xUD8pQ6q.js → Scheduler-C6wHSZP7.js} +3 -3
  17. package/dist/assets/{ScopeSwitcher-DVlKMck5.js → ScopeSwitcher-DsaiYTgl.js} +1 -1
  18. package/dist/assets/{Settings-C457mxOK.js → Settings-m2fyU8Q5.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-D3dDc5X7.js → SkillReferenceGraph-yS-lgBFU.js} +1 -1
  20. package/dist/assets/{Skills-BNDlbftC.js → Skills-DtQgCs9-.js} +1 -1
  21. package/dist/assets/{SystemPrompt-d-ykNRec.js → SystemPrompt-CLuVdoMA.js} +1 -1
  22. package/dist/assets/{TagLibrary-D3JWkQK-.js → TagLibrary-DQXoI752.js} +1 -1
  23. package/dist/assets/{TiptapBody-B1Ox6D6E.js → TiptapBody-BGKVXrvq.js} +1 -1
  24. package/dist/assets/{Toggle-CaYw6Lrj.js → Toggle-RFnEV71m.js} +1 -1
  25. package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
  26. package/dist/assets/{index-CEeLxNCz.js → index-Ddrw7J7O.js} +4 -4
  27. package/dist/assets/{settingsSchema-DUkd45M5.js → settingsSchema-LwuJXKD1.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/scheduler-mcp-server.cjs +16 -1
  31. package/src/main/__tests__/prdAdminRoutes.test.cjs +100 -0
  32. package/src/main/__tests__/queueHistory.test.cjs +7 -0
  33. package/src/main/__tests__/scheduleJobSchema.test.cjs +1 -1
  34. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +1 -1
  35. package/src/main/__tests__/scheduleJobTransitions.test.cjs +24 -0
  36. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +116 -0
  37. package/src/main/__tests__/scheduler-autofix-select.test.cjs +113 -7
  38. package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +134 -0
  39. package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +119 -0
  40. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +5 -1
  41. package/src/main/ipcSchemas.cjs +10 -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/scheduleJobSchema.cjs +1 -1
  46. package/src/main/lib/scheduleJobTransitions.cjs +19 -6
  47. package/src/main/lib/schedulerBatch.cjs +17 -1
  48. package/src/main/scheduler.cjs +224 -42
  49. package/src/preload/api.d.ts +1 -1
@@ -11,7 +11,7 @@ const assert = require('node:assert/strict');
11
11
  const {
12
12
  selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate,
13
13
  healTargetForFix, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH,
14
- isEligibleForImmediateAutoFix,
14
+ isEligibleForImmediateAutoFix, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
15
15
  } = require('../scheduler.cjs');
16
16
 
17
17
  const noSiblingOnDisk = () => false;
@@ -34,8 +34,8 @@ test('selects a fresh needs_review job', () => {
34
34
  assert.strictEqual(result[0].slug, '05-my-feature');
35
35
  });
36
36
 
37
- test('excludes job with autoFixAttempted: true', () => {
38
- const jobs = [makeJob({ autoFixAttempted: true })];
37
+ test('excludes job with autoFixAttempted: true whose retry is already spent', () => {
38
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 1 })];
39
39
  const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
40
40
  assert.strictEqual(result.length, 0);
41
41
  });
@@ -105,12 +105,112 @@ test('autoFixOutcome no-plan with retries=1 is exhausted (not selected)', () =>
105
105
  assert.strictEqual(result.length, 0);
106
106
  });
107
107
 
108
- test('autoFixAttempted true with no outcome recorded stays excluded (existing 1-attempt cap)', () => {
109
- const jobs = [makeJob({ autoFixAttempted: true })];
108
+ // ---- PRD: unstamped/error outcomes must retry once, not dead-end forever ----
109
+
110
+ test('autoFixAttempted true with NO outcome recorded (unstamped — spawn threw before onExit wired) is retry-eligible once', () => {
111
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 0 })];
112
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
113
+ assert.strictEqual(result.length, 1);
114
+ });
115
+
116
+ test('autoFixAttempted true with no outcome AND retries=1 is exhausted (not selected)', () => {
117
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixRetries: 1 })];
110
118
  const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
111
119
  assert.strictEqual(result.length, 0);
112
120
  });
113
121
 
122
+ test('autoFixOutcome error with retries=0 is retry-eligible', () => {
123
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 0 })];
124
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
125
+ assert.strictEqual(result.length, 1);
126
+ });
127
+
128
+ test('autoFixOutcome error with retries=1 is exhausted (not selected)', () => {
129
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 1 })];
130
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
131
+ assert.strictEqual(result.length, 0);
132
+ });
133
+
134
+ test('autoFixOutcome plan is NEVER retried, regardless of autoFixRetries', () => {
135
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 })];
136
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
137
+ assert.strictEqual(result.length, 0);
138
+ });
139
+
140
+ // ---- isExhaustedAutoFix / isPlanUnqueued (reverifyNeedsReview annotation) ----
141
+
142
+ test('isExhaustedAutoFix: no-plan outcome with retries spent → true', () => {
143
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 1 });
144
+ assert.strictEqual(isExhaustedAutoFix(job), true);
145
+ });
146
+
147
+ test('isExhaustedAutoFix: error outcome with retries spent → true', () => {
148
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'error', autoFixRetries: 1 });
149
+ assert.strictEqual(isExhaustedAutoFix(job), true);
150
+ });
151
+
152
+ test('isExhaustedAutoFix: unstamped outcome with retries spent → true', () => {
153
+ const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
154
+ assert.strictEqual(isExhaustedAutoFix(job), true);
155
+ });
156
+
157
+ test('isExhaustedAutoFix: retries not yet spent → false (still eligible for its one retry)', () => {
158
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 0 });
159
+ assert.strictEqual(isExhaustedAutoFix(job), false);
160
+ });
161
+
162
+ test('isExhaustedAutoFix: plan outcome is never "exhausted" here — isPlanUnqueued\'s job instead', () => {
163
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1 });
164
+ assert.strictEqual(isExhaustedAutoFix(job), false);
165
+ });
166
+
167
+ test('isPlanUnqueued: plan outcome, fix slug absent from queue → true', () => {
168
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
169
+ assert.strictEqual(isPlanUnqueued(job, new Set()), true);
170
+ });
171
+
172
+ test('isPlanUnqueued: plan outcome, fix slug present in queue → false', () => {
173
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
174
+ assert.strictEqual(isPlanUnqueued(job, new Set([fixSlugFor(job)])), false);
175
+ });
176
+
177
+ test('isPlanUnqueued: no-plan outcome is never "plan unqueued"', () => {
178
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan' });
179
+ assert.strictEqual(isPlanUnqueued(job, new Set()), false);
180
+ });
181
+
182
+ // ---- invariant: every needs_review job is either an auto-fix target, or
183
+ // classified by one of the annotation predicates (i.e. would receive a
184
+ // non-null verifierVerdict) — no job may fall through both. ----
185
+
186
+ test('invariant: attempted+no-outcome, retries spent → not a target, but IS annotatable', () => {
187
+ const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
188
+ const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
189
+ const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, new Set())
190
+ || isUnresolvableNeedsReview(job, { hasRunDir: true });
191
+ assert.strictEqual(isTarget, false);
192
+ assert.strictEqual(isAnnotatable, true);
193
+ });
194
+
195
+ test('invariant: plan outcome, no queue row → not a target, but IS annotatable (autofix_plan_unqueued)', () => {
196
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
197
+ const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
198
+ const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, new Set())
199
+ || isUnresolvableNeedsReview(job, { hasRunDir: true });
200
+ assert.strictEqual(isTarget, false);
201
+ assert.strictEqual(isAnnotatable, true);
202
+ });
203
+
204
+ test('invariant: plan outcome WITH a queue row → not a target and NOT annotated (waiting on the fix-plan job itself)', () => {
205
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan' });
206
+ const queuedSlugs = new Set([fixSlugFor(job)]);
207
+ const isTarget = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).length === 1;
208
+ const isAnnotatable = isExhaustedAutoFix(job) || isPlanUnqueued(job, queuedSlugs)
209
+ || isUnresolvableNeedsReview(job, { hasRunDir: true });
210
+ assert.strictEqual(isTarget, false);
211
+ assert.strictEqual(isAnnotatable, false);
212
+ });
213
+
114
214
  test('excludes when fix sibling exists on disk', () => {
115
215
  const jobs = [makeJob()];
116
216
  // fixSlug = '05-fix-my-feature'
@@ -224,12 +324,18 @@ test('isEligibleForImmediateAutoFix: fresh needs_review job (fresh from spawnJob
224
324
  assert.strictEqual(result, true);
225
325
  });
226
326
 
227
- test('isEligibleForImmediateAutoFix: job already autoFixAttempted is excluded (prevents double-spawn with the periodic pass)', () => {
228
- const job = makeJob({ autoFixAttempted: true });
327
+ test('isEligibleForImmediateAutoFix: attempted job with retry already spent is excluded (prevents double-spawn with the periodic pass)', () => {
328
+ const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1 });
229
329
  const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
230
330
  assert.strictEqual(result, false);
231
331
  });
232
332
 
333
+ test('isEligibleForImmediateAutoFix: attempted job with an unstamped outcome and its retry still unspent IS eligible (bounded once)', () => {
334
+ const job = makeJob({ autoFixAttempted: true, autoFixRetries: 0 });
335
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
336
+ assert.strictEqual(result, true);
337
+ });
338
+
233
339
  test('isEligibleForImmediateAutoFix: fix sibling already on disk excludes the job', () => {
234
340
  const job = makeJob();
235
341
  // fixSlug = '05-fix-my-feature'
@@ -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
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * scheduler-fix-plan-path.test.cjs — regression test for the self-heal
3
+ * dead-letter bug: spawnInvestigation used to write every auto-fix plan into
4
+ * the RETIRED flat `<cwd>/session-manager-operations/scheduler/prds/` dir
5
+ * (prdDirForCwd), which reconcile()'s consolidateAllFlatPrds sweeps BEFORE
6
+ * scanning for queue rows — so a freshly-authored fix plan (no queue row yet
7
+ * by definition) was archived into `prds-archived/` on the very next pass
8
+ * and never enqueued. Confirmed live in starry-night-ships: job
9
+ * 105-arena-perf-fences produced a valid fix plan that landed in
10
+ * prds-archived/ having never appeared in queue.json or history.jsonl.
11
+ *
12
+ * The fix: resolveFixPlanPath resolves the write dir from the ORIGINAL job's
13
+ * live PRD location (findPrdDir, via resolveVerifyPrdPath) — normally the
14
+ * originating Epic's own `epics/<epicId>/prds/`, which consolidateFlatPrds
15
+ * never touches — falling back to the flat dir only when the original can't
16
+ * be found live anywhere on disk.
17
+ *
18
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-fix-plan-path.test.cjs
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ import { test, expect, beforeAll, afterAll } from 'vitest';
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ let tmpHome;
29
+ let originalHome;
30
+ let resolveFixPlanPath;
31
+ let PRDS_DIR;
32
+ let consolidateFlatPrds;
33
+
34
+ beforeAll(() => {
35
+ originalHome = process.env.HOME;
36
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-fix-plan-path-'));
37
+ process.env.HOME = tmpHome;
38
+
39
+ // Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
40
+ // PRDS_DIR at module load, not lazily (see scheduler-find-prd-dir.test.cjs).
41
+ ({ resolveFixPlanPath, PRDS_DIR } = require('../scheduler.cjs'));
42
+ ({ consolidateFlatPrds } = require('../lib/prdMigration.cjs'));
43
+
44
+ if (!PRDS_DIR.startsWith(tmpHome)) {
45
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
46
+ }
47
+
48
+ // allProjectCwds()/activeProjectCwds() (consumed by findPrdDir's candidate
49
+ // search) discover project cwds by scanning ~/.claude/projects/*/*.jsonl
50
+ // for a `cwd` field — fake one project transcript pointing at the real
51
+ // on-disk cwd used below (mirrors scheduler-verify-prd-path.test.cjs).
52
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
53
+ const projDir = path.join(projectsDir, 'fake-fix-plan-project-slug');
54
+ fs.mkdirSync(projDir, { recursive: true });
55
+ fs.writeFileSync(
56
+ path.join(projDir, 'session.jsonl'),
57
+ `${JSON.stringify({ cwd: projectCwd() })}\n`,
58
+ 'utf8',
59
+ );
60
+ });
61
+
62
+ afterAll(() => {
63
+ process.env.HOME = originalHome;
64
+ fs.rmSync(tmpHome, { recursive: true, force: true });
65
+ });
66
+
67
+ function projectCwd() {
68
+ return path.join(tmpHome, 'fix-plan-project-cwd');
69
+ }
70
+
71
+ test('resolveFixPlanPath resolves the fix plan into the SAME Epic-scoped dir as the original PRD, and it survives consolidateFlatPrds', async () => {
72
+ const cwd = projectCwd();
73
+ const epicId = 'test-epic-arena-perf';
74
+ const slug = `105-arena-perf-fences-${process.pid}`;
75
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
76
+ fs.mkdirSync(prdsDir, { recursive: true });
77
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '---\ntitle: arena perf fences\n---\n\n# Goal\n\ntest\n', 'utf8');
78
+
79
+ const failedJob = { slug, cwd, parallelGroup: 5 };
80
+ const resolved = await resolveFixPlanPath(failedJob);
81
+
82
+ const expectedFixSlug = '05-fix-arena-perf-fences-' + process.pid;
83
+ expect(resolved.fixSlug).toBe(expectedFixSlug);
84
+ expect(resolved.livePrdDir).toBe(prdsDir);
85
+ expect(resolved.fixPath).toBe(path.join(prdsDir, `${expectedFixSlug}.md`));
86
+
87
+ // A fix plan written at the resolved path must survive a consolidateFlatPrds
88
+ // pass over the SAME project cwd — the sweep only ever touches the flat dir.
89
+ fs.writeFileSync(resolved.fixPath, `---\ntitle: Fix: arena perf fences\ncwd: ${cwd}\nparallelGroup: 5\nestimateMinutes: 30\n---\n\nfix body\n`, 'utf8');
90
+ await consolidateFlatPrds(cwd);
91
+ expect(fs.existsSync(resolved.fixPath)).toBe(true);
92
+ });
93
+
94
+ test('resolveFixPlanPath falls back to the flat dir when the original PRD cannot be found live anywhere on disk', async () => {
95
+ const cwd = path.join(tmpHome, 'fix-plan-project-cwd-missing');
96
+ const slug = `999-nonexistent-${process.pid}`;
97
+ const failedJob = { slug, cwd, parallelGroup: 3 };
98
+
99
+ const resolved = await resolveFixPlanPath(failedJob);
100
+
101
+ expect(resolved.livePrdDir).toBe(null);
102
+ const flatDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
103
+ expect(resolved.fixPath).toBe(path.join(flatDir, `${resolved.fixSlug}.md`));
104
+ });
105
+
106
+ test('the OLD flat-dir path (regression guard) does NOT survive consolidateFlatPrds — proves the bug this fix addresses', async () => {
107
+ const cwd = path.join(tmpHome, 'fix-plan-project-cwd-old-behavior');
108
+ const flatDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
109
+ fs.mkdirSync(flatDir, { recursive: true });
110
+ const oldFixPath = path.join(flatDir, '07-fix-old-behavior.md');
111
+ fs.writeFileSync(oldFixPath, `---\ntitle: Fix: old behavior\ncwd: ${cwd}\nparallelGroup: 7\nestimateMinutes: 30\n---\n\nfix body\n`, 'utf8');
112
+
113
+ // No queue.json at all — the file has no live queue row, exactly the
114
+ // freshly-authored-fix-plan situation the bug hit.
115
+ await consolidateFlatPrds(cwd);
116
+
117
+ expect(fs.existsSync(oldFixPath)).toBe(false);
118
+ expect(fs.existsSync(path.join(cwd, 'session-manager-operations', 'scheduler', 'prds-archived', '07-fix-old-behavior.md'))).toBe(true);
119
+ });
@@ -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({
@@ -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);
@@ -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
  }