claude-code-session-manager 0.87.0 → 0.88.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 (291) hide show
  1. package/README.md +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-kQF8_wAw.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-CY_mMnsr.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-Sji2brb0.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CtqpO-p5.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-DN0ydOCr.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-CoiLZTKh.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CmqiQ_io.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-HPoNeKhT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-Bj1X3_NT.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-1PKjsM3b.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-I1s6ghPo.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-BhiiZjHQ.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-gv3_fkPX.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-BGcgBd8V.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BGr9eZ4g.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-Bdo_awPq.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-ukl0qWaA.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-DJkFj22G.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-0t1Qblp6.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-CeVdBGE-.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-CfKvNHi3.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-DAzNaUPd.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody--S_rGXoA.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-Dszm0xnp.js} +1 -1
  26. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  27. package/dist/assets/{index-fc_JjdxL.js → index-Dp4Rc--q.js} +356 -356
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-BLbssMYo.js} +1 -1
  29. package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
  30. package/dist/index.html +2 -2
  31. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
  32. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  33. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
  34. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  35. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
  36. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  37. package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
  38. package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
  39. package/package.json +10 -13
  40. package/scripts/README.md +59 -0
  41. package/scripts/audit-ops-hygiene.cjs +350 -0
  42. package/scripts/hooks/guard-destructive-git.cjs +10 -34
  43. package/scripts/hooks/guard-inline-implementation.cjs +14 -8
  44. package/scripts/hooks/guard-prd-writes.cjs +7 -36
  45. package/scripts/hooks/guard-self-schedule.cjs +175 -0
  46. package/scripts/ops-sweep.cjs +355 -0
  47. package/scripts/scheduler-mcp-server.cjs +28 -71
  48. package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
  49. package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
  50. package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
  51. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
  52. package/src/main/__tests__/chat-queue.test.cjs +2 -2
  53. package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
  54. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
  55. package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
  56. package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
  57. package/src/main/__tests__/dod-report.test.cjs +2 -2
  58. package/src/main/__tests__/dod-reverify.test.cjs +2 -2
  59. package/src/main/__tests__/epicMint.test.cjs +2 -2
  60. package/src/main/__tests__/exchanges.test.cjs +2 -2
  61. package/src/main/__tests__/extractJson.test.cjs +2 -2
  62. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  63. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
  64. package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
  65. package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
  66. package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
  67. package/src/main/__tests__/health-tick-liveness.test.cjs +27 -3
  68. package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
  69. package/src/main/__tests__/historyRollup.test.cjs +2 -2
  70. package/src/main/__tests__/kg-augment.test.cjs +2 -2
  71. package/src/main/__tests__/mcpStatus.test.cjs +2 -2
  72. package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
  73. package/src/main/__tests__/memoryStale.test.cjs +1 -1
  74. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
  75. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
  76. package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
  77. package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
  78. package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
  79. package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
  80. package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
  81. package/src/main/__tests__/proc-role-env.test.cjs +125 -0
  82. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
  83. package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
  84. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
  85. package/src/main/__tests__/projectPages.test.cjs +63 -149
  86. package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
  87. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
  88. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  89. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
  90. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
  91. package/src/main/__tests__/runVerify.test.cjs +5 -5
  92. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
  93. package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
  94. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
  95. package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
  96. package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
  97. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
  98. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
  99. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
  100. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
  101. package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
  102. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
  103. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
  104. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
  105. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
  106. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
  107. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
  108. package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
  109. package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
  110. package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
  111. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
  112. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
  113. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
  114. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
  115. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
  116. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
  117. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
  118. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
  119. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
  120. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
  121. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
  122. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
  123. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +14 -0
  124. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
  125. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  126. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  127. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  128. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  129. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  130. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  131. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  132. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  133. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  134. package/src/main/bilkoHost.cjs +16 -7
  135. package/src/main/build-info.json +8 -0
  136. package/src/main/chatRunner.cjs +4 -2
  137. package/src/main/config.cjs +2 -3
  138. package/src/main/docEdit.cjs +4 -2
  139. package/src/main/health.cjs +276 -52
  140. package/src/main/heapSnapshot.cjs +2 -2
  141. package/src/main/historyAggregator.cjs +1 -1
  142. package/src/main/index.cjs +61 -10
  143. package/src/main/ipcSchemas.cjs +6 -17
  144. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  145. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  146. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  147. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  148. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  149. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  150. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  151. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  152. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  153. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  154. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  155. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  156. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  157. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  158. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  159. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  160. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  161. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  162. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  163. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  164. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  165. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  166. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  167. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  168. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  169. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  170. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  171. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  172. package/src/main/lib/activeSessions.cjs +20 -88
  173. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  174. package/src/main/lib/agentModelResolve.cjs +33 -1
  175. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  176. package/src/main/lib/auditLog.cjs +30 -5
  177. package/src/main/lib/buildIdentity.cjs +113 -0
  178. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  179. package/src/main/lib/claudeBin.cjs +37 -2
  180. package/src/main/lib/cleanEnv.cjs +27 -1
  181. package/src/main/lib/credentials.cjs +4 -2
  182. package/src/main/lib/cwdClassify.cjs +185 -0
  183. package/src/main/lib/definitionOfDone.cjs +296 -52
  184. package/src/main/lib/dispatchLoop.cjs +40 -0
  185. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  186. package/src/main/lib/ephemeralCwd.cjs +7 -28
  187. package/src/main/lib/gitWorktree.cjs +95 -8
  188. package/src/main/lib/guardShims.cjs +3 -3
  189. package/src/main/lib/historyRollup.cjs +6 -7
  190. package/src/main/lib/instanceLock.cjs +32 -5
  191. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  192. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  193. package/src/main/lib/localAdminHttp.cjs +9 -27
  194. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  195. package/src/main/lib/opsOwnership.cjs +27 -19
  196. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  197. package/src/main/lib/prdLocations.cjs +48 -1
  198. package/src/main/lib/procIdentity.cjs +126 -0
  199. package/src/main/lib/procName.cjs +98 -0
  200. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  201. package/src/main/lib/queueHistory.cjs +8 -7
  202. package/src/main/lib/queueStore.cjs +57 -37
  203. package/src/main/lib/quietMachineLease.cjs +19 -2
  204. package/src/main/lib/reservationExpiry.cjs +30 -0
  205. package/src/main/lib/runClaudeP.cjs +4 -2
  206. package/src/main/lib/runLogRetention.cjs +3 -2
  207. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  208. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  209. package/src/main/lib/schedulerBatch.cjs +27 -2
  210. package/src/main/lib/schedulerPaths.cjs +175 -0
  211. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  212. package/src/main/lib/sessionSlots.cjs +47 -9
  213. package/src/main/lib/smProcNames.cjs +51 -0
  214. package/src/main/lib/upgradeDrain.cjs +188 -0
  215. package/src/main/lib/usageCircuit.cjs +53 -6
  216. package/src/main/lib/watchdogHelpers.cjs +70 -37
  217. package/src/main/lib/withTimeout.cjs +33 -0
  218. package/src/main/mcpStatus.cjs +4 -2
  219. package/src/main/pluginInstall.cjs +6 -2
  220. package/src/main/projectPages.cjs +41 -145
  221. package/src/main/pty.cjs +8 -1
  222. package/src/main/queueOps.cjs +10 -10
  223. package/src/main/runVerify.cjs +69 -3
  224. package/src/main/scheduler.cjs +1607 -383
  225. package/src/main/seedAgentPersonas.cjs +1 -1
  226. package/src/main/seedDevPlugin.cjs +1 -1
  227. package/src/main/seedSchedulerMcp.cjs +7 -6
  228. package/src/main/seedStatus.cjs +1 -1
  229. package/src/main/supervisor.cjs +5 -3
  230. package/src/main/usage.cjs +126 -62
  231. package/src/preload/api.d.ts +18 -64
  232. package/src/preload/index.cjs +2 -4
  233. package/src/seed/agents/project-home-builder.md +31 -48
  234. package/screenshots/.gitkeep +0 -0
  235. package/screenshots/README-screenshots.md +0 -13
  236. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  237. package/src/main/teams.cjs +0 -95
  238. package/src/main/templates/project-pages-catalog.json +0 -741
  239. package/src/main/templates/project-pages-default-home.html +0 -123
  240. package/src/main/templates/project-pages-pipeline.md +0 -417
  241. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  242. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  243. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  244. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  245. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  246. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  247. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  248. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  249. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  250. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  251. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  252. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  253. package/src/seed/prompts/documentation/module-readme.md +0 -8
  254. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  255. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  256. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  257. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  258. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  259. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  260. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  261. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  262. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  263. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  264. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  265. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  266. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  267. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  268. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  269. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  270. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  271. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  272. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  273. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  274. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  275. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  276. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  277. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  278. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  279. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  280. package/src/seed/prompts/security/authz-audit.md +0 -8
  281. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  282. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  283. package/src/seed/prompts/security/dependency-audit.md +0 -8
  284. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  285. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  286. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  287. package/web/README.md +0 -41
  288. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  289. package/web/project-pages/render.cjs +0 -70
  290. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  291. package/web/project-pages/validate-summary.cjs +0 -62
