claude-code-session-manager 0.35.18 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
  2. package/dist/assets/index-CTTjT08J.css +32 -0
  3. package/dist/assets/index-_iBXLuvt.js +3496 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  8. package/src/main/__tests__/docEdit.test.cjs +82 -0
  9. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  10. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  11. package/src/main/__tests__/memoryStale.test.cjs +88 -0
  12. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  14. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  16. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
  17. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  18. package/src/main/chatRunner.cjs +57 -18
  19. package/src/main/config.cjs +8 -0
  20. package/src/main/docEdit.cjs +133 -0
  21. package/src/main/files.cjs +53 -0
  22. package/src/main/historyAggregator.cjs +391 -50
  23. package/src/main/historyDashboard.cjs +226 -0
  24. package/src/main/index.cjs +37 -1
  25. package/src/main/ipcSchemas.cjs +29 -0
  26. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  27. package/src/main/lib/historyRollup.cjs +148 -0
  28. package/src/main/lib/memoryStale.cjs +116 -0
  29. package/src/main/lib/queueHistory.cjs +216 -0
  30. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  31. package/src/main/lib/schedulerConfig.cjs +16 -0
  32. package/src/main/memoryTool.cjs +49 -0
  33. package/src/main/queueOps.cjs +92 -0
  34. package/src/main/scheduler/prdParser.cjs +52 -1
  35. package/src/main/scheduler.cjs +237 -29
  36. package/src/preload/api.d.ts +92 -1
  37. package/src/preload/index.cjs +7 -0
  38. package/dist/assets/index-4dJkn9nT.js +0 -3559
  39. package/dist/assets/index-DX2w2YhJ.css +0 -32
@@ -38,6 +38,7 @@ const { SCHEDULE_SLUG_RE: SLUG_RE, schemas } = require('./ipcSchemas.cjs');
38
38
  const logs = require('./logs.cjs');
39
39
  const config = require('./config.cjs');
40
40
  const { expandHome } = require('./lib/expandHome.cjs');
41
+ const { HISTORY_RETENTION_MS } = require('./lib/schedulerConfig.cjs');
41
42
 
42
43
  const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
43
44
  const PRDS_DIR = path.join(ROOT, 'prds');
@@ -243,6 +244,95 @@ async function archiveMany(slugs) {
243
244
  return { ok: true, archived, archivedTo: archiveDir, results };
244
245
  }
245
246
 
