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
@@ -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;
@@ -2289,6 +2289,37 @@ async function resolveVerifyPrdPath(job) {
2289
2289
  return liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
2290
2290
  }
2291
2291
 
2292
+ /**
2293
+ * resolveFixPlanPath(failedJob) → { fixPath, fixSlug, group, livePrdDir }
2294
+ *
2295
+ * Where spawnInvestigation's auto-fix plan for `failedJob` must be written.
2296
+ * The plan belongs in the SAME dir the original PRD lives in right now
2297
+ * (`livePrdDir`, from resolveVerifyPrdPath's live-dir search — usually the
2298
+ * originating Epic's `epics/<epicId>/prds/`), never the retired flat dir:
2299
+ * consolidateFlatPrds (prdMigration.cjs) sweeps only the flat dir and
2300
+ * archives any `.md` there with no live queue row, which a freshly-authored
2301
+ * fix plan never has yet — so writing it flat means the very next
2302
+ * reconcile() pass (src/main/scheduler.cjs's consolidateAllFlatPrds call)
2303
+ * archives it before it can ever be enqueued. `livePrdDir` is null (and the
2304
+ * flat dir, `prdDirForCwd`, is used instead) only when the original PRD
2305
+ * can't be found live anywhere on disk — an archived twin is deliberately
2306
+ * NOT treated as a live location for this purpose.
2307
+ *
2308
+ * Exported as a pure(ish) helper (one fs-free branch, one await) so tests
2309
+ * can prove the round trip — fix plan lands beside its original and
2310
+ * survives a consolidateFlatPrds(cwd) pass — without spawning a real
2311
+ * investigation.
2312
+ */
2313
+ async function resolveFixPlanPath(failedJob) {
2314
+ const livePrdPath = await resolveVerifyPrdPath(failedJob);
2315
+ const livePrdDir = livePrdPath ? path.dirname(livePrdPath) : null;
2316
+ const baseSlug = failedJob.slug.replace(/^\d+-/, '');
2317
+ const group = failedJob.parallelGroup ?? 99;
2318
+ const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
2319
+ const fixPath = path.join(livePrdDir ?? prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
2320
+ return { fixPath, fixSlug, group, livePrdDir };
2321
+ }
2322
+
2292
2323
  /**
2293
2324
  * notifyOriginatingTab(job) → void
2294
2325
  *
@@ -2980,6 +3011,16 @@ function isFixPlanSlug(slug) {
2980
3011
  return /^\d+-fix-/.test(slug);
2981
3012
  }
2982
3013
 
3014
+ /**
3015
+ * The fix-plan slug spawnInvestigation authors for a given failed job —
3016
+ * shared by selectAutoFixTargets (queue-membership check) and
3017
+ * reverifyNeedsReview's autofix_plan_unqueued annotation (same check, run
3018
+ * one or more passes later) so both use one slug formula.
3019
+ */
3020
+ function fixSlugFor(job) {
3021
+ return `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
3022
+ }
3023
+
2983
3024
  /**
2984
3025
  * Returns true for statuses that a fix-plan completion should promote
2985
3026
  * (clear) on the original job. Both 'failed' and 'needs_review' are
@@ -3165,9 +3206,18 @@ async function spawnInvestigation(failedJob, runDir) {
3165
3206
  const failedLogPath = path.join(runDir, `${failedJob.slug}.log`);
3166
3207
  const investigationLogPath = path.join(runDir, `${failedJob.slug}.investigation.log`);
3167
3208
 
3209
+ // livePrdDir is the SAME dir the original PRD lives in right now (usually
3210
+ // its Epic-scoped `epics/<epicId>/prds/`) — the fix plan must land there
3211
+ // too, since consolidateFlatPrds only ever sweeps the retired flat dir
3212
+ // (prdDirForCwd) and would archive a freshly-authored fix plan on the very
3213
+ // next reconcile() pass before it ever gets a queue row. Only fall back to
3214
+ // the flat dir when the original PRD can't be found on disk at all — an
3215
+ // archived twin (archivedPrdPathForJob) is deliberately NOT used for the
3216
+ // write dir, only for reading originalBody below.
3168
3217
  let originalBody = '';
3218
+ const { fixPath, fixSlug, group, livePrdDir } = await resolveFixPlanPath(failedJob);
3169
3219
  try {
3170
- const originalPath = (await resolveVerifyPrdPath(failedJob)) ?? archivedPrdPathForJob(failedJob);
3220
+ const originalPath = livePrdDir ? path.join(livePrdDir, `${failedJob.slug}.md`) : archivedPrdPathForJob(failedJob);
3171
3221
  originalBody = (await parsePrd(originalPath)).body;
3172
3222
  } catch {
3173
3223
  originalBody = failedJob.bodyPreview || '(original PRD missing from disk)';
@@ -3175,11 +3225,6 @@ async function spawnInvestigation(failedJob, runDir) {
3175
3225
 
3176
3226
  const logTail = readTail(failedLogPath, 16 * 1024) || '(failed to read log)';
3177
3227
 
3178
- const baseSlug = failedJob.slug.replace(/^\d+-/, '');
3179
- const group = failedJob.parallelGroup ?? 99;
3180
- const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
3181
- const fixPath = path.join(prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
3182
-
3183
3228
  if (fs.existsSync(fixPath)) {
3184
3229
  console.log(`[scheduler] skip investigation: fix plan already exists at ${fixPath}`);
3185
3230
  releaseSlot();
@@ -3281,6 +3326,13 @@ async function spawnInvestigation(failedJob, runDir) {
3281
3326
  ? `investigation spawn failed: ${error?.message ?? String(error)}`
3282
3327
  : `investigation error: ${error.message}`;
3283
3328
  sl(`\n[scheduler] ${errMsg}\n`);
3329
+ // Stamp the outcome even on this early exit — otherwise the job is
3330
+ // left with autoFixAttempted: true and no outcome, which used to
3331
+ // read as a permanent dead-end (see selectAutoFixTargets).
3332
+ mutate((s) => {
3333
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
3334
+ if (j) j.autoFixOutcome = 'error';
3335
+ }).catch(() => {});
3284
3336
  return;
3285
3337
  }
3286
3338
  sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
@@ -3301,6 +3353,10 @@ async function spawnInvestigation(failedJob, runDir) {
3301
3353
  }
3302
3354
  if (fs.existsSync(fixPath)) {
3303
3355
  console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
3356
+ mutate((s) => {
3357
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
3358
+ if (j) j.autoFixOutcome = 'plan';
3359
+ }).catch(() => {});
3304
3360
  } else {
3305
3361
  console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
3306
3362
  // Record the no-plan outcome so selectAutoFixTargets can offer one
@@ -3430,13 +3486,18 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3430
3486
  // Stale queue entry: the PRD was archived (already shipped) or is gone
3431
3487
  // from disk entirely (deleted before dispatch). Both are retire-not-fail
3432
3488
  // results from prdArchivedSkipResult with exitCode 0 and no transcript —
3433
- // treat any truthy skip reason as a plain completion: no verify pass, no
3434
- // 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).
3435
3495
  if (res.skipped) {
3496
+ const targetStatus = res.skipped === 'prd-missing' ? 'skipped' : 'completed';
3436
3497
  await mutate((s) => {
3437
3498
  const idx = s.jobs.findIndex((x) => x.slug === job.slug);
3438
3499
  if (idx >= 0) {
3439
- 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' });
3440
3501
  s.jobs[idx].finishedAt = new Date().toISOString();
3441
3502
  s.jobs[idx].exitCode = 0;
3442
3503
  s.jobs[idx].error = null;
@@ -3711,10 +3772,10 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3711
3772
  process.env.SM_AUTOFIX_DISABLE !== '1' &&
3712
3773
  isEligibleForImmediateAutoFix(s.jobs[i2], s.jobs, fixSlugExists)
3713
3774
  ) {
3714
- const isNoPlanRetry = s.jobs[i2].autoFixAttempted && s.jobs[i2].autoFixOutcome === 'no-plan';
3775
+ const isRetryAttempt = s.jobs[i2].autoFixAttempted === true;
3715
3776
  s.jobs[i2].autoFixAttempted = true;
3716
3777
  if (!s.jobs[i2].runId) s.jobs[i2].runId = runId;
3717
- if (isNoPlanRetry) {
3778
+ if (isRetryAttempt) {
3718
3779
  s.jobs[i2].autoFixRetries = (s.jobs[i2].autoFixRetries ?? 0) + 1;
3719
3780
  delete s.jobs[i2].autoFixOutcome;
3720
3781
  }
@@ -4241,7 +4302,7 @@ async function pollLoop() {
4241
4302
  // callers/tests that only pass `jobs` are unaffected.
4242
4303
  function selectHistoryJobs(jobs, limit, historyEntries = []) {
4243
4304
  const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
4244
- 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'));
4245
4306
  const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
4246
4307
  const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
4247
4308
  if (!j) return false;
@@ -4337,6 +4398,35 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
4337
4398
  return !!job && job.status === 'needs_review' && !job.runId && !hasRunDir;
4338
4399
  }
4339
4400
 
4401
+ /**
4402
+ * Pure predicate: an auto-fix attempt whose one bounded retry is already
4403
+ * spent, and whose outcome was NOT 'plan' (a produced plan is judged by
4404
+ * isPlanUnqueued instead — a plan is never retried, only annotated if it
4405
+ * never reaches the queue). Covers all three exhaustible outcomes
4406
+ * ('no-plan', 'error', and unstamped/undefined) — mirrors the retry
4407
+ * eligibility rule in selectAutoFixTargets so a job can never be retry-
4408
+ * eligible there and simultaneously un-annotatable here.
4409
+ */
4410
+ function isExhaustedAutoFix(job) {
4411
+ return !!job && job.status === 'needs_review'
4412
+ && job.autoFixAttempted === true
4413
+ && job.autoFixOutcome !== 'plan'
4414
+ && (job.autoFixRetries ?? 0) >= 1;
4415
+ }
4416
+
4417
+ /**
4418
+ * Pure predicate: an investigation produced a fix plan (autoFixOutcome ===
4419
+ * 'plan') but its fix-plan slug is not present among `queuedSlugs` — the
4420
+ * plan file failed to become a queue row (write failure, or some other
4421
+ * reason reconcile() never ingested it). `queuedSlugs` is the caller's
4422
+ * current job-slug set, passed in rather than read here so this stays pure.
4423
+ */
4424
+ function isPlanUnqueued(job, queuedSlugs) {
4425
+ if (!job || job.status !== 'needs_review') return false;
4426
+ if (job.autoFixOutcome !== 'plan') return false;
4427
+ return !queuedSlugs.has(fixSlugFor(job));
4428
+ }
4429
+
4340
4430
  /**
4341
4431
  * Pure predicate: is this job eligible for the boot re-verify self-heal? Only
4342
4432
  * needs_review jobs with a run log (own or backfilled via resolveRunId) AND a
@@ -4417,9 +4507,13 @@ function isRescanCandidate(job) {
4417
4507
  * - a runId, own or backfilled via resolveJobRunId (need a run log to investigate)
4418
4508
  * - not itself a fix-plan slug (avoids infinite recursion)
4419
4509
  * - autoFixAttempted cap: a job with no prior attempt is eligible; a job
4420
- * whose prior attempt outcome was 'no-plan' gets ONE bounded retry
4421
- * (autoFixRetries < 1); any other attempted job (or an exhausted
4422
- * no-plan retry) is excluded
4510
+ * whose prior attempt outcome was 'no-plan', 'error', or UNSTAMPED
4511
+ * (investigation exited without recording any outcome — unknown is not
4512
+ * "succeeded") gets ONE bounded retry (autoFixRetries < 1); a job whose
4513
+ * outcome was 'plan' (a fix plan was produced) is never retried here —
4514
+ * it either lands as a queue row on its own or gets flagged
4515
+ * autofix_plan_unqueued by reverifyNeedsReview's annotation pass; an
4516
+ * exhausted retry is excluded
4423
4517
  * - no fix sibling on disk (fixSlugExists) or already in the queue
4424
4518
  */
4425
4519
  function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
@@ -4430,10 +4524,13 @@ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRu
4430
4524
  if (!runId) return false;
4431
4525
  if (isFixPlanBeyondDepthCap(job.slug, job.investigationDepth)) return false;
4432
4526
  if (job.autoFixAttempted) {
4433
- if (job.autoFixOutcome !== 'no-plan') return false;
4527
+ const retryEligible = job.autoFixOutcome === 'no-plan'
4528
+ || job.autoFixOutcome === 'error'
4529
+ || job.autoFixOutcome == null;
4530
+ if (!retryEligible) return false;
4434
4531
  if ((job.autoFixRetries ?? 0) >= 1) return false;
4435
4532
  }
4436
- const fixSlug = `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
4533
+ const fixSlug = fixSlugFor(job);
4437
4534
  if (fixSlugExists(fixSlug)) return false;
4438
4535
  if (slugsInQueue.has(fixSlug)) return false;
4439
4536
  return true;
@@ -4561,20 +4658,29 @@ async function reverifyNeedsReview() {
4561
4658
 
4562
4659
  // Surface needs_review jobs that can never self-heal or get an auto-fix
4563
4660
  // investigation (no runId, no backfillable run dir) instead of leaving
4564
- // them silently stranded. Also surface no-plan auto-fix jobs whose one
4565
- // bounded retry is already exhausted. Both are annotated, never looped on.
4661
+ // them silently stranded. Also surface auto-fix jobs whose one bounded
4662
+ // retry is already exhausted (outcome 'no-plan', 'error', or unstamped —
4663
+ // selectAutoFixTargets treats all three as retry-eligible, so all three
4664
+ // must also be annotatable once the retry is spent), and jobs whose
4665
+ // investigation DID produce a fix plan that never became a queue row
4666
+ // (write failed, or reconcile hasn't picked it up for some other reason).
4667
+ // All three classes are annotated, never looped on — a needs_review job
4668
+ // must not exit this pass both un-retried and un-annotated.
4566
4669
  const afterHealForAnnotate = await readQueue();
4567
4670
  const unresolvable = afterHealForAnnotate.jobs.filter(
4568
4671
  (j) => isUnresolvableNeedsReview(j, { hasRunDir: !!resolveRunId(j) }) && j.verifierVerdict !== 'no_run_artifacts',
4569
4672
  );
4570
- const exhaustedNoPlan = afterHealForAnnotate.jobs.filter(
4571
- (j) => j.status === 'needs_review'
4572
- && j.autoFixAttempted && j.autoFixOutcome === 'no-plan' && (j.autoFixRetries ?? 0) >= 1
4573
- && j.verifierVerdict !== 'autofix_no_plan',
4673
+ const exhaustedAutoFix = afterHealForAnnotate.jobs.filter(
4674
+ (j) => isExhaustedAutoFix(j) && j.verifierVerdict !== 'autofix_no_plan',
4675
+ );
4676
+ const queuedSlugs = new Set(afterHealForAnnotate.jobs.map((j) => j.slug));
4677
+ const planUnqueued = afterHealForAnnotate.jobs.filter(
4678
+ (j) => isPlanUnqueued(j, queuedSlugs) && j.verifierVerdict !== 'autofix_plan_unqueued',
4574
4679
  );
4575
- if (unresolvable.length || exhaustedNoPlan.length) {
4680
+ if (unresolvable.length || exhaustedAutoFix.length || planUnqueued.length) {
4576
4681
  const unresolvableSet = new Set(unresolvable.map((j) => j.slug));
4577
- const exhaustedSet = new Set(exhaustedNoPlan.map((j) => j.slug));
4682
+ const exhaustedSet = new Set(exhaustedAutoFix.map((j) => j.slug));
4683
+ const planUnqueuedSet = new Set(planUnqueued.map((j) => j.slug));
4578
4684
  await mutate((s) => {
4579
4685
  for (const j of s.jobs) {
4580
4686
  if (unresolvableSet.has(j.slug)) {
@@ -4583,14 +4689,22 @@ async function reverifyNeedsReview() {
4583
4689
  } else if (exhaustedSet.has(j.slug)) {
4584
4690
  j.verifierVerdict = 'autofix_no_plan';
4585
4691
  j.error = 'auto-fix investigation produced no fix plan after retry — manual review';
4692
+ } else if (planUnqueuedSet.has(j.slug)) {
4693
+ j.verifierVerdict = 'autofix_plan_unqueued';
4694
+ j.error = 'auto-fix investigation produced a fix plan that never reached the queue — manual review';
4586
4695
  }
4587
4696
  }
4588
4697
  });
4589
4698
  if (unresolvable.length) {
4590
4699
  console.log(`[scheduler] boot reverify: no run artifacts for ${unresolvable.map((j) => j.slug).join(', ')} — flagged for manual review`);
4591
4700
  }
4592
- if (exhaustedNoPlan.length) {
4593
- console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${exhaustedNoPlan.map((j) => j.slug).join(', ')} — flagged for manual review`);
4701
+ if (exhaustedAutoFix.length) {
4702
+ console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${exhaustedAutoFix.map((j) => j.slug).join(', ')} — flagged for manual review`);
4703
+ }
4704
+ if (planUnqueued.length) {
4705
+ for (const j of planUnqueued) {
4706
+ console.log(`[scheduler] boot reverify: auto-fix plan never queued for ${j.slug} — flagged for manual review`);
4707
+ }
4594
4708
  }
4595
4709
  await broadcast();
4596
4710
  }
@@ -4608,7 +4722,7 @@ async function reverifyNeedsReview() {
4608
4722
  for (const job of targets) {
4609
4723
  const runId = job.runId || resolveRunId(job);
4610
4724
  const runDir = path.join(RUNS_DIR, runId);
4611
- const isNoPlanRetry = job.autoFixAttempted && job.autoFixOutcome === 'no-plan';
4725
+ const isRetryAttempt = job.autoFixAttempted === true;
4612
4726
  // Persist the attempt BEFORE spawning — a crash mid-investigation still
4613
4727
  // counts it (mirrors orphanRetries). Safe even when the slot is busy: the
4614
4728
  // investigation is queued and drained as slots free, so it is genuinely
@@ -4618,13 +4732,13 @@ async function reverifyNeedsReview() {
4618
4732
  if (j) {
4619
4733
  j.autoFixAttempted = true;
4620
4734
  if (!j.runId && runId) j.runId = runId;
4621
- if (isNoPlanRetry) {
4735
+ if (isRetryAttempt) {
4622
4736
  j.autoFixRetries = (j.autoFixRetries ?? 0) + 1;
4623
4737
  delete j.autoFixOutcome;
4624
4738
  }
4625
4739
  }
4626
4740
  });
4627
- console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${isNoPlanRetry ? 'retry' : '1/1'})`);
4741
+ console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${isRetryAttempt ? 'retry' : '1/1'})`);
4628
4742
  spawnInvestigation(job, runDir).catch((e) => {
4629
4743
  console.error('[scheduler] auto-fix spawnInvestigation error', job.slug, e);
4630
4744
  });
@@ -4634,6 +4748,43 @@ async function reverifyNeedsReview() {
4634
4748
  return { rescanned: candidates.length, healed, leftForReview };
4635
4749
  }
4636
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
+
4637
4788
  function registerScheduleHandlers() {
4638
4789
  ensureDirs();
4639
4790
  supervisor.registerHandlers();
@@ -4796,10 +4947,6 @@ function registerScheduleHandlers() {
4796
4947
  return { ok: true, kind: 'info', message };
4797
4948
  });
4798
4949
 
4799
- // Archive every non-running PRD and drop its entry from queue.json.
4800
- // Running entries are kept (would orphan an in-flight job). PRD files are
4801
- // moved (not deleted) to prds-archived/<ISO>/ so the user can recover them.
4802
- // Path containment is enforced — only files inside PRDS_DIR are moved.
4803
4950
  ipcMain.handle('schedule:clear-queue', async () => {
4804
4951
  ensureDirs();
4805
4952
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -4811,6 +4958,7 @@ function registerScheduleHandlers() {
4811
4958
  }
4812
4959
  await fsp.mkdir(archiveDir, { recursive: true });
4813
4960
  let archived = 0;
4961
+ const archiveNoteBySlug = new Map();
4814
4962
  for (const job of victims) {
4815
4963
  const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
4816
4964
  const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
@@ -4819,17 +4967,27 @@ function registerScheduleHandlers() {
4819
4967
  try {
4820
4968
  await fsp.rename(src, dst);
4821
4969
  archived++;
4970
+ archiveNoteBySlug.set(job.slug, `PRD file archived to ${archiveDir}`);
4822
4971
  } catch (e) {
4823
4972
  // ENOENT: the .md is already gone (reconcile would drop it on next
4824
4973
  // read anyway). Either way, fall through and remove from queue.
4825
4974
  if (e?.code !== 'ENOENT') {
4826
4975
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'clear-queue: rename failed', meta: { slug: job.slug, error: e?.message } });
4827
4976
  }
4977
+ archiveNoteBySlug.set(job.slug, `PRD file already gone (ENOENT) when archiving to ${archiveDir}`);
4828
4978
  }
4829
4979
  }
4830
4980
  await mutate(async (s) => {
4831
4981
  const victimSlugs = new Set(victims.map((j) => j.slug));
4832
- 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
+ }
4833
4991
  await reconcile(s);
4834
4992
  return null;
4835
4993
  });
@@ -5244,6 +5402,20 @@ async function init() {
5244
5402
  }
5245
5403
  }
5246
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
+
5247
5419
  /**
5248
5420
  * listPrdsInternal() → every live + archived PRD across every project,
5249
5421
  * with each entry's real job status folded in (`status`: the live queue
@@ -5503,15 +5675,25 @@ const remote = {
5503
5675
 
5504
5676
  // Every live+archived PRD across every project (listPrdsInternal, shared
5505
5677
  // with the renderer's schedule:list-prds IPC handler), filtered by the
5506
- // 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.
5507
5683
  async listPrds(filter = {}) {
5508
5684
  const all = await listPrdsInternal();
5509
- return all.filter((entry) => {
5685
+ const filtered = all.filter((entry) => {
5510
5686
  if (filter.cwd && entry.cwd !== filter.cwd) return false;
5511
5687
  if (filter.epicId && entry.epicId !== filter.epicId) return false;
5512
5688
  if (filter.status && entry.status !== filter.status) return false;
5513
5689
  return true;
5514
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 };
5515
5697
  },
5516
5698
 
5517
5699
  // Full body + parsed frontmatter for one PRD, live or archived. Mirrors
@@ -5650,7 +5832,7 @@ const remote = {
5650
5832
  const state = await readQueue();
5651
5833
  const job = state.jobs.find((j) => j.slug === slug);
5652
5834
  if (!job) return { ok: false, error: 'not found' };
5653
- 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') {
5654
5836
  return { ok: false, error: `job already terminal (status: "${job.status}") — nothing to cancel` };
5655
5837
  }
5656
5838
  const wasRunning = job.status === 'running';
@@ -5712,4 +5894,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
5712
5894
  });
5713
5895
  }
5714
5896
 
5715
- 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, 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, 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;