claude-code-session-manager 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-BZ6IY2g1.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-dAoGAODX.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-CF157HyX.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CCPPipBk.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-Dlmz2m06.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-BaXcboWX.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CoUCzG5S.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-DTg3JlOT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-DdjY1sA3.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-BJ3Jlw0C.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-ygRSgc41.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-m0KAnccF.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-CJRl6B3Q.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-w8cnlVvW.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BpyPcUIk.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-D2ppxSfy.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-EyuIwt-j.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-BHSGGFEc.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-BNaZ2JCF.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-Dl3q75QV.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-Bg6F-Cyp.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-BSutflCy.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody-CXih4xrc.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-2qtUHhA9.js} +1 -1
  26. package/dist/assets/{index-fc_JjdxL.js → index-B3emy2oI.js} +356 -356
  27. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-CdzxaqwM.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 +11 -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-starve-escalation.test.cjs +4 -4
  124. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  125. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  126. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  127. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  128. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  129. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  130. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  131. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  132. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  133. package/src/main/bilkoHost.cjs +16 -7
  134. package/src/main/build-info.json +8 -0
  135. package/src/main/chatRunner.cjs +4 -2
  136. package/src/main/config.cjs +2 -3
  137. package/src/main/docEdit.cjs +4 -2
  138. package/src/main/health.cjs +267 -51
  139. package/src/main/heapSnapshot.cjs +2 -2
  140. package/src/main/historyAggregator.cjs +1 -1
  141. package/src/main/index.cjs +61 -10
  142. package/src/main/ipcSchemas.cjs +6 -17
  143. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  144. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  145. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  146. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  147. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  148. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  149. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  150. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  151. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  152. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  153. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  154. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  155. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  156. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  157. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  158. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  159. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  160. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  161. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  162. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  163. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  164. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  165. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  166. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  167. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  168. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  169. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  170. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  171. package/src/main/lib/activeSessions.cjs +20 -88
  172. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  173. package/src/main/lib/agentModelResolve.cjs +33 -1
  174. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  175. package/src/main/lib/auditLog.cjs +30 -5
  176. package/src/main/lib/buildIdentity.cjs +113 -0
  177. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  178. package/src/main/lib/claudeBin.cjs +37 -2
  179. package/src/main/lib/cleanEnv.cjs +27 -1
  180. package/src/main/lib/credentials.cjs +4 -2
  181. package/src/main/lib/cwdClassify.cjs +185 -0
  182. package/src/main/lib/definitionOfDone.cjs +296 -52
  183. package/src/main/lib/dispatchLoop.cjs +40 -0
  184. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  185. package/src/main/lib/ephemeralCwd.cjs +7 -28
  186. package/src/main/lib/gitWorktree.cjs +95 -8
  187. package/src/main/lib/guardShims.cjs +3 -3
  188. package/src/main/lib/historyRollup.cjs +6 -7
  189. package/src/main/lib/instanceLock.cjs +32 -5
  190. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  191. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  192. package/src/main/lib/localAdminHttp.cjs +9 -27
  193. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  194. package/src/main/lib/opsOwnership.cjs +27 -19
  195. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  196. package/src/main/lib/prdLocations.cjs +48 -1
  197. package/src/main/lib/procIdentity.cjs +126 -0
  198. package/src/main/lib/procName.cjs +98 -0
  199. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  200. package/src/main/lib/queueHistory.cjs +8 -7
  201. package/src/main/lib/queueStore.cjs +57 -37
  202. package/src/main/lib/quietMachineLease.cjs +19 -2
  203. package/src/main/lib/reservationExpiry.cjs +30 -0
  204. package/src/main/lib/runClaudeP.cjs +4 -2
  205. package/src/main/lib/runLogRetention.cjs +3 -2
  206. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  207. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  208. package/src/main/lib/schedulerBatch.cjs +27 -2
  209. package/src/main/lib/schedulerPaths.cjs +175 -0
  210. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  211. package/src/main/lib/sessionSlots.cjs +47 -9
  212. package/src/main/lib/smProcNames.cjs +51 -0
  213. package/src/main/lib/upgradeDrain.cjs +188 -0
  214. package/src/main/lib/usageCircuit.cjs +53 -6
  215. package/src/main/lib/watchdogHelpers.cjs +70 -37
  216. package/src/main/lib/withTimeout.cjs +33 -0
  217. package/src/main/mcpStatus.cjs +4 -2
  218. package/src/main/pluginInstall.cjs +6 -2
  219. package/src/main/projectPages.cjs +41 -145
  220. package/src/main/pty.cjs +8 -1
  221. package/src/main/queueOps.cjs +10 -10
  222. package/src/main/runVerify.cjs +69 -3
  223. package/src/main/scheduler.cjs +1598 -381
  224. package/src/main/seedAgentPersonas.cjs +1 -1
  225. package/src/main/seedDevPlugin.cjs +1 -1
  226. package/src/main/seedSchedulerMcp.cjs +7 -6
  227. package/src/main/seedStatus.cjs +1 -1
  228. package/src/main/supervisor.cjs +5 -3
  229. package/src/main/usage.cjs +126 -62
  230. package/src/preload/api.d.ts +18 -64
  231. package/src/preload/index.cjs +2 -4
  232. package/src/seed/agents/project-home-builder.md +31 -48
  233. package/screenshots/.gitkeep +0 -0
  234. package/screenshots/README-screenshots.md +0 -13
  235. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  236. package/src/main/teams.cjs +0 -95
  237. package/src/main/templates/project-pages-catalog.json +0 -741
  238. package/src/main/templates/project-pages-default-home.html +0 -123
  239. package/src/main/templates/project-pages-pipeline.md +0 -417
  240. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  241. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  242. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  243. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  244. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  245. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  246. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  247. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  248. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  249. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  250. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  251. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  252. package/src/seed/prompts/documentation/module-readme.md +0 -8
  253. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  254. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  255. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  256. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  257. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  258. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  259. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  260. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  261. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  262. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  263. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  264. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  265. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  266. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  267. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  268. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  269. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  270. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  271. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  272. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  273. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  274. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  275. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  276. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  277. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  278. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  279. package/src/seed/prompts/security/authz-audit.md +0 -8
  280. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  281. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  282. package/src/seed/prompts/security/dependency-audit.md +0 -8
  283. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  284. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  285. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  286. package/web/README.md +0 -41
  287. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  288. package/web/project-pages/render.cjs +0 -70
  289. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  290. package/web/project-pages/validate-summary.cjs +0 -62