247
+ // ────────────────────────────────────────────── auto-archive completed PRDs
248
+ //
249
+ // PRDS_DIR grows monotonically (738+ .md files, ~100% long-completed) because
250
+ // nothing ever moves a finished PRD's .md out of the live directory — every
251
+ // allocateParallelGroup scan, reconcile listing, and lint pass walks all of
252
+ // them. Once a job is durably 'completed' (queueHistory.cjs's same retention
253
+ // window has already judged it safe to leave jobs[]) and nothing still needs
254
+ // its file in place, move the .md to prds-archived/ alongside it.
255
+ //
256
+ // 'needs_review' and 'failed' PRDs are NEVER selected here — humans and the
257
+ // auto-fix loop (scheduler.cjs's healTargetForFix) still need those files on
258
+ // disk. Same fix-plan-protection regex as queueHistory.cjs's partitionJobs,
259
+ // duplicated (not shared) because the two modules intentionally stay
260
+ // decoupled — kept in sync by comment, same convention queueHistory.cjs uses
261
+ // against scheduler.cjs's healTargetForFix.
262
+
263
+ const FIX_PLAN_RE = /^\d+-fix-/;
264
+
265
+ function protectedByPendingFix(jobs) {
266
+ const protectedSlugs = new Set();
267
+ for (const j of jobs) {
268
+ if (!j || !FIX_PLAN_RE.test(j.slug || '')) continue;
269
+ if (j.status !== 'pending' && j.status !== 'running') continue;
270
+ protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
271
+ }
272
+ return protectedSlugs;
273
+ }
274
+
275
+ /**
276
+ * Pure predicate: which slugs are safe to move to prds-archived/. O(n) over
277
+ * `jobs`. A slug qualifies only when ALL of:
278
+ * - its job status is 'completed' (never 'needs_review'/'failed'/
279
+ * 'pending'/'running' — the completed check alone also covers "not
280
+ * itself a pending/running job")
281
+ * - finishedAt parses and is older than retentionMs (default
282
+ * HISTORY_RETENTION_MS, the same constant queueHistory.cjs's
283
+ * partitionJobs uses — by the time a file is archive-eligible its queue
284
+ * row has already left jobs[] into history.jsonl)
285
+ * - no pending/running `NN-fix-<slug>` sibling still needs it in place
286
+ */
287
+ function selectAutoArchivable(jobs, { nowMs, retentionMs } = {}) {
288
+ const list = Array.isArray(jobs) ? jobs : [];
289
+ const retention = retentionMs ?? HISTORY_RETENTION_MS;
290
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
291
+ const protectedSlugs = protectedByPendingFix(list);
292
+
293
+ const slugs = [];
294
+ for (const j of list) {
295
+ if (!j || !j.slug) continue;
296
+ if (j.status !== 'completed') continue;
297
+ const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
298
+ if (!Number.isFinite(finishedMs)) continue;
299
+ if ((now - finishedMs) <= retention) continue;
300
+ if (protectedSlugs.has(j.slug)) continue;
301
+ slugs.push(j.slug);
302
+ }
303
+ return slugs;
304
+ }
305
+
306
+ /**
307
+ * Selects and moves eligible completed PRDs' .md files to prds-archived/.
308
+ * Reuses archiveMany (no second mover) so moves stay atomic fsp.rename with
309
+ * the same path-containment. No-op (without touching disk) when
310
+ * SM_PRD_AUTOARCHIVE_DISABLE=1. Each successful move is logged one line
311
+ * (slug -> dest).
312
+ */
313
+ async function autoArchiveCompleted(state, { nowMs } = {}) {
314
+ if (process.env.SM_PRD_AUTOARCHIVE_DISABLE === '1') {
315
+ return { ok: true, archived: 0, archivedTo: null, results: [], skipped: 'disabled' };
316
+ }
317
+ const jobs = Array.isArray(state?.jobs) ? state.jobs : [];
318
+ const slugs = selectAutoArchivable(jobs, { nowMs });
319
+ if (slugs.length === 0) {
320
+ return { ok: true, archived: 0, archivedTo: null, results: [] };
321
+ }
322
+ const result = await archiveMany(slugs);
323
+ for (const r of result.results) {
324
+ if (r.ok) {
325
+ logs.writeLine({
326
+ level: 'info',
327
+ scope: 'queueOps',
328
+ message: `auto-archived completed PRD: ${r.slug} -> ${r.archivedTo}`,
329
+ meta: { slug: r.slug, dest: r.archivedTo },
330
+ });
331
+ }
332
+ }
333
+ return result;
334
+ }
335
+
246
336
  // ────────────────────────────────────────────── retag
247
337
 
248
338
  /**
@@ -398,6 +488,8 @@ module.exports = {
398
488
  lintAll,
399
489
  lintOneAsync,
400
490
  archiveMany,
491
+ selectAutoArchivable,
492
+ autoArchiveCompleted,
401
493
  retagMany,
402
494
  PRDS_DIR,
403
495
  PRDS_ARCHIVE_DIR,
@@ -172,6 +172,48 @@ const RESERVATION_RE = /^\.reserved-(\d+)$/;
172
172
  const GROUP_PREFIX_RE = /^(\d+)-/;
173
173
  const MAX_RESERVE_ATTEMPTS = 1000;
174
174
 
175
+ // Auto-archiving completed PRDs (see queueOps.cjs's autoArchiveCompleted)
176
+ // moves old `NN-*.md` files out of prdsDir into prds-archived/, which would
177
+ // let maxParallelGroupInUse's directory scan regress once the highest-NN
178
+ // files age out — a later allocation could then reissue an already-used NN
179
+ // and collide with an archived group. A high-water-mark sidecar closes that:
180
+ // once a scan or allocation has seen NN, the allocator never returns
181
+ // anything ≤ NN again, even if every file at or above NN is later archived.
182
+ // Sidecar (not queue.json) so this module stays self-contained and testable
183
+ // against a bare tmp dir, matching the `.reserved-NN` marker convention.
184
+ const MAX_GROUP_FILE = '.max-allocated-group';
185
+
186
+ async function readHighWaterMark(prdsDir) {
187
+ try {
188
+ const raw = await fsp.readFile(path.join(prdsDir, MAX_GROUP_FILE), 'utf8');
189
+ const n = Number(raw.trim());
190
+ return Number.isFinite(n) && n >= 0 ? n : 0;
191
+ } catch {
192
+ return 0;
193
+ }
194
+ }
195
+
196
+ async function writeHighWaterMark(prdsDir, n) {
197
+ // Re-read immediately before writing so two concurrent allocations that
198
+ // both raced past the caller's earlier read can't have the smaller `n`
199
+ // land last and regress the persisted mark below the larger one. Narrows
200
+ // (does not fully eliminate) the race; the reservation markers remain the
201
+ // authoritative concurrency guard, this file is a best-effort backstop
202
+ // for after files are archived away.
203
+ const current = await readHighWaterMark(prdsDir);
204
+ if (n <= current) return;
205
+
206
+ const dest = path.join(prdsDir, MAX_GROUP_FILE);
207
+ // `n` itself is unique per concurrent caller (the reservation marker each
208
+ // caller holds guarantees no two callers ever share a candidate), so it
209
+ // doubles as a collision-free tmp-file suffix — process.pid + Date.now()
210
+ // is NOT safe here since concurrent allocateParallelGroup calls in the
211
+ // same process can land in the same millisecond.
212
+ const tmp = `${dest}.tmp-${n}-${process.pid}`;
213
+ await fsp.writeFile(tmp, String(n), 'utf8');
214
+ await fsp.rename(tmp, dest);
215
+ }
216
+
175
217
  /**
176
218
  * Highest `NN` currently in use under prdsDir, across real `NN-slug.md`
177
219
  * PRD files (any digit count, unpadded or not — matches groupFromName
@@ -208,7 +250,10 @@ async function maxParallelGroupInUse(prdsDir) {
208
250
  */
