claude-code-session-manager 0.121.0 → 0.122.1

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 (76) hide show
  1. package/dist/assets/{AgentLibrary-FO2ei2wB.js → AgentLibrary-C2qUX9WU.js} +1 -1
  2. package/dist/assets/{Button-CeIOXUPU.js → Button-ssBzvFha.js} +1 -1
  3. package/dist/assets/{DataModel-D0p__Hil.js → DataModel-CENoTvc2.js} +1 -1
  4. package/dist/assets/History-BRdRp1J_.js +2 -0
  5. package/dist/assets/{Hooks-DkTnRHyT.js → Hooks--hiGPBQy.js} +1 -1
  6. package/dist/assets/{Library-l7KS6pkE.js → Library-0XYvkhh2.js} +1 -1
  7. package/dist/assets/{ListDetail-DDw8fuRT.js → ListDetail-CkKXAM59.js} +1 -1
  8. package/dist/assets/{McpServers-UUNIxk_3.js → McpServers-CCjHSwFm.js} +1 -1
  9. package/dist/assets/{Memory-CNYUoQ8C.js → Memory-C7RXoVO4.js} +1 -1
  10. package/dist/assets/{Panel-Bb54soc2.js → Panel-C92O_aAH.js} +1 -1
  11. package/dist/assets/{Permissions-D-dNdV3f.js → Permissions-DuTJ1FUb.js} +1 -1
  12. package/dist/assets/Plugins-DSXsP4hM.js +2 -0
  13. package/dist/assets/{ProvenanceBadge-DEAkDNjA.js → ProvenanceBadge-CWdDkU1m.js} +1 -1
  14. package/dist/assets/{RemoteMachines-xCEgvTGU.js → RemoteMachines-BFmbIGpW.js} +1 -1
  15. package/dist/assets/{SaveBar-BSEs7ecA.js → SaveBar-ob2dJJjH.js} +1 -1
  16. package/dist/assets/Scheduler-Du4HgPKY.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-DcaFyAV-.js → ScopeSwitcher-Dv_6M_Ci.js} +1 -1
  18. package/dist/assets/{Settings-vcdWy_Jx.js → Settings-DMoI0cqB.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-CLrJWTPd.js → SkillReferenceGraph-Dx1Qs8Y0.js} +1 -1
  20. package/dist/assets/{Skills-CYM4rBEC.js → Skills-DYWQTQB1.js} +1 -1
  21. package/dist/assets/{SystemPrompt-BWNIu-iY.js → SystemPrompt-Df0IfOuI.js} +1 -1
  22. package/dist/assets/{TagLibrary-CYdwHK6m.js → TagLibrary-BuMsGx-2.js} +1 -1
  23. package/dist/assets/{TiptapBody-Kt90lHrD.js → TiptapBody-Cihe5er3.js} +1 -1
  24. package/dist/assets/{Toggle-D1OXWYOz.js → Toggle-CpYVC8bF.js} +1 -1
  25. package/dist/assets/{ViewTabs-BeQR3CKh.js → ViewTabs-C3FN6P_C.js} +1 -1
  26. package/dist/assets/{historyTotals-B0qafrRo.js → historyTotals-C4Rs37Cd.js} +1 -1
  27. package/dist/assets/{index-Bq9uH08R.js → index-S-F5jp_h.js} +396 -396
  28. package/dist/assets/{settingsSchema-0ZksSAPB.js → settingsSchema-BZcdzHTQ.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-C0nh8yfa.js → skillFrontmatter-EfNERt4b.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-DPkoBybS.js → useAgentsDirWatch-BfbrFvB3.js} +1 -1
  31. package/dist/assets/{useRemotePeers-BDJMUihy.js → useRemotePeers-CKVakQPc.js} +1 -1
  32. package/dist/index.html +1 -1
  33. package/package.json +1 -1
  34. package/plugins/session-manager-dev/skills/develop/SKILL.md +8 -7
  35. package/plugins/session-manager-dev/skills/develop/standards.md +4 -10
  36. package/scripts/scheduler-mcp-server.cjs +20 -6
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +13 -37
  39. package/src/main/docEdit.cjs +1 -0
  40. package/src/main/index.cjs +12 -13
  41. package/src/main/ipcSchemas.cjs +2 -2
  42. package/src/main/lib/classifyPromptTicket.cjs +1 -0
  43. package/src/main/lib/claude/effectiveModelInfo.cjs +9 -21
  44. package/src/main/lib/claude/personaToolScope.cjs +7 -1
  45. package/src/main/lib/claude/runClaudeP.cjs +1 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +75 -8
  47. package/src/main/lib/mcpToolCatalog.cjs +5 -8
  48. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +11 -0
  49. package/src/main/lib/prd/prdCreate.cjs +48 -1
  50. package/src/main/lib/prd/prdSizing.cjs +1 -8
  51. package/src/main/lib/runSeeders.cjs +21 -0
  52. package/src/main/lib/scheduler/loopEfficiency.cjs +109 -0
  53. package/src/main/lib/scheduler/queueHistory.cjs +44 -1
  54. package/src/main/lib/scheduler/queueStore.cjs +4 -72
  55. package/src/main/lib/scheduler/rcaReport.cjs +1 -1
  56. package/src/main/lib/scheduler/refireCap.cjs +40 -0
  57. package/src/main/lib/scheduler/reviewNotice.cjs +1 -1
  58. package/src/main/lib/scheduler/schedulerConfig.cjs +1 -2
  59. package/src/main/lib/scheduler/schedulerPaths.cjs +0 -2
  60. package/src/main/lib/scheduler/sessionSlots.cjs +1 -1
  61. package/src/main/lib/telemetry/auditLog.cjs +65 -14
  62. package/src/main/loopEfficiencyHandlers.cjs +160 -0
  63. package/src/main/queueOps.cjs +4 -9
  64. package/src/main/runVerify.cjs +22 -454
  65. package/src/main/scheduler/reviewLadder.cjs +12 -2
  66. package/src/main/scheduler.cjs +116 -181
  67. package/src/main/seedDevPlugin.cjs +49 -22
  68. package/src/main/seedStatus.cjs +3 -2
  69. package/src/main/supervisor.cjs +1 -0
  70. package/src/main/templates/PRD_AUTHORING.md +16 -31
  71. package/src/preload/api.d.ts +26 -6
  72. package/src/preload/index.cjs +1 -0
  73. package/src/seed/agents/dev-lead.md +2 -9
  74. package/dist/assets/History-DVqQ5azN.js +0 -2
  75. package/dist/assets/Plugins-DhcIXRhQ.js +0 -2
  76. package/dist/assets/Scheduler-BIKJIU-C.js +0 -16