@@ -5,6 +5,13 @@
5
5
  * resume timer when both the log and the billing endpoint come up empty
6
6
  * (the live failure mode: the usage endpoint itself 429s).
7
7
  *
8
+ * Also (usage-meter circuit PRD): neither function may arm a PAST reset —
9
+ * resolveRateLimitPauseReset/computeEffectiveResumeAt now reject a stale ISO
10
+ * via usageCircuit.isResetFresh and fall back to the bounded 30-MINUTE pause
11
+ * instead. A stale reset used to survive straight into computeResumeDelay's
12
+ * `Math.max(30_000, <negative>)`, arming a 30-SECOND nap that spun the queue
13
+ * right back into the same still-active rate limit.
14
+ *
8
15
  * These test the PURE decision functions scheduler.cjs's setPaused() and the
9
16
  * spawnJob() rateLimited branch delegate to — never the real setPaused/mutate
10
17
  * path, which writes the machine's live scheduler queue.json at a fixed
@@ -21,9 +28,11 @@ const assert = require('node:assert/strict');
21
28
  const path = require('node:path');
22
29
  const {
23
30
  resolveRateLimitPauseReset,
31
+ billingResetForPause,
24
32
  computeEffectiveResumeAt,
25
33
  computeResumeDelay,
26
34
  } = require('../scheduler.cjs');