209
251
  async function allocateParallelGroup(prdsDir) {
210
252
  await fsp.mkdir(prdsDir, { recursive: true });
211
- let candidate = (await maxParallelGroupInUse(prdsDir)) + 1;
253
+ const scanMax = await maxParallelGroupInUse(prdsDir);
254
+ const highWater = await readHighWaterMark(prdsDir);
255
+ const floor = Math.max(scanMax, highWater);
256
+ let candidate = floor + 1;
212
257
  for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
213
258
  const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
214
259
  let fh;
@@ -222,6 +267,12 @@ async function allocateParallelGroup(prdsDir) {
222
267
  throw e;
223
268
  }
224
269
  await fh.close();
270
+ // Persist the new floor before returning so a later archive of every
271
+ // NN-*.md file (including this one) can never regress the allocator
272
+ // below it.
273
+ if (candidate > highWater) {
274
+ await writeHighWaterMark(prdsDir, candidate);
275
+ }
225
276
  return candidate;
226
277
  }
227
278
  throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
@@ -36,8 +36,11 @@
36
36
  * Orphaned entries (.md gone) are pruned.
37
37
  *
38
38
  * Renderer events:
39
- * - 'schedule:state' broadcasts the full state on any change. Keeps the
40
- * panel UI dead simple — no diff machinery.
39
+ * - 'schedule:state' broadcasts the full state on mutation. Keeps the
40
+ * panel UI dead simple — no diff machinery. Sends are trailing-edge
41
+ * debounced (~BROADCAST_COALESCE_MS) by default so a burst of mutations
42
+ * collapses into one push; state-machine transitions (pause/resume, job
43
+ * start/finish) call broadcast({ flush: true }) to bypass the window.
41
44
  */
42
45
 
43
46
  const fs = require('node:fs');
@@ -55,6 +58,7 @@ const { readTail } = require('./lib/fileTail.cjs');
55
58
  const { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP } = require('./lib/reaperHelpers.cjs');
56
59
  const { openLog, withChildAndLog } = require('./lib/childWithLog.cjs');
57
60
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
61
+ const { createBroadcastCoalescer } = require('./lib/broadcastCoalescer.cjs');
58
62
  const prdParser = require('./scheduler/prdParser.cjs');
59
63
  const { verifyRun } = require('./runVerify.cjs');
60
64
  const logs = require('./logs.cjs');
@@ -63,9 +67,13 @@ const {
63
67
  POLL_INTERVAL_MS,
64
68
  USAGE_REFRESH_INTERVAL_MS,
65
69
  MAX_JOB_DURATION_MS,
70
+ BROADCAST_COALESCE_MS,
66
71
  } = require('./lib/schedulerConfig.cjs');
67
72
  const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
68
73
  const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
74
+ const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
75
+ const queueHistory = require('./lib/queueHistory.cjs');
76
+ const queueOps = require('./queueOps.cjs');
69
77
 
70
78
  const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
71
79
 
@@ -224,9 +232,14 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
224
232
  const HEARTBEAT_MAX_BYTES = 1024 * 1024;
225
233
  // DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
226
234
 
227
- const ENV_CAP = process.env.SM_SCHEDULER_MAX_CONCURRENCY
228
- ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
229
- : null;
235
+ // Read lazily (not captured at module-load time) so tests can stub the env
236
+ // per-case without vi.resetModules(); production behavior is unaffected
237
+ // since the env var never changes mid-process.
238
+ function getEnvCap() {
239
+ return process.env.SM_SCHEDULER_MAX_CONCURRENCY
240
+ ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
241
+ : null;
242
+ }
230
243
 
