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
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * schedulerPaths.cjs — single resolver for every machine-wide, home-rooted
5
+ * scheduler path. Plain Node (no electron) so queueStore.cjs and the watchdog
6
+ * can load it.
7
+ *
8
+ * Every export is a LAZY function: the root is read from the environment at
9
+ * point of use, never at require time, so a test (or a second instance) that
10
+ * sets SM_SCHEDULER_HOME redirects ALL scheduler state — queue shards, machine
11
+ * state, runs, PRDs, heartbeat, slot config, admin token — with no
12
+ * half-redirect. SM_SCHEDULER_HOME set on the app propagates to spawned
13
+ * executor jobs BY DESIGN (children inherit env), so a redirected app runs
14
+ * redirected jobs.
15
+ *
16
+ * Worktree roots are NOT under schedulerHome (they live on the tmp filesystem)
17
+ * but are resolved here all the same, via SM_WORKTREE_ROOT (else os.tmpdir()),
18
+ * so the job and epic roots always move together under one override.
19
+ */
20
+
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+
24
+ function isUnder(child, parent) {
25
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
26
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
27
+ }
28
+
29
+ /**
30
+ * Test-time fail-closed guard (same shape as opsOwnership.assertOpsWrite): under
31
+ * vitest (process.env.VITEST) a resolved path must sit under SM_SCHEDULER_HOME,
32
+ * SM_WORKTREE_ROOT, or os.tmpdir() — anything else is a LIVE root (real ~/.claude,
33
+ * real home) that a unit test must never touch. Returns `resolved` so resolvers
34
+ * can `return assertNotLiveRoot(...)`. O(#roots) per call.
35
+ */
36
+ function assertNotLiveRoot(resolved, resolverName) {
37
+ if (!process.env.VITEST) return resolved;
38
+ // Narrow, per-test opt-in for a READ-ONLY probe of this machine's real user config
39
+ // (health-delegation-chain.test.cjs). Never set it around anything that writes or sweeps.
40
+ if (process.env.SM_ALLOW_LIVE_ROOT_READS === '1') return resolved;
41
+ // HOLE CLOSED (2026-09-18 defaultCwd leak): the allow-list below is env-derived, and so was
42
+ // the notion of "live" — both follow process.env.HOME / SM_SCHEDULER_HOME, which a test (or an
43
+ // env inherited from the live app) freely reassigns. A live SM_SCHEDULER_HOME therefore
44
+ // ALLOW-LISTED the very root the guard exists to refuse, and a HOME swapped mid-test moved
45
+ // "live" out from under it. The passwd-database home (os.userInfo(), immune to $HOME) is the
46
+ // one anchor a test cannot rewrite: nothing under its ~/.claude may be touched, whatever the
47
+ // allow-list says.
48
+ if (isLivePasswdRoot(resolved)) throw liveRootError(resolved, resolverName);
49
+ const roots = [process.env.SM_SCHEDULER_HOME, process.env.SM_WORKTREE_ROOT, os.tmpdir()].filter(Boolean);
50
+ if (roots.some((r) => isUnder(resolved, r))) return resolved;
51
+ throw liveRootError(resolved, resolverName);
52
+ }
53
+
54
+ function liveRootError(resolved, resolverName) {
55
+ return new Error(
56
+ `schedulerPaths.${resolverName}() resolved to live root ${resolved} under vitest — ` +
57
+ 'set SM_SCHEDULER_HOME / SM_WORKTREE_ROOT / HOME to a temp dir (tests/setup/schedulerSandbox.cjs does this per run)',
58
+ );
59
+ }
60
+
61
+ /** True when `resolved` sits under the passwd-home's ~/.claude (HOME-env independent). */
62
+ function isLivePasswdRoot(resolved) {
63
+ let home;
64
+ try { home = os.userInfo().homedir; } catch { return false; }
65
+ if (!home) return false;
66
+ // A sandbox that genuinely lives under the passwd home's tmp is not live; only ~/.claude is.
67
+ return isUnder(resolved, path.join(home, '.claude'));
68
+ }
69
+
70
+ /** SM_SCHEDULER_HOME, else ~/.claude/session-manager. */
71
+ function schedulerHome() {
72
+ return assertNotLiveRoot(
73
+ process.env.SM_SCHEDULER_HOME || path.join(os.homedir(), '.claude', 'session-manager'),
74
+ 'schedulerHome',
75
+ );
76
+ }
77
+
78
+ /** ~/.claude — Claude Code's own config dir (NOT redirected by SM_SCHEDULER_HOME). */
79
+ function claudeHome() { return assertNotLiveRoot(path.join(os.homedir(), '.claude'), 'claudeHome'); }
80
+ function claudeProjectsDir() { return assertNotLiveRoot(path.join(claudeHome(), 'projects'), 'claudeProjectsDir'); }
81
+
82
+ function scheduledPlansRoot() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduled-plans'), 'scheduledPlansRoot'); }
83
+ function runsDir() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'runs'), 'runsDir'); }
84
+ function prdsRoot() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'prds'), 'prdsRoot'); }
85
+ function machineStatePath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-machine.json'), 'machineStatePath'); }
86
+ function legacyQueuePath() { return assertNotLiveRoot(path.join(scheduledPlansRoot(), 'queue.json'), 'legacyQueuePath'); }
87
+ function schedulerStatePath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-state.json'), 'schedulerStatePath'); }
88
+ function heartbeatPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'scheduler-heartbeat.log'), 'heartbeatPath'); }
89
+ function sessionSlotsConfigPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'session-slots-config.json'), 'sessionSlotsConfigPath'); }
90
+ function watchdogLogsDir() { return assertNotLiveRoot(path.join(schedulerHome(), 'logs'), 'watchdogLogsDir'); }
91
+ function restartRequestPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'restart-request.json'), 'restartRequestPath'); }
92
+ function restartingMarkerPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'restarting.json'), 'restartingMarkerPath'); }
93
+ function watchdogRelaunchStatePath() { return assertNotLiveRoot(path.join(schedulerHome(), 'watchdog-relaunch-state.json'), 'watchdogRelaunchStatePath'); }
94
+ // Sibling of schedulerHome() (i.e. ~/.claude/logs by default), not inside it.
95
+ function watchdogRelaunchLogPath() {
96
+ return assertNotLiveRoot(
97
+ path.join(path.dirname(schedulerHome()), 'logs', 'scheduler-watchdog-relaunch.log'),
98
+ 'watchdogRelaunchLogPath',
99
+ );
100
+ }
101
+ function historyRollupStampPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'history-rollup.stamp'), 'historyRollupStampPath'); }
102
+ function historyRollupLockPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'history-rollup.lock'), 'historyRollupLockPath'); }
103
+ function historyRollupPath() { return path.join(schedulerHome(), 'history-rollup.jsonl'); }
104
+ function auditLogPath() { return path.join(schedulerHome(), 'audit-log.jsonl'); }
105
+ function procnamesRoot() { return path.join(schedulerHome(), 'procnames'); }
106
+ /** Legacy global history sidecar (read-only; new appends go to per-project shards). */
107
+ function queueHistoryPath() { return path.join(scheduledPlansRoot(), 'history.jsonl'); }
108
+ function instanceLockPath() { return path.join(schedulerHome(), 'scheduler-owner.lock'); }
109
+ /** Where heap snapshots are written — the scheduler home itself. */
110
+ function heapSnapshotDir() { return schedulerHome(); }
111
+
112
+ /** SM_WORKTREE_ROOT, else os.tmpdir() — the parent of every managed worktree root. */
113
+ function worktreeBase() { return assertNotLiveRoot(process.env.SM_WORKTREE_ROOT || os.tmpdir(), 'worktreeBase'); }
114
+
115
+ /** Managed worktree root for `kind` ('job' | 'epic'), resolved fresh on every call. */
116
+ function worktreeRoot(kind) {
117
+ return assertNotLiveRoot(path.join(worktreeBase(), `session-manager-${kind}-worktrees`), 'worktreeRoot');
118
+ }
119
+
120
+ /**
121
+ * Mode-aware admin token path, resolved fresh on every call. Precedence:
122
+ * SM_ADMIN_TOKEN_PATH (explicit override) > SM_DEV > SM_E2E > production file.
123
+ * index.cjs treats SM_DEV and SM_E2E as one OR'd boolean; if both are set the
124
+ * SM_DEV path wins (arbitrary but harmless — real launches set one).
125
+ */
126
+ function adminTokenPath() {
127
+ if (process.env.SM_ADMIN_TOKEN_PATH) return assertNotLiveRoot(process.env.SM_ADMIN_TOKEN_PATH, 'adminTokenPath');
128
+ if (process.env.SM_DEV === '1') return assertNotLiveRoot(path.join(schedulerHome(), 'admin-api.dev.json'), 'adminTokenPath');
129
+ if (process.env.SM_E2E === '1') return assertNotLiveRoot(path.join(schedulerHome(), 'admin-api.e2e.json'), 'adminTokenPath');
130
+ return assertNotLiveRoot(path.join(schedulerHome(), 'admin-api.json'), 'adminTokenPath');
131
+ }
132
+
133
+ /**
134
+ * cwd that machine-level scheduler errors are attributed to (they have no
135
+ * owning project). SM_SCHEDULER_LOG_CWD, else the literal
136
+ * ~/Projects/session-manager — deliberately NOT inferred from process.cwd().
137
+ */
138
+ function machineStateLogCwd() {
139
+ return assertNotLiveRoot(
140
+ process.env.SM_SCHEDULER_LOG_CWD || path.join(os.homedir(), 'Projects', 'session-manager'),
141
+ 'machineStateLogCwd',
142
+ );
143
+ }
144
+
145
+ module.exports = {
146
+ assertNotLiveRoot,
147
+ claudeHome,
148
+ claudeProjectsDir,
149
+ schedulerHome,
150
+ scheduledPlansRoot,
151
+ runsDir,
152
+ prdsRoot,
153
+ machineStatePath,
154
+ legacyQueuePath,
155
+ schedulerStatePath,
156
+ heartbeatPath,
157
+ sessionSlotsConfigPath,
158
+ adminTokenPath,
159
+ machineStateLogCwd,
160
+ worktreeBase,
161
+ worktreeRoot,
162
+ watchdogLogsDir,
163
+ restartRequestPath,
164
+ restartingMarkerPath,
165
+ watchdogRelaunchStatePath,
166
+ watchdogRelaunchLogPath,
167
+ historyRollupStampPath,
168
+ historyRollupLockPath,
169
+ historyRollupPath,
170
+ auditLogPath,
171
+ procnamesRoot,
172
+ queueHistoryPath,
173
+ instanceLockPath,
174
+ heapSnapshotDir,
175
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * schedulerRuntimeState.cjs — process-local runtime reservations that used to
3
+ * be bare ++/-- counters in scheduler.cjs. The auto-fix investigation cap is
4
+ * now a Map of slug → { claimedAt, pid } so a reservation stranded by a
5
+ * promise that never settles can be expired when its owner is provably dead
6
+ * (reservationExpiry.isProvablyDead), instead of shrinking the cap forever.
7
+ */
8
+ 'use strict';
9
+
10
+ const { appendAuditEvent } = require('./auditLog.cjs');
11
+ const { isProvablyDead, DEFAULT_GRACE_MS } = require('./reservationExpiry.cjs');
12
+
13
+ const investigations = new Map(); // slug -> { claimedAt, pid }
14
+
15
+ function investigationCount() { return investigations.size; }
16
+
17
+ /** reserveInvestigation(slug) — false if already reserved. Cap checks stay with the caller. */
18
+ function reserveInvestigation(slug, { claimedAt = Date.now() } = {}) {
19
+ const key = String(slug);
20
+ if (investigations.has(key)) return false;
21
+ investigations.set(key, { claimedAt, pid: null });
22
+ return true;
23
+ }
24
+
25
+ function stampInvestigationPid(slug, pid) {
26
+ const r = investigations.get(String(slug));
27
+ if (!r || !Number.isInteger(pid) || pid <= 0) return false;
28
+ r.pid = pid;
29
+ return true;
30
+ }
31
+
32
+ /** releaseInvestigation — idempotent; returns whether a reservation was removed. */
33
+ function releaseInvestigation(slug) {
34
+ return investigations.delete(String(slug));
35
+ }
36
+
37
+ /** Expire investigations whose slug has no `investigating` row and whose owner is provably dead. */
38
+ function expireDeadInvestigations({ liveSlugs, pidAlive, now = Date.now(), graceMs = DEFAULT_GRACE_MS } = {}) {
39
+ const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || []);
40
+ const expired = [];
41
+ for (const [slug, r] of [...investigations]) {
42
+ if (!isProvablyDead(r, { live: live.has(slug), pidAlive, now, graceMs })) continue;
43
+ investigations.delete(slug);
44
+ expired.push(slug);
45
+ appendAuditEvent('investigation_reservation_expired', { slug, pid: r.pid });
46
+ }
47
+ return expired;
48
+ }
49
+
50
+ function __resetForTests() { investigations.clear(); }
51
+
52
+ module.exports = {
53
+ investigationCount,
54
+ reserveInvestigation,
55
+ stampInvestigationPid,
56
+ releaseInvestigation,
57
+ expireDeadInvestigations,
58
+ __resetForTests,
59
+ };
@@ -25,14 +25,15 @@
25
25
 