35
+ const billing = require('../usage.cjs');
27
36
 
28
37
  // Repo-tracked, byte-for-byte copy of the real 2026-09-05 starry-night-ships
29
38
  // incident log (see src/main/lib/__tests__/rateLimitWindow.test.cjs for the
@@ -32,37 +41,84 @@ const {
32
41
  const FIXTURE = path.join(__dirname, '..', 'lib', '__tests__', 'fixtures', '204-mercury-steam-horse.log.txt');
33
42
  const FIVE_HOUR_ISO = new Date(1788643800 * 1000).toISOString(); // what refreshNextReset() would have returned
34
43
  const SEVEN_DAY_ISO = new Date(1789059600 * 1000).toISOString(); // the actually-binding window
44
+ // Fixed "now" contemporaneous with the fixture's own timestamps (BEFORE both
45
+ // resets above), so freshness checks read the fixture the way it looked at
46
+ // incident time — not relative to whenever this suite happens to run.
47
+ const FIXTURE_NOW = Date.parse('2026-09-05T16:32:00.000Z');
35
48
 
36
49
  test('resolveRateLimitPauseReset: prefers the log-derived seven-day reset over the billing five_hour reset', () => {
37
- const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO);
50
+ const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO, FIXTURE_NOW);
38
51
  assert.strictEqual(resetIso, SEVEN_DAY_ISO);
39
52
  assert.notStrictEqual(resetIso, FIVE_HOUR_ISO);
40
53
  });
41
54
 
42
55
  test('resolveRateLimitPauseReset: falls back to the billing reset when the log yields none', () => {
43
- const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO);
56
+ const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO, FIXTURE_NOW);
44
57
  assert.strictEqual(resetIso, FIVE_HOUR_ISO);
45
58
  });
46
59
 
47
60
  test('resolveRateLimitPauseReset: null when BOTH the log and the billing endpoint are unavailable', () => {
48
- assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null), null);
61
+ assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null, FIXTURE_NOW), null);
62
+ });
63
+
64
+ test('resolveRateLimitPauseReset: a PAST log-derived reset is rejected, not returned stale', () => {
65
+ // "now" is AFTER both fixture resets have already elapsed.
66
+ const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
67
+ const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO, longAfter);
68
+ assert.strictEqual(resetIso, null);
69
+ });
70
+
71
+ test('resolveRateLimitPauseReset: log yields nothing and the billing reset has already passed -> null, not the stale value', () => {
72
+ const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
73
+ const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO, longAfter);
74
+ assert.strictEqual(resetIso, null);
49
75
  });
50
76
 
51
77
  test('computeEffectiveResumeAt: rate_limit with no resumeAt gets the same bounded 30-minute fallback as network', () => {
52
- const now = Date.parse('2026-09-05T16:32:00.000Z');
78
+ const now = FIXTURE_NOW;
53
79
  const resumeAt = computeEffectiveResumeAt('rate_limit', null, now);
54
80
  assert.strictEqual(resumeAt, new Date(now + 30 * 60_000).toISOString());
55
81
  });
56
82
 
57
- test('computeEffectiveResumeAt: an explicit resumeAt (the log-derived reset) is never overridden', () => {
58
- const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, Date.now());
83
+ test('computeEffectiveResumeAt: a FRESH explicit resumeAt (the log-derived reset) is never overridden', () => {
84
+ const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, FIXTURE_NOW);
59
85
  assert.strictEqual(resumeAt, SEVEN_DAY_ISO);
60
86
  });
61
87
 
88
+ test('computeEffectiveResumeAt: a PAST resumeAt gets the 30-minute fallback, not a ~30-second nap', () => {
89
+ // now is well AFTER SEVEN_DAY_ISO has already elapsed — the exact shape of
90
+ // the bug: Math.max(30_000, <negative>) used to arm a 30-second resume.
91
+ const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
92
+ const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, longAfter);
93
+ assert.strictEqual(resumeAt, new Date(longAfter + 30 * 60_000).toISOString());
94
+ assert.notStrictEqual(resumeAt, SEVEN_DAY_ISO);
95
+ const { delayMs } = computeResumeDelay(resumeAt, longAfter);
96
+ assert.ok(delayMs >= 30 * 60_000, `expected >= 30 minutes, got ${delayMs}ms`);
97
+ });
98
+
62
99
  test('computeEffectiveResumeAt: other reasons (e.g. auth) get no fallback — stays indefinite', () => {
63
100
  assert.strictEqual(computeEffectiveResumeAt('auth', null, Date.now()), null);
64
101
  });
65
102
 