231
244
  // Each headless claude -p job can shell out to tsc/vite/pytest and grow well
232
245
  // past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
@@ -252,7 +265,7 @@ const OOM_SCORE_ADJ_JOB = 500;
252
265
 
253
266
  const DEFAULT_CONFIG = {
254
267
  offsetMinutes: 15,
255
- concurrencyCap: ENV_CAP ?? 3,
268
+ concurrencyCap: getEnvCap() ?? 3,
256
269
  defaultCwd: DEFAULT_PROJECT_CWD,
257
270
  // 'when-available' = poll usage and fire whenever utilization < threshold.
258
271
  // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
@@ -361,6 +374,46 @@ function ensureDirs() {
361
374
  } catch { /* non-fatal: the guide is a convenience, not load-bearing for a run */ }
362
375
  }
363
376
 
377
+ // Matches only numbered timestamp backups (queue.json.bak-<epoch>), not the
378
+ // bare `queue.json.bak` single-shot copy some older code paths left behind.
379
+ const QUEUE_BAK_RE = /^queue\.json\.bak-\d+$/;
380
+ const QUEUE_BAK_KEEP = 5;
381
+
382
+ /**
383
+ * Sweeps accumulated queue.json.bak-<epoch> files down to the newest
384
+ * QUEUE_BAK_KEEP, deleting the rest. Runs at boot. `entries` comes from
385
+ * fs.readdir(ROOT), so every candidate path is already contained in ROOT.
386
+ */
387
+ async function sweepQueueBackups() {
388
+ let entries;
389
+ try {
390
+ entries = await fsp.readdir(ROOT);
391
+ } catch {
392
+ return;
393
+ }
394
+ const baks = entries.filter((f) => QUEUE_BAK_RE.test(f));
395
+ if (baks.length <= QUEUE_BAK_KEEP) return;
396
+
397
+ baks.sort((a, b) => {
398
+ const ta = Number(a.slice('queue.json.bak-'.length));
399
+ const tb = Number(b.slice('queue.json.bak-'.length));
400
+ return tb - ta; // newest (largest epoch) first
401
+ });
402
+ const toDelete = baks.slice(QUEUE_BAK_KEEP);
403
+ let removed = 0;
404
+ for (const f of toDelete) {
405
+ try {
406
+ await fsp.unlink(path.join(ROOT, f));
407
+ removed++;
408
+ } catch (e) {
409
+ console.warn('[scheduler] backup sweep: unlink failed', f, e?.message);
410
+ }
411
+ }
412
+ if (removed > 0) {
413
+ console.log(`[scheduler] backup sweep: removed ${removed} old queue.json.bak-* file(s), kept ${QUEUE_BAK_KEEP}`);
414
+ }
415
+ }
416
+
364
417
  // Atomic JSON write helpers delegate to config.cjs's shared implementation.
365
418
  // Sync variant is required for the executeJob exit handler (Promise resolver
366
419
  // callback that must flush meta.json before resolving) — replacing with async
@@ -625,8 +678,41 @@ async function reconcile(state) {
625
678
  bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
626
679
  });
627
680
  }