26
26
  const crypto = require('node:crypto');
27
27
  const fs = require('node:fs');
28
- const os = require('node:os');
29
28
  const path = require('node:path');
29
+ const { sessionSlotsConfigPath } = require('./schedulerPaths.cjs');
30
+ const { appendAuditEvent } = require('./auditLog.cjs');
31
+ const { isProvablyDead, DEFAULT_GRACE_MS } = require('./reservationExpiry.cjs');
30
32
 
31
33
  const MIN_SLOTS = 0;
32
34
  const MAX_SLOTS = 10;
33
35
  const DEFAULT_SLOTS = 5;
34
36
 
35
- const CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'session-slots-config.json');
36
37
 
37
38
  function clamp(n) {
38
39
  return Math.min(MAX_SLOTS, Math.max(MIN_SLOTS, n));
@@ -40,7 +41,7 @@ function clamp(n) {
40
41
 
41
42
  function readPersistedCap() {
42
43
  try {
43
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
44
+ const raw = fs.readFileSync(sessionSlotsConfigPath(), 'utf8');
44
45
  const parsed = JSON.parse(raw);
45
46
  const cap = Number(parsed.cap);
46
47
  return Number.isFinite(cap) ? clamp(Math.trunc(cap)) : DEFAULT_SLOTS;
@@ -55,10 +56,11 @@ function setCap(cap) {
55
56
  if (!Number.isFinite(n) || Math.trunc(n) !== n || n < MIN_SLOTS || n > MAX_SLOTS) {
56
57
  throw new Error(`sessionSlots.setCap: cap must be an integer in [${MIN_SLOTS}, ${MAX_SLOTS}]`);
57
58
  }
58
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
59
- const tmp = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
59
+ const configPath = sessionSlotsConfigPath();
60
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
61
+ const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`;
60
62
  fs.writeFileSync(tmp, JSON.stringify({ cap: n }, null, 2));
61
- fs.renameSync(tmp, CONFIG_PATH);
63
+ fs.renameSync(tmp, configPath);
62
64
  for (const fn of listeners) {
63
65
  try { fn(); } catch { /* a consumer's pump error is its own problem */ }
64
66
  }
@@ -73,7 +75,7 @@ function totalSlots() {
73
75
  return readPersistedCap();
74
76
  }
75
77
 
76
- // token → { owner, at }
78
+ // token → { owner, at, claimedAt (ms), pid (null until stampPid) }
77
79
  const holders = new Map();
78
80
 
79
81
  function inUse() {
@@ -89,13 +91,47 @@ function available() {
89
91
  * `owner` is a diagnostic label ("scheduler:<slug>", "chat:<tabId>") shown in
90
92
  * snapshot() so a stuck holder is attributable.
91
93
  */
92
- function acquire(owner) {
94
+ function acquire(owner, { claimedAt = Date.now() } = {}) {
93
95
  if (holders.size >= totalSlots()) return null;
94
96
  const token = crypto.randomUUID();
95
- holders.set(token, { owner: String(owner || 'unknown'), at: new Date().toISOString() });
97
+ holders.set(token, { owner: String(owner || 'unknown'), at: new Date(claimedAt).toISOString(), claimedAt, pid: null });
96
98
  return token;
97
99
  }
98
100
 
101
+ /** stampPid(token, pid) — record the child pid once known, so expireDead can prove death by pid. */
102
+ function stampPid(token, pid) {
103
+ const h = holders.get(token);
104
+ if (!h || !Number.isInteger(pid) || pid <= 0) return false;
105
+ h.pid = pid;
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * expireDead — release `scheduler:` reservations whose owner is provably dead
111
+ * (see reservationExpiry.isProvablyDead). `chat:` / `project-brief:` tokens are
112
+ * never touched. Accounting only: no process is signalled. Returns the released
113
+ * owners. Each release is audited once (the token is gone after release).
114
+ * @param {{ liveSlugs: Set<string>, pidAlive?: Function, now?: number, graceMs?: number }} opts
115
+ */
116
+ function expireDead({ liveSlugs, pidAlive, now = Date.now(), graceMs = DEFAULT_GRACE_MS } = {}) {
117
+ const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || []);
118
+ const expired = [];
119
+ for (const [token, h] of [...holders]) {
120
+ if (!h.owner.startsWith('scheduler:')) continue;
121
+ const slug = h.owner.slice('scheduler:'.length);
122
+ if (!isProvablyDead(h, { live: live.has(slug), pidAlive, now, graceMs })) continue;
123
+ holders.delete(token);
124
+ expired.push(h.owner);
125
+ appendAuditEvent('slot_reservation_expired', { owner: h.owner, pid: h.pid, claimedAt: h.at });
126
+ }
127
+ if (expired.length) {
128
+ for (const fn of listeners) {
129
+ try { fn(); } catch { /* a consumer's pump error is its own problem */ }
130
+ }
131
+ }
132
+ return expired;
133
+ }
134
+
99
135
  // Release listeners: each consumer registers its own "a slot freed — try to
100
136
  // start work" pump so a scheduler release wakes the chat lane and vice versa.
101
137
  // Also fired when the cap itself increases (setCap), for the same reason.
@@ -143,6 +179,8 @@ module.exports = {
143
179
  inUse,
144
180
  available,
145
181
  acquire,
182
+ stampPid,
183
+ expireDead,
146
184
  release,
147
185
  subscribe,
148
186
  snapshot,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * smProcNames.cjs — kernel `comm` names for the processes Session Manager
3
+ * itself leaves in the user's process list (pn-03). `claude` children are
4
+ * named separately (procName.cjs smArgv0 / pn-02).
5
+ *
6
+ * Every name is <= 15 chars (Linux TASK_COMM_LEN). `session-manager` is
7
+ * EXACTLY 15, so NEVER derive another name from it by suffixing — it would
8
+ * truncate back to `session-manager` and be indistinguishable from main.
9
+ */
10
+ 'use strict';
11
+
12
+ const path = require('node:path');
13
+ const { aliasBinFor } = require('./procName.cjs');
14
+
15
+ const PROC_NAMES = Object.freeze({
16
+ main: 'session-manager',
17
+ mcpServer: 'sm-mcp-server',
18
+ watchdog: 'sm-watchdog',
19
+ inhibitHold: 'sm-inhibit-hold',
20
+ shell: 'sm-shell',
21
+ });
22
+
23
+ // Shells whose argv0-basename behaviour (sh/rsh/ksh emulation, $0-branching rc
24
+ // files) is known to be safe under the `sm-shell` alias. Anything else spawns
25
+ // the real path — never guess at another shell's argv0 semantics.
26
+ const ALIASABLE_SHELLS = new Set(['bash', 'zsh']);
27
+
28
+ /**
29
+ * On Linux Node's process.title setter calls prctl(PR_SET_NAME), changing
30
+ * `comm`. It overwrites the argv memory region, but process.argv is a JS
31
+ * array copied at startup and is unaffected. Never throws.
32
+ */
33
+ function setProcessTitle(name) {
34
+ try { process.title = name; } catch { /* naming must never break boot */ }
35
+ }
36
+
37
+ /** Alias `shell` as `sm-shell` iff it is bash/zsh; otherwise return it unchanged. Fail-open. */
38
+ function aliasedShellBin(shell) {
39
+ try {
40
+ if (typeof shell !== 'string' || !shell) return shell;
41
+ if (!ALIASABLE_SHELLS.has(path.basename(shell))) return shell;
42
+ return aliasBinFor(shell, PROC_NAMES.shell);
43
+ } catch { return shell; }
44
+ }
45
+
46
+ /** `sh` aliased as `sm-inhibit-hold` for the systemd-inhibit poll holder. Fail-open. */
47
+ function inhibitHolderShell() {
48
+ try { return aliasBinFor('/bin/sh', PROC_NAMES.inhibitHold); } catch { return '/bin/sh'; }
49
+ }
50
+
51
+ module.exports = { PROC_NAMES, ALIASABLE_SHELLS, setProcessTitle, aliasedShellBin, inhibitHolderShell };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * upgradeDrain.cjs — restart as a REQUEST that drains first and never kills.
5
+ *
6
+ * requestRestart() → writes <schedulerHome>/restart-request.json (atomic).
7
+ * evaluateDrain() → PURE state machine: none | pause | wait | restart | abort.
8
+ * scheduler.cjs → runs it from the existing 60 s heartbeat interval (no
9
+ * new driver) and owns the `drain` field on the machine
10
+ * runtime file. `drain` is deliberately NOT `state.paused`:
11
+ * a rate-limit pause can't overwrite it and the manual-clear
12
+ * cooldown (isCooldownSuppressed) can't swallow it.
13
+ *
14
+ * Lifecycle: request → pause (drain.active) → wait (running/investigating rows
15
+ * finish; nothing new dispatches) → restart (zero busy, checked inside a mutate
16
+ * right before exit) | abort (deadline; retires the request, clears the drain).
17
+ * The request is stamped drainCompletedAt before exit; the next boot clears a
18
+ * leftover drain whose request is complete.
19
+ *
20
+ * A `restarting.json` marker with a bounded lifetime covers the deliberate exit
21
+ * window so scripts/scheduler-watchdog.cjs does not relaunch a second instance.
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const schedulerPaths = require('./schedulerPaths.cjs');
27
+
28
+ // Observed max job run is 240 min; 4 h converts a stuck drain to an abort.
29
+ const DEFAULT_DRAIN_DEADLINE_MS = 4 * 60 * 60_000;
30
+ // Long enough for a cold `npx` resolve + boot, short enough that a failed
31
+ // relaunch is picked up by the watchdog again.
32
+ const RESTARTING_MARKER_TTL_MS = 5 * 60_000;
33
+
34
+ function writeJsonAtomicSync(file, value) {
35
+ fs.mkdirSync(path.dirname(file), { recursive: true });
36
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
37
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
38
+ fs.renameSync(tmp, file);
39
+ }
40
+
41
+ function readJsonSafe(file) {
42
+ try {
43
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
44
+ return v && typeof v === 'object' ? v : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ // ---------- request file ----------
51
+
52
+ function readRestartRequest(file = schedulerPaths.restartRequestPath()) {
53
+ return readJsonSafe(file);
54
+ }
55
+
56
+ /**
57
+ * requestRestart({reason, requestedBy}) → the request written. Idempotent: an
58
+ * already-pending (not completed) request is returned unchanged so a repeated
59
+ * click or the auto trigger can never reset the drain deadline.
60
+ */
61
+ function requestRestart({ reason, requestedBy } = {}, { file = schedulerPaths.restartRequestPath(), now = Date.now() } = {}) {
62
+ const existing = readRestartRequest(file);
63
+ if (existing && !existing.drainCompletedAt) return existing;
64
+ const request = {
65
+ reason: typeof reason === 'string' && reason ? reason : 'unspecified',
66
+ requestedBy: typeof requestedBy === 'string' && requestedBy ? requestedBy : 'unknown',
67
+ requestedAt: new Date(now).toISOString(),
68
+ };
69
+ writeJsonAtomicSync(file, request);
70
+ return request;
71
+ }
72
+
73
+ /** Stamp drainCompletedAt (called immediately before exit). */
74
+ function stampDrainCompleted(now = Date.now(), file = schedulerPaths.restartRequestPath()) {
75
+ const existing = readRestartRequest(file);
76
+ if (!existing) return null;
77
+ const stamped = { ...existing, drainCompletedAt: new Date(now).toISOString() };
78
+ writeJsonAtomicSync(file, stamped);
79
+ return stamped;
80
+ }
81
+
82
+ function retireRestartRequest(file = schedulerPaths.restartRequestPath()) {
83
+ try { fs.unlinkSync(file); } catch (e) { if (e?.code !== 'ENOENT') throw e; }
84
+ }
85
+
86
+ // ---------- pure state machine ----------
87
+
88
+ /**
89
+ * queueBusyCount(jobs, extraInFlight?) → running + investigating rows, plus any
90
+ * in-memory investigations the queue file cannot see.
91
+ */
92
+ function queueBusyCount(jobs, extraInFlight = 0) {
93
+ let n = 0;
94
+ for (const j of Array.isArray(jobs) ? jobs : []) {
95
+ if (j && (j.status === 'running' || j.status === 'investigating')) n++;
96
+ }
97
+ return n + (Number.isFinite(extraInFlight) ? extraInFlight : 0);
98
+ }
99
+
100
+ /**
101
+ * evaluateDrain({request, queueSnapshot, drainState, now, deadlineMs}) → {action, reason}
102
+ *
103
+ * queueSnapshot: { running, investigating } counts (in-memory investigations
104
+ * already folded in by the caller). drainState: the machine `drain` field
105
+ * (null / {active}). Pure — no IO, no clock.
106
+ *
107
+ * no request, or request already completed → none (abort if a stale drain is still active)
108
+ * deadline passed → abort
109
+ * request pending, drain not yet active → pause (engage drain)
110
+ * drain active, anything running/investigating → wait
111
+ * drain active, zero busy → restart
112
+ */
113
+ function evaluateDrain({ request, queueSnapshot, drainState, now, deadlineMs = DEFAULT_DRAIN_DEADLINE_MS } = {}) {
114
+ const active = Boolean(drainState && drainState.active);
115
+ if (!request || request.drainCompletedAt) {
116
+ return active && !request
117
+ ? { action: 'abort', reason: 'drain-without-request' }
118
+ : { action: 'none', reason: request ? 'request-complete' : 'no-request' };
119
+ }
120
+ const requestedMs = Date.parse(request.requestedAt ?? '');
121
+ if (Number.isFinite(requestedMs) && now - requestedMs >= deadlineMs) {
122
+ return { action: 'abort', reason: 'deadline' };
123
+ }
124
+ if (!active) return { action: 'pause', reason: 'engage' };
125
+ const busy = (queueSnapshot?.running ?? 0) + (queueSnapshot?.investigating ?? 0);
126
+ return busy > 0 ? { action: 'wait', reason: `busy:${busy}` } : { action: 'restart', reason: 'idle' };
127
+ }
128
+
129
+ /**
130
+ * bootDrainAction({drainState, request}) → 'clear' | 'keep' | 'none'.
131
+ * A drain whose request is complete (the restart happened) or gone is stale.
132
+ */
133
+ function bootDrainAction({ drainState, request } = {}) {
134
+ if (!drainState || !drainState.active) return 'none';
135
+ if (!request || request.drainCompletedAt) return 'clear';
136
+ return 'keep';
137
+ }
138
+
139
+ /** The pause-like view every dispatch classifier must use: paused OR draining. */
140
+ function effectivePaused(state) {
141
+ if (state?.paused) return state.paused;
142
+ return state?.drain?.active ? { reason: 'drain-for-upgrade' } : null;
143
+ }
144
+
145
+ // ---------- automatic trigger ----------
146
+
147
+ /**
148
+ * installedBuildDiffers({running, installed}) — true only when BOTH shas are
149
+ * known and differ (an install/update already happened). Never consults npm.
150
+ */
151
+ function installedBuildDiffers({ running, installed } = {}) {
152
+ return typeof running === 'string' && running !== ''
153
+ && typeof installed === 'string' && installed !== ''
154
+ && running !== installed;
155
+ }
156
+
157
+ // ---------- restarting marker (watchdog) ----------
158
+
159
+ function markRestarting({ file = schedulerPaths.restartingMarkerPath(), now = Date.now(), ttlMs = RESTARTING_MARKER_TTL_MS } = {}) {
160
+ writeJsonAtomicSync(file, { pid: process.pid, at: now, expiresAt: now + ttlMs });
161
+ }
162
+
163
+ function clearRestartingMarker(file = schedulerPaths.restartingMarkerPath()) {
164
+ try { fs.unlinkSync(file); } catch { /* absent */ }
165
+ }
166
+
167
+ /** True while the marker exists and has not expired (bounded lifetime). */
168
+ function isRestartingMarkerActive({ file = schedulerPaths.restartingMarkerPath(), now = Date.now() } = {}) {
169
+ const m = readJsonSafe(file);
170
+ return Boolean(m) && typeof m.expiresAt === 'number' && now < m.expiresAt;
171
+ }
172
+
173
+ module.exports = {
174
+ DEFAULT_DRAIN_DEADLINE_MS,
175
+ RESTARTING_MARKER_TTL_MS,
176
+ readRestartRequest,
177
+ requestRestart,
178
+ stampDrainCompleted,
179
+ retireRestartRequest,
180
+ queueBusyCount,
181
+ evaluateDrain,
182
+ bootDrainAction,
183
+ effectivePaused,
184
+ installedBuildDiffers,
185
+ markRestarting,
186
+ clearRestartingMarker,
187
+ isRestartingMarkerActive,
188
+ };