@@ -66,6 +66,7 @@ const { headlessPermissionArgs, ensureCliCapsProbed } = require('./lib/claude/cl
66
66
  // as the authoritative guarantee.
67
67
  ensureCliCapsProbed().catch(() => {});
68
68
  const launchFailure = require('./lib/scheduler/launchFailure.cjs');
69
+ const { decideVerifyDowngrade } = require('./lib/scheduler/refireCap.cjs');
69
70
  const { finalizeReasonFor } = require('./lib/scheduler/finalizeReason.cjs');
70
71
  const { shouldRunFlatSweep, FLAT_SWEEP_INTERVAL_MS } = require('./lib/scheduler/flatSweepThrottle.cjs');
71
72
  const { appendError } = require('./lib/telemetry/opsErrorLog.cjs');
@@ -124,15 +125,12 @@ const {
124
125
  MAX_JOB_DURATION_MS,
125
126
  BROADCAST_COALESCE_MS,
126
127
  RECONCILE_SLOW_PASS_MS,
127
- QUARANTINE_ESCALATE_MS: QUARANTINE_ESCALATE_MS_DEFAULT,
128
+ QUARANTINE_ESCALATE_MS,
128
129
  PIDLESS_SPAWN_GRACE_MS,
129
130
  INVESTIGATION_MAX_MS,
130
131
  STARVATION_ESCALATE_MS,
131
132
  STARVE_ESCALATION_MS,
132
133
  } = require('./lib/scheduler/schedulerConfig.cjs');
133
- const QUARANTINE_ESCALATE_MS = process.env.SM_QUARANTINE_ESCALATE_HOURS
134
- ? Number(process.env.SM_QUARANTINE_ESCALATE_HOURS) * 60 * 60_000
135
- : QUARANTINE_ESCALATE_MS_DEFAULT;
136
134
  // A running job past this fraction of its own budget gets a durable
137
135
  // `budgetWarning` stamp on its row (see the budget watchdog below) so the
138
136
  // renderer can warn BEFORE the kill, not only after.
@@ -444,7 +442,6 @@ const DEFAULT_CONFIG = {
444
442
  offsetMinutes: 15,
445
443
  defaultCwd: DEFAULT_PROJECT_CWD,
446
444
  // 'when-available' = poll usage and fire whenever utilization < threshold.
447
- // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
448
445
  // 'manual' = only fire on explicit Run now click.
449
446
  firePolicy: 'when-available',
450
447
  // For 'when-available'. Fire only when BINDING-window utilization < this
@@ -646,7 +643,6 @@ function ensureDirs() {
646
643
  // would deadlock the exit path.
647
644
  const config = require('./config.cjs');
648
645
  const { seedAuthoringGuide } = require('./lib/prd/prdAuthoringSeed.cjs');
649
- const atomicWriteJsonSync = (p, data) => config.writeJsonSync(p, data);
650
646
 
651
647
  // ---------- scheduler-state.json (sidecar) ----------
652
648
 
@@ -721,9 +717,22 @@ function persistSchedulerState() {
721
717
 
722
718
  // ---------- heartbeat log ----------
723
719
 
720
+ // Memory time series stamped on every heartbeat line (MB, whole numbers).
721
+ function memoryFields() {
722
+ const m = process.memoryUsage();
723
+ const mb = (b) => Math.max(0, Math.round((b || 0) / 1048576));
724
+ return {
725
+ rssMB: mb(m.rss),
726
+ heapUsedMB: mb(m.heapUsed),
727
+ heapTotalMB: mb(m.heapTotal),
728
+ externalMB: mb(m.external),
729
+ arrayBuffersMB: mb(m.arrayBuffers),
730
+ };
731
+ }
732
+
724
733
  function appendHeartbeat(entry) {
725
734
  try {
726
- const line = JSON.stringify(entry) + '\n';
735
+ const line = JSON.stringify({ ...memoryFields(), ...entry }) + '\n';
727
736
  let size = 0;
728
737
  try { size = fs.statSync(schedulerPaths.heartbeatPath()).size; } catch { /* new file */ }
729
738
  if (size >= HEARTBEAT_MAX_BYTES) {
@@ -766,10 +775,10 @@ function drainSnapshot(jobs) {
766
775
  /**
767
776
  * Restart is triggered automatically ONLY when the installed build-info.json
768
777
  * differs from the running codeSha (an install/update already happened) —
769
- * never by polling npm. SM_AUTO_UPGRADE_RESTART=0 disables it.
778
+ * never by polling npm.
770
779
  */
771
780
  function maybeAutoRequestRestart() {
772
- if (process.env.SM_AUTO_UPGRADE_RESTART === '0' || process.env.SM_DEV === '1') return null;
781
+ if (process.env.SM_DEV === '1') return null;
773
782
  const installed = readInstalledBuildInfo();
774
783
  const installedSha = typeof installed?.gitShortSha === 'string' ? installed.gitShortSha : null;
775
784
  if (!upgradeDrain.installedBuildDiffers({ running: SCHEDULER_CODE_SHA, installed: installedSha })) return null;
@@ -1109,15 +1118,21 @@ function flagUnreadable(state) {
1109
1118
  // shards under `<cwd>/session-manager-operations/scheduler/state/queue.json`
1110
1119
  // plus one machine-runtime file (config/paused/lastRunAt). Reads merge every
1111
1120
  // shard into the single state object all downstream logic already expects;
1112
- // writes split it back. The old global scheduled-plans/queue.json is retired
1113
- // (split at boot by queueStore.migrateLegacyGlobalQueue).
1121
+ // writes split it back. The old global scheduled-plans/queue.json is retired.
1114
1122
 
1115
1123
  // Sync queue read — passed to the supervisor module (which calls it from
1116
1124
  // supervisorTick / applyAction with no await) and the heartbeat interval.
1117
1125
  // IPC handlers and mutate() use readQueue (async) below.
1126
+ // Migration: the retired legacy fire policy becomes 'when-available'
1127
+ // so an old install keeps dispatching. Persisted by the next mutate() write.
1128
+ function normalizeConfig(cfg) {
1129
+ if (cfg && cfg.firePolicy === 'on-reset') return { ...cfg, firePolicy: 'when-available' };
1130
+ return cfg;
1131
+ }
1132
+
1118
1133
  function readQueueSync() {
1119
1134
  const s = queueStore.readMergedSync();
1120
- s.config = { ...DEFAULT_CONFIG, ...(s.config || {}) };
1135
+ s.config = normalizeConfig({ ...DEFAULT_CONFIG, ...(s.config || {}) });
1121
1136
  return flagUnreadable(s);
1122
1137
  }
1123
1138
 
@@ -1126,7 +1141,7 @@ function readQueueSync() {
1126
1141
  // shard reads hands control back to the renderer between files.
1127
1142
  async function readQueue() {
1128
1143
  const s = await queueStore.readMerged();
1129
- s.config = { ...DEFAULT_CONFIG, ...(s.config || {}) };
1144
+ s.config = normalizeConfig({ ...DEFAULT_CONFIG, ...(s.config || {}) });
1130
1145
  return flagUnreadable(s);
1131
1146
  }
1132
1147
 
@@ -2087,10 +2102,6 @@ async function refreshNextReset() {
2087
2102
  return cachedNextReset;
2088
2103
  }
2089
2104
 
2090
- function getNextResetCached() {
2091
- return cachedNextReset;
2092
- }
2093
-
2094
2105
  /**
2095
2106
  * Pure: picks the reset to pause against for a rate-limited run (PRD 1118).
2096
2107
  * Prefers the BINDING window read off the run's own log — refreshNextReset()
@@ -2223,7 +2234,6 @@ const consecutiveRapidRateLimitsBySlug = new Map();
2223
2234
  // ---------- timer ----------
2224
2235
 
2225
2236
  let mainWindow = null;
2226
- let fireTimer = null;
2227
2237
  let resumeTimer = null;
2228
2238
  let pollLoopTimer = null;
2229
2239
  let rescheduleInterval = null;
@@ -2344,14 +2354,13 @@ function buildScheduleStatePayload(state) {
2344
2354
  const payload = {
2345
2355
  config: state.config,
2346
2356
  jobs: state.jobs,
2347
- scheduledFor: state.scheduledFor,
2348
2357
  lastRunAt: state.lastRunAt,
2349
2358
  // Distinct from lastRunAt (only stamped when a batch actually launches):
2350
2359
  // stamped every time tickQueue reaches the picker at all. See
2351
2360
  // classifyQueueHealth/classifyQueueStarvation's header comments for why
2352
2361
  // the two must never merge.
2353
2362
  lastDispatchAttemptAt: state.lastDispatchAttemptAt ?? null,
2354
- nextReset: getNextResetCached(),
2363
+ nextReset: cachedNextReset,
2355
2364
  paused: state.paused,
2356
2365
  drain: state.drain ?? null,
2357
2366
  // Launch circuit breaker (issue #11): which personas cannot launch right
@@ -2428,24 +2437,6 @@ async function broadcast(opts = {}) {
2428
2437
  }
2429
2438
  }
2430
2439
 
2431
- function clearFireTimer() {
2432
- if (fireTimer) {
2433
- clearTimeout(fireTimer);
2434
- fireTimer = null;
2435
- }
2436
- }
2437
-
2438
- function computeFireAt(state, nextResetIso) {
2439
- // Only the legacy 'on-reset' policy uses scheduled fire times. Other
2440
- // policies fire either immediately on demand ('manual') or via the
2441
- // when-available poll loop.
2442
- if (state.config.firePolicy !== 'on-reset') return null;
2443
- if (!nextResetIso) return null;
2444
- const reset = new Date(nextResetIso).getTime();
2445
- if (Number.isNaN(reset)) return null;
2446
- return reset + (state.config.offsetMinutes * 60_000);
2447
- }
2448
-
2449
2440
  // PRD 1446: refreshNextReset() awaits billing.fetchUsage(), which can hang
2450
2441
  // or back off for minutes while the usage meter is rate-limited. Left
2451
2442
  // unbounded, that stalled THIS function's own mutate(reconcile) call for as
@@ -2459,12 +2450,9 @@ function computeFireAt(state, nextResetIso) {
2459
2450
  const RESCHEDULE_TIMER_BILLING_RACE_MS = 10_000;
2460
2451
 
2461
2452
  async function rescheduleTimer() {
2462
- clearFireTimer();
2463
- // Wrap in try/catch — on failure use the cached value so the on-reset
2464
- // timer can still be armed from the last known reset.
2465
- let nextResetIso;
2453
+ // Wrap in try/catch — on failure the cached value is kept.
2466
2454
  try {
2467
- nextResetIso = await Promise.race([
2455
+ await Promise.race([
2468
2456
  refreshNextReset(),
2469
2457
  new Promise((resolve) => {
2470
2458
  const t = setTimeout(() => resolve(cachedNextReset), RESCHEDULE_TIMER_BILLING_RACE_MS);
@@ -2472,20 +2460,12 @@ async function rescheduleTimer() {
2472
2460
  }),
2473
2461
  ]);
2474
2462
  } catch {
2475
- nextResetIso = cachedNextReset;
2463
+ // cachedNextReset stays as-is; reconcile below does not need the value.
2476
2464
  }
2477
- const fireAt = await mutate(async (state) => {
2465
+ await mutate(async (state) => {
2478
2466
  await reconcile(state);
2479
- const fa = computeFireAt(state, nextResetIso);
2480
- state.scheduledFor = fa ? new Date(fa).toISOString() : null;
2481
- return fa;
2482
2467
  });
2483
2468
  await broadcast();
2484
- if (!fireAt) return;
2485
-
2486
- const delay = Math.max(1000, fireAt - Date.now());
2487
- fireTimer = setTimeout(() => { runDueJobs().catch(() => {}); }, delay);
2488
- console.log(`[scheduler] next fire in ${Math.round(delay / 1000)}s @ ${new Date(fireAt).toISOString()}`);
2489
2469
  }
2490
2470
 
2491
2471
  // ---------- pause / resume ----------
@@ -2709,6 +2689,9 @@ function resetJobFields(job, errorMsg, opts = {}) {
2709
2689
  job.error = errorMsg ?? null;
2710
2690
  delete job.runtime;
2711
2691
  delete job.verifierVerdict;
2692
+ // A reset starts the consecutive-downgrade count over (the refire path in
2693
+ // spawnJob re-sets it right after this call).
2694
+ delete job.verifyRefireCount;
2712
2695
  delete job.humanQuestion;
2713
2696
  delete job.uncommittedPaths;
2714
2697
  delete job.resumeRecoveryAttempted;
@@ -2868,7 +2851,9 @@ function isBootRowAlive(j, {
2868
2851
  * applyOrphanOutcome(job, outcome, killNote?, confirmedLandedCommit?) → void
2869
2852
  *
2870
2853
  * Mutates `job` in place to finalize a boot-orphaned 'running' job given its
2871
- * classified run outcome: success/failed finalize terminally; no_result/unknown
2854
+ * classified run outcome: success finalizes only with landed-commit evidence
2855
+ * (or for artifact-only PRDs), otherwise it takes the re-queue path below;
2856
+ * failed finalizes terminally; no_result/unknown
2872
2857
  * re-queues to pending bounded by ORPHAN_REQUEUE_CAP. The status-mutation
2873
2858
  * semantics (and the cap-exhaustion boundary) match the now-deleted
2874
2859
  * reconcileQueueOffline (src/main/lib/scheduler/watchdogHelpers.cjs) verbatim; killNote
@@ -2882,12 +2867,21 @@ function isBootRowAlive(j, {
2882
2867
  * orphaned job that actually landed a real commit is reachable via boot
2883
2868
  * reconciliation even though the live reap path is now guarded. Callers
2884
2869
  * must resolve this (a git spawn) BEFORE calling mutate(), never inside it —
2885
- * pass null to skip the gate (e.g. when the outcome isn't 'failed').
2870
+ * pass null when there is none (a 'success' then re-queues, unless artifact-only).
2886
2871
  */
2887
2872
  function applyOrphanOutcome(job, outcome, killNote = '', confirmedLandedCommit = null) {
2888
2873
  const now = new Date().toISOString();
2889
- if (outcome === 'success') {
2890
- transitionJob(job, 'completed', { reason: 'boot orphan reconciliation: run succeeded', source: 'applyOrphanOutcome' });
2874
+ // A reported 'success' completes only on landed-commit evidence, or for a
2875
+ // DECLARED artifact-only PRD (`deliverable: artifact` + artifactPaths — same
2876
+ // check as runVerify's artifact exemption) where no commit is legitimate.
2877
+ const artifactOnly = job.deliverable === 'artifact' && Array.isArray(job.artifactPaths) && job.artifactPaths.length > 0;
2878
+ if (outcome === 'success' && (confirmedLandedCommit || artifactOnly)) {
2879
+ if (confirmedLandedCommit) {
2880
+ transitionJob(job, 'completed', { reason: `boot orphan reconciliation: run succeeded, landedCommit ${confirmedLandedCommit} resolves`, source: 'applyOrphanOutcome:landed' });
2881
+ job.landedCommit = confirmedLandedCommit;
2882
+ } else {
2883
+ transitionJob(job, 'completed', { reason: 'boot orphan reconciliation: run succeeded (artifact-only PRD, no commit expected)', source: 'applyOrphanOutcome' });
2884
+ }
2891
2885
  job.exitCode = 0;
2892
2886
  job.error = null;
2893
2887
  job.finishedAt = now;
@@ -2907,8 +2901,9 @@ function applyOrphanOutcome(job, outcome, killNote = '', confirmedLandedCommit =
2907
2901
  delete job.runtime;
2908
2902
  } else {
2909
2903
  const tries = job.orphanRetries ?? 0;
2904
+ const noLanded = outcome === 'success' ? ' — run reported success but no landed commit was found' : '';
2910
2905
  if (tries < ORPHAN_REQUEUE_CAP) {
2911
- resetJobFields(job, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}`, { source: 'applyOrphanOutcome' });
2906
+ resetJobFields(job, `orphaned: app restarted mid-run, re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})${killNote}${noLanded}`, { source: 'applyOrphanOutcome' });
2912
2907
  job.orphanRetries = tries + 1;
2913
2908
  } else if (confirmedLandedCommit) {
2914
2909
  transitionJob(job, 'completed', { reason: `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}, but landedCommit ${confirmedLandedCommit} resolves — completed on evidence`, source: 'applyOrphanOutcome:landed' });
@@ -2918,9 +2913,9 @@ function applyOrphanOutcome(job, outcome, killNote = '', confirmedLandedCommit =
2918
2913
  job.finishedAt = now;
2919
2914
  delete job.runtime;
2920
2915
  } else {
2921
- transitionJob(job, 'failed', { reason: `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`, source: 'applyOrphanOutcome' });
2916
+ transitionJob(job, 'failed', { reason: `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}${noLanded}`, source: 'applyOrphanOutcome' });
2922
2917
  job.exitCode = job.exitCode ?? 1;
2923
- job.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}`;
2918
+ job.error = `orphaned: app restarted while running, exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts${killNote}${noLanded}`;
2924
2919
  job.finishedAt = now;
2925
2920
  delete job.runtime;
2926
2921
  }
@@ -3148,10 +3143,10 @@ async function notifyOriginatingTab(job, {
3148
3143
  // survive an entirely unreadable PRD: the queue row carries the Epic FK
3149
3144
  // independently of the .md on disk (20 of 21 history rows had it when
3150
3145
  // PRD 985 was written), so a parse failure degrades to "notified without
3151
- // frontmatter detail" instead of "silently dropped". Mirrors
3152
- // notifyNeedsReview, which has had this fallback since PRD 854 — which
3153
- // is precisely why needs_review routing kept working while
3154
- // completed/failed routing did not.
3146
+ // frontmatter detail" instead of "silently dropped". The
3147
+ // needs_review notice path carries the same fallback (PRD 854), which
3148
+ // is why needs_review routing kept working while completed/failed
3149
+ // routing did not.
3155
3150
  const epicId = prd?.sourcePromptId || job.epicId || null;
3156
3151
 
3157
3152
  // Persist the job's real result text (not just the short status chip
@@ -3283,61 +3278,6 @@ async function notifyOriginatingTab(job, {
3283
3278
  }
3284
3279
  }
3285
3280
 
3286
- /**
3287
- * notifyNeedsReview(job, report) → Promise<boolean>
3288
- *
3289
- * A job parked in `needs_review` is a question, not a result: something about
3290
- * the PRD was unclear, unverifiable, or ran past its acceptance criteria.
3291
- * That question belongs in the conversation that WROTE the PRD, so this
3292
- * appends a 'response' event to the authoring Epic's own chain (the same
3293
- * channel notifyOriginatingTab uses for completed/failed, which deliberately
3294
- * excludes needs_review — isNotifiableTerminalStatus). It resolves the Epic
3295
- * from the PRD's `sourcePromptId`, falling back to the job's own `epicId`.
3296
- *
3297
- * Join-only by construction: appendResponseEventIfKnown refuses an unknown or
3298
- * non-active session and returns false. Nothing here can create an Epic — if
3299
- * no open authoring Epic exists, the root-cause report in the run directory
3300
- * is the whole record and the Scheduler tab is where it surfaces.
3301
- *
3302
- * Quiet by default (this PRD): the park path no longer calls this at park
3303
- * time. It records a `reviewNotice` on the job row instead and lets
3304
- * flushDueReviewNotices send one grouped message once the self-heal ladder
3305
- * gives up or the hold time passes. This function now only runs under the
3306
- * SM_REVIEW_NOTICE_IMMEDIATE=1 kill switch (same park-time call site, for
3307
- * local debugging) — it is otherwise dead code outside its own tests. That
3308
- * call site stamps `reviewNotice.sentAt` itself afterward, and only when
3309
- * this returns true AND the row's live notice still matches the one it just
3310
- * built — never on the strength of this function's return value alone.
3311
- */
3312
- async function notifyNeedsReview(job, report, {
3313
- parsePrdRaw = prdParser.parsePrdRaw,
3314
- appendResponseEvent = appendResponseEventIfKnown,
3315
- } = {}) {
3316
- try {
3317
- if (!job || !report || !report.filed) return false;
3318
- const prd = await resolveNotifyPrd(job, parsePrdRaw);
3319
- const epicId = prd?.sourcePromptId || job.epicId || null;
3320
- if (!epicId || !job.cwd) {
3321
- console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
3322
- return false;
3323
- }
3324
- const message = `${report.summary}. Root-cause report: ${report.path}`;
3325
- // PRD 986: like every scheduler check-in, born 'unvalidated' — the RCA
3326
- // report is itself a question, not a verified outcome. No validation
3327
- // prompt is enqueued here: that fires only for the terminal
3328
- // completed/failed path (notifyOriginatingTab); needs_review already
3329
- // routes its own question via this very report.
3330
- return await appendResponseEvent(job.cwd, epicId, message, {
3331
- prdSlug: job.slug,
3332
- outcome: 'needs_review',
3333
- validation: 'unvalidated',
3334
- });
3335
- } catch (e) {
3336
- console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
3337
- return false;
3338
- }
3339
- }
3340
-
3341
3281
  // Single-flight guard for flushDueReviewNotices — same shape as
3342
3282
  // gateShadowPending above: a module-level flag rather than a class, so two
3343
3283
  // triggers landing close together (an auto-resolve skip right next to the
@@ -3348,8 +3288,7 @@ let reviewNoticeFlushPending = null;
3348
3288
  /**
3349
3289
  * flushDueReviewNotices() → Promise<void>
3350
3290
  *
3351
- * The only path that still sends a needs_review notice (outside the
3352
- * SM_REVIEW_NOTICE_IMMEDIATE=1 kill switch above notifyNeedsReview). Reads
3291
+ * The only path that still sends a needs_review notice. Reads
3353
3292
  * the live queue, asks reviewNotice.cjs's selectDueReviewNotices which (cwd,
3354
3293
  * Epic, cause) groups have gone quiet — the ladder gave up
3355
3294
  * (needsReviewAutoResolvedSkip) or the hold time passed (holdMsFromEnv) —
@@ -4234,10 +4173,9 @@ function pickRunDir() {
4234
4173
  // process. It never throws and never blocks or fails a spawn: a failed
4235
4174
  // install just logs one line and leaves the PATH addition pointing at a shim
4236
4175
  // dir that may have nothing in it yet, no worse than today's
4237
- // no-shim-at-all. SM_TIMEOUT_SHIM_DISABLE=1 skips it outright.
4176
+ // no-shim-at-all.
4238
4177
  let timeoutShimEnsured = null;
4239
4178
  function ensureTimeoutShimOnce() {
4240
- if (process.env.SM_TIMEOUT_SHIM_DISABLE === '1') return Promise.resolve();
4241
4179
  if (!timeoutShimEnsured) {
4242
4180
  timeoutShimEnsured = ensureTimeoutShim().then(
4243
4181
  (result) => {
@@ -4541,11 +4479,8 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4541
4479
  // `launchEnv` is the launch circuit breaker's degraded-mode env (e.g.
4542
4480
  // MAX_THINKING_TOKENS=0 while an outdated CLI's thinking parameter is
4543
4481
  // being rejected — lib/scheduler/launchFailure.cjs); applied last so it wins.
4544
- // SM_TIMEOUT_SHIM_DISABLE=1 is the kill switch for the PATH addition
4545
- // below — paired with the install skip inside ensureTimeoutShimOnce().
4546
- const timeoutShimEnabled = process.env.SM_TIMEOUT_SHIM_DISABLE !== '1';
4547
4482
  const childEnv = cleanChildEnv({
4548
- PATH: timeoutShimEnabled ? withTimeoutShimOnPath(pathWithUserBins()) : pathWithUserBins(),
4483
+ PATH: withTimeoutShimOnPath(pathWithUserBins()),
4549
4484
  SM_PROJECT_ROOT: cwd,
4550
4485
  SM_SCHEDULER_JOB_SLUG: job.slug,
4551
4486
  SM_SCHEDULER_JOB_MAY_QUEUE: job.agentType === 'architect' ? '1' : '0',
@@ -5272,11 +5207,8 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
5272
5207
  await ensureCliCapsProbed();
5273
5208
  await ensureTimeoutShimOnce();
5274
5209
  const claudeBin = resolveClaudeBin();
5275
- // SM_TIMEOUT_SHIM_DISABLE=1 is the kill switch for the PATH addition below —
5276
- // paired with the install skip inside ensureTimeoutShimOnce().
5277
- const timeoutShimEnabled = process.env.SM_TIMEOUT_SHIM_DISABLE !== '1';
5278
5210
  const childEnv = cleanChildEnv({
5279
- PATH: timeoutShimEnabled ? withTimeoutShimOnPath(pathWithUserBins()) : pathWithUserBins(), // Homebrew/user bins for macOS
5211
+ PATH: withTimeoutShimOnPath(pathWithUserBins()), // Homebrew/user bins for macOS
5280
5212
  BASH_DEFAULT_TIMEOUT_MS: String(BASH_DEFAULT_TIMEOUT_MS),
5281
5213
  BASH_MAX_TIMEOUT_MS: String(BASH_MAX_TIMEOUT_MS),
5282
5214
  });
@@ -6620,6 +6552,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6620
6552
  // Determine effective status, applying the verifier verdict for exit=0 runs.
6621
6553
  let effectiveStatus;
6622
6554
  let sigtermOverrideReason = null;
6555
+ let capParkVerdict = null; // set only when the re-fire cap parks the job
6623
6556
  // Wall-clock budget kill — checked FIRST and unconditionally wins:
6624
6557
  // never 'failed', never silently 'completed', and (via
6625
6558
  // sigtermLandedCommitEvidence/plainExitLandedCommitEvidence, whichever
@@ -6707,9 +6640,18 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6707
6640
  ) {
6708
6641
  effectiveStatus = 'completed';
6709
6642
  } else if (verifyResult.downgradeTo === 'pending') {
6710
- // HALT or deps_unmet: reset to pending so the job re-fires.
6711
- resetJobFields(s.jobs[i2], verifyResult.reason, { source: 'spawnJob:verify-downgrade' });
6712
- return; // job already mutated by resetJobFields; skip the rest
6643
+ // HALT or deps_unmet: re-fire, bounded by decideVerifyDowngrade.
6644
+ const downgrade = decideVerifyDowngrade(s.jobs[i2], verifyResult);
6645
+ if (downgrade.action === 'refire') {
6646
+ if (resetJobFields(s.jobs[i2], verifyResult.reason, { source: 'spawnJob:verify-downgrade' })) {
6647
+ s.jobs[i2].verifyRefireCount = downgrade.nextCount;
6648
+ }
6649
+ return; // job already mutated by resetJobFields; skip the rest
6650
+ }
6651
+ // Cap hit: park through the existing needs_review stamping path.
6652
+ effectiveStatus = 'needs_review';
6653
+ capParkVerdict = downgrade.verdict;
6654
+ sigtermOverrideReason = downgrade.reason;
6713
6655
  } else {
6714
6656
  // transcript_errors or verify_unavailable: escalate to needs_review.
6715
6657
  effectiveStatus = 'needs_review';
@@ -6728,7 +6670,9 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6728
6670
  // needs_review ledger stale/leftover values from before this run,
6729
6671
  // defeating its whole `byReason` rollup for the two escalation
6730
6672
  // paths that land here.
6731
- s.jobs[i2].error = (effectiveStatus === 'needs_review' || s.jobs[i2].blockedByForeignWip === true)
6673
+ s.jobs[i2].error = capParkVerdict
6674
+ ? sigtermOverrideReason
6675
+ : (effectiveStatus === 'needs_review' || s.jobs[i2].blockedByForeignWip === true)
6732
6676
  ? (verifyResult?.reason ?? sigtermOverrideReason ?? null)
6733
6677
  // A failed job (non-zero exit) never consults verifyResult above,
6734
6678
  // but a worktree integration failure is still worth surfacing on
@@ -6748,11 +6692,16 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6748
6692
  // 'blocked_by_foreign_wip_streak'/'budget_exceeded' are set above
6749
6693
  // from the sigterm/exit-code path, never from verifyResult (which
6750
6694
  // stays null on a non-zero exit) — never clobber either here.
6751
- if (verifyResult?.verdict && verifyResult.verdict !== 'clean') {
6695
+ if (capParkVerdict) {
6696
+ s.jobs[i2].verifierVerdict = capParkVerdict;
6697
+ } else if (verifyResult?.verdict && verifyResult.verdict !== 'clean') {
6752
6698
  s.jobs[i2].verifierVerdict = verifyResult.verdict;
6753
6699
  } else if (!['blocked_by_foreign_wip_streak', 'budget_exceeded'].includes(s.jobs[i2].verifierVerdict)) {
6754
6700
  delete s.jobs[i2].verifierVerdict;
6755
6701
  }
6702
+ // Only a downgrade-to-pending (which returned above) keeps the count;
6703
+ // every other finalize outcome, including the cap park, clears it.
6704
+ delete s.jobs[i2].verifyRefireCount;
6756
6705
  if (verifyResult?.verdict === 'needs_human') {
6757
6706
  s.jobs[i2].humanQuestion = verifyResult.humanQuestion || '';
6758
6707
  } else {
@@ -6996,7 +6945,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6996
6945
  annotations: needsReviewRcaSnapshot.verifierAnnotations,
6997
6946
  })
6998
6947
  .then(async (report) => {
6999
- // Resolve the authoring Epic the same way notifyNeedsReview does —
6948
+ // Resolve the authoring Epic (sourcePromptId, else the job epicId) —
7000
6949
  // the review notice needs the same epicId so flushDueReviewNotices
7001
6950
  // can route the eventual grouped message to the right place.
7002
6951
  let epicId = null;
@@ -7011,7 +6960,6 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
7011
6960
  // classification (e.g. selectAutoFixTargets excluding 'archive')
7012
6961
  // and flushDueReviewNotices routes on the notice, neither re-parsing
7013
6962
  // the RCA markdown nor losing the notice to a lost race.
7014
- let builtNotice = null;
7015
6963
  await mutate((s) => {
7016
6964
  const j = s.jobs.find((x) => x.slug === needsReviewRcaSnapshot.slug);
7017
6965
  applyRcaClassification(j, report);
@@ -7021,31 +6969,13 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
7021
6969
  // lands, must not get a stale notice grafted back onto it.
7022
6970
  if (j && j.status === 'needs_review') {
7023
6971
  j.reviewNotice = buildReviewNotice({ job: j, report, epicId, now: new Date().toISOString(), prior: j.reviewNotice });
7024
- builtNotice = j.reviewNotice;
7025
6972
  }
7026
6973
  }).catch((e) => {
7027
6974
  console.error('[scheduler] writeRcaReport reviewNotice mutate error', needsReviewRcaSnapshot.slug, e);
7028
6975
  });
7029
- // Quiet by default (this PRD): the notice recorded above waits for
6976
+ // Quiet by default: the notice recorded above waits for
7030
6977
  // flushDueReviewNotices to send it, grouped, once the ladder gives
7031
- // up or the hold time passes. SM_REVIEW_NOTICE_IMMEDIATE=1 restores
7032
- // the old immediate-notify behavior, for local debugging.
7033
- if (process.env.SM_REVIEW_NOTICE_IMMEDIATE === '1') {
7034
- const sent = await notifyNeedsReview(needsReviewRcaSnapshot, report);
7035
- if (sent && builtNotice) {
7036
- await mutate((s) => {
7037
- const j = s.jobs.find((x) => x.slug === needsReviewRcaSnapshot.slug);
7038
- // Stamp only if the live notice is still the exact one just
7039
- // sent — a re-park between the send and this mutate must not
7040
- // be marked sent for a notice that never went out.
7041
- if (j && j.reviewNotice && j.reviewNotice.firstParkedAt === builtNotice.firstParkedAt && j.reviewNotice.cause === builtNotice.cause) {
7042
- j.reviewNotice.sentAt = new Date().toISOString();
7043
- }
7044
- }).catch((e) => {
7045
- console.error('[scheduler] writeRcaReport immediate-stamp mutate error', needsReviewRcaSnapshot.slug, e);
7046
- });
7047
- }
7048
- }
6978
+ // up or the hold time passes.
7049
6979
  })
7050
6980
  .catch((e) => {
7051
6981
  console.error('[scheduler] writeRcaReport error', job.slug, e);
@@ -7586,8 +7516,6 @@ async function runDueJobs({ bypassLoadGate = false } = {}) {
7586
7516
  }
7587
7517
  cancelToken = { cancelled: false };
7588
7518
  const result = await tickQueue({ bypassLoadGate });
7589
- // Clear the one-shot scheduledFor without waiting for jobs to settle.
7590
- await mutate((s) => { s.scheduledFor = null; });
7591
7519
  await broadcast();
7592
7520
  return result;
7593
7521
  }
@@ -8997,7 +8925,7 @@ async function runGateShadow(job) {
8997
8925
  if (fs.existsSync(path.dirname(verdictsPath))) {
8998
8926
  let existing = {};
8999
8927
  try { existing = JSON.parse(fs.readFileSync(verdictsPath, 'utf8')) || {}; } catch { /* absent/unparseable → fresh */ }
9000
- try { atomicWriteJsonSync(verdictsPath, { ...existing, gateShadow }); } catch { /* best-effort */ }
8928
+ try { config.writeJsonSync(verdictsPath, { ...existing, gateShadow }); } catch { /* best-effort */ }
9001
8929
  }
9002
8930
  }
9003
8931
 
@@ -9213,7 +9141,7 @@ async function reverifyNeedsReview() {
9213
9141
  // pass starting before this one's pick resolves could choose a target of
9214
9142
  // its own too, breaking the single-flight rule.
9215
9143
  let pick = null;
9216
- if (!gateShadowPending && process.env.SM_GATE_SHADOW_DISABLE !== '1') {
9144
+ if (!gateShadowPending) {
9217
9145
  pick = (async () => {
9218
9146
  const needsReview = snap.jobs.filter((j) => j.status === 'needs_review');
9219
9147
  const headByCwd = new Map();
@@ -9931,12 +9859,12 @@ function rescheduleIntervalTick() {
9931
9859
  // auto-clears WITHOUT waiting for the next app restart. Cheap-guarded by
9932
9860
  // shouldRunPeriodicReverify, which reuses isRescanCandidate so the guard
9933
9861
  // and the candidate filter can never drift apart again (they did once —
9934
- // see that function's comment). Kill-switch:
9935
- // SM_REVERIFY_PERIODIC_DISABLE=1 (boot reverify above stays always-on).
9862
+ // see that function's comment). Test seam:
9863
+ // _setPeriodicReverifyEnabledForTests(false) (boot reverify above stays always-on).
9936
9864
  // reverifyNeedsReview's auto-fix loop is capped downstream by
9937
9865
  // MAX_CONCURRENT_INVESTIGATIONS (spawnInvestigation queues/early-returns
9938
9866
  // past it), so this interval firing cannot fan out investigations.
9939
- if (process.env.SM_REVERIFY_PERIODIC_DISABLE !== '1') {
9867
+ if (periodicReverifyEnabled) {
9940
9868
  if (reviewLadder.shouldRunPeriodicReverify(s.jobs)) {
9941
9869
  reverifyNeedsReview().catch(() => {});
9942
9870
  }
@@ -10149,6 +10077,13 @@ function rescheduleIntervalTick() {
10149
10077
  }
10150
10078
  }
10151
10079
 
10080
+ // Test-only seam: the periodic reverify pass is on in production; tests turn it
10081
+ // off to isolate themselves from the unrelated reverify/broadcast side effects.
10082
+ let periodicReverifyEnabled = true;
10083
+ function _setPeriodicReverifyEnabledForTests(enabled) {
10084
+ periodicReverifyEnabled = enabled !== false;
10085
+ }
10086
+
10152
10087
  async function init() {
10153
10088
  ensureDirs();
10154
10089
  // Boot phase — reconciliation, migrations, self-heal, first reset probe.
@@ -10167,16 +10102,6 @@ async function init() {
10167
10102
  // Boot-time expiry pass (process-local state is empty after a restart, so
10168
10103
  // this is a cheap belt-and-braces run against the freshly read queue).
10169
10104
  try { runReservationExpiryPass((await readQueue()).jobs); } catch { /* best-effort */ }
10170
- // Retire the global queue.json: split its rows into per-project shards
10171
- // BEFORE the first read below, so boot reconciliation sees the shards.
10172
- try {
10173
- const m = await queueStore.migrateLegacyGlobalQueue(DEFAULT_PROJECT_CWD);
10174
- if (m.migrated) {
10175
- console.log(`[scheduler] legacy global queue retired: ${m.moved} row(s) split across ${m.projects} project shard(s)`);
10176
- }
10177
- } catch (e) {
10178
- console.error('[scheduler] legacy queue split failed', e?.message);
10179
- }
10180
10105
  await prdPaths.runPrdMigration();
10181
10106
  prdPaths.sweepQueueBackups().catch((e) => console.warn('[scheduler] backup sweep failed', e?.message));
10182
10107
 
@@ -10273,7 +10198,7 @@ async function init() {
10273
10198
  const bootLandedCommitEvidence = new Map();
10274
10199
  await Promise.all(bootSnap.jobs.map(async (j) => {
10275
10200
  if (!immediateSlugs.includes(j.slug) || j.status !== 'running') return;
10276
- if (bootOutcomes.get(j.slug) === 'success' || !j.landedCommit) return;
10201
+ if (!j.landedCommit) return;
10277
10202
  const resolved = await resolveLandedCommitEvidence(j.cwd || DEFAULT_PROJECT_CWD, j.landedCommit, j.startedAt);
10278
10203
  if (resolved) bootLandedCommitEvidence.set(j.slug, j.landedCommit);
10279
10204
  }));
@@ -10433,7 +10358,7 @@ function toCompactPrdEntry(entry) {
10433
10358
  * `schedule:list-prds` IPC handler and the admin HTTP `GET
10434
10359
  * /admin/scheduler/prds` route (PRD 1024) — neither re-implements this scan.
10435
10360
  */
10436
- async function listPrdsInternal() {
10361
+ async function listPrdsInternal({ cwd } = {}) {
10437
10362
  ensureDirs();
10438
10363
  const out = [];
10439
10364
  const seenSlugs = new Set();
@@ -10485,12 +10410,21 @@ async function listPrdsInternal() {
10485
10410
  // Live PRDs first, so an archived duplicate (shouldn't exist, but a
10486
10411
  // stale rename copy is possible) never shadows the still-runnable live
10487
10412
  // entry.
10488
- for (const dir of prdPaths.candidatePrdsDirs()) {
10413
+ // A non-empty cwd scopes the scan to that one project's dirs (cost = that
10414
+ // project's files, not every project's); no cwd = the machine-wide scan.
10415
+ const scoped = typeof cwd === 'string' && cwd.length > 0;
10416
+ const liveDirs = scoped
10417
+ ? [...new Set([schedulerPaths.prdsRoot(), prdPaths.prdDirForCwd(cwd), ...listEpicPrdDirs(cwd)])]
10418
+ : prdPaths.candidatePrdsDirs();
10419
+ const archivedDirs = scoped
10420
+ ? [...new Set(listArchivedPrdDirs(cwd))]
10421
+ : prdPaths.candidateArchivedPrdsDirs();
10422
+ for (const dir of liveDirs) {
10489
10423
  await readDirInto(dir, { archived: false });
10490
10424
  }
10491
10425
 
10492
10426
  const archivedStart = out.length;
10493
- for (const dir of prdPaths.candidateArchivedPrdsDirs()) {
10427
+ for (const dir of archivedDirs) {
10494
10428
  await readDirInto(dir, { archived: true });
10495
10429
  }
10496
10430
 
@@ -10733,7 +10667,7 @@ const remote = {
10733
10667
  // paging by offset can't skip or duplicate an entry between requests as
10734
10668
  // long as the underlying PRD set doesn't change mid-page.
10735
10669
  async listPrds(filter = {}) {
10736
- const all = await listPrdsInternal();
10670
+ const all = await listPrdsInternal({ cwd: filter.cwd });
10737
10671
  const filtered = all.filter((entry) => {
10738
10672
  if (filter.cwd && entry.cwd !== filter.cwd) return false;
10739
10673
  if (filter.epicId && entry.epicId !== filter.epicId) return false;
@@ -11100,6 +11034,7 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
11100
11034
  }
11101
11035
 
11102
11036
  module.exports = {
11037
+ normalizeConfig,
11103
11038
  reportSchedulerError,
11104
11039
  finalizeJobWorktree,
11105
11040
  worktreeIntegrationVerdict,
@@ -11117,6 +11052,7 @@ module.exports = {
11117
11052
  getSchedulerStateSnapshot,
11118
11053
  computeDegradedBudget,
11119
11054
  writeQueue,
11055
+ _setPeriodicReverifyEnabledForTests,
11120
11056
  _mutateForTests: mutate, // mutate() has no other exported call site; test-only seam.
11121
11057
  reconcile,
11122
11058
  broadcast,
@@ -11175,7 +11111,6 @@ module.exports = {
11175
11111
  applyOrphanOutcome,
11176
11112
  registerAdminRoutes,
11177
11113
  notifyOriginatingTab,
11178
- notifyNeedsReview,
11179
11114
  flushDueReviewNotices,
11180
11115
  isNotifiableTerminalStatus,
11181
11116
  extractResultTextFromLog,