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
@@ -24,6 +24,7 @@ let originalJobDisable;
24
24
  let originalJobMax;
25
25
  let originalEpicDisable;
26
26
  let originalEpicMax;
27
+ let originalWorktreeRoot;
27
28
 
28
29
  function git(args, cwd) {
29
30
  return execFileSync('git', args, { cwd, encoding: 'utf8' });
@@ -43,6 +44,9 @@ beforeEach(() => {
43
44
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-'));
44
45
  repoCwd = path.join(tmpRoot, 'repo');
45
46
  initRepo(repoCwd);
47
+ // Every sweep below runs against THIS throwaway root, never the live job-worktree root.
48
+ originalWorktreeRoot = process.env.SM_WORKTREE_ROOT;
49
+ process.env.SM_WORKTREE_ROOT = path.join(tmpRoot, 'worktrees');
46
50
  originalJobDisable = process.env.SM_JOB_WORKTREE_DISABLE;
47
51
  originalJobMax = process.env.SM_JOB_WORKTREE_MAX;
48
52
  originalEpicDisable = process.env.SM_EPIC_WORKTREE_DISABLE;
@@ -65,6 +69,7 @@ afterEach(async () => {
65
69
  // Best-effort: prune any worktree either kind's tests created before removing the repo.
66
70
  try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' }); } catch { /* ignore */ }
67
71
  try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' }); } catch { /* ignore */ }
72
+ restore('SM_WORKTREE_ROOT', originalWorktreeRoot);
68
73
  fs.rmSync(tmpRoot, { recursive: true, force: true });
69
74
  });
70
75
 
@@ -1043,7 +1048,9 @@ test('[job] sweepStaleWorktreeCheckouts does NOT remove an old-mtime checkout wi
1043
1048
  test('[job] sweepStaleWorktreeCheckouts removes an old-mtime checkout when there is no live holder', async () => {
1044
1049
  const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-and-dead' });
1045
1050
  expect(worktree.ok).toBe(true);
1046
- const holders = new Set(); // nothing holds it
1051
+ // A populated holders set (some unrelated live cwd) that does not cover this checkout —
1052
+ // exercises the liveness gate for real instead of defeating it with an empty set.
1053
+ const holders = new Set([path.resolve(tmpRoot, 'unrelated-live-holder')]);
1047
1054
 
1048
1055
  const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
1049
1056
 
@@ -1,17 +1,49 @@
1
1
  // PRD 834 — machine-wide scheduler-ownership lock. Secondary instances must
2
2
  // go scheduler-passive instead of running boot reconciliation / admin server
3
3
  // against the owner's queue state (live incident 2026-07-31).
4
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4
+ //
5
+ // HOME is stubbed to a mkdtemp dir BEFORE requiring instanceLock.cjs (mirrors
6
+ // scheduler-reconcile-invalid-repair.test.cjs): instanceLock.cjs pulls in
7
+ // auditLog.cjs, whose audit log path resolves from
8
+ // os.homedir() — so real ~/.claude/session-manager state must never be
9
+ // touched by this suite.
10
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
5
11
  import fs from 'node:fs';
6
12
  import os from 'node:os';
7
13
  import path from 'node:path';
8
14
 
9
- const {
10
- acquireSchedulerOwnership,
11
- releaseSchedulerOwnership,
12
- pidAlive,
13
- lockPath,
14
- } = require('../instanceLock.cjs');
15
+ let originalHome;
16
+ let tmpHome;
17
+ let instanceLock;
18
+ let auditLogPath;
19
+
20
+ beforeAll(() => {
21
+ originalHome = process.env.HOME;
22
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-instance-lock-home-'));
23
+ process.env.HOME = tmpHome;
24
+ instanceLock = require('../instanceLock.cjs');
25
+ ({ auditLogPath } = require('../auditLog.cjs'));
26
+ if (!auditLogPath().startsWith(tmpHome)) {
27
+ throw new Error(`refusing to run: auditLogPath() (${auditLogPath()}) is not under the temp HOME (${tmpHome})`);
28
+ }
29
+ });
30
+
31
+ afterAll(() => {
32
+ process.env.HOME = originalHome;
33
+ fs.rmSync(tmpHome, { recursive: true, force: true });
34
+ });
35
+
36
+ // Thin pass-throughs so every test body below can keep calling these as
37
+ // bare names, resolved lazily against the module loaded in beforeAll.
38
+ const acquireSchedulerOwnership = (...args) => instanceLock.acquireSchedulerOwnership(...args);
39
+ const releaseSchedulerOwnership = (...args) => instanceLock.releaseSchedulerOwnership(...args);
40
+ const pidAlive = (...args) => instanceLock.pidAlive(...args);
41
+ const lockPath = (...args) => instanceLock.lockPath(...args);
42
+
43
+ function readAuditEvents() {
44
+ if (!fs.existsSync(auditLogPath())) return [];
45
+ return fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
46
+ }
15
47
 
16
48
  let dir;
17
49
 
@@ -22,7 +54,9 @@ beforeEach(() => {
22
54
 
23
55
  afterEach(() => {
24
56
  delete process.env.SM_SCHEDULER_LOCK_PATH;
57
+ delete process.env.SM_PROC_ROOT;
25
58
  fs.rmSync(dir, { recursive: true, force: true });
59
+ if (fs.existsSync(auditLogPath())) fs.rmSync(auditLogPath(), { force: true });
26
60
  });
27
61
 
28
62
  describe('instanceLock (PRD 834)', () => {
@@ -84,4 +118,56 @@ describe('instanceLock (PRD 834)', () => {
84
118
  expect(pidAlive(-5)).toBe(false);
85
119
  expect(pidAlive(0)).toBe(false);
86
120
  });
121
+
122
+ it('a legacy lock ({pid, startedAt} only, no identity) is respected while its pid is alive', () => {
123
+ // Mirrors the on-disk shape of every lock written before this PRD.
124
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
125
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
126
+ const res = acquireSchedulerOwnership();
127
+ expect(res.owner).toBe(false);
128
+ expect(res.holderPid).toBe(1);
129
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(1);
130
+ // No identity information to compare, so no stale-lock-broken audit event.
131
+ expect(readAuditEvents().some((e) => e.kind === 'stale_lock_broken')).toBe(false);
132
+ });
133
+
134
+ it('a recorded identity that no longer matches the live process at that (recycled) pid is broken, and audited', () => {
135
+ // process.ppid is guaranteed alive for the lifetime of this test (the
136
+ // vitest worker's own parent) — stand-in for "a hard crash left a lock
137
+ // whose pid was later reused by an unrelated live process".
138
+ const recycledPid = process.ppid;
139
+ const procRoot = path.join(dir, 'proc');
140
+ fs.mkdirSync(path.join(procRoot, String(recycledPid)), { recursive: true });
141
+ fs.writeFileSync(
142
+ path.join(procRoot, String(recycledPid), 'stat'),
143
+ `${recycledPid} (bash) S 1 ${recycledPid} ${recycledPid} 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 222222 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0\n`,
144
+ );
145
+ fs.writeFileSync(path.join(procRoot, String(recycledPid), 'cmdline'), 'bash\0');
146
+ process.env.SM_PROC_ROOT = procRoot;
147
+
148
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
149
+ fs.writeFileSync(lockPath(), JSON.stringify({
150
+ pid: recycledPid,
151
+ startedAt: 'x',
152
+ // The identity the crashed scheduler instance actually recorded — a
153
+ // different cmdline/startTicks than the process now holding this pid.
154
+ identity: { pid: recycledPid, startTicks: 111111, cmdline: 'claude --session-id old', complete: true },
155
+ }));
156
+
157
+ const res = acquireSchedulerOwnership();
158
+ expect(res.owner).toBe(true);
159
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid);
160
+
161
+ const auditEvents = readAuditEvents().filter((e) => e.kind === 'stale_lock_broken');
162
+ expect(auditEvents.length).toBe(1);
163
+ expect(auditEvents[0].recordedPid).toBe(recycledPid);
164
+ });
165
+
166
+ it('writeLockExclusive (via acquire) records a complete identity for this process', () => {
167
+ acquireSchedulerOwnership();
168
+ const onDisk = JSON.parse(fs.readFileSync(lockPath(), 'utf8'));
169
+ expect(onDisk.identity).toBeTruthy();
170
+ expect(onDisk.identity.pid).toBe(process.pid);
171
+ expect(onDisk.identity.complete).toBe(true);
172
+ });
87
173
  });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * jobSupervisorRecord.test.cjs — record round-trip, live listing, retention
3
+ * claim, and the pure classifyAdoption matrix.
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/jobSupervisorRecord.test.cjs
6
+ */
7
+ 'use strict';
8
+
9
+ import { test, expect, beforeEach, afterEach } from 'vitest';
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+ const rec = require('../jobSupervisorRecord.cjs');
14
+
15
+ let root;
16
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-supervisor-')); });
17
+ afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
18
+
19
+ const base = (over = {}) => ({
20
+ runDir: path.join(root, 'run-1'), slug: 'p1', cwd: '/x', runId: 'run-1', pid: 42, pgid: 42,
21
+ identity: { pid: 42, startTicks: 7, cmdline: 'claude -p', complete: true },
22
+ execCwd: '/wt', worktreeDir: '/wt', worktreeBranch: 'sm-job/p1', sessionId: 's',
23
+ startedAt: 1_000, budgetMs: 10_000, maxDurationMs: null, idleKillMs: 5_000, schedulerPid: 1, codeSha: 'abc', ...over,
24
+ });
25
+
26
+ test('write → read round-trips every field at <runDir>/<slug>.supervisor.json', () => {
27
+ const input = base();
28
+ rec.writeSupervisorRecord(input);
29
+ expect(fs.existsSync(path.join(input.runDir, 'p1.supervisor.json'))).toBe(true);
30
+ const back = rec.readSupervisorRecord(input.runDir, 'p1');
31
+ const { runDir, ...persisted } = input;
32
+ expect(back).toMatchObject({ ...persisted, kind: 'job' });
33
+ expect(back.runDir).toBeUndefined();
34
+ });
35
+
36
+ test('readSupervisorRecord returns null for absent or torn files', () => {
37
+ expect(rec.readSupervisorRecord(path.join(root, 'nope'), 'p1')).toBeNull();
38
+ fs.mkdirSync(path.join(root, 'r'));
39
+ fs.writeFileSync(path.join(root, 'r', 'p1.supervisor.json'), '{"pid":');
40
+ expect(rec.readSupervisorRecord(path.join(root, 'r'), 'p1')).toBeNull();
41
+ });
42
+
43
+ test('listLiveSupervisorRecords skips exited (meta.json) and stale records', () => {
44
+ rec.writeSupervisorRecord(base({ slug: 'live' }));
45
+ rec.writeSupervisorRecord(base({ slug: 'done' }));
46
+ fs.writeFileSync(path.join(root, 'run-1', 'done.meta.json'), '{}');
47
+ const live = rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 });
48
+ expect(live.map((r) => r.slug)).toEqual(['live']);
49
+ expect(live[0].runDir).toBe(path.join(root, 'run-1'));
50
+ expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 1, now: Date.now() + 10_000 })).toEqual([]);
51
+ expect(rec.listLiveSupervisorRecords(path.join(root, 'missing'), { maxAgeMs: 1 })).toEqual([]);
52
+ });
53
+
54
+ test('investigation records use the .investigation.log exit line as their marker', () => {
55
+ const runDir = path.join(root, 'run-1');
56
+ rec.writeSupervisorRecord(base({ slug: 'p1.investigation', kind: 'investigation' }));
57
+ fs.writeFileSync(path.join(runDir, 'p1.investigation.log'), 'working\n');
58
+ expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 })).toHaveLength(1);
59
+ fs.appendFileSync(path.join(runDir, 'p1.investigation.log'), '\n[scheduler] investigation exit code=0\n');
60
+ expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 })).toEqual([]);
61
+ });
62
+
63
+ test('classifyAdoption matrix', () => {
64
+ const r = base();
65
+ const same = { pid: 42, startTicks: 7, cmdline: 'claude -p', complete: true };
66
+ const other = { pid: 42, startTicks: 99, cmdline: 'claude -p', complete: true };
67
+ const now = 2_000;
68
+ const c = (o) => rec.classifyAdoption(r, { identity: same, pidAlive: true, logMtimeMs: now, exitMarker: false, now, ...o });
69
+ expect(c({})).toBe('adopt');
70
+ expect(c({ pidAlive: false })).toBe('dead');
71
+ expect(c({ pidAlive: false, exitMarker: true })).toBe('exited');
72
+ expect(c({ identity: other })).toBe('foreign-pid');
73
+ expect(c({ identity: { pid: 42, startTicks: null, cmdline: null, complete: false } })).toBe('adopt'); // fail-closed
74
+ expect(c({ now: 1_000 + 10_001, logMtimeMs: 1_000 + 10_000 })).toBe('over-budget');
75
+ expect(c({ now: 1_000 + 5_001, logMtimeMs: 1_000 })).toBe('over-budget'); // log stalled past idleKillMs
76
+ expect(rec.classifyAdoption(base({ budgetMs: null, maxDurationMs: 100 }), { identity: same, pidAlive: true, logMtimeMs: 1_050, now: 1_200 })).toBe('over-budget');
77
+ expect(rec.classifyAdoption(base({ budgetMs: null, idleKillMs: null }), { identity: same, pidAlive: true, now: 9e9 })).toBe('adopt');
78
+ });
@@ -69,3 +69,14 @@ test('a precomputed cwdHolders set is forwarded through to hasLiveHolder unchang
69
69
  isLive('job-d', { worktree: '/tmp/checkout-d' });
70
70
  expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/checkout-d', holders);
71
71
  });