681
+ // Slugs on disk with no matching state.jobs row are normally brand-new
682
+ // PRDs — but once queueHistory.partitionJobs (above, later this same
683
+ // function) has been dropping terminal jobs into history.jsonl across
684
+ // prior reconcile passes, an old completed/failed job's row can ALSO be
685
+ // "unmatched" simply because it already left jobs[]. Without this check
686
+ // it would get resurrected below as a fresh 'pending' entry and the
687
+ // scheduler would genuinely re-execute an already-completed PRD — the
688
+ // exact backlog this auto-archive feature exists to clean up. Only pay
689
+ // the history lookup when there's actually an unmatched slug to resolve.
690
+ const unmatchedSlugs = [];
691
+ for (const [slug] of onDisk) {
692
+ if (!seen.has(slug)) unmatchedSlugs.push(slug);
693
+ }
694
+ const historyBySlug = unmatchedSlugs.length > 0
695
+ ? await queueHistory.historyTerminalBySlug()
696
+ : new Map();
697
+
698
+ // Terminal-in-history slugs whose .md file is still on disk: fed into the
699
+ // auto-archive selection pass below (as synthetic completed entries) so
700
+ // their file can still be swept, without ever creating a live job row
701
+ // for them (that row already exists, durably, in history.jsonl).
702
+ const historyArchiveCandidates = [];
703
+
628
704
  for (const [slug, p] of onDisk) {
629
705
  if (seen.has(slug)) continue;
706
+ const hist = historyBySlug.get(slug);
707
+ if (hist) {
708
+ if (hist.status === 'completed') {
709
+ historyArchiveCandidates.push({ slug, status: hist.status, finishedAt: hist.finishedAt });
710
+ }
711
+ // 'failed' (or any other terminal status found in history) is left
712
+ // alone entirely: not resurrected, not archived — matches "needs_
713
+ // review and failed PRD files are NEVER auto-archived."
714
+ continue;
715
+ }
630
716
  const entry = {
631
717
  slug,
632
718
  title: p.title,
@@ -651,7 +737,44 @@ async function reconcile(state) {
651
737
  }
652
738
  next.push(entry);
653
739
  }
654
- state.jobs = next.sort((a, b) => b.slug.localeCompare(a.slug));
740
+ const sorted = next.sort((a, b) => b.slug.localeCompare(a.slug));
741
+
742
+ // Move terminal jobs past the retention window out to history.jsonl so
743
+ // queue.json (mutation cost, broadcast payload, pickNextBatch scan) stays
744
+ // small. Append BEFORE dropping so a crash between the two can't lose a
745
+ // record — appendHistory dedupes by slug+runId, so a replay of the same
746
+ // batch on next boot is a safe no-op.
747
+ const nowMs = Date.now();
748
+ const { hot, toArchive } = queueHistory.partitionJobs(sorted, nowMs);
749
+ if (toArchive.length > 0) {
750
+ await queueHistory.appendHistory(toArchive);
751
+ console.log(`[scheduler] queue history: archived ${toArchive.length} job(s), jobs[] ${sorted.length} -> ${hot.length}`);
752
+ state.jobs = hot;
753
+ } else {
754
+ state.jobs = sorted;
755
+ }
756
+
757
+ // Auto-archive completed PRDs' .md files out of the live prds/ dir. Runs
758
+ // AFTER the history append above (which is awaited) so a job's queue row
759
+ // is always durably in history.jsonl before its file can be moved — a
760
+ // file is never removed ahead of its queue row. `sorted` (not `hot`) is
761
+ // passed so a job that just crossed retention THIS pass is eligible for
762
+ // file-archiving in the same pass, per its already-persisted history row.
763
+ // `historyArchiveCandidates` adds slugs whose row left jobs[] on an
764
+ // EARLIER pass (or before this feature shipped) — same predicate applies
765
+ // via selectAutoArchivable, they just don't get a live job row.
766
+ try {
767
+ const archiveInput = historyArchiveCandidates.length > 0
768
+ ? sorted.concat(historyArchiveCandidates)
769
+ : sorted;
770
+ const archiveResult = await queueOps.autoArchiveCompleted({ jobs: archiveInput }, { nowMs });
771
+ if (archiveResult.archived > 0) {
772
+ console.log(`[scheduler] auto-archived ${archiveResult.archived} completed PRD file(s) -> ${archiveResult.archivedTo}`);
773
+ }
774
+ } catch (e) {
775
+ console.warn('[scheduler] autoArchiveCompleted failed', e?.message);
776
+ }
777
+
655
778
  return state;
656
779
  }
657
780
 
@@ -775,6 +898,10 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
775
898
  lastFailureKind,
776
899
  },
777
900
  memGate: lastMemGate,
901
+ effectiveConcurrency: {
902
+ cap: getEnvCap() ?? state.config.concurrencyCap,
903
+ source: getEnvCap() != null ? 'env' : 'config',
904
+ },
778
905
  };
779
906
  if (withPaths) {
780
907
  payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: QUEUE_PATH };
@@ -782,12 +909,32 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
782
909
  return payload;
783
910
  }
784
911
 
