claude-code-session-manager 0.75.1 → 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 (46) hide show
  1. package/dist/assets/{AgentLibrary-CGplUKxY.js → AgentLibrary-s1jRGOji.js} +1 -1
  2. package/dist/assets/{DataModel-DQ15KxVb.js → DataModel-DNefcWbk.js} +1 -1
  3. package/dist/assets/{History-v2TSC_6A.js → History-4sMk80yI.js} +1 -1
  4. package/dist/assets/{Hooks-Co7s6BNB.js → Hooks-CNUVwH5P.js} +1 -1
  5. package/dist/assets/{HostBilko-DJj8FWPK.js → HostBilko-BhkyvIhw.js} +1 -1
  6. package/dist/assets/{Library-DQaC9VQ1.js → Library-BpockanA.js} +1 -1
  7. package/dist/assets/{ListDetail-BwFRfAZm.js → ListDetail-Cre7h3U0.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-C155gNpc.js → MarkdownEditor-x-FzvaXm.js} +1 -1
  9. package/dist/assets/{McpServers-BmLgE_gS.js → McpServers-CXAcQLMB.js} +1 -1
  10. package/dist/assets/{Memory-ISwNcS5j.js → Memory-CGBv1Rco.js} +1 -1
  11. package/dist/assets/{Panel-1adjEpj-.js → Panel-czEs21N2.js} +1 -1
  12. package/dist/assets/{Permissions-FrCz5cGa.js → Permissions-rGkLOpqI.js} +1 -1
  13. package/dist/assets/{Plugins-zUssKp_b.js → Plugins-BTmB_vgU.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CAXqA_HX.js → ProvenanceBadge-dmCivDq5.js} +1 -1
  15. package/dist/assets/{SaveBar-D9cqms3Y.js → SaveBar-D80TNkAx.js} +1 -1
  16. package/dist/assets/{Scheduler-dNNc9S11.js → Scheduler-C6wHSZP7.js} +3 -3
  17. package/dist/assets/{ScopeSwitcher-1wBTne7S.js → ScopeSwitcher-DsaiYTgl.js} +1 -1
  18. package/dist/assets/{Settings-BT_expIg.js → Settings-m2fyU8Q5.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CVccq4Cu.js → SkillReferenceGraph-yS-lgBFU.js} +1 -1
  20. package/dist/assets/{Skills-xFpRY0ld.js → Skills-DtQgCs9-.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CEk_71th.js → SystemPrompt-CLuVdoMA.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2zhihQG.js → TagLibrary-DQXoI752.js} +1 -1
  23. package/dist/assets/{TiptapBody-CQKcHKaD.js → TiptapBody-BGKVXrvq.js} +1 -1
  24. package/dist/assets/{Toggle-CQ-IBb8m.js → Toggle-RFnEV71m.js} +1 -1
  25. package/dist/assets/{index-Dbo9R4e0.css → index-CMLnzdZC.css} +1 -1
  26. package/dist/assets/{index-BgbV3sKX.js → index-Ddrw7J7O.js} +4 -4
  27. package/dist/assets/{settingsSchema-D6ccSKqm.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-clear-queue-history.test.cjs +134 -0
  37. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +5 -1
  38. package/src/main/ipcSchemas.cjs +10 -0
  39. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +26 -0
  40. package/src/main/lib/prdAdminRoutes.cjs +12 -2
  41. package/src/main/lib/queueHistory.cjs +6 -4
  42. package/src/main/lib/scheduleJobSchema.cjs +1 -1
  43. package/src/main/lib/scheduleJobTransitions.cjs +19 -6
  44. package/src/main/lib/schedulerBatch.cjs +17 -1
  45. package/src/main/scheduler.cjs +89 -16
  46. package/src/preload/api.d.ts +1 -1
@@ -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
  }
@@ -100,7 +100,7 @@ const queueOps = require('./queueOps.cjs');
100
100
  // home-dir layout.
101
101
  const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
102
102
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
103
- const { transitionJob } = require('./lib/scheduleJobTransitions.cjs');
103
+ const { transitionJob, STATUS_HISTORY_CAP } = require('./lib/scheduleJobTransitions.cjs');
104
104
  const { buildContextDigest, composeExecutorPrompt } = require('./lib/epicContextDigest.cjs');
105
105
  const { JOB_STATUSES } = require('./lib/scheduleJobSchema.cjs');
106
106
  const { appendAuditEvent } = require('./lib/auditLog.cjs');