72
+
73
+ test('rowPid (the record/log ladder) is consulted instead of runtime.pid alone', () => {
74
+ const claudePidAlive = vi.fn((pid) => pid === 777);
75
+ const isLive = buildJobWorktreeIsLive({
76
+ bootJobs: [{ slug: 'job-e', status: 'running' }],
77
+ claudePidAlive,
78
+ hasLiveHolder: () => false,
79
+ rowPid: () => 777,
80
+ });
81
+ expect(isLive('job-e', { worktree: '/tmp/checkout-e' })).toBe(true);
82
+ });
@@ -173,7 +173,7 @@ test('with neither SM_DEV nor SM_E2E set, resolves to the original admin-api.jso
173
173
  delete process.env.SM_E2E;
174
174
  delete process.env.SM_ADMIN_TOKEN_PATH;
175
175
  try {
176
- expect(resolveTokenPath()).toBe(TOKEN_PATH);
176
+ expect(resolveTokenPath()).toBe(path.join(require('../schedulerPaths.cjs').schedulerHome(), 'admin-api.json'));
177
177
  } finally {
178
178
  if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
179
179
  if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
@@ -78,12 +78,17 @@ test.each(MCP_TOOL_CATALOG)('$name composeDescription joins purpose/whenToUse/wh
78
78
  expect(composeDescription(entry).length).toBeGreaterThan(0);
79
79
  });
80
80
 
81
+ test('project-home group is exactly the single project_home_write tool', () => {
82
+ expect(MCP_TOOL_CATALOG.filter((e) => e.group === 'project-home').map((e) => e.name)).toEqual(['project_home_write']);
83
+ });
84
+
81
85
  test('MCP_RECIPES covers queue-work, unstick-needs-review, and hand-off-to-another-project', () => {
82
86
  const ids = MCP_RECIPES.map((r) => r.id);
83
87
  expect(ids).toContain('queue-work-via-develop');
84
88
  expect(ids).toContain('unstick-needs-review-job');
85
89
  expect(ids).toContain('hand-finding-to-another-project');
86
- expect(ids).toContain('generate-project-home');
90
+ // the single-tool project_home_write needs no recipe; the old 4-step one is gone
91
+ expect(ids).not.toContain('generate-project-home');
87
92
  for (const recipe of MCP_RECIPES) {
88
93
  expect(recipe.title).toBeTruthy();
89
94
  expect(Array.isArray(recipe.steps)).toBe(true);
@@ -0,0 +1,119 @@
1
+ // PRD: fail-closed process identity so a recycled pid can never pin
2
+ // instanceLock ownership or dodge killOrphanClaudePid's veto. Stat parsing is
3
+ // exercised against fixture files under SM_PROC_ROOT (never real /proc) so a
4
+ // synthetic `comm` containing parens/spaces can be asserted precisely.
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ const { identity, isDifferentProcess } = require('../procIdentity.cjs');
11
+
12
+ let dir;
13
+
14
+ function writeProcEntry(root, pid, { stat, cmdline }) {
15
+ const entryDir = path.join(root, String(pid));
16
+ fs.mkdirSync(entryDir, { recursive: true });
17
+ if (stat !== undefined) fs.writeFileSync(path.join(entryDir, 'stat'), stat);
18
+ if (cmdline !== undefined) fs.writeFileSync(path.join(entryDir, 'cmdline'), cmdline);
19
+ }
20
+
21
+ // Real /proc/<pid>/stat line, comm replaced with one containing a space and
22
+ // an embedded ')' — field 22 (starttime) is `124686`, at the position AFTER
23
+ // the LAST ')', not the first.
24
+ function statLine(comm, startTicks) {
25
+ return `12345 (${comm}) R 1 12345 12345 0 -1 4194304 118 0 0 0 0 0 0 0 32 12 1 0 ${startTicks} 18698240 440 0 0 0 0 0 0 0 0 0 0 0 17 7 0 0 0 0 0 0 0 0 0 0 0 0\n`;
26
+ }
27
+
28
+ beforeEach(() => {
29
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-proc-identity-'));
30
+ process.env.SM_PROC_ROOT = dir;
31
+ });
32
+
33
+ afterEach(() => {
34
+ delete process.env.SM_PROC_ROOT;
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ });
37
+
38
+ describe('procIdentity.identity — /proc parsing', () => {
39
+ it('parses field 22 located after the LAST ")" when comm contains a paren and a space', () => {
40
+ writeProcEntry(dir, 4242, {
41
+ stat: statLine('claude (worker)', 999888),
42
+ cmdline: 'node\0claude\0--session-id\0abc\0',
43
+ });
44
+ const id = identity(4242);
45
+ expect(id.pid).toBe(4242);
46
+ expect(id.startTicks).toBe(999888);
47
+ expect(id.cmdline).toBe('node claude --session-id abc');
48
+ expect(id.complete).toBe(true);
49
+ });
50
+
51
+ it('reads the live node process itself from real /proc (sanity check against production code path)', () => {
52
+ delete process.env.SM_PROC_ROOT;
53
+ const id = identity(process.pid);
54
+ expect(id.pid).toBe(process.pid);
55
+ expect(typeof id.startTicks).toBe('number');
56
+ expect(id.startTicks).toBeGreaterThan(0);
57
+ expect(typeof id.cmdline).toBe('string');
58
+ expect(id.cmdline.length).toBeGreaterThan(0);
59
+ expect(id.complete).toBe(true);
60
+ process.env.SM_PROC_ROOT = dir; // restore for afterEach
61
+ });
62
+
63
+ it('tri-state: missing stat file → incomplete (startTicks null) even though cmdline is readable', () => {
64
+ writeProcEntry(dir, 5001, { cmdline: 'node\0claude\0' });
65
+ const id = identity(5001);
66
+ expect(id.startTicks).toBeNull();
67
+ expect(id.cmdline).toBe('node claude');
68
+ expect(id.complete).toBe(false);
69
+ });
70
+
71
+ it('tri-state: missing cmdline file → incomplete (cmdline null) even though stat is readable', () => {
72
+ writeProcEntry(dir, 5002, { stat: statLine('claude', 111) });
73
+ const id = identity(5002);
74
+ expect(id.startTicks).toBe(111);
75
+ expect(id.cmdline).toBeNull();
76
+ expect(id.complete).toBe(false);
77
+ });
78
+
79
+ it('tri-state: neither file exists (pid never existed / already reaped) → incomplete, never throws', () => {
80
+ const id = identity(999999);
81
+ expect(id.startTicks).toBeNull();
82
+ expect(id.cmdline).toBeNull();
83
+ expect(id.complete).toBe(false);
84
+ });
85
+
86
+ it('rejects a non-positive-integer pid without touching the filesystem', () => {
87
+ expect(identity(-1).complete).toBe(false);
88
+ expect(identity(0).complete).toBe(false);
89
+ expect(identity(NaN).complete).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('procIdentity.isDifferentProcess — fail-closed decision', () => {
94
+ const complete = (over = {}) => ({ pid: 100, startTicks: 555, cmdline: 'node claude', complete: true, ...over });
95
+
96
+ it('true only when both sides are complete AND a field differs', () => {
97
+ expect(isDifferentProcess(complete(), complete({ startTicks: 999 }))).toBe(true);
98
+ expect(isDifferentProcess(complete(), complete({ cmdline: 'node other' }))).toBe(true);
99
+ expect(isDifferentProcess(complete(), complete())).toBe(false);
100
+ });
101
+
102
+ it('an incomplete recorded or live side never reads as different (fail closed)', () => {
103
+ const incomplete = { pid: 100, startTicks: null, cmdline: null, complete: false };
104
+ expect(isDifferentProcess(incomplete, complete())).toBe(false);
105
+ expect(isDifferentProcess(complete(), incomplete)).toBe(false);
106
+ expect(isDifferentProcess(incomplete, incomplete)).toBe(false);
107
+ });
108
+
109
+ it('a null/undefined probe (unreadable /proc, process gone) never reads as different', () => {
110
+ expect(isDifferentProcess(complete(), null)).toBe(false);
111
+ expect(isDifferentProcess(null, complete())).toBe(false);
112
+ expect(isDifferentProcess(undefined, undefined)).toBe(false);
113
+ });
114
+
115
+ it('a legacy record with no identity fields at all never reads as different', () => {
116
+ const legacy = { pid: 100 }; // e.g. an old {pid, startedAt}-only lock body
117
+ expect(isDifferentProcess(legacy, complete())).toBe(false);
118
+ });
119
+ });
@@ -0,0 +1,92 @@
1
+ // PRD pn-01: procName primitive. HOME is redirected to a temp dir for the whole file
2
+ // (procnamesRoot() resolves lazily from HOME) — never the real ~/.claude.
3
+ import { describe, it, expect, afterAll } from 'vitest';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'procname-'));
9
+ const realHome = process.env.HOME;
10
+ process.env.HOME = tmpHome; // stays redirected: procnamesRoot() resolves lazily
11
+ const { aliasBinFor, smArgv0, procnamesRoot, pruneStaleAliases } = require('../procName.cjs');
12
+
13
+ afterAll(() => {
14
+ process.env.HOME = realHome;
15
+ try { fs.chmodSync(procnamesRoot(), 0o700); } catch { /* absent */ }
16
+ fs.rmSync(tmpHome, { recursive: true, force: true });
17
+ });
18
+
19
+ function fakeBin(name) {
20
+ const p = path.join(tmpHome, name);
21
+ fs.writeFileSync(p, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
22
+ return p;
23
+ }
24
+
25
+ describe('procName', () => {
26
+ it('procnamesRoot() lives under the temp HOME machine-state root', () => {
27
+ expect(procnamesRoot()).toBe(path.join(tmpHome, '.claude', 'session-manager', 'procnames'));
28
+ });
29
+
30
+ it('creates a symlink and returns the alias path', () => {
31
+ const bin = fakeBin('bin-a');
32
+ const out = aliasBinFor(bin, 'sm-claude-job');
33
+ expect(out).toBe(path.join(procnamesRoot(), 'sm-claude-job'));
34
+ expect(fs.readlinkSync(out)).toBe(bin);
35
+ });
36
+
37
+ it('self-repairs a drifted link', () => {
38
+ const a = fakeBin('bin-b1');
39
+ const b = fakeBin('bin-b2');
40
+ aliasBinFor(a, 'sm-claude-chat');
41
+ const out = aliasBinFor(b, 'sm-claude-chat');
42
+ expect(fs.readlinkSync(out)).toBe(b);
43
+ });
44
+
45
+ it('throws on an alias longer than 15 chars', () => {
46
+ expect(() => aliasBinFor(fakeBin('bin-c'), 'sm-claude-toolong-x')).toThrow(/TASK_COMM_LEN/);
47
+ expect(() => aliasBinFor(fakeBin('bin-c'), 'session-manager')).not.toThrow();
48
+ });
49
+
50
+ it('returns the original bin for an unresolvable bare name', () => {
51
+ expect(aliasBinFor('definitely-not-a-real-cmd-xyz', 'sm-claude-aux')).toBe('definitely-not-a-real-cmd-xyz');
52
+ });
53
+
54
+ it.skipIf(process.getuid && process.getuid() === 0)('fails open when procnamesRoot() is read-only', () => {
55
+ const bin = fakeBin('bin-d');
56
+ fs.mkdirSync(procnamesRoot(), { recursive: true });
57
+ fs.chmodSync(procnamesRoot(), 0o500);
58
+ try {
59
+ expect(aliasBinFor(bin, 'sm-shell')).toBe(bin);
60
+ } finally {
61
+ fs.chmodSync(procnamesRoot(), 0o700);
62
+ }
63
+ });
64
+
65
+ it('smArgv0 shape, sanitisation, and length bound', () => {
66
+ expect(smArgv0('job', 'prd 12/x!')).toBe('sm-claude-job:prd12x');
67
+ expect(smArgv0('job', 'a'.repeat(500)).length).toBeLessThanOrEqual(120);
68
+ });
69
+
70
+ it('smArgv0 always matches the live /\\bclaude\\b/ gates for every role', () => {
71
+ // Replicated verbatim from scheduler.cjs killOrphanClaudePid & reaperHelpers.cjs claudePidAlive.
72
+ const gate = /\bclaude\b/;
73
+ for (const role of ['job', 'chat', 'aux', 'helper', '', undefined, '!!!', 'x'.repeat(300)]) {
74
+ for (const detail of ['abc', '', undefined, null, '///', 'y'.repeat(300)]) {
75
+ const label = smArgv0(role, detail);
76
+ expect(gate.test(label)).toBe(true);
77
+ expect(label.length).toBeLessThanOrEqual(120);
78
+ }
79
+ }
80
+ });
81
+
82
+ it('pruneStaleAliases removes dead links and keeps live ones', () => {
83
+ const live = fakeBin('bin-live');
84
+ const dead = fakeBin('bin-dead');
85
+ const liveAlias = aliasBinFor(live, 'sm-live');
86
+ const deadAlias = aliasBinFor(dead, 'sm-dead');
87
+ fs.rmSync(dead);
88
+ expect(() => pruneStaleAliases()).not.toThrow();
89
+ expect(fs.existsSync(liveAlias)).toBe(true);
90
+ expect(fs.lstatSync(deadAlias, { throwIfNoEntry: false })).toBeUndefined();
91
+ });
92
+ });
@@ -121,3 +121,70 @@ test('readMerged (async) recovers the same real fixture', async () => {
121
121
  expect(state.machineStateRecoveryMode).toBe('prefix');
122
122
  expect(state.lastRunAt).toBe('2026-09-11T10:00:14.441Z');
123
123
  });
124
+
125
+ // ---------- per-cwd shard quarantine (never recover, never write) ----------
126
+
127
+ function makeProject(name, jobs) {
128
+ const cwd = path.join(tmpHome, 'Projects', name);
129
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
130
+ fs.mkdirSync(stateDir, { recursive: true });
131
+ const file = path.join(stateDir, 'queue.json');
132
+ if (jobs) fs.writeFileSync(file, JSON.stringify({ jobs }));
133
+ const slugDir = path.join(tmpHome, '.claude', 'projects', `slug-${name}`);
134
+ fs.mkdirSync(slugDir, { recursive: true });
135
+ fs.writeFileSync(path.join(slugDir, 't.jsonl'), JSON.stringify({ cwd }) + '\n');
136
+ return { cwd, file };
137
+ }
138
+
139
+ const TORN = '{"jobs":[{"slug":"a","status":"running"}]} {"jobs":[{"slug":"a","stat';
140
+ const goodJob = (slug, cwd) => ({
141
+ slug, status: 'pending', prdPath: `/x/${slug}.md`, cwd, scheduledFor: null, createdAt: new Date().toISOString(),
142
+ });
143
+
144
+ test('a torn shard is quarantined per cwd; the machine-level `unreadable` stays unset and the sibling shard still loads', () => {
145
+ const good = makeProject('good', [goodJob('g1', path.join(tmpHome, 'Projects', 'good'))]);
146
+ const torn = makeProject('torn');
147
+ fs.writeFileSync(torn.file, TORN);
148
+ const opts = { projectsDir: path.join(tmpHome, '.claude', 'projects') };
149
+
150
+ const state = queueStore.readMergedSync(opts);
151
+
152
+ expect(state.unreadable).toBeUndefined();
153
+ expect(state.unreadableCwds).toHaveLength(1);
154
+ expect(state.unreadableCwds[0]).toMatchObject({ cwd: torn.cwd, file: torn.file });
155
+ expect(typeof state.unreadableCwds[0].error).toBe('string');
156
+ expect(state.jobs.map((j) => j.slug)).toEqual(['g1']);
157
+ expect(state.sourceCwds).not.toContain(torn.cwd);
158
+ });
159
+
160
+ test('readMerged (async) quarantines the same way', async () => {
161
+ const torn = makeProject('torn2');
162
+ fs.writeFileSync(torn.file, TORN);
163
+ const state = await queueStore.readMerged({ projectsDir: path.join(tmpHome, '.claude', 'projects') });
164
+ expect(state.unreadable).toBeUndefined();
165
+ expect(state.unreadableCwds.map((u) => u.cwd)).toEqual([torn.cwd]);
166
+ });
167
+
168
+ test('writeSplit leaves a torn shard byte-identical even when merged rows carry its cwd, and still writes healthy shards', async () => {
169
+ const good = makeProject('good3', [goodJob('g1', path.join(tmpHome, 'Projects', 'good3'))]);
170
+ const torn = makeProject('torn3');
171
+ fs.writeFileSync(torn.file, TORN);
172
+ const before = fs.readFileSync(torn.file);
173
+ const opts = { projectsDir: path.join(tmpHome, '.claude', 'projects') };
174
+
175
+ const state = await queueStore.readMerged(opts);
176
+ state.jobs.push(goodJob('intruder', torn.cwd)); // a row that claims the torn cwd
177
+ state.jobs[0].status = 'running';
178
+ await queueStore.writeSplit(state, good.cwd);
179
+
180
+ expect(fs.readFileSync(torn.file).equals(before)).toBe(true);
181
+ const written = JSON.parse(fs.readFileSync(good.file, 'utf8'));
182
+ expect(written.jobs.map((j) => j.status)).toEqual(['running']);
183
+ });
184
+
185
+ test('writeSplit never re-creates a quarantined shard that has no file at all', async () => {
186
+ const ghost = makeProject('ghost');
187
+ const state = { config: {}, jobs: [goodJob('x', ghost.cwd)], sourceCwds: [], unreadableCwds: [{ cwd: ghost.cwd, file: ghost.file, error: 'x' }] };
188
+ await queueStore.writeSplit(state, ghost.cwd);
189
+ expect(fs.existsSync(ghost.file)).toBe(false);
190
+ });
@@ -6,11 +6,11 @@
6
6
  * when the admin API is unreachable — only the readiness section may report
7
7
  * unavailable.
8
8
  *
9
- * TOKEN_PATH (scripts/scheduler-mcp-server.cjs) is computed once, at
10
- * require-time, from os.homedir(). Since os.homedir() reads process.env.HOME
11
- * on POSIX, each test temporarily repoints HOME at a fresh tmp dir, clears
12
- * the require cache, and re-requires the module so TOKEN_PATH resolves under
13
- * that tmp dir — never touching the real ~/.claude/session-manager/admin-api.json.
9
+ * The admin token path (schedulerPaths.adminTokenPath) resolves lazily from
10
+ * os.homedir(). Since os.homedir() reads process.env.HOME on POSIX, each test
11
+ * repoints HOME at a fresh tmp dir for its duration (restored in afterEach)
12
+ * so the token path resolves under that tmp dir, never the real
13
+ * ~/.claude/session-manager/admin-api.json.
14
14
  *
15
15
  * Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs
16
16
  */
@@ -72,8 +72,7 @@ function requireServerWithHome(homeDir) {
72
72
  process.env.HOME = homeDir;
73
73
  delete require.cache[require.resolve(SERVER_PATH)];
74
74
  const mod = require(SERVER_PATH);
75
- process.env.HOME = originalHome;
76
- originalHome = undefined;
75
+ // HOME stays redirected until afterEach: admin-token path resolves lazily.
77
76
  return mod;
78
77
  }
79
78