@@ -4,44 +4,106 @@
4
4
  * external watchdog's reconcileQueueOffline(), which is now deleted from
5
5
  * src/main/lib/watchdogHelpers.cjs).
6
6
  *
7
- * Covers the two behaviors most likely to drift in the move:
8
- * - a still-alive orphaned pid must be DEFERRED, never classified from its
9
- * (possibly still-being-written) log in the same pass (partitionBootOrphans)
7
+ * Covers:
8
+ * - a proven-alive row is ADOPTED (left running, never signalled) and only
9
+ * proven-dead / exited rows are finalized (partitionBootOrphans)
10
10
  * - the ORPHAN_REQUEUE_CAP re-queue/exhaustion boundary (applyOrphanOutcome)
11
11
  *
12
- * Run: timeout 120 node --test src/main/__tests__/scheduler-boot-orphans.test.cjs
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-boot-orphans.test.cjs
13
13
  */
14
14
 
15
15
  'use strict';
16
16
 
17
- const { test } = require('node:test');
17
+ const { spawn } = require('node:child_process');
18
18
  const assert = require('node:assert/strict');
19
19
  const fs = require('node:fs');
20
20
  const os = require('node:os');
21
21
  const path = require('node:path');
22
22
 
23
+ const {
24
+ writeSupervisorRecord,
25
+ } = require('../lib/jobSupervisorRecord.cjs');
23
26
  const {
24
27
  partitionBootOrphans,
25
28
  applyOrphanOutcome,
26
- feedbackSweepDue,
27
- FEEDBACK_SWEEP_TICK_INTERVAL,
28
- sweepFeedback,
29
29
  } = require('../scheduler.cjs');
30
30
  const { ORPHAN_REQUEUE_CAP } = require('../lib/reaperHelpers.cjs');
31
31
 