785
- async function broadcast() {
912
+ // Trailing-edge debounce for the `schedule:state` IPC push: a burst of
913
+ // broadcast() calls (boot reverify healing several rows, poll-loop refreshes,
914
+ // queue-linter fixups) arms one BROADCAST_COALESCE_MS timer and sends a
915
+ // single payload built fresh at fire time, instead of one full-payload push
916
+ // per mutation. Callers where latency matters (pause/resume, job
917
+ // start/finish/reap/reset) pass `{ flush: true }` to bypass the window and
918
+ // send immediately.
919
+ const broadcastCoalescer = createBroadcastCoalescer({
920
+ delayMs: BROADCAST_COALESCE_MS,
921
+ send: (payload) => {
922
+ if (!mainWindow || mainWindow.isDestroyed()) return;
923
+ sendIfAlive(mainWindow, 'schedule:state', payload);
924
+ },
925
+ getPayload: async () => buildScheduleStatePayload(await readQueue()),
926
+ });
927
+
928
+ async function broadcast(opts = {}) {
786
929
  if (!mainWindow || mainWindow.isDestroyed()) return;
787
930
  const state = await readQueue();
788
931
  await reconcile(state);
789
932
  await writeQueue(state);
790
- sendIfAlive(mainWindow, 'schedule:state', buildScheduleStatePayload(state));
933
+ if (opts.flush) {
934
+ await broadcastCoalescer.flush();
935
+ } else {
936
+ broadcastCoalescer.schedule();
937
+ }
791
938
  }
792
939
 
793
940
  function clearFireTimer() {
@@ -855,7 +1002,7 @@ async function setPaused(reason, resumeAtIso) {
855
1002
  s.paused = { reason, since: new Date().toISOString(), resumeAt: effectiveResumeAt || null };
856
1003
  }
857
1004
  });
858
- await broadcast();
1005
+ await broadcast({ flush: true });
859
1006
  cancelToken.cancelled = true;
860
1007
  if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
861
1008
  if (!effectiveResumeAt) return;
@@ -897,7 +1044,7 @@ async function clearPause(source) {
897
1044
  lastFailureKind = null;
898
1045
  persistSchedulerState();
899
1046
  }
900
- if (wasPaused) await broadcast();
1047
+ if (wasPaused) await broadcast({ flush: true });
901
1048
  }
902
1049
 
903
1050
  /** Mutate a job in place to "pending" with cleared run metadata. */
@@ -1312,6 +1459,17 @@ ${logTail}
1312
1459
  1. Read the full failure log at ${failedLogPath} if the tail above isn't sufficient.
1313
1460
  2. Read source files in ${cwd} as needed to understand the context.
1314
1461
  3. Identify the root cause of the failure.
1462
+ 3.5. Before writing the fix-plan PRD file, print a block summarizing the root
1463
+ cause in 15 lines or fewer, in exactly this form (plain text between the
1464
+ tags, no markdown fences):
1465
+
1466
+ <RCA>
1467
+ <your root-cause summary here>
1468
+ </RCA>
1469
+
1470
+ This is parsed out of your transcript and folded into the RCA feedback item
1471
+ already filed for this job, so it must stand alone (the reader won't have
1472
+ your reasoning, only this block).
1315
1473
  4. Write a NEW fix-plan PRD file at exactly this path:
1316
1474
 
1317
1475
  ${fixPath}
@@ -1482,6 +1640,21 @@ async function spawnInvestigation(failedJob, runDir) {
1482
1640
  return;
1483
1641
  }
1484
1642
  sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
1643
+ // Fold the investigation's <RCA> summary into the RCA feedback item already
1644
+ // filed for this job (needs_review jobs only — fileRcaFeedback no-ops when
1645
+ // failedJob has no verifierVerdict, e.g. plain 'failed' jobs never got one).
1646
+ const investigationText = extractRcaBlock(readTail(investigationLogPath, 64 * 1024));
1647
+ if (investigationText) {
1648
+ fileRcaFeedback({
1649
+ job: failedJob,
1650
+ runDir,
1651
+ verdict: failedJob.verifierVerdict,
1652
+ annotations: failedJob.verifierAnnotations,
1653
+ investigationText,
1654
+ }).catch((e) => {
1655
+ console.error('[scheduler] fileRcaFeedback (investigation enrichment) error', failedJob.slug, e);
1656
+ });
1657
+ }
1485
1658
  if (fs.existsSync(fixPath)) {
1486
1659
  console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
1487
1660
  } else {
@@ -1522,7 +1695,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1522
1695
  s.jobs[idx].startedAt = new Date().toISOString();
1523
1696
  }
1524
1697
  });
1525
- await broadcast();
1698
+ await broadcast({ flush: true });
1526
1699
 
1527
1700
  // Commit-guard baseline: snapshot the working tree BEFORE the run so the
1528
1701
  // post-run check flags only paths THIS job left dirty, not pre-existing WIP.
@@ -1538,7 +1711,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1538
1711
  s.jobs[idx].runtime = { pid, runId, startedAt: s.jobs[idx].startedAt, sessionId, cwd };
1539
1712
  }
1540
1713
  });
1541
- await broadcast();
1714
+ await broadcast({ flush: true });
1542
1715
  });
1543
1716
 