@@ -1424,7 +1424,7 @@ async function reconcile(state) {
1424
1424
  seen.add(job.slug);
1425
1425
  next.push({ ...job });
1426
1426
  console.warn(`[scheduler] reconcile: keeping ${job.status} job ${job.slug} — PRD source not visible in any candidate dir`);
1427
- } else if (job.status === 'completed' || job.status === 'failed') {
1427
+ } else if (job.status === 'completed' || job.status === 'failed' || job.status === 'skipped') {
1428
1428
  terminalDroppedNeedingHistoryCheck.push(job);
1429
1429
  }
1430
1430
  continue;
@@ -2104,7 +2104,7 @@ async function clearPause(source) {
2104
2104
  * already-'completed' job, and that path is exactly what this guards.
2105
2105
  */
2106
2106
  function resetJobFields(job, errorMsg, opts = {}) {
2107
- if (job.status === 'completed' && opts.force !== true) return false;
2107
+ if ((job.status === 'completed' || job.status === 'skipped') && opts.force !== true) return false;
2108
2108
  if (!transitionJob(job, 'pending', { reason: errorMsg ?? 'reset to pending', source: opts.source ?? 'resetJobFields' })) return false;
2109
2109
  job.runId = null;
2110
2110
  job.startedAt = null;
@@ -3486,13 +3486,18 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3486
3486
  // Stale queue entry: the PRD was archived (already shipped) or is gone
3487
3487
  // from disk entirely (deleted before dispatch). Both are retire-not-fail
3488
3488
  // results from prdArchivedSkipResult with exitCode 0 and no transcript —
3489
- // treat any truthy skip reason as a plain completion: no verify pass, no
3490
- // commit guard, no RCA feedback item.
3489
+ // no verify pass, no commit guard, no RCA feedback item — but only
3490
+ // 'prd-archived' actually means the work shipped (archivedTwinExists
3491
+ // found the file under a prds-archived/ dir). 'prd-missing' means no
3492
+ // executor ever ran at all, so it must NOT land on the same 'completed'
3493
+ // status a genuine successful run produces — that conflation is what let
3494
+ // 20 never-executed sigma PRDs read as shipped (2026-08-30 incident).
3491
3495
  if (res.skipped) {
3496
+ const targetStatus = res.skipped === 'prd-missing' ? 'skipped' : 'completed';
3492
3497
  await mutate((s) => {
3493
3498
  const idx = s.jobs.findIndex((x) => x.slug === job.slug);
3494
3499
  if (idx >= 0) {
3495
- transitionJob(s.jobs[idx], 'completed', { reason: res.note ?? 'PRD archived or missing — treated as already-shipped', source: 'spawnJob:skip-archived' });
3500
+ transitionJob(s.jobs[idx], targetStatus, { reason: res.note ?? 'PRD archived or missing — treated as already-shipped', source: 'spawnJob:skip-archived' });
3496
3501
  s.jobs[idx].finishedAt = new Date().toISOString();
3497
3502
  s.jobs[idx].exitCode = 0;
3498
3503
  s.jobs[idx].error = null;
@@ -4297,7 +4302,7 @@ async function pollLoop() {
4297
4302
  // callers/tests that only pass `jobs` are unaffected.
4298
4303
  function selectHistoryJobs(jobs, limit, historyEntries = []) {
4299
4304
  const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
4300
- const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed'));
4305
+ const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed' || j.status === 'skipped'));
4301
4306
  const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
4302
4307
  const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
4303
4308
  if (!j) return false;
@@ -4743,6 +4748,43 @@ async function reverifyNeedsReview() {
4743
4748
  return { rescanned: candidates.length, healed, leftForReview };
4744
4749
  }
4745
4750
 
4751
+ /**
4752
+ * schedule:clear-queue's core state mutation, split out so it's testable
4753
+ * without ipcMain/electron: every victim (non-running job whose slug is in
4754
+ * `victimSlugs`) must leave a durable history.jsonl record before it
4755
+ * disappears from queue.json — a manual clear used to just filter the row
4756
+ * out with no trace (a job could vanish with zero audit trail; see the
4757
+ * 2026-08-30 sigma `788-pr-sweep-final-gate` incident). Non-terminal victims
4758
+ * (pending/investigating/needs_review/quarantined) are force-transitioned to
4759
+ * 'skipped' — never 'completed', since a manually-cleared job did not ship.
4760
+ * Victims already terminal (completed/failed/skipped) keep their real
4761
+ * status; they just get a statusHistory note + a history row so they don't
4762
+ * silently drop before aging past HISTORY_RETENTION_MS.
4763
+ *
4764
+ * Mutates `state.jobs` in place (drops the victims, same contract as
4765
+ * reconcile()) and returns the job snapshots to append to history.jsonl.
4766
+ */
4767
+ function applyClearQueueVictims(state, victimSlugs, archiveNoteBySlug, archiveDir) {
4768
+ const historyEntries = [];
4769
+ for (const job of state.jobs) {
4770
+ if (!victimSlugs.has(job.slug)) continue;
4771
+ const reason = archiveNoteBySlug?.get(job.slug) ?? `PRD archived to ${archiveDir}`;
4772
+ if (job.status === 'completed' || job.status === 'failed' || job.status === 'skipped') {
4773
+ // Already terminal — keep the real status, just record the clear.
4774
+ const history = Array.isArray(job.statusHistory) ? job.statusHistory : [];
4775
+ history.push({ from: job.status, to: job.status, reason, source: 'clear-queue', at: new Date().toISOString() });
4776
+ while (history.length > STATUS_HISTORY_CAP) history.shift();
4777
+ job.statusHistory = history;
4778
+ } else {
4779
+ transitionJob(job, 'skipped', { reason, source: 'clear-queue' });
4780
+ }
4781
+ if (!job.finishedAt) job.finishedAt = new Date().toISOString();
4782
+ historyEntries.push({ ...job });
4783
+ }
4784
+ state.jobs = state.jobs.filter((j) => !victimSlugs.has(j.slug));
4785
+ return historyEntries;
4786
+ }
4787
+
4746
4788
  function registerScheduleHandlers() {
4747
4789
  ensureDirs();
4748
4790
  supervisor.registerHandlers();
@@ -4905,10 +4947,6 @@ function registerScheduleHandlers() {
4905
4947
  return { ok: true, kind: 'info', message };
4906
4948
  });
4907
4949
 
4908
- // Archive every non-running PRD and drop its entry from queue.json.
4909
- // Running entries are kept (would orphan an in-flight job). PRD files are
4910
- // moved (not deleted) to prds-archived/<ISO>/ so the user can recover them.
4911
- // Path containment is enforced — only files inside PRDS_DIR are moved.
4912
4950
  ipcMain.handle('schedule:clear-queue', async () => {
4913
4951
  ensureDirs();
4914
4952
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -4920,6 +4958,7 @@ function registerScheduleHandlers() {
4920
4958
  }
4921
4959
  await fsp.mkdir(archiveDir, { recursive: true });
4922
4960
  let archived = 0;
4961
+ const archiveNoteBySlug = new Map();
4923
4962
  for (const job of victims) {
4924
4963
  const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
4925
4964
  const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
@@ -4928,17 +4967,27 @@ function registerScheduleHandlers() {
4928
4967
  try {
4929
4968
  await fsp.rename(src, dst);
4930
4969
  archived++;
4970
+ archiveNoteBySlug.set(job.slug, `PRD file archived to ${archiveDir}`);
4931
4971
  } catch (e) {
4932
4972
  // ENOENT: the .md is already gone (reconcile would drop it on next
4933
4973
  // read anyway). Either way, fall through and remove from queue.
4934
4974
  if (e?.code !== 'ENOENT') {
4935
4975
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'clear-queue: rename failed', meta: { slug: job.slug, error: e?.message } });
4936
4976
  }
4977
+ archiveNoteBySlug.set(job.slug, `PRD file already gone (ENOENT) when archiving to ${archiveDir}`);
4937
4978
  }
4938
4979
  }
4939
4980
  await mutate(async (s) => {
4940
4981
  const victimSlugs = new Set(victims.map((j) => j.slug));
4941
- s.jobs = s.jobs.filter((j) => !victimSlugs.has(j.slug));
4982
+ const historyEntries = applyClearQueueVictims(s, victimSlugs, archiveNoteBySlug, archiveDir);
4983
+ // Append BEFORE this mutate's writeQueue persists s.jobs without the
4984
+ // victims (mutate() only writes after this callback resolves) — same
4985
+ // append-before-drop ordering queueHistory.cjs's reconcile() callers
4986
+ // use, so a crash right after this handler returns can never leave a
4987
+ // row gone from queue.json with nothing in history.jsonl yet.
4988
+ if (historyEntries.length > 0) {
4989
+ await queueHistory.appendHistory(historyEntries);
4990
+ }
4942
4991
  await reconcile(s);
4943
4992
  return null;
4944
4993
  });
@@ -5353,6 +5402,20 @@ async function init() {
5353
5402
  }
5354
5403
  }
5355
5404
 
5405
+ // Measured against this repo's own live+archived PRD set (868 entries
5406
+ // across every local project — the admin route has no cwd filter by
5407
+ // default): no single field dominates a listPrdsInternal() entry: title is
5408
+ // the largest at ~30% of payload bytes, followed by slug/cwd/sourcePromptId/
5409
+ // epicId at 12-14% each — the 353KB the route was shipping came from
5410
+ // returning the full unbounded row count, not from one oversized field.
5411
+ // This trims the fields an agent doesn't need to answer "what PRDs are
5412
+ // there" (parallelGroup/estimateMinutes/sourcePromptId/epicId/
5413
+ // archivedStatus); `fields=full` restores them.
5414
+ function toCompactPrdEntry(entry) {
5415
+ const { slug, title, cwd, mtimeMs, archived, status } = entry;
5416
+ return { slug, title, cwd, mtimeMs, archived, status };
5417
+ }
5418
+
5356
5419
  /**
5357
5420
  * listPrdsInternal() → every live + archived PRD across every project,
5358
5421
  * with each entry's real job status folded in (`status`: the live queue
@@ -5612,15 +5675,25 @@ const remote = {
5612
5675
 
5613
5676
  // Every live+archived PRD across every project (listPrdsInternal, shared
5614
5677
  // with the renderer's schedule:list-prds IPC handler), filtered by the
5615
- // admin route's cwd/epicId/status query params.
5678
+ // admin route's cwd/epicId/status query params and paged by
5679
+ // limit/offset. Sort order is listPrdsInternal's own slug.localeCompare
5680
+ // (numeric) — stable across calls since it's a pure string sort, so
5681
+ // paging by offset can't skip or duplicate an entry between requests as
5682
+ // long as the underlying PRD set doesn't change mid-page.
5616
5683
  async listPrds(filter = {}) {
5617
5684
  const all = await listPrdsInternal();
5618
- return all.filter((entry) => {
5685
+ const filtered = all.filter((entry) => {
5619
5686
  if (filter.cwd && entry.cwd !== filter.cwd) return false;
5620
5687
  if (filter.epicId && entry.epicId !== filter.epicId) return false;
5621
5688
  if (filter.status && entry.status !== filter.status) return false;
5622
5689
  return true;
5623
5690
  });
5691
+ const total = filtered.length;
5692
+ const limit = filter.limit ?? 100;
5693
+ const offset = filter.offset ?? 0;
5694
+ const page = filtered.slice(offset, offset + limit);
5695
+ const prds = filter.fields === 'full' ? page : page.map(toCompactPrdEntry);
5696
+ return { prds, total, limit, offset, hasMore: offset + page.length < total };
5624
5697
  },
5625
5698
 
5626
5699
  // Full body + parsed frontmatter for one PRD, live or archived. Mirrors
@@ -5759,7 +5832,7 @@ const remote = {
5759
5832
  const state = await readQueue();
5760
5833
  const job = state.jobs.find((j) => j.slug === slug);
5761
5834
  if (!job) return { ok: false, error: 'not found' };
5762
- if (job.status === 'completed' || job.status === 'failed' || job.status === 'needs_review') {
5835
+ if (job.status === 'completed' || job.status === 'failed' || job.status === 'needs_review' || job.status === 'skipped') {
5763
5836
  return { ok: false, error: `job already terminal (status: "${job.status}") — nothing to cancel` };
5764
5837
  }
5765
5838
  const wasRunning = job.status === 'running';
@@ -5821,4 +5894,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
5821
5894
  });
5822
5895
  }
5823
5896
 
5824
- module.exports = { findOverrunningJobs, JOB_OVERRUN_FACTOR, JOB_OVERRUN_FLOOR_MS, registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor, healTargetForFix, buildInvestigationPrompt, isGitRepoSync, 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, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveFixPlanPath, resolveNotifyPrd, runPrdMigration, consolidateAllFlatPrds, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult, spawnJob, listPrdsInternal, computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS };
5897
+ module.exports = { findOverrunningJobs, JOB_OVERRUN_FACTOR, JOB_OVERRUN_FLOOR_MS, registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor, healTargetForFix, buildInvestigationPrompt, isGitRepoSync, 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, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveFixPlanPath, resolveNotifyPrd, runPrdMigration, consolidateAllFlatPrds, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult, spawnJob, listPrdsInternal, computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS, applyClearQueueVictims };
@@ -391,7 +391,7 @@ export interface ScheduleConfig {
391
391
  * so this union is a manually-kept copy; drift from JOB_STATUSES (and from
392
392
  * the renderer's own mirrors in StatusBadge.tsx/SchedulePanel.tsx) is
393
393
  * caught by src/main/__tests__/scheduleJobStatusDrift.test.cjs. */
394
- export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'failed' | 'needs_review' | 'quarantined';
394
+ export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'skipped' | 'failed' | 'needs_review' | 'quarantined';
395
395
 
396
396
  export interface ScheduleJobRuntime {
397
397
  pid: number;