32
- test('partitionBootOrphans defers a running job whose pid is still alive', () => {
32
+ function spawnStub() {
33
+ return spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' });
34
+ }
35
+
36
+ function withRunsDir(fn) {
37
+ const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sched-boot-runs-'));
38
+ try { return fn(runsDir); } finally { fs.rmSync(runsDir, { recursive: true, force: true }); }
39
+ }
40
+
41
+ test('a running row with a live pid survives boot untouched — adopted, no kill issued', () => {
42
+ const child = spawnStub();
43
+ const killSpy = vi.spyOn(process, 'kill');
44
+ try {
45
+ const jobs = [{ slug: 'alive-orphan', status: 'running', runtime: { pid: child.pid } }];
46
+ const { immediate, adopted } = partitionBootOrphans(jobs, { pidAlive: (pid) => pid === child.pid, runsDir: os.tmpdir() });
47
+ assert.deepEqual(adopted, ['alive-orphan']);
48
+ assert.deepEqual(immediate, []);
49
+ assert.equal(killSpy.mock.calls.length, 0, 'boot classification must never signal an executor');
50
+ assert.equal(jobs[0].status, 'running', 'the row must not transition');
51
+ } finally {
52
+ killSpy.mockRestore();
53
+ child.kill();
54
+ }
55
+ });
56
+
57
+ test('a dead-pid row and a pidless row with no liveness evidence are finalized', () => {
33
58
  const jobs = [
34
- { slug: 'alive-orphan', status: 'running', runtime: { pid: 111 } },
35
59
  { slug: 'dead-orphan', status: 'running', runtime: { pid: 222 } },
36
60
  { slug: 'no-pid-orphan', status: 'running', runtime: {} },
37
61
  { slug: 'not-running', status: 'pending' },
38
62
  ];
39
- const isAlive = (pid) => pid === 111;
63
+ const { immediate, adopted } = partitionBootOrphans(jobs, { pidAlive: () => false, runsDir: os.tmpdir() });
64
+ assert.deepEqual(immediate.sort(), ['dead-orphan', 'no-pid-orphan']);
65
+ assert.deepEqual(adopted, []);
66
+ });
40
67
 
41
- const { immediate, deferred } = partitionBootOrphans(jobs, isAlive);
68
+ test('a pidless row is adopted on any one of the reaper liveness signals', () => {
69
+ const job = () => ({ slug: 'p', status: 'running', runtime: {} });
70
+ const base = { pidAlive: (pid) => pid === 77, runsDir: os.tmpdir(), now: 1_000_000 };
71
+ assert.deepEqual(partitionBootOrphans([job()], { ...base, getLogMtimeMs: () => 999_000, logFreshWindowMs: 5_000 }).adopted, ['p'], 'fresh log');
72
+ assert.deepEqual(partitionBootOrphans([job()], { ...base, getLogPid: () => 77 }).adopted, ['p'], 'log pid alive');
73
+ assert.deepEqual(partitionBootOrphans([job()], { ...base, findLiveProcess: () => 88 }).adopted, ['p'], '/proc scan');
74
+ assert.deepEqual(partitionBootOrphans([job()], { ...base, getLogMtimeMs: () => 1_000, logFreshWindowMs: 5_000 }).immediate, ['p'], 'stale log');
75
+ });
42
76
 
43
- assert.deepEqual(deferred, ['alive-orphan']);
44
- assert.deepEqual(immediate.sort(), ['dead-orphan', 'no-pid-orphan']);
77
+ test('a row whose supervisor record says exited is finalized from its meta, not adopted', () => {
78
+ withRunsDir((runsDir) => {
79
+ const runDir = path.join(runsDir, 'run-1');
80
+ fs.mkdirSync(runDir, { recursive: true });
81
+ writeSupervisorRecord({ runDir, slug: 'exited-row', pid: 4242, runId: 'run-1', startedAt: Date.now() });
82
+ fs.writeFileSync(path.join(runDir, 'exited-row.meta.json'), JSON.stringify({ slug: 'exited-row', exitCode: 0 }));
83
+ const job = { slug: 'exited-row', status: 'running', runId: 'run-1', runtime: { pid: 4242 } };
84
+ // Even with the pid reported alive (recycled/lingering), the exit marker wins.
85
+ const { immediate, adopted } = partitionBootOrphans([job], { pidAlive: () => true, runsDir });
86
+ assert.deepEqual(immediate, ['exited-row']);
87
+ assert.deepEqual(adopted, []);
88
+ applyOrphanOutcome(job, 'success');
89
+ assert.equal(job.status, 'completed');
90
+ });
91
+ });
92
+
93
+ test('a record with a live, same-identity pid is adopted; a dead record without an exit marker is finalized', () => {
94
+ withRunsDir((runsDir) => {
95
+ const runDir = path.join(runsDir, 'run-2');
96
+ fs.mkdirSync(runDir, { recursive: true });
97
+ writeSupervisorRecord({ runDir, slug: 'live-rec', pid: 31337, runId: 'run-2', startedAt: Date.now() });
98
+ writeSupervisorRecord({ runDir, slug: 'dead-rec', pid: 31338, runId: 'run-2', startedAt: Date.now() });
99
+ const jobs = [
100
+ { slug: 'live-rec', status: 'running', runId: 'run-2', runtime: { pid: 31337 } },
101
+ { slug: 'dead-rec', status: 'running', runId: 'run-2', runtime: { pid: 31338 } },
102
+ ];
103
+ const { immediate, adopted } = partitionBootOrphans(jobs, { pidAlive: (pid) => pid === 31337, identityOf: () => null, runsDir });
104
+ assert.deepEqual(adopted, ['live-rec']);
105
+ assert.deepEqual(immediate, ['dead-rec']);
106
+ });
45
107
  });
46
108
 
47
109
  test('partitionBootOrphans ignores non-running jobs entirely', () => {
@@ -49,9 +111,9 @@ test('partitionBootOrphans ignores non-running jobs entirely', () => {
49
111
  { slug: 'a', status: 'completed', runtime: { pid: 1 } },
50
112
  { slug: 'b', status: 'failed', runtime: { pid: 2 } },
51
113
  ];
52
- const { immediate, deferred } = partitionBootOrphans(jobs, () => true);
114
+ const { immediate, adopted } = partitionBootOrphans(jobs, { pidAlive: () => true, runsDir: os.tmpdir() });
53
115
  assert.deepEqual(immediate, []);
54
- assert.deepEqual(deferred, []);
116
+ assert.deepEqual(adopted, []);
55
117
  });
56
118
 
57
119
  test('applyOrphanOutcome: success finalizes to completed, clears runtime', () => {
@@ -89,84 +151,3 @@ test('applyOrphanOutcome: no_result at the cap fails terminally instead of re-qu
89
151
  assert.match(job.error, new RegExp(`exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts`));
90
152
  assert.equal(job.runtime, undefined);
91
153
  });
92
-
93
- // ── feedback sweep moved into the 60s poll tick (PRD 686) ───────────────────
94
-
95
- test('feedbackSweepDue gates on the tick interval, not every tick', () => {
96
- for (let i = 1; i < FEEDBACK_SWEEP_TICK_INTERVAL; i++) {
97
- assert.equal(feedbackSweepDue(i), false, `tick ${i} should not be due yet`);
98
- }
99
- assert.equal(feedbackSweepDue(FEEDBACK_SWEEP_TICK_INTERVAL), true);
100
- assert.equal(feedbackSweepDue(FEEDBACK_SWEEP_TICK_INTERVAL + 1), true);
101
- });
102
-
103
- test('scheduler.cjs reuses watchdogHelpers.sweep verbatim (no forked copy)', () => {
104
- const { sweep } = require('../lib/watchdogHelpers.cjs');
105
- assert.equal(sweepFeedback, sweep, 'scheduler.cjs must import the same sweep function, not reimplement it');
106
- });
107
-
108
- function makeTmpDir() {
109
- return fs.mkdtempSync(path.join(os.tmpdir(), 'sched-boot-sweep-'));
110
- }
111
-
112
- function makeQueue(base) {
113
- const queuePath = path.join(base, 'queue.json');
114
- fs.writeFileSync(queuePath, JSON.stringify({ version: 1, jobs: [], paused: null }, null, 2));
115
- return queuePath;
116
- }
117
-
118
- const FAKE_SKILL = '---\nname: process-feedback\ndescription: x\n---\n\n# process-feedback\n\nbody\n';
119
- const FAKE_STANDARDS = '# Engineering standards\n\nbody\n';
120
-
121
- test('the poll-tick sweep call emits a PRD for an active project with open feedback, and skips one outside the 90-min window', () => {
122
- const base = makeTmpDir();
123
- try {
124
- // Active project (10 min ago) with open feedback → should emit.
125
- const activeProj = path.join(base, 'active-proj');
126
- fs.mkdirSync(path.join(activeProj, 'session-manager-operations', 'feedback'), { recursive: true });
127
- fs.writeFileSync(path.join(activeProj, 'session-manager-operations', 'feedback', '2026-01-01-item.md'), '# Item\n');
128
-
129
- // Stale project (120 min ago, outside the 90-min window) with open feedback → should NOT be scanned at all.
130
- const staleProj = path.join(base, 'stale-proj');
131
- fs.mkdirSync(path.join(staleProj, 'session-manager-operations', 'feedback'), { recursive: true });
132
- fs.writeFileSync(path.join(staleProj, 'session-manager-operations', 'feedback', '2026-01-01-item.md'), '# Item\n');
133
-
134
- // Active project with no open feedback → scanned but not emitted.
135
- const noFeedbackProj = path.join(base, 'no-feedback-proj');
136
- fs.mkdirSync(noFeedbackProj, { recursive: true });
137
-
138
- const projectsDir = path.join(base, 'projects');
139
- const recent = new Date(Date.now() - 10 * 60 * 1000);
140
- const stale = new Date(Date.now() - 120 * 60 * 1000);
141
- for (const [slug, cwd, mtime] of [
142
- ['slug-active', activeProj, recent],
143
- ['slug-stale', staleProj, stale],
144
- ['slug-no-feedback', noFeedbackProj, recent],
145
- ]) {
146
- const projDir = path.join(projectsDir, slug);
147
- fs.mkdirSync(projDir, { recursive: true });
148
- const fp = path.join(projDir, 'a.jsonl');
149
- fs.writeFileSync(fp, JSON.stringify({ cwd }) + '\n');
150
- fs.utimesSync(fp, mtime, mtime);
151
- }
152
-
153
- const prdsDir = path.join(base, 'prds');
154
- fs.mkdirSync(prdsDir);
155
- const queuePath = makeQueue(base);
156
- const skillPath = path.join(base, 'SKILL.md');
157
- const standardsPath = path.join(base, 'standards.md');
158
- fs.writeFileSync(skillPath, FAKE_SKILL);
159
- fs.writeFileSync(standardsPath, FAKE_STANDARDS);
160
-
161
- const result = sweepFeedback({ projectsDir, prdsDir, queuePath, skillPath, standardsPath });
162
-
163
- assert.equal(result.scanned, 2, 'only the two in-window projects should be scanned');
164
- assert.equal(result.emitted, 1, 'only the active project with open feedback should get a PRD');
165
-
166
- const emittedFiles = fs.readdirSync(prdsDir).filter((f) => f.endsWith('.md'));
167
- assert.equal(emittedFiles.length, 1);
168
- assert.match(emittedFiles[0], /feedback-active-proj\.md$/);
169
- } finally {
170
- fs.rmSync(base, { recursive: true, force: true });
171
- }
172
- });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * scheduler-default-eligible-heal.test.cjs — the periodic heal ladder is
3
+ * default-ELIGIBLE (2026-09-18, 1229-fo-03 parked shared_tree_reverted and
4
+ * blocked 19 of 20 pending rows because that verdict was on no allow-list).
5
+ *
6
+ * Covers: any needs_review verdict is a candidate unless named in
7
+ * RESCAN_EXCLUDED_VERDICTS; the evidence rung is bounded; a row that cannot
8
+ * heal terminates under the bounded ladder; health names a silent blocker.
9
+ *
10
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-default-eligible-heal.test.cjs
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const assert = require('node:assert/strict');
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+
20
+ process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'default-eligible-heal-test-'));
21
+
22
+ const {
23
+ isRescanCandidate,
24
+ isTranscriptRescannable,
25
+ isStrandedAutoFixPark,
26
+ isEligibleForNeedsReviewAutoResolve,
27
+ shouldRunPeriodicReverify,
28
+ selectEvidenceScanTargets,
29
+ selectAutoFixTargets,
30
+ selectMechanicalRecoveryTarget,
31
+ selectResumeRecoveryTarget,
32
+ applyNeedsReviewAutoResolve,
33
+ RESCAN_EXCLUDED_VERDICTS,
34
+ RESCANNABLE_VERDICTS,
35
+ EVIDENCE_SCAN_MAX_PER_PASS,
36
+ EVIDENCE_SCAN_MIN_INTERVAL_MS,
37
+ REVERIFY_INTERVAL_MS,
38
+ NEEDS_REVIEW_RESOLVE_CAP,
39
+ } = require('../scheduler.cjs');
40
+ const { evaluateBlockingParkHealth } = require('../health.cjs');
41
+
42
+ const row = (o = {}) => ({ slug: '10-x', status: 'needs_review', runId: 'r1', cwd: '/p', ...o });
43
+
44
+ test('shared_tree_reverted is a rescan candidate without being on any list', () => {
45
+ assert.equal(RESCANNABLE_VERDICTS.has('shared_tree_reverted'), false);
46
+ assert.equal(isRescanCandidate(row({ verifierVerdict: 'shared_tree_reverted' })), true);
47
+ });
48
+
49
+ test('an invented verdict name the code has never seen is a rescan candidate', () => {
50
+ const j = row({ verifierVerdict: 'some_future_guard_verdict' });
51
+ assert.equal(isRescanCandidate(j), true);
52
+ assert.equal(shouldRunPeriodicReverify([j]), true);
53
+ // Not transcript-rescannable: it gets the evidence rung, never a verifyRun heal.
54
+ assert.equal(isTranscriptRescannable(j), false);
55
+ assert.deepEqual(selectEvidenceScanTargets([j]).map((x) => x.slug), ['10-x']);
56
+ });
57
+
58
+ test('a needs_review row with no runId and no verdict is still a candidate', () => {
59
+ assert.equal(isRescanCandidate({ slug: 's', status: 'needs_review' }), true);
60
+ });
61
+
62
+ test('only the named exclusions are refused, each verdict in the set', () => {
63
+ for (const v of RESCAN_EXCLUDED_VERDICTS) assert.equal(isRescanCandidate(row({ verifierVerdict: v })), false, v);
64
+ assert.deepEqual([...RESCAN_EXCLUDED_VERDICTS].sort(), ['budget_exceeded', 'uncommitted_changes', 'worktree_integration_failed']);
65
+ });
66
+
67
+ test('rescannable verdict keeps the transcript rung as a hint', () => {
68
+ assert.equal(isTranscriptRescannable(row({ verifierVerdict: 'transcript_errors' })), true);
69
+ assert.deepEqual(selectEvidenceScanTargets([row({ verifierVerdict: 'transcript_errors' })]), []);
70
+ });
71
+
72
+ test('1229-fo-03 shape: stranded auto-fix park is an auto-resolve door AND an evidence-scan target', () => {
73
+ const j = row({ verifierVerdict: 'shared_tree_reverted', autoFixAttempted: true });
74
+ assert.equal(isStrandedAutoFixPark(j, [j]), true);
75
+ assert.equal(isEligibleForNeedsReviewAutoResolve(j, [j]), true);
76
+ assert.equal(selectEvidenceScanTargets([j]).length, 1, 'looksDone evidence is now computed for it, so the door can fire');
77
+ });
78
+
79
+ test('a live auto-fix history that is not an auto-resolve door gets no evidence scan (PRD 1136)', () => {
80
+ const j = row({ verifierVerdict: 'some_future_guard_verdict', autoFixAttempted: true, autoFixOutcome: 'plan' });
81
+ assert.equal(selectEvidenceScanTargets([j]).length, 0);
82
+ });
83
+
84
+ test('cost bound: per-pass cap, looksDone skip, and per-row re-scan throttle', () => {
85
+ const now = Date.now();
86
+ const many = Array.from({ length: 100 }, (_, i) => row({ slug: `${i}-p`, verifierVerdict: 'v_new' }));
87
+ assert.equal(selectEvidenceScanTargets(many, now).length, EVIDENCE_SCAN_MAX_PER_PASS);
88
+ const done = many.map((j) => ({ ...j, looksDone: { commits: ['a'] } }));
89
+ assert.equal(selectEvidenceScanTargets(done, now).length, 0);
90
+ const fresh = many.map((j) => ({ ...j, evidenceScannedAt: new Date(now - EVIDENCE_SCAN_MIN_INTERVAL_MS + 1000).toISOString() }));
91
+ assert.equal(selectEvidenceScanTargets(fresh, now).length, 0);
92
+ // never-scanned rows go before stale-scanned ones
93
+ const mixed = [
94
+ row({ slug: 'old', verifierVerdict: 'v', evidenceScannedAt: new Date(now - 10 * EVIDENCE_SCAN_MIN_INTERVAL_MS).toISOString() }),
95
+ row({ slug: 'never', verifierVerdict: 'v' }),
96
+ ];
97
+ assert.equal(selectEvidenceScanTargets(mixed, now)[0].slug, 'never');
98
+ });
99
+
100
+ test('unhealable row terminates: N passes yield a bounded number of recovery attempts, reason intact', () => {
101
+ let now = Date.now();
102
+ const job = row({ slug: '20-stuck', verifierVerdict: 'some_future_guard_verdict' });
103
+ const jobs = [job];
104
+ let evidenceScans = 0;
105
+ let autoFixDispatches = 0;
106
+ let resumeOrMechanical = 0;
107
+ const PASSES = 500;
108
+ for (let n = 0; n < PASSES; n++) {
109
+ now += REVERIFY_INTERVAL_MS;
110
+ for (const t of selectEvidenceScanTargets(jobs, now)) { evidenceScans++; t.evidenceScannedAt = new Date(now).toISOString(); }
111
+ resumeOrMechanical += jobs.filter((j) => selectMechanicalRecoveryTarget(j) || selectResumeRecoveryTarget(j)).length;
112
+ for (const t of selectAutoFixTargets(jobs, { fixSlugExists: () => false, resolveJobRunId: () => 'r1' })) {
113
+ autoFixDispatches++;
114
+ // what reverifyNeedsReview stamps before spawning
115
+ t.autoFixRetries = t.autoFixAttempted === true ? (t.autoFixRetries ?? 0) + 1 : t.autoFixRetries;
116
+ t.autoFixAttempted = true;
117
+ }
118
+ }
119
+ assert.ok(autoFixDispatches <= 2, `auto-fix dispatched ${autoFixDispatches}x`);
120
+ assert.equal(resumeOrMechanical, 0);
121
+ // one scan per throttle window, not one per pass
122
+ assert.ok(evidenceScans <= Math.ceil(PASSES * REVERIFY_INTERVAL_MS / EVIDENCE_SCAN_MIN_INTERVAL_MS), `evidence scans ${evidenceScans}`);
123
+ assert.ok(evidenceScans < PASSES / 2);
124
+ assert.equal(job.status, 'needs_review');
125
+ assert.equal(job.verifierVerdict, 'some_future_guard_verdict');
126
+ });
127
+
128
+ test('auto-resolve ladder is capped: requeue attempts stop at NEEDS_REVIEW_RESOLVE_CAP then skip', () => {
129
+ const j = row({ slug: '30-cap', verifierVerdict: 'x', autoFixAttempted: true });
130
+ const outcomes = [];
131
+ for (let i = 0; i < NEEDS_REVIEW_RESOLVE_CAP + 3; i++) {
132
+ const o = applyNeedsReviewAutoResolve(j, [j]);
133
+ if (!o) break;
134
+ outcomes.push(o);
135
+ if (o === 'requeued') j.status = 'needs_review';
136
+ if (o === 'skipped') break;
137
+ }
138
+ assert.equal(outcomes.filter((o) => o === 'requeued').length, NEEDS_REVIEW_RESOLVE_CAP);
139
+ assert.equal(outcomes.at(-1), 'skipped');
140
+ });
141
+
142
+ describe('evaluateBlockingParkHealth', () => {
143
+ const now = Date.parse('2026-09-18T20:00:00Z');
144
+ const parkedAgo = (ms) => [{ to: 'needs_review', at: new Date(now - ms).toISOString() }];
145
+ const park = (ms, o = {}) => ({ slug: '5-park', status: 'needs_review', cwd: '/p', verifierVerdict: 'shared_tree_reverted', statusHistory: parkedAgo(ms), ...o });
146
+ const dep = (slug, dependsOn) => ({ slug, status: 'pending', cwd: '/p', dependsOn });
147
+
148
+ test('parked past one interval with pending dependents (transitive) is non-green, naming row + count', () => {
149
+ const r = evaluateBlockingParkHealth([park(REVERIFY_INTERVAL_MS + 1000), dep('6-a', ['5-park']), dep('7-b', ['6-a']), dep('8-c', [])], now);
150
+ assert.equal(r.ok, false);
151
+ assert.equal(r.parks[0].slug, '5-park');
152
+ assert.equal(r.parks[0].dependents, 2);
153
+ assert.match(r.message, /5-park/);
154
+ assert.match(r.message, /blocking 2 pending dependent/);
155
+ });
156
+
157
+ test('young park, or park with no dependents, is healthy', () => {
158
+ assert.equal(evaluateBlockingParkHealth([park(1000), dep('6-a', ['5-park'])], now).ok, true);
159
+ assert.equal(evaluateBlockingParkHealth([park(REVERIFY_INTERVAL_MS * 5), dep('6-a', [])], now).ok, true);
160
+ });
161
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * scheduler-dispatch-loop.test.cjs — dispatch cadence is the loop's, not the billing
3
+ * poller's: with the meter failing (backoff at 480 s) a pending dispatchable row still
4
+ * gets a tickQueue within 30 s.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-dispatch-loop.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, vi, beforeAll, afterAll } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ let tmpHome;
16
+ let originalHome;
17
+ let scheduler;
18
+
19
+ beforeAll(() => {
20
+ originalHome = process.env.HOME;
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-dispatch-loop-'));
22
+ process.env.HOME = tmpHome;
23
+ process.env.SM_AUTOFIX_DISABLE = '1';
24
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
25
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
26
+ scheduler = require('../scheduler.cjs');
27
+ });
28
+
29
+ afterAll(() => {
30
+ process.env.HOME = originalHome;
31
+ delete process.env.SM_AUTOFIX_DISABLE;
32
+ fs.rmSync(tmpHome, { recursive: true, force: true });
33
+ });
34
+
35
+ test('with the billing poll backed off to 480 s, a pending row is still ticked within 30 s', async () => {
36
+ const { startDispatchLoop } = require('../lib/dispatchLoop.cjs');
37
+ await scheduler.writeQueue({
38
+ jobs: [{ slug: `9004-dispatch-loop-${process.pid}`, title: 'x', cwd: tmpHome, status: 'pending', dependsOn: [] }],
39
+ config: {},
40
+ paused: null,
41
+ });
42
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
43
+ const seen = [];
44
+ const h = startDispatchLoop({
45
+ // Same wiring init() uses (tick: () => tickQueue()), observed.
46
+ tick: async () => { seen.push(await scheduler.tickQueue()); },
47
+ });
48
+ try {
49
+ // Billing poll is at 480 s backoff: nothing else fires. The loop's own 30 s cadence does.
50
+ await vi.advanceTimersByTimeAsync(30_000);
51
+ // tickQueue does real file I/O; wait for the (real-time) pass to settle.
52
+ await vi.waitFor(() => expect(seen.length).toBe(1), { timeout: 10_000 });
53
+ expect(seen[0]).toBeTruthy(); // tickQueue ran and recorded an outcome for the pending row
54
+ } finally {
55
+ h.stop();
56
+ vi.useRealTimers();
57
+ }
58
+ });
@@ -19,6 +19,7 @@ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
19
19
  const fs = require('node:fs');
20
20
  const os = require('node:os');
21
21
  const path = require('node:path');
22
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
22
23
 
23
24
  let tmpHome;
24
25
  let originalHome;
@@ -51,17 +52,14 @@ afterEach(() => {
51
52
  // Stub `claude` binary: captures the '-p' prompt argv to `capturePath`, then
52
53
  // emits one stream-json result line and exits 0.
53
54
  function writeClaudeStub(capturePath) {
54
- const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
55
- const body = `
55
+ return claudeStub.writeClaudeStub({ body: `
56
56
  const fs = require('fs');
57
57
  const idx = process.argv.indexOf('-p');
58
58
  const prompt = idx >= 0 ? process.argv[idx + 1] : '';
59
59
  fs.writeFileSync(${JSON.stringify(capturePath)}, prompt, 'utf8');
60
60
  process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
61
61
  process.exit(0);
62
- `;
63
- fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
64
- return stubPath;
62
+ ` });
65
63
  }
66
64
 
67
65
  function setupProject(prefix) {
@@ -8,12 +8,12 @@
8
8
  * against a manual force-tick click), the message must say so instead of
9
9
  * flatly claiming nothing is pending.
10
10
  *
11
- * Run: timeout 120 node --test src/main/__tests__/scheduler-force-tick-outcome.test.cjs
11
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-force-tick-outcome.test.cjs
12
12
  */
13
13
 
14
14
  'use strict';
15
15
 
16
- const { test } = require('node:test');
16
+ import { test } from 'vitest';
17
17
  const assert = require('node:assert/strict');
18
18
  const { forceTickOutcome } = require('../scheduler.cjs');
19
19
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * scheduler-gate-shadow.test.cjs — the shadow gate records `gateShadow` on the
3
+ * verdicts sidecar and the row, and NEVER changes a status.
4
+ *
5
+ * HOME is overridden BEFORE requiring scheduler.cjs (top-level consts bake in
6
+ * os.homedir() — see scheduler-looks-done.test.cjs).
7
+ *
8
+ * Run: timeout 180 npx vitest run src/main/__tests__/scheduler-gate-shadow.test.cjs
9
+ */
10
+ 'use strict';
11
+
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const { execFileSync } = require('node:child_process');
17
+
18
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-shadow-test-'));
19
+ process.env.HOME = tmpHome;
20
+
21
+ const { runGateShadow } = require('../scheduler.cjs');
22
+ const { runGateSequence } = require('../lib/definitionOfDone.cjs');
23
+ const { resolveEpicPrdWriteDir } = require('../lib/prdLocations.cjs');
24
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
25
+
26
+ function git(args, cwd) { return execFileSync('git', args, { cwd, encoding: 'utf8' }); }
27
+
28
+ function setup(name, gateBody) {
29
+ const cwd = path.join(tmpHome, name);
30
+ fs.mkdirSync(cwd, { recursive: true });
31
+ git(['init', '-q'], cwd);
32
+ git(['config', 'user.email', 't@example.com'], cwd);
33
+ git(['config', 'user.name', 'T'], cwd);
34
+ fs.writeFileSync(path.join(cwd, 'README.md'), 'x\n');
35
+ git(['add', '-A'], cwd);
36
+ git(['commit', '-q', '-m', 'init'], cwd);
37
+ const slugDir = path.join(tmpHome, '.claude', 'projects', `${name}-slug`);
38
+ fs.mkdirSync(slugDir, { recursive: true });
39
+ fs.writeFileSync(path.join(slugDir, 't.jsonl'), JSON.stringify({ cwd }) + '\n');
40
+ bustCwdCache();
41
+ const prdDir = resolveEpicPrdWriteDir(cwd, 'gate-epic');
42
+ fs.mkdirSync(prdDir, { recursive: true });
43
+ const slug = `01-${name}`;
44
+ fs.writeFileSync(path.join(prdDir, `${slug}.md`), `---\ntitle: T\ncwd: ${cwd}\nestimateMinutes: 5\n---\n${gateBody}\n`);
45
+ const runId = `run-${name}`;
46
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
47
+ fs.mkdirSync(runDir, { recursive: true });
48
+ const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
49
+ fs.writeFileSync(verdictsPath, JSON.stringify({ verdict: 'transcript_errors', reason: 'r' }));
50
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
51
+ fs.mkdirSync(stateDir, { recursive: true });
52
+ const queuePath = path.join(stateDir, 'queue.json');
53
+ const job = { slug, status: 'needs_review', cwd, runId, verifierVerdict: 'transcript_errors', startedAt: new Date().toISOString(), finishedAt: new Date().toISOString() };
54
+ fs.writeFileSync(queuePath, JSON.stringify({ jobs: [job] }, null, 2));
55
+ return { cwd, slug, job, verdictsPath, queuePath };
56
+ }
57
+
58
+ const ac = (cmd) => `# Acceptance criteria\n\n- [ ] Gate: \`${cmd}\`\n`;
59
+
60
+ test('green gate: recorded on sidecar + row, status untouched, verdict keys preserved', async () => {
61
+ const t = setup('green', ac('timeout 30 node -e "process.exit(0)" && timeout 30 node -e "process.exit(0)"'));
62
+ const rec = await runGateShadow(t.job);
63
+ assert.equal(rec.status, 'green');
64
+ assert.equal(rec.results.length, 2);
65
+ const sidecar = JSON.parse(fs.readFileSync(t.verdictsPath, 'utf8'));
66
+ assert.equal(sidecar.verdict, 'transcript_errors', 'read-merge keeps runVerify keys');
67
+ assert.equal(sidecar.gateShadow.status, 'green');
68
+ const row = JSON.parse(fs.readFileSync(t.queuePath, 'utf8')).jobs.find((j) => j.slug === t.slug);
69
+ assert.equal(row.status, 'needs_review', 'shadow never transitions');
70
+ assert.equal(row.gateShadow.status, 'green');
71
+ });
72
+
73
+ test('red gate stops at the first failure and records red; status still untouched', async () => {
74
+ const t = setup('red', ac('timeout 30 node -e "process.exit(3)" && timeout 30 node -e "process.exit(0)"'));
75
+ const rec = await runGateShadow(t.job);
76
+ assert.equal(rec.status, 'red');
77
+ assert.equal(rec.results.length, 1);
78
+ assert.equal(rec.results[0].code, 3);
79
+ const row = JSON.parse(fs.readFileSync(t.queuePath, 'utf8')).jobs.find((j) => j.slug === t.slug);
80
+ assert.equal(row.status, 'needs_review');
81
+ });
82
+
83
+ test('the gate runs with CI=1 and an isolated TMPDIR', async () => {
84
+ const t = setup('env', ac('timeout 30 node -e "process.exit(process.env.CI===\'1\'&&process.env.TMPDIR.includes(\'sm-gate-shadow-\')?0:9)"'));
85
+ assert.equal((await runGateShadow(t.job)).status, 'green');
86
+ });
87
+
88
+ test('gate: none is recorded as an explicit opt-out (unavailable), nothing spawned', async () => {
89
+ const t = setup('optout', '# Acceptance criteria\n\n- [ ] `timeout 30 node -e "process.exit(9)"`\n');
90
+ t.job.slug = t.slug;
91
+ fs.writeFileSync(path.join(resolveEpicPrdWriteDir(t.cwd, 'gate-epic'), `${t.slug}.md`), `---\ntitle: T\ncwd: ${t.cwd}\ngate: none\n---\nbody\n`);
92
+ const rec = await runGateShadow(t.job);
93
+ assert.equal(rec.status, 'unavailable');
94
+ assert.equal(rec.reason, 'gate-opt-out');
95
+ assert.deepEqual(rec.results, []);
96
+ });
97
+
98
+ test('no parseable gate → unavailable', async () => {
99
+ const t = setup('nogate', '# Acceptance criteria\n\n- [ ] just prose\n');
100
+ const rec = await runGateShadow(t.job);
101
+ assert.equal(rec.status, 'unavailable');
102
+ assert.equal(rec.reason, 'no-parseable-gate');
103
+ });
104
+
105
+ test('at most one shadow gate in flight: a concurrent run reports busy and runs nothing', async () => {
106
+ const slow = [{ argv: ['node', '-e', 'setTimeout(()=>{},400)'], timeoutMs: 10_000, env: {} }];
107
+ const first = runGateSequence(slow, { cwd: tmpHome });
108
+ const second = await runGateSequence(slow, { cwd: tmpHome });
109
+ assert.equal(second.status, 'busy');
110
+ assert.deepEqual(second.results, []);
111
+ assert.equal((await first).status, 'green');
112
+ });
113
+
114
+ test('a hung command is killed at its timeout and the gate is red', async () => {
115
+ const hang = [{ argv: ['node', '-e', 'setTimeout(()=>{},60000)'], timeoutMs: 300, env: {} }];
116
+ const r = await runGateSequence(hang, { cwd: tmpHome });
117
+ assert.equal(r.status, 'red');
118
+ assert.equal(r.results[0].timedOut, true);
119
+ });
@@ -83,6 +83,17 @@ test('GUARD_VERDICT_EVIDENCE_ELIGIBLE contains exactly the benign guard verdicts
83
83
  assert.equal(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('worktree_integration_failed'), false);
84
84
  });
85
85
 
86
+ // reaperHelpers.resolvePidlessFailureOverride parks a pidless reap on this
87
+ // exact verdict when the row already carries a landedCommit — same shape as
88
+ // silent_no_op/shared_tree_reverted (exit never observed, no autoFixAttempted,
89
+ // real commit evidence), so it must share this ladder too, or a pidless-reap
90
+ // row stalls in needs_review with nothing to spend and nothing to exhaust —
91
+ // the same bug this file's other tests exist to prevent for the other two
92
+ // verdicts.
93
+ test('GUARD_VERDICT_EVIDENCE_ELIGIBLE also contains pidless_reap_with_landed_commit', () => {
94
+ assert.ok(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('pidless_reap_with_landed_commit'));
95
+ });
96
+
86
97
  // --- isGuardParkedWithoutAutoFix / isEligibleForNeedsReviewAutoResolve ---
87
98
 
88
99
  test('a guard-verdict park with no auto-fix history is guard-parked-without-autofix', () => {
@@ -342,3 +353,38 @@ test('reverifyNeedsReview never computes looksDone for a worktree_integration_fa
342
353
  assert.equal(jobs[0].looksDone, undefined, 'a stranded-branch verdict must never be treated as evidence-gatherable');
343
354
  assert.equal(jobs[0].status, 'needs_review');
344
355
  });
356
+
357
+ test('reverifyNeedsReview heals a stale shared_tree_reverted row (autoFixAttempted, no outcome) whose landedCommit is still an ancestor of HEAD', async () => {
358
+ const projectCwd = path.join(tmpHome, 'proj-stale-shared-tree');
359
+ initRepo(projectCwd);
360
+ registerActiveProject(projectCwd, 'proj-stale-shared-tree-slug');
361
+ writePrd(projectCwd, '1229-stale-shared-tree', '# Implementation notes\nEdit `src/thing.js`.');
362
+
363
+ const startedAt = new Date(Date.now() - 5000).toISOString();
364
+ await wait(1100);
365
+ commitFile(projectCwd, 'src/thing.js', 'job work', 'the job own commit');
366
+ const landed = git(['rev-parse', 'HEAD'], projectCwd).trim();
367
+ commitFile(projectCwd, 'docs/unrelated.md', 'human', 'human commit after the job');
368
+
369
+ const queuePath = writeProjectQueue(projectCwd, [
370
+ {
371
+ slug: '1229-stale-shared-tree',
372
+ status: 'needs_review',
373
+ cwd: projectCwd,
374
+ runId: 'run-1229',
375
+ startedAt,
376
+ finishedAt: new Date().toISOString(),
377
+ exitCode: 0,
378
+ verifierVerdict: 'shared_tree_reverted',
379
+ landedCommit: landed,
380
+ autoFixAttempted: true,
381
+ },
382
+ ]);
383
+
384
+ await reverifyNeedsReview();
385
+
386
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
387
+ // Healed rows are archived out of the live queue on the same pass.
388
+ const row = jobs.find((j) => j.slug === '1229-stale-shared-tree');
389
+ assert.ok(!row || row.status === 'completed', 'row must no longer be parked in needs_review');
390
+ });