1544
1717
  if (res.rateLimited) {
@@ -1635,6 +1808,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1635
1808
  let failedJobSnapshot = null;
1636
1809
  let needsInvestigationNow = false;
1637
1810
  let investigationJobSnapshot = null;
1811
+ let needsReviewRcaSnapshot = null;
1638
1812
  await mutate((s) => {
1639
1813
  const i2 = s.jobs.findIndex((x) => x.slug === job.slug);
1640
1814
  if (i2 >= 0) {
@@ -1692,6 +1866,12 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1692
1866
  actuallyFailed = true;
1693
1867
  failedJobSnapshot = { ...s.jobs[i2] };
1694
1868
  } else if (effectiveStatus === 'needs_review') {
1869
+ // Snapshot for the RCA feedback hook (fired outside mutate(), below).
1870
+ // Every needs_review transition gets an RCA — including uncommitted_changes,
1871
+ // the least self-healing verdict — but never a rate-limit pause (that
1872
+ // takes the treatAsPending branch above and never reaches here).
1873
+ needsReviewRcaSnapshot = { ...s.jobs[i2] };
1874
+
1695
1875
  // Same-tick auto-fix (feedback 2026-07-12): rather than waiting up to
1696
1876
  // 10 min for reverifyNeedsReview()'s periodic pass, check right here
1697
1877
  // whether this job qualifies for auto-fix (same eligibility rule
@@ -1738,7 +1918,20 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1738
1918
  }
1739
1919
  }
1740
1920
  });
1741
- await broadcast();
1921
+ await broadcast({ flush: true });
1922
+
1923
+ if (needsReviewRcaSnapshot) {
1924
+ // Fire-and-forget, mirroring the DoD drain hook: never blocks the status
1925
+ // transition, never throws to this caller.
1926
+ fileRcaFeedback({
1927
+ job: needsReviewRcaSnapshot,
1928
+ runDir,
1929
+ verdict: needsReviewRcaSnapshot.verifierVerdict,
1930
+ annotations: needsReviewRcaSnapshot.verifierAnnotations,
1931
+ }).catch((e) => {
1932
+ console.error('[scheduler] fileRcaFeedback error', job.slug, e);
1933
+ });
1934
+ }
1742
1935
 
1743
1936
  if (actuallyFailed && failedJobSnapshot) {
1744
1937
  // Transient-failure detector. A 143/137 exit is ALWAYS a signal kill — the
@@ -1786,7 +1979,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1786
1979
  s.jobs[i].transientRetries = decision.retries + 1;
1787
1980
  }
1788
1981
  });
1789
- await broadcast();
1982
+ await broadcast({ flush: true });
1790
1983
  } else if (decision.action === 'fail-dirty') {
1791
1984
  console.log(`[scheduler] transient failure (${decision.transientKind}) for ${job.slug} left ${newlyDirtyCount} uncommitted file(s) (e.g. ${dirtySample}) — not auto-requeuing`);
1792
1985
  await mutate((s) => {
@@ -1796,7 +1989,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1796
1989
  s.jobs[i].error = `transient failure (${decision.transientKind}) left ${newlyDirtyCount} uncommitted file(s) in working tree (e.g. ${dirtySample}) — not auto-requeued to avoid overwriting partial work; review and commit or discard manually`;
1797
1990
  }
1798
1991
  });
1799
- await broadcast();
1992
+ await broadcast({ flush: true });
1800
1993
  // No auto-fix investigation: this isn't a code defect, and the
1801
1994
  // dirty tree needs a human, not a diagnosis run.
1802
1995
  } else if (decision.action === 'fail-cap') {
@@ -1810,7 +2003,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1810
2003
  s.jobs[i].error = `transient failure (${decision.transientKind}) exhausted retry cap (${decision.retries}/${TRANSIENT_RETRY_CAP}) — marking failed without auto-fix investigation`;
1811
2004
  }
1812
2005
  });