103
+ test('billingResetForPause: skips refreshNextReset() (no billing.fetchUsage call) while the shared circuit is OPEN', async () => {
104
+ const originalFetchUsage = billing.fetchUsage;
105
+ let fetchCalls = 0;
106
+ billing.fetchUsage = async () => { fetchCalls++; return { kind: 'ok', data: { usage: {} } }; };
107
+ try {
108
+ // Force the shared circuit open (3 consecutive failures), same threshold usageCircuit.cjs uses.
109
+ billing.usageCircuit.recordFailure('a');
110
+ billing.usageCircuit.recordFailure('a');
111
+ billing.usageCircuit.recordFailure('a');
112
+ assert.strictEqual(billing.usageCircuit.state(), 'open');
113
+
114
+ await billingResetForPause();
115
+ assert.strictEqual(fetchCalls, 0, 'refreshNextReset() must not call billing.fetchUsage() while the circuit is open');
116
+ } finally {
117
+ billing.usageCircuit.recordSuccess({}); // close the shared circuit back up for later tests/files
118
+ billing.fetchUsage = originalFetchUsage;
119
+ }
120
+ });
121
+
66
122
  test('computeResumeDelay: a seven-day-out reset (~5 days from now) is armed, not treated as too-far', () => {
67
123
  const now = Date.parse('2026-09-05T16:32:00.000Z');
68
124
  // ~5 days out — comfortably under the 0x7fffffff (~24.8 day) setTimeout cap.
@@ -27,6 +27,7 @@ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
27
27
  const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
30
31
  const { execFileSync } = require('node:child_process');
31
32
 
32
33
  let tmpHome;
@@ -92,15 +93,12 @@ function writeProjectQueue(cwd, jobs) {
92
93
  // non-zero almost instantly, and bumps a counter file each invocation so the
93
94
  // test can assert exactly how many times it was actually spawned.
94
95
  function writeAlways429ClaudeStub(counterPath) {
95
- const stubPath = path.join(os.tmpdir(), `sm-claude-stub-429-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
96
- const body = `
96
+ return claudeStub.writeClaudeStub({ body: `
97
97
  const fs = require('fs');
98
98
  fs.appendFileSync(${JSON.stringify(counterPath)}, 'x');
99
99
  process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429 }) + '\\n');
100
100
  process.exit(1);
101
- `;
102
- fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
103
- return stubPath;
101
+ ` });
104
102
  }
105
103
 
106
104
  test('a manual Resume immediately followed by a permanently-429ing job never produces a runaway dispatch loop', async () => {
@@ -38,7 +38,7 @@ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-te
38
38
  process.env.HOME = tmpHome;
39
39
 
40
40
  const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
41
- const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
41
+ const { auditLogPath } = require('../lib/auditLog.cjs');
42
42
  // queueStore's cwd discovery is cached for 30s (queueStore.cjs's CACHE_MS) —
43
43
  // a project registered by THIS test after an earlier test already populated
44
44
  // that cache (and didn't itself bust it, e.g. because it found nothing
@@ -68,10 +68,29 @@ function registerActiveProject(cwd) {
68
68
  fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
69
69
  }
70
70
 
71
+ // Every real scheduler row is born from a real PRD .md file — reconcile()'s
72
+ // auto-archive-drop path (a terminal job whose slug has no on-disk PRD
73
+ // anywhere is treated as "archived on purpose": dropped from queue.json and
74
+ // backfilled to history.jsonl, see scheduler-reconcile-history-backfill.
75
+ // test.cjs) fires the very next time broadcast({flush:true}) reconciles —
76
+ // which reapDeadRunningJobs() itself does on every reap. Without a matching
77
+ // PRD source, a row this test just reaped to a terminal status vanishes from
78
+ // queue.json before the assertions below ever read it back. Epic-scoped (not
79
+ // the legacy flat prds/ dir) so it's never swept by consolidateFlatPrds
80
+ // either.
81
+ function writeFixturePrd(cwd, slug) {
82
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
83
+ fs.mkdirSync(prdsDir, { recursive: true });
84
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Test fixture PRD.', 'utf8');
85
+ }
86
+
71
87
  function writeProjectQueue(cwd, jobs) {
72
88
  const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
73
89
  fs.mkdirSync(stateDir, { recursive: true });
74
90
  fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
91
+ for (const job of jobs) {
92
+ if (job && job.slug) writeFixturePrd(job.cwd || cwd, job.slug);
93
+ }
75
94
  return path.join(stateDir, 'queue.json');
76
95
  }
77
96
 
@@ -131,7 +150,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
131
150
  // Empty run dir: created, but never written to (the exact 2026-09-01 repro).
132
151
  fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-pidless-zombie'), { recursive: true });
133
152
 
134
- const auditSizeBefore = fs.existsSync(AUDIT_LOG_PATH) ? fs.statSync(AUDIT_LOG_PATH).size : 0;
153
+ const auditSizeBefore = fs.existsSync(auditLogPath()) ? fs.statSync(auditLogPath()).size : 0;
135
154
 
136
155
  await reapDeadRunningJobs();
137
156
 
@@ -142,7 +161,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
142
161
  assert.equal(jobs[0].runtime, undefined);
143
162
  assert.equal(jobs[0].gateOutcome, 'never_ran', 'a pidless reap means the gate never had a chance to run');
144
163
 
145
- const auditText = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').slice(auditSizeBefore);
164
+ const auditText = fs.readFileSync(auditLogPath(), 'utf8').slice(auditSizeBefore);
146
165
  const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
147
166
  const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'pidless-zombie');
148
167
  assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
@@ -184,7 +203,7 @@ test('reapDeadRunningJobs: a pidless row past grace with a landedCommit already
184
203
  // multi-byte characters (e.g. an em dash in a reaper reason string), and
185
204
  // slicing a utf8-decoded JS string (UTF-16 code units) at a byte offset
186
205
  // then silently corrupts the boundary. The slug is unique to this test.
187
- const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
206
+ const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
188
207
  const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'shipped-but-pidless');
189
208
  assert.ok(pidlessEvent, 'reaping this row must still leave an audit trace');
190
209
  assert.equal(pidlessEvent.landedCommit, landedSha, 'the audit event must carry the landed-commit evidence so the decision is reconstructable from the audit log alone');
@@ -0,0 +1,100 @@
1
+ /**
2
+ * scheduler-reconcile-cwd-preserve.test.cjs — regression test for the
3
+ * data-loss bug found while chasing PRD 1218's test:unit gate: reconcile()
4
+ * rebuilt a queue row's `cwd` unconditionally from its PRD's own
5
+ * frontmatter (`cwd: p.cwd`) at all three of its row-construction sites. A
6
+ * PRD file with no `cwd:` frontmatter key — every hand-written or legacy PRD,
7
+ * and every fixture in this test suite before this fix — parses to
8
+ * `p.cwd === undefined`, which nulled the row's real, already-correct `cwd`.
9
+ * queueStore.writeSplit's `job.cwd || defaultCwd` bucketing then relocated
10
+ * the row into schedulerBatch.cjs's DEFAULT_PROJECT_CWD shard
11
+ * (`~/Projects/session-manager`) — the WRONG project — while the row's own
12
+ * project's shard was rewritten without it. A PRD's frontmatter `cwd` must
13
+ * REFINE a row's existing `cwd`, never erase one.
14
+ *
15
+ * Covers the normal refresh path (an existing row matched to its PRD) and
16
+ * the fresh-discovery path (a PRD with no prior row, falling back to the
17
+ * project root it was actually found under via
18
+ * prdLocations.deriveProjectCwdFromPrdPath rather than nulling to
19
+ * DEFAULT_PROJECT_CWD).
20
+ *
21
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ import { test, expect, beforeAll, afterAll } from 'vitest';
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ let tmpHome;
32
+ let originalHome;
33
+ let reconcile;
34
+ let DEFAULT_PROJECT_CWD;
35
+
36
+ beforeAll(() => {
37
+ originalHome = process.env.HOME;
38
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-'));
39
+ process.env.HOME = tmpHome;
40
+ ({ reconcile } = require('../scheduler.cjs'));
41
+ ({ DEFAULT_PROJECT_CWD } = require('../lib/schedulerBatch.cjs'));
42
+ });
43
+
44
+ afterAll(() => {
45
+ process.env.HOME = originalHome;
46
+ fs.rmSync(tmpHome, { recursive: true, force: true });
47
+ });
48
+
49
+ // allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds) discover
50
+ // project cwds by scanning ~/.claude/projects/*/*.jsonl for a `cwd` field —
51
+ // same pattern as scheduler-reap-dead-running-jobs.test.cjs and friends.
52
+ function registerActiveProject(cwd) {
53
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
54
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
55
+ fs.mkdirSync(slugDir, { recursive: true });
56
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
57
+ }
58
+
59
+ test('reconcile() never nulls an existing row\'s cwd because its PRD has no cwd: frontmatter', async () => {
60
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-project-'));
61
+ registerActiveProject(projectCwd);
62
+
63
+ const slug = `1218-cwd-preserve-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
64
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
65
+ fs.mkdirSync(prdsDir, { recursive: true });
66
+ // Deliberately NO `cwd:` frontmatter key — the exact shape of every
67
+ // hand-written or legacy PRD that triggered the bug.
68
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nNo cwd frontmatter here.\n', 'utf8');
69
+
70
+ const job = { slug, title: 'x', cwd: projectCwd, status: 'pending' };
71
+ const state = { jobs: [job] };
72
+
73
+ await reconcile(state);
74
+
75
+ const row = state.jobs.find((j) => j.slug === slug);
76
+ expect(row).toBeDefined();
77
+ expect(row.cwd).toBe(projectCwd);
78
+ expect(row.cwd).not.toBe(DEFAULT_PROJECT_CWD);
79
+ });
80
+
81
+ test('reconcile() gives a freshly-discovered, frontmatter-less PRD the project root it was actually found under, never DEFAULT_PROJECT_CWD', async () => {
82
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-fresh-'));
83
+ registerActiveProject(projectCwd);
84
+
85
+ const slug = `1218-cwd-preserve-fresh-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
86
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
87
+ fs.mkdirSync(prdsDir, { recursive: true });
88
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nNo cwd frontmatter here either.\n', 'utf8');
89
+
90
+ // No prior row at all — this PRD is discovered fresh by reconcile()'s own
91
+ // onDisk scan, not handed in via `state.jobs`.
92
+ const state = { jobs: [] };
93
+
94
+ await reconcile(state);
95
+
96
+ const row = state.jobs.find((j) => j.slug === slug);
97
+ expect(row).toBeDefined();
98
+ expect(row.cwd).toBe(projectCwd);
99
+ expect(row.cwd).not.toBe(DEFAULT_PROJECT_CWD);
100
+ });
@@ -127,8 +127,8 @@ test('reconcile() repairs a queue row with an invalid status back to pending, lo
127
127
  // require time, so spying on the module's export property after the
128
128
  // fact wouldn't intercept scheduler.cjs's already-bound reference —
129
129
  // assert against the real (tmpHome-isolated) audit log file instead.
130
- const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
131
- expect(AUDIT_LOG_PATH.startsWith(tmpHome)).toBe(true);
130
+ const { auditLogPath } = require('../lib/auditLog.cjs');
131
+ expect(auditLogPath().startsWith(tmpHome)).toBe(true);
132
132
 
133
133
  queueStore.bustCwdCache();
134
134
  await scheduler.reconcile(state);
@@ -142,7 +142,7 @@ test('reconcile() repairs a queue row with an invalid status back to pending, lo
142
142
  );
143
143
  expect(warnedRepair).toBe(true);
144
144
 
145
- const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
145
+ const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
146
146
  const auditedRepair = auditLines.some(
147
147
  (rec) => rec.kind === 'scheduler_row_repaired' && rec.slug === badRow.slug && rec.oldStatus === 'queued',
148
148
  );
@@ -80,7 +80,7 @@ test('reconcile() quarantines a newly-discovered PRD with no createdVia provenan
80
80
  fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
81
81
 
82
82
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
83
- const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
83
+ const { auditLogPath } = require('../lib/auditLog.cjs');
84
84
 
85
85
  queueStore.bustCwdCache();
86
86
  const state = { jobs: [], invalidJobs: [], paused: null };
@@ -95,7 +95,7 @@ test('reconcile() quarantines a newly-discovered PRD with no createdVia provenan
95
95
  );
96
96
  expect(warned).toBe(true);
97
97
 
98
- const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
98
+ const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
99
99
  expect(auditLines.some((rec) => rec.kind === 'prd_quarantined' && rec.slug === '9001-hand-written')).toBe(true);
100
100
  } finally {
101
101
  fs.rmSync(cwd, { recursive: true, force: true });
@@ -230,7 +230,7 @@ test('reconcile() adopts a quarantined row to pending once its PRD file is stamp
230
230
  fm.issuedAt = '2026-08-07T01:00:00.000Z';
231
231
  fs.writeFileSync(prdPath, serializePrdFile(fm, body), 'utf8');
232
232
 
233
- const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
233
+ const { auditLogPath } = require('../lib/auditLog.cjs');
234
234
  queueStore.bustCwdCache();
235
235
  const state = { jobs: [quarantinedRow], invalidJobs: [], paused: null };
236
236
  await scheduler.reconcile(state);
@@ -239,7 +239,7 @@ test('reconcile() adopts a quarantined row to pending once its PRD file is stamp
239
239
  expect(row).toBeDefined();
240
240
  expect(row.status).toBe('pending');
241
241
 
242
- const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
242
+ const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
243
243
  expect(auditLines.some((rec) => rec.kind === 'scheduler_prd_adopted' && rec.slug === '9004-adopt-me')).toBe(true);
244
244
  } finally {
245
245
  fs.rmSync(cwd, { recursive: true, force: true });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * scheduler-shard-quarantine.test.cjs — a torn per-project queue shard
3
+ * quarantines THAT project only. tickQueue keeps dispatching the healthy
4
+ * project, never scans/mints rows for the torn project's PRDs, never touches
5
+ * the torn file, and health names the quarantined cwd (non-GREEN).
6
+ *
7
+ * Everything runs under a temp HOME; the stub `claude` writes a marker into
8
+ * its own cwd so the test can prove where a spawn did (not) happen.
9
+ *
10
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-shard-quarantine.test.cjs
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ import { test, expect, beforeAll, afterAll } from 'vitest';
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
20
+
21
+ let tmpHome;
22
+ let originalHome;
23
+ let originalWt;
24
+ let scheduler;
25
+
26
+ function registerActiveProject(cwd) {
27
+ const slugDir = path.join(tmpHome, '.claude', 'projects', `fake-project-slug-${path.basename(cwd)}`);
28
+ fs.mkdirSync(slugDir, { recursive: true });
29
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
30
+ }
31
+
32
+ function makeProject(name) {
33
+ const cwd = path.join(tmpHome, 'Projects', name);
34
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
35
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'fixture-epic', 'prds');
36
+ fs.mkdirSync(stateDir, { recursive: true });
37
+ fs.mkdirSync(prdsDir, { recursive: true });
38
+ registerActiveProject(cwd);
39
+ return { cwd, prdsDir, queueFile: path.join(stateDir, 'queue.json') };
40
+ }
41
+
42
+ function writeClaudeStub() {
43
+ return claudeStub.writeClaudeStub({ body: `
44
+ require('fs').writeFileSync(require('path').join(process.cwd(), 'ran-here.marker'), 'yes');
45
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
46
+ process.exit(0);
47
+ ` });
48
+ }
49
+
50
+ beforeAll(() => {
51
+ originalHome = process.env.HOME;
52
+ originalWt = process.env.SM_JOB_WORKTREE_DISABLE;
53
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-shard-quarantine-'));
54
+ process.env.HOME = tmpHome;
55
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
56
+ process.env.SM_AUTOFIX_DISABLE = '1';
57
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
58
+ scheduler = require('../scheduler.cjs');
59
+ });
60
+
61
+ afterAll(() => {
62
+ process.env.HOME = originalHome;
63
+ if (originalWt === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
64
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalWt;
65
+ delete process.env.SM_AUTOFIX_DISABLE;
66
+ delete process.env.SM_CLAUDE_BIN;
67
+ fs.rmSync(tmpHome, { recursive: true, force: true });
68
+ });
69
+
70
+ test('tickQueue dispatches the healthy project while the torn project is skipped; health names it', async () => {
71
+ const good = makeProject('good-proj');
72
+ const torn = makeProject('torn-proj');
73
+ const goodSlug = `9101-good-${process.pid}`;
74
+ const tornSlug = `9102-torn-${process.pid}`;
75
+ fs.writeFileSync(path.join(good.prdsDir, `${goodSlug}.md`), 'Healthy PRD.', 'utf8');
76
+ fs.writeFileSync(path.join(torn.prdsDir, `${tornSlug}.md`), 'PRD in the torn project.', 'utf8');
77
+ fs.writeFileSync(good.queueFile, JSON.stringify({
78
+ jobs: [{ slug: goodSlug, title: 'good', status: 'pending', cwd: good.cwd, dependsOn: [], createdAt: new Date().toISOString() }],
79
+ }));
80
+ // A running row was in flight when the shard tore — must never become pending.
81
+ const TORN = '{"jobs":[{"slug":"' + tornSlug + '","status":"running"}]}{"jobs":[{"slug":"';
82
+ fs.writeFileSync(torn.queueFile, TORN);
83
+ const before = fs.readFileSync(torn.queueFile);
84
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
85
+
86
+ const result = await scheduler.tickQueue({ bypassLoadGate: true });
87
+ expect(result.reason).not.toBe('unreadable');
88
+ expect(result.fired).toBe(true);
89
+ // flagUnreadable (via tickQueue's readQueue) snapshots the torn shard beside itself.
90
+ const snaps = fs.readdirSync(path.dirname(torn.queueFile)).filter((f) => f.startsWith('queue.json.corrupt-'));
91
+ expect(snaps).toHaveLength(1);
92
+
93
+ await new Promise((resolve, reject) => {
94
+ const t0 = Date.now();
95
+ const poll = () => {
96
+ if (fs.existsSync(path.join(good.cwd, 'ran-here.marker'))) return resolve();
97
+ if (Date.now() - t0 > 20_000) return reject(new Error('healthy project never dispatched'));
98
+ setTimeout(poll, 100);
99
+ };
100
+ poll();
101
+ });
102
+
103
+ expect(fs.existsSync(path.join(torn.cwd, 'ran-here.marker'))).toBe(false);
104
+ expect(fs.readFileSync(torn.queueFile).equals(before)).toBe(true);
105
+
106
+ const health = await require('../health.cjs').check();
107
+ expect(health.components.scheduler_queue.ok).toBe(false);
108
+ expect(health.components.scheduler_queue.quarantinedCwds).toEqual([torn.cwd]);
109
+ expect(health.issues.some((i) => i.includes(torn.cwd))).toBe(true);
110
+ }, 120_000); // health.check() alone can take tens of seconds
@@ -285,7 +285,13 @@ test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — un
285
285
  vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha-after');
286
286
  vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue(['.gitignore']);
287
287
  vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]); // the log path no longer shows up at all
288
- vi.spyOn(fs, 'existsSync').mockImplementation((p) => String(p).includes('errors-2026-09-12.jsonl'));
288
+ // checkSharedTreeGuard's existence check runs concurrently via
289
+ // fs.promises.access (fsp === fs.promises — same object) rather than
290
+ // fs.existsSync, so a large untracked baseline doesn't block the event
291
+ // loop with hundreds of synchronous stats back-to-back.
292
+ vi.spyOn(fs.promises, 'access').mockImplementation((p) => (
293
+ String(p).includes('errors-2026-09-12.jsonl') ? Promise.resolve() : Promise.reject(new Error('ENOENT'))
294
+ ));
289
295
 
290
296
  const result = await checkSharedTreeGuard({
291
297
  cwd: '/repo',
@@ -298,3 +304,77 @@ test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — un
298
304
  expect(result.reverted).toBeUndefined();
299
305
  expect(result.nowIgnored).toEqual([logPath]);
300
306
  });
307
+
308
+ // --- landedCommit ground truth (2026-09-18, 1229-fo-03 false positive) ---
309
+ // Real temp git fixture, never the live repo. Baseline-dirty path `cfg.txt`
310
+ // goes clean with no commit covering it (the shape that makes the baseline
311
+ // diff say "reverted"); only the landedCommit ancestry decides the verdict.
312
+ const { execFileSync } = require('node:child_process');
313
+ const os = require('node:os');
314
+ const nodePath = require('node:path');
315
+
316
+ function gitIn(dir, ...args) {
317
+ return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim();
318
+ }
319
+
320
+ function fixtureRepo() {
321
+ const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'shared-tree-landed-'));
322
+ gitIn(dir, 'init', '-q');
323
+ gitIn(dir, 'config', 'user.email', 't@example.com');
324
+ gitIn(dir, 'config', 'user.name', 'T');
325
+ fs.writeFileSync(nodePath.join(dir, 'cfg.txt'), 'base\n');
326
+ gitIn(dir, 'add', '-A');
327
+ gitIn(dir, 'commit', '-q', '-m', 'base');
328
+ const base = gitIn(dir, 'rev-parse', 'HEAD');
329
+ fs.writeFileSync(nodePath.join(dir, 'cfg.txt'), 'human wip\n'); // baseline-dirty
330
+ return { dir, base };
331
+ }
332
+
333
+ function commitPath(dir, rel, msg) {
334
+ fs.writeFileSync(nodePath.join(dir, rel), `${msg}\n`);
335
+ gitIn(dir, 'add', rel);
336
+ gitIn(dir, 'commit', '-q', '-m', msg);
337
+ return gitIn(dir, 'rev-parse', 'HEAD');
338
+ }
339
+
340
+ test('checkSharedTreeGuard: landedCommit still an ancestor of HEAD plus an unrelated later commit is NOT reverted', async () => {
341
+ const { dir, base } = fixtureRepo();
342
+ const landed = commitPath(dir, 'job.txt', 'job work');
343
+ gitIn(dir, 'checkout', '--', 'cfg.txt'); // baseline-dirty path goes clean, no commit covers it
344
+ commitPath(dir, 'later.txt', 'unrelated human commit');
345
+
346
+ const result = await checkSharedTreeGuard({
347
+ cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
348
+ headBefore: base, slug: 'job-a', landedCommit: landed,
349
+ });
350
+ expect(result).toBeNull();
351
+ });
352
+
353
+ test('checkSharedTreeGuard: landedCommit absent from history still parks as reverted', async () => {
354
+ const { dir, base } = fixtureRepo();
355
+ const landed = commitPath(dir, 'job.txt', 'job work');
356
+ gitIn(dir, 'reset', '-q', '--hard', base); // job commit discarded
357
+ commitPath(dir, 'later.txt', 'unrelated human commit');
358
+
359
+ const result = await checkSharedTreeGuard({
360
+ cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
361
+ headBefore: base, slug: 'job-b', landedCommit: landed,
362
+ });
363
+ expect(result).toEqual({ reverted: ['cfg.txt'] });
364
+ });
365
+
366
+ test('checkSharedTreeGuard: no landedCommit at all still parks as reverted', async () => {
367
+ const { dir, base } = fixtureRepo();
368
+ gitIn(dir, 'checkout', '--', 'cfg.txt');
369
+ const result = await checkSharedTreeGuard({
370
+ cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
371
+ headBefore: base, slug: 'job-c', landedCommit: null,
372
+ });
373
+ expect(result).toEqual({ reverted: ['cfg.txt'] });
374
+ });
375
+
376
+ test('landedCommitIsAncestorOfHead: empty/unknown sha is false, never throws', async () => {
377
+ const { dir } = fixtureRepo();
378
+ expect(await scheduler.landedCommitIsAncestorOfHead(dir, '')).toBe(false);
379
+ expect(await scheduler.landedCommitIsAncestorOfHead(dir, 'deadbeefdeadbeef')).toBe(false);
380
+ });
@@ -110,3 +110,17 @@ test('findStaleQuarantinedJobs: fires past threshold, not before', () => {
110
110
  assert.deepStrictEqual(slugs, ['exactly-at-threshold', 'stale']);
111
111
  assert.strictEqual(stale.find((s) => s.slug === 'stale').cwd, '/p1');
112
112
  });
113
+
114
+ test('computeStallSummary: a fully drained project (all completed) is idle, not stalled', () => {
115
+ const state = {
116
+ paused: null,
117
+ jobs: [
118
+ { slug: 'a', cwd: '/p1', status: 'completed' },
119
+ { slug: 'b', cwd: '/p2', status: 'running' },
120
+ ],
121
+ invalidJobs: [],
122
+ };
123
+ const summary = computeStallSummary(state);
124
+ assert.strictEqual(summary.byProject['/p1'].stalled, false);
125
+ assert.strictEqual(computeStallSummary({ paused: null, jobs: [{ slug: 'a', cwd: '/p1', status: 'completed' }], invalidJobs: [] }).stalled, false);
126
+ });
@@ -39,7 +39,7 @@ const {
39
39
  STARVE_ESCALATION_MS,
40
40
  attachWindow,
41
41
  } = require('../scheduler.cjs');
42
- const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
42
+ const { auditLogPath } = require('../lib/auditLog.cjs');
43
43
 
44
44
  afterAll(() => {
45
45
  process.env.HOME = originalHome;
@@ -52,7 +52,7 @@ const CWD_B = '/home/bilko/Projects/session-manager';
52
52
  function readAuditKind(kind) {
53
53
  let lines;
54
54
  try {
55
- lines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').split('\n').filter(Boolean);
55
+ lines = fs.readFileSync(auditLogPath(), 'utf8').split('\n').filter(Boolean);
56
56
  } catch {
57
57
  return [];
58
58
  }
@@ -75,8 +75,8 @@ function fakeWindow() {
75
75
  beforeEach(() => {
76
76
  // Fresh scratch audit log per test so escalation counts never bleed across
77
77
  // tests in this file.
78
- fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
79
- fs.writeFileSync(AUDIT_LOG_PATH, '');
78
+ fs.mkdirSync(path.dirname(auditLogPath()), { recursive: true });
79
+ fs.writeFileSync(auditLogPath(), '');
80
80
  attachWindow(null);
81
81
  });
82
82