1813
- await broadcast();
2006
+ await broadcast({ flush: true });
1814
2007
  } else {
1815
2008
  spawnInvestigation(failedJobSnapshot, runDir).catch((e) => {
1816
2009
  console.error('[scheduler] spawnInvestigation error', job.slug, e);
@@ -1846,7 +2039,7 @@ function tickQueue() {
1846
2039
  if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
1847
2040
 
1848
2041
  await reconcile(state);
1849
- const cap = ENV_CAP ?? state.config.concurrencyCap;
2042
+ const cap = getEnvCap() ?? state.config.concurrencyCap;
1850
2043
  const { batch, reason: holdReason } = pickNextBatch(state.jobs, runningSet, cap);
1851
2044
  if (batch.length === 0) {
1852
2045
  // Queue drained — run the definition-of-done gate fire-and-forget.
@@ -2009,7 +2202,7 @@ async function reapDeadRunningJobs() {
2009
2202
  }
2010
2203
  });
2011
2204
 
2012
- await broadcast();
2205
+ await broadcast({ flush: true });
2013
2206
  tickQueue().catch(() => {});
2014
2207
  } catch (e) {
2015
2208
  console.warn('[scheduler] reapDeadRunningJobs error', e?.message);
@@ -2149,13 +2342,26 @@ async function pollLoop() {
2149
2342
 
2150
2343
  // ---------- IPC ----------
2151
2344
 
2152
- // Pure helper: filter to completed/failed, sort newest-finished first, cap to
2153
- // limit clamped to [1, 500] (default 50). O(n log n) on queue size (small).
2154
- // Exported for unit testing.
2155
- function selectHistoryJobs(jobs, limit) {
2345
+ // Pure helper: filter to completed/failed, merge with archived history
2346
+ // entries (now that terminal jobs age out of jobs[] into history.jsonl
2347
+ // see queueHistory.cjs), dedupe by slug+runId (jobs[] wins on overlap —
2348
+ // it's the fresher copy in the append-before-drop crash window), sort
2349
+ // newest-finished first, cap to limit clamped to [1, 500] (default 50).
2350
+ // O(n log n) on queue+history size (small at any realistic scale).
2351
+ // Exported for unit testing. `historyEntries` defaults to [] so existing
2352
+ // callers/tests that only pass `jobs` are unaffected.
2353
+ function selectHistoryJobs(jobs, limit, historyEntries = []) {
2156
2354
  const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
2157
- return (Array.isArray(jobs) ? jobs : [])
2158
- .filter((j) => j && (j.status === 'completed' || j.status === 'failed'))
2355
+ const hot = (Array.isArray(jobs) ? jobs : []).filter((j) => j && (j.status === 'completed' || j.status === 'failed'));
2356
+ const seen = new Set(hot.map((j) => `${j.slug}|${j.runId ?? ''}`));
2357
+ const archived = (Array.isArray(historyEntries) ? historyEntries : []).filter((j) => {
2358
+ if (!j) return false;
2359
+ const key = `${j.slug}|${j.runId ?? ''}`;
2360
+ if (seen.has(key)) return false;
2361
+ seen.add(key);
2362
+ return true;
2363
+ });
2364
+ return [...hot, ...archived]
2159
2365
  .sort((a, b) => {
2160
2366
  const at = a.finishedAt ? Date.parse(a.finishedAt) : 0;
2161
2367
  const bt = b.finishedAt ? Date.parse(b.finishedAt) : 0;
@@ -2550,7 +2756,7 @@ function registerScheduleHandlers() {
2550
2756
  return true;
2551
2757
  });
2552
2758
  if (!found) return { ok: false, error: 'not found' };
2553
- await broadcast();
2759
+ await broadcast({ flush: true });
2554
2760
  return { ok: true };
2555
2761
  }));
2556
2762
 
@@ -2713,7 +2919,8 @@ function registerScheduleHandlers() {
2713
2919
  const limit = (payload && typeof payload.limit === 'number') ? payload.limit : 50;
2714
2920
  try {
2715
2921
  const state = await readQueue();
2716
- return { ok: true, jobs: selectHistoryJobs(state.jobs, limit) };
2922
+ const historyEntries = await queueHistory.readHistory({ limit });
2923
+ return { ok: true, jobs: selectHistoryJobs(state.jobs, limit, historyEntries) };
2717
2924
  } catch (e) {
2718
2925
  return { ok: false, jobs: [], error: e?.message ?? 'read failed' };
2719
2926
  }
@@ -2722,6 +2929,7 @@ function registerScheduleHandlers() {
2722
2929
 
2723
2930
  async function init() {
2724
2931
  ensureDirs();
2932
+ sweepQueueBackups().catch((e) => console.warn('[scheduler] backup sweep failed', e?.message));
2725
2933
 
2726
2934
  // Hydrate cached state from the sidecar before any scheduling decisions.
2727
2935
  loadSchedulerState();
@@ -2970,7 +3178,7 @@ const remote = {
2970
3178
  return true;
2971
3179
  });
2972
3180
  if (!found) return { ok: false, error: 'not found' };
2973
- await broadcast();
3181
+ await broadcast({ flush: true });
2974
3182
  return { ok: true, slug, status: 'pending' };
2975
3183
  },
2976
3184
 
@@ -3008,4 +3216,4 @@ const remote = {
3008
3216
  },
3009
3217
  };
3010
3218
 
3011
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP };
3219
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload };