claude-code-session-manager 0.87.0 → 0.88.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-kQF8_wAw.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-CY_mMnsr.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-Sji2brb0.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CtqpO-p5.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-DN0ydOCr.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-CoiLZTKh.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CmqiQ_io.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-HPoNeKhT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-Bj1X3_NT.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-1PKjsM3b.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-I1s6ghPo.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-BhiiZjHQ.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-gv3_fkPX.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-BGcgBd8V.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BGr9eZ4g.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-Bdo_awPq.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-ukl0qWaA.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-DJkFj22G.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-0t1Qblp6.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-CeVdBGE-.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-CfKvNHi3.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-DAzNaUPd.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody--S_rGXoA.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-Dszm0xnp.js} +1 -1
  26. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  27. package/dist/assets/{index-fc_JjdxL.js → index-Dp4Rc--q.js} +356 -356
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-BLbssMYo.js} +1 -1
  29. package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
  30. package/dist/index.html +2 -2
  31. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
  32. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  33. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
  34. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  35. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
  36. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  37. package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
  38. package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
  39. package/package.json +10 -13
  40. package/scripts/README.md +59 -0
  41. package/scripts/audit-ops-hygiene.cjs +350 -0
  42. package/scripts/hooks/guard-destructive-git.cjs +10 -34
  43. package/scripts/hooks/guard-inline-implementation.cjs +14 -8
  44. package/scripts/hooks/guard-prd-writes.cjs +7 -36
  45. package/scripts/hooks/guard-self-schedule.cjs +175 -0
  46. package/scripts/ops-sweep.cjs +355 -0
  47. package/scripts/scheduler-mcp-server.cjs +28 -71
  48. package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
  49. package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
  50. package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
  51. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
  52. package/src/main/__tests__/chat-queue.test.cjs +2 -2
  53. package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
  54. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
  55. package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
  56. package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
  57. package/src/main/__tests__/dod-report.test.cjs +2 -2
  58. package/src/main/__tests__/dod-reverify.test.cjs +2 -2
  59. package/src/main/__tests__/epicMint.test.cjs +2 -2
  60. package/src/main/__tests__/exchanges.test.cjs +2 -2
  61. package/src/main/__tests__/extractJson.test.cjs +2 -2
  62. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  63. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
  64. package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
  65. package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
  66. package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
  67. package/src/main/__tests__/health-tick-liveness.test.cjs +27 -3
  68. package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
  69. package/src/main/__tests__/historyRollup.test.cjs +2 -2
  70. package/src/main/__tests__/kg-augment.test.cjs +2 -2
  71. package/src/main/__tests__/mcpStatus.test.cjs +2 -2
  72. package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
  73. package/src/main/__tests__/memoryStale.test.cjs +1 -1
  74. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
  75. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
  76. package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
  77. package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
  78. package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
  79. package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
  80. package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
  81. package/src/main/__tests__/proc-role-env.test.cjs +125 -0
  82. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
  83. package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
  84. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
  85. package/src/main/__tests__/projectPages.test.cjs +63 -149
  86. package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
  87. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
  88. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  89. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
  90. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
  91. package/src/main/__tests__/runVerify.test.cjs +5 -5
  92. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
  93. package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
  94. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
  95. package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
  96. package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
  97. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
  98. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
  99. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
  100. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
  101. package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
  102. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
  103. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
  104. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
  105. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
  106. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
  107. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
  108. package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
  109. package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
  110. package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
  111. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
  112. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
  113. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
  114. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
  115. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
  116. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
  117. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
  118. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
  119. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
  120. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
  121. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
  122. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
  123. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +14 -0
  124. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
  125. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  126. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  127. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  128. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  129. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  130. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  131. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  132. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  133. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  134. package/src/main/bilkoHost.cjs +16 -7
  135. package/src/main/build-info.json +8 -0
  136. package/src/main/chatRunner.cjs +4 -2
  137. package/src/main/config.cjs +2 -3
  138. package/src/main/docEdit.cjs +4 -2
  139. package/src/main/health.cjs +276 -52
  140. package/src/main/heapSnapshot.cjs +2 -2
  141. package/src/main/historyAggregator.cjs +1 -1
  142. package/src/main/index.cjs +61 -10
  143. package/src/main/ipcSchemas.cjs +6 -17
  144. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  145. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  146. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  147. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  148. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  149. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  150. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  151. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  152. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  153. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  154. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  155. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  156. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  157. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  158. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  159. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  160. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  161. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  162. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  163. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  164. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  165. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  166. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  167. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  168. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  169. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  170. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  171. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  172. package/src/main/lib/activeSessions.cjs +20 -88
  173. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  174. package/src/main/lib/agentModelResolve.cjs +33 -1
  175. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  176. package/src/main/lib/auditLog.cjs +30 -5
  177. package/src/main/lib/buildIdentity.cjs +113 -0
  178. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  179. package/src/main/lib/claudeBin.cjs +37 -2
  180. package/src/main/lib/cleanEnv.cjs +27 -1
  181. package/src/main/lib/credentials.cjs +4 -2
  182. package/src/main/lib/cwdClassify.cjs +185 -0
  183. package/src/main/lib/definitionOfDone.cjs +296 -52
  184. package/src/main/lib/dispatchLoop.cjs +40 -0
  185. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  186. package/src/main/lib/ephemeralCwd.cjs +7 -28
  187. package/src/main/lib/gitWorktree.cjs +95 -8
  188. package/src/main/lib/guardShims.cjs +3 -3
  189. package/src/main/lib/historyRollup.cjs +6 -7
  190. package/src/main/lib/instanceLock.cjs +32 -5
  191. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  192. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  193. package/src/main/lib/localAdminHttp.cjs +9 -27
  194. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  195. package/src/main/lib/opsOwnership.cjs +27 -19
  196. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  197. package/src/main/lib/prdLocations.cjs +48 -1
  198. package/src/main/lib/procIdentity.cjs +126 -0
  199. package/src/main/lib/procName.cjs +98 -0
  200. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  201. package/src/main/lib/queueHistory.cjs +8 -7
  202. package/src/main/lib/queueStore.cjs +57 -37
  203. package/src/main/lib/quietMachineLease.cjs +19 -2
  204. package/src/main/lib/reservationExpiry.cjs +30 -0
  205. package/src/main/lib/runClaudeP.cjs +4 -2
  206. package/src/main/lib/runLogRetention.cjs +3 -2
  207. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  208. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  209. package/src/main/lib/schedulerBatch.cjs +27 -2
  210. package/src/main/lib/schedulerPaths.cjs +175 -0
  211. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  212. package/src/main/lib/sessionSlots.cjs +47 -9
  213. package/src/main/lib/smProcNames.cjs +51 -0
  214. package/src/main/lib/upgradeDrain.cjs +188 -0
  215. package/src/main/lib/usageCircuit.cjs +53 -6
  216. package/src/main/lib/watchdogHelpers.cjs +70 -37
  217. package/src/main/lib/withTimeout.cjs +33 -0
  218. package/src/main/mcpStatus.cjs +4 -2
  219. package/src/main/pluginInstall.cjs +6 -2
  220. package/src/main/projectPages.cjs +41 -145
  221. package/src/main/pty.cjs +8 -1
  222. package/src/main/queueOps.cjs +10 -10
  223. package/src/main/runVerify.cjs +69 -3
  224. package/src/main/scheduler.cjs +1607 -383
  225. package/src/main/seedAgentPersonas.cjs +1 -1
  226. package/src/main/seedDevPlugin.cjs +1 -1
  227. package/src/main/seedSchedulerMcp.cjs +7 -6
  228. package/src/main/seedStatus.cjs +1 -1
  229. package/src/main/supervisor.cjs +5 -3
  230. package/src/main/usage.cjs +126 -62
  231. package/src/preload/api.d.ts +18 -64
  232. package/src/preload/index.cjs +2 -4
  233. package/src/seed/agents/project-home-builder.md +31 -48
  234. package/screenshots/.gitkeep +0 -0
  235. package/screenshots/README-screenshots.md +0 -13
  236. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  237. package/src/main/teams.cjs +0 -95
  238. package/src/main/templates/project-pages-catalog.json +0 -741
  239. package/src/main/templates/project-pages-default-home.html +0 -123
  240. package/src/main/templates/project-pages-pipeline.md +0 -417
  241. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  242. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  243. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  244. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  245. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  246. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  247. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  248. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  249. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  250. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  251. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  252. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  253. package/src/seed/prompts/documentation/module-readme.md +0 -8
  254. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  255. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  256. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  257. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  258. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  259. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  260. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  261. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  262. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  263. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  264. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  265. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  266. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  267. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  268. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  269. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  270. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  271. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  272. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  273. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  274. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  275. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  276. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  277. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  278. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  279. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  280. package/src/seed/prompts/security/authz-audit.md +0 -8
  281. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  282. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  283. package/src/seed/prompts/security/dependency-audit.md +0 -8
  284. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  285. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  286. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  287. package/web/README.md +0 -41
  288. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  289. package/web/project-pages/render.cjs +0 -70
  290. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  291. package/web/project-pages/validate-summary.cjs +0 -62
@@ -0,0 +1,245 @@
1
+ /**
2
+ * scheduler-stranded-autofix-park.test.cjs — PRD "1218-fo-01 stranded
3
+ * auto-fix park" (2026-09-13, findings filed at session-manager-operations/
4
+ * reviews/2026-09-13-scheduler-stability-investigation.md, "post-run
5
+ * adjudication" section).
6
+ *
7
+ * spawnInvestigation restores a job's status from 'investigating' back to
8
+ * needs_review in ONE mutate() call (scheduler.cjs, source
9
+ * 'spawnInvestigation:onExit') and stamps autoFixOutcome in a SEPARATE,
10
+ * later mutate() call. A crash/restart between the two leaves a row with
11
+ * autoFixAttempted: true and autoFixOutcome permanently undefined —
12
+ * isExhaustedAutoFix wants autoFixRetries >= 1 (never incremented here) and
13
+ * isGuardParkedWithoutAutoFix wants autoFixAttempted === false, so the row
14
+ * falls through every resolving door and the periodic reverify pass just
15
+ * re-scans the same frozen transcript forever. Job 1218-fo-01 sat exactly
16
+ * like this with a landed commit while every dependent PRD waited.
17
+ *
18
+ * This file covers the new isStrandedAutoFixPark predicate and its wiring
19
+ * into isEligibleForNeedsReviewAutoResolve / selectExhaustedNeedsReviewTargets
20
+ * / applyNeedsReviewAutoResolve — the SAME bounded ladder the exhausted-
21
+ * auto-fix and guard-parked doors already use, unchanged.
22
+ *
23
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
24
+ * reason as scheduler-looks-done.test.cjs (appendAuditEvent writes under
25
+ * $HOME/.claude/session-manager/audit-log.jsonl).
26
+ *
27
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-stranded-autofix-park.test.cjs
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const assert = require('node:assert/strict');
33
+ const fs = require('node:fs');
34
+ const os = require('node:os');
35
+ const path = require('node:path');
36
+ const { execFileSync } = require('node:child_process');
37
+
38
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'stranded-autofix-park-test-'));
39
+ process.env.HOME = tmpHome;
40
+
41
+ const {
42
+ isStrandedAutoFixPark,
43
+ isExhaustedAutoFix,
44
+ isGuardParkedWithoutAutoFix,
45
+ isEligibleForNeedsReviewAutoResolve,
46
+ selectExhaustedNeedsReviewTargets,
47
+ applyNeedsReviewAutoResolve,
48
+ computeLooksDone,
49
+ fixSlugFor,
50
+ NEEDS_REVIEW_RESOLVE_CAP,
51
+ } = require('../scheduler.cjs');
52
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
53
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
54
+
55
+ const MIN_MS = 60_000;
56
+ const THRESHOLD_MS = 30 * MIN_MS;
57
+
58
+ // findPrdDir (via resolvePrdsDirs -> allProjectCwds) only discovers a PRD
59
+ // dir under a cwd it already knows about — same registration computeLooksDone
60
+ // needs in scheduler-looks-done.test.cjs / scheduler-guard-verdict-autoresolve.test.cjs.
61
+ function registerActiveProject(cwd, slug) {
62
+ const slugDir = path.join(tmpHome, '.claude', 'projects', slug);
63
+ fs.mkdirSync(slugDir, { recursive: true });
64
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
65
+ bustCwdCache();
66
+ }
67
+
68
+ // Replays the live 1218-fo-01 tuple: needs_review, verifierVerdict
69
+ // transcript_errors, exitCode 0, a landed commit, autoFixAttempted true,
70
+ // autoFixOutcome undefined, statusHistory ending in the exact
71
+ // spawnInvestigation:onExit restore reason.
72
+ function strandedJob(overrides = {}) {
73
+ return {
74
+ slug: '1218-fo-01-move-scripts-lib-into-src-main-lib',
75
+ cwd: '/home/user/project',
76
+ status: 'needs_review',
77
+ exitCode: 0,
78
+ verifierVerdict: 'transcript_errors',
79
+ landedCommit: 'd1edf15babc1234',
80
+ autoFixAttempted: true,
81
+ statusHistory: [
82
+ { to: 'needs_review', at: new Date(Date.now() - 45 * MIN_MS).toISOString() },
83
+ { to: 'investigating', at: new Date(Date.now() - 40 * MIN_MS).toISOString(), reason: 'spawning investigation probe' },
84
+ {
85
+ to: 'needs_review',
86
+ at: new Date(Date.now() - 35 * MIN_MS).toISOString(),
87
+ reason: 'investigation probe exited — restoring prior status',
88
+ },
89
+ ],
90
+ ...overrides,
91
+ };
92
+ }
93
+
94
+ // --- isStrandedAutoFixPark ---
95
+
96
+ test('the 1218 tuple (autoFixAttempted true, autoFixOutcome undefined, no child row) is a stranded auto-fix park', () => {
97
+ const job = strandedJob();
98
+ assert.equal(isStrandedAutoFixPark(job, [job]), true);
99
+ assert.equal(isExhaustedAutoFix(job), false, 'never accumulated a retry — must not double-count as exhausted');
100
+ assert.equal(isGuardParkedWithoutAutoFix(job), false, 'autoFixAttempted is true — the guard-parked door requires false');
101
+ });
102
+
103
+ test('autoFixOutcome "error" or "no-plan" with an unspent retry is NOT stranded — selectAutoFixTargets still owns it', () => {
104
+ // Durably stamped (unlike the 1218 tuple's permanently-unset outcome), so
105
+ // its one bounded retry (autoFixRetries < 1) is still selectAutoFixTargets's
106
+ // to spend — pulling it into this ladder instead would race it away from
107
+ // that retry (see scheduler-needs-review-autoresolve.test.cjs's "a
108
+ // non-exhausted needs_review row … is left alone").
109
+ assert.equal(isStrandedAutoFixPark(strandedJob({ autoFixOutcome: 'error' }), []), false);
110
+ assert.equal(isStrandedAutoFixPark(strandedJob({ autoFixOutcome: 'no-plan' }), []), false);
111
+ });
112
+
113
+ test('autoFixOutcome "plan" is never a stranded park — that is isPlanUnqueued/isFixPlanDead territory', () => {
114
+ assert.equal(isStrandedAutoFixPark(strandedJob({ autoFixOutcome: 'plan' }), []), false);
115
+ });
116
+
117
+ test('a row that never attempted auto-fix at all is not a stranded park', () => {
118
+ assert.equal(isStrandedAutoFixPark(strandedJob({ autoFixAttempted: undefined }), []), false);
119
+ });
120
+
121
+ test('a row not in needs_review is never a stranded park, whatever its autoFix* fields say', () => {
122
+ assert.equal(isStrandedAutoFixPark(strandedJob({ status: 'failed' }), []), false);
123
+ });
124
+
125
+ test('a live fix-plan child (running) means there IS something in flight — not stranded', () => {
126
+ const job = strandedJob();
127
+ const child = { slug: fixSlugFor(job), status: 'running' };
128
+ assert.equal(isStrandedAutoFixPark(job, [job, child]), false);
129
+ });
130
+
131
+ test('a row with a live investigating status (the fix-plan child mid-probe) is NOT selected', () => {
132
+ const job = strandedJob();
133
+ const child = { slug: fixSlugFor(job), status: 'investigating' };
134
+ assert.equal(isStrandedAutoFixPark(job, [job, child]), false);
135
+ assert.equal(selectExhaustedNeedsReviewTargets([job, child], Date.now(), THRESHOLD_MS).length, 0);
136
+ });
137
+
138
+ test('a queued (pending) fix-plan child also blocks the stranded-park door', () => {
139
+ const job = strandedJob();
140
+ const child = { slug: fixSlugFor(job), status: 'pending' };
141
+ assert.equal(isStrandedAutoFixPark(job, [job, child]), false);
142
+ });
143
+
144
+ test('a DEAD fix-plan child (needs_review/failed/quarantined/skipped) does not block it — nothing left to wait on', () => {
145
+ const job = strandedJob();
146
+ for (const deadStatus of ['needs_review', 'failed', 'quarantined', 'skipped']) {
147
+ const child = { slug: fixSlugFor(job), status: deadStatus };
148
+ assert.equal(isStrandedAutoFixPark(job, [job, child]), true, `child status ${deadStatus} must not block`);
149
+ }
150
+ });
151
+
152
+ test('a COMPLETED fix-plan child does not block it either', () => {
153
+ const job = strandedJob();
154
+ const child = { slug: fixSlugFor(job), status: 'completed' };
155
+ assert.equal(isStrandedAutoFixPark(job, [job, child]), true);
156
+ });
157
+
158
+ test('isEligibleForNeedsReviewAutoResolve admits the stranded-park door alongside the other two', () => {
159
+ const job = strandedJob();
160
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job, [job]), true);
161
+ });
162
+
163
+ // --- selectExhaustedNeedsReviewTargets: age-gated selection ---
164
+
165
+ test('a fresh stranded park (under threshold) is not selected', () => {
166
+ const job = strandedJob({
167
+ statusHistory: [
168
+ { to: 'needs_review', at: new Date(Date.now() - 2 * MIN_MS).toISOString(), reason: 'investigation probe exited — restoring prior status' },
169
+ ],
170
+ });
171
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
172
+ });
173
+
174
+ test('a stranded park past NEEDS_REVIEW_RESOLVE_MS, under the cap, IS selected', () => {
175
+ const job = strandedJob();
176
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
177
+ assert.equal(found.length, 1);
178
+ assert.equal(found[0].slug, job.slug);
179
+ });
180
+
181
+ // --- applyNeedsReviewAutoResolve: same bounded ladder, entered via the new door ---
182
+
183
+ test('computeLooksDone evidence completes the stranded row via the existing looksDone branch', async () => {
184
+ const projectCwd = fs.mkdtempSync(path.join(tmpHome, 'proj-stranded-'));
185
+ fs.mkdirSync(projectCwd, { recursive: true });
186
+ const git = (args) => execFileSync('git', args, { cwd: projectCwd, encoding: 'utf8' });
187
+ git(['init', '-q']);
188
+ git(['config', 'user.email', 'test@example.com']);
189
+ git(['config', 'user.name', 'Test']);
190
+ fs.writeFileSync(path.join(projectCwd, 'README.md'), 'hello\n');
191
+ git(['add', '-A']);
192
+ git(['commit', '-q', '-m', 'initial']);
193
+ registerActiveProject(projectCwd, path.basename(projectCwd));
194
+
195
+ const dir = resolvePrdWriteDir(projectCwd);
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ const slug = '1218-fo-01-move-scripts-lib-into-src-main-lib';
198
+ fs.writeFileSync(
199
+ path.join(dir, `${slug}.md`),
200
+ `---\ntitle: Test PRD\ncwd: ${projectCwd}\nestimateMinutes: 30\n---\n# Implementation notes\nEdit \`src/thing.js\`.\n`,
201
+ );
202
+
203
+ const startedAt = new Date(Date.now() - 60 * MIN_MS).toISOString();
204
+ await new Promise((r) => setTimeout(r, 1100)); // git --since has 1s resolution
205
+ fs.mkdirSync(path.join(projectCwd, 'src'), { recursive: true });
206
+ fs.writeFileSync(path.join(projectCwd, 'src', 'thing.js'), 'module.exports = {};\n');
207
+ git(['add', '-A']);
208
+ git(['commit', '-q', '-m', `the actual fix for ${slug} landed`]);
209
+
210
+ const job = strandedJob({ slug, cwd: projectCwd, startedAt });
211
+ assert.equal(isStrandedAutoFixPark(job, [job]), true);
212
+
213
+ const looksDone = await computeLooksDone(job);
214
+ assert.ok(looksDone, 'expected computeLooksDone to find the attributable commit');
215
+ job.looksDone = looksDone;
216
+
217
+ const outcome = applyNeedsReviewAutoResolve(job, [job]);
218
+ assert.equal(outcome, 'completed');
219
+ assert.equal(job.status, 'completed');
220
+ });
221
+
222
+ test('with no completion evidence yet, the stranded row gets one bounded requeue, same cap/logic as the other doors', () => {
223
+ const job = strandedJob({ exhaustedResolveAttempts: 0 });
224
+ assert.equal(job.looksDone, undefined);
225
+ const outcome = applyNeedsReviewAutoResolve(job, [job]);
226
+ assert.equal(outcome, 'requeued');
227
+ assert.equal(job.status, 'pending');
228
+ assert.equal(job.exhaustedResolveAttempts, 1);
229
+ });
230
+
231
+ test('once the cap is spent, the stranded row auto-skips like every other door', () => {
232
+ const job = strandedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
233
+ const outcome = applyNeedsReviewAutoResolve(job, [job]);
234
+ assert.equal(outcome, 'skipped');
235
+ assert.equal(job.status, 'skipped');
236
+ assert.equal(job.needsReviewAutoResolvedSkip, true);
237
+ });
238
+
239
+ test('a live fix-plan child also blocks applyNeedsReviewAutoResolve, not just selection', () => {
240
+ const job = strandedJob();
241
+ const child = { slug: fixSlugFor(job), status: 'running' };
242
+ const outcome = applyNeedsReviewAutoResolve(job, [job, child]);
243
+ assert.equal(outcome, null, 'nothing to resolve while the fix-plan child is still live');
244
+ assert.equal(job.status, 'needs_review', 'must stay parked while the child is in flight');
245
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * scheduler-supervisor-record.test.cjs — executeJob writes
3
+ * <runDir>/<slug>.supervisor.json synchronously at the `spawned pid=` point,
4
+ * i.e. before the child's first output and before onPid's mutate runs.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-supervisor-record.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeAll, afterAll, afterEach } 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 executeJob;
18
+
19
+ beforeAll(() => {
20
+ originalHome = process.env.HOME;
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-supervisor-rec-'));
22
+ process.env.HOME = tmpHome;
23
+ ({ executeJob } = require('../scheduler.cjs'));
24
+ });
25
+ afterAll(() => {
26
+ process.env.HOME = originalHome;
27
+ fs.rmSync(tmpHome, { recursive: true, force: true });
28
+ });
29
+ afterEach(() => { delete process.env.SM_CLAUDE_BIN; });
30
+
31
+ function writeSlowStub() {
32
+ const stubPath = path.join(os.tmpdir(), `sm-sup-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
33
+ const body = `
34
+ setTimeout(() => {
35
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
36
+ process.exit(0);
37
+ }, 500);
38
+ `;
39
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
40
+ return stubPath;
41
+ }
42
+
43
+ test('the supervisor record exists by the time onPid fires, before the child prints anything', async () => {
44
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-sup-main-'));
45
+ const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-sup-wt-'));
46
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
47
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-sup-run-'));
48
+ try {
49
+ const slug = `sup-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
50
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
51
+ fs.mkdirSync(prdsDir, { recursive: true });
52
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'body', 'utf8');
53
+ process.env.SM_CLAUDE_BIN = writeSlowStub();
54
+
55
+ const seen = {};
56
+ const result = await executeJob({ slug, cwd: mainCwd }, runDir, mainCwd, (pid) => {
57
+ const file = path.join(runDir, `${slug}.supervisor.json`);
58
+ seen.exists = fs.existsSync(file);
59
+ seen.record = seen.exists ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
60
+ seen.pid = pid;
61
+ seen.logHasOutput = fs.readFileSync(path.join(runDir, `${slug}.log`), 'utf8').includes('"type":"result"');
62
+ seen.metaExists = fs.existsSync(path.join(runDir, `${slug}.meta.json`));
63
+ return Promise.resolve();
64
+ }, worktreeCwd);
65
+
66
+ expect(result.exitCode).toBe(0);
67
+ expect(seen.exists).toBe(true);
68
+ expect(seen.logHasOutput).toBe(false);
69
+ expect(seen.metaExists).toBe(false);
70
+ expect(seen.record).toMatchObject({
71
+ kind: 'job', slug, cwd: mainCwd, runId: path.basename(runDir), pid: seen.pid, pgid: seen.pid,
72
+ execCwd: worktreeCwd, worktreeDir: worktreeCwd, worktreeBranch: `sm-job/${slug}`, schedulerPid: process.pid,
73
+ });
74
+ expect(typeof seen.record.startedAt).toBe('number');
75
+ expect(seen.record.idleKillMs).toBeGreaterThan(0);
76
+ } finally {
77
+ fs.rmSync(mainCwd, { recursive: true, force: true });
78
+ fs.rmSync(worktreeCwd, { recursive: true, force: true });
79
+ fs.rmSync(runDir, { recursive: true, force: true });
80
+ }
81
+ });
@@ -8,12 +8,12 @@
8
8
  * subsequent tickQueue() call short-circuited on the stale flag forever,
9
9
  * even though queue.json's `paused` field was correctly null.
10
10
  *
11
- * Run: timeout 120 node --test src/main/__tests__/scheduler-tick-cancel-token.test.cjs
11
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-tick-cancel-token.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 { applyPauseCleared, pickNextBatch } = require('../scheduler.cjs');
19
19
 
@@ -0,0 +1,172 @@
1
+ /**
2
+ * scheduler-tick-wedge.test.cjs — tickQueue's serialized chain (tickTail) must
3
+ * survive a tick body that never settles: the body is bounded by a watchdog,
4
+ * the chain resets, and a late-resuming stale body is fenced off (no spawn, no
5
+ * mutate). mutateTail is never reset.
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-tick-wedge.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeAll, afterAll } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+
17
+ let tmpHome;
18
+ let originalHome;
19
+ let originalClaudeBin;
20
+ let originalAutofix;
21
+ let originalWatchdog;
22
+ let originalClaudeVersion;
23
+ let scheduler;
24
+ let queueStore;
25
+ let auditLog;
26
+ let spawnMarker;
27
+
28
+ function auditKinds() {
29
+ try {
30
+ return fs.readFileSync(auditLog.auditLogPath(), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l).kind);
31
+ } catch { return []; }
32
+ }
33
+
34
+ beforeAll(() => {
35
+ originalHome = process.env.HOME;
36
+ originalClaudeBin = process.env.SM_CLAUDE_BIN;
37
+ originalAutofix = process.env.SM_AUTOFIX_DISABLE;
38
+ originalWatchdog = process.env.SM_TICK_WATCHDOG_MS;
39
+ // This test's spawn marker is written by the stub on ANY invocation, including the
40
+ // `claude --version` probe — so it needs the real probe, not the sandbox's pinned version.
41
+ originalClaudeVersion = process.env.SM_CLAUDE_VERSION;
42
+ delete process.env.SM_CLAUDE_VERSION;
43
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-tick-wedge-'));
44
+ process.env.HOME = tmpHome;
45
+ process.env.SM_AUTOFIX_DISABLE = '1';
46
+ // Any spawn of the "claude" binary drops a marker file: proof spawnJob ran.
47
+ spawnMarker = path.join(tmpHome, 'claude-spawned.marker');
48
+ const stubPath = path.join(tmpHome, 'claude-stub.cjs');
49
+ const body = `require('node:fs').appendFileSync(${JSON.stringify(spawnMarker)}, 'x');\n`
50
+ + `process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');\n`
51
+ + 'process.exit(0);\n';
52
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}`, { mode: 0o755 });
53
+ process.env.SM_CLAUDE_BIN = stubPath;
54
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
55
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
56
+ scheduler = require('../scheduler.cjs');
57
+ queueStore = require('../lib/queueStore.cjs');
58
+ auditLog = require('../lib/auditLog.cjs');
59
+ });
60
+
61
+ afterAll(() => {
62
+ process.env.HOME = originalHome;
63
+ for (const [k, v] of [['SM_CLAUDE_BIN', originalClaudeBin], ['SM_AUTOFIX_DISABLE', originalAutofix], ['SM_TICK_WATCHDOG_MS', originalWatchdog], ['SM_CLAUDE_VERSION', originalClaudeVersion]]) {
64
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
65
+ }
66
+ fs.rmSync(tmpHome, { recursive: true, force: true });
67
+ });
68
+
69
+ test('a never-settling tick body is declared wedged, the chain resets, the stale body is fenced, audit fires once', async () => {
70
+ process.env.SM_TICK_WATCHDOG_MS = '150';
71
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-tick-wedge-project-'));
72
+ const projDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'projects', 'sm-tick-wedge-fake-'));
73
+ fs.writeFileSync(path.join(projDir, 'session.jsonl'), `${JSON.stringify({ cwd })}\n`, 'utf8');
74
+ const slug = `9003-tick-wedge-${process.pid}`;
75
+ await scheduler.writeQueue({
76
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
77
+ config: {},
78
+ paused: null,
79
+ });
80
+
81
+ // The first readMerged (the tick body's first await) hangs until released;
82
+ // every later call delegates to the real reader.
83
+ const realRead = queueStore.readMerged;
84
+ let release;
85
+ const hung = new Promise((r) => { release = r; });
86
+ let calls = 0;
87
+ queueStore.readMerged = function patched(...args) {
88
+ calls += 1;
89
+ if (calls === 1) return hung;
90
+ return realRead.apply(this, args);
91
+ };
92
+
93
+ try {
94
+ const wedgedResult = await scheduler.tickQueue();
95
+ expect(wedgedResult).toMatchObject({ fired: false, reason: 'wedged' });
96
+ expect(auditKinds().filter((k) => k === 'tick_wedged')).toHaveLength(1);
97
+
98
+ // A second wedge in the same episode (no normal tick between) must not re-audit.
99
+ calls = 0;
100
+ const hung2 = new Promise(() => {});
101
+ queueStore.readMerged = function patched2(...args) {
102
+ calls += 1;
103
+ if (calls === 1) return hung2;
104
+ return realRead.apply(this, args);
105
+ };
106
+ const again = await scheduler.tickQueue();
107
+ expect(again).toMatchObject({ reason: 'wedged' });
108
+ expect(auditKinds().filter((k) => k === 'tick_wedged')).toHaveLength(1);
109
+
110
+ // Next tick runs on a fresh chain (real reader, generous budget).
111
+ queueStore.readMerged = realRead;
112
+ process.env.SM_TICK_WATCHDOG_MS = '30000';
113
+ // Launch gates this case controls, so the dispatch assertion below does not
114
+ // depend on host state: the CPU load gate (loadavg1/cores > 0.85 -> 'load-deferred')
115
+ // via tickQueue's bypassLoadGate, and the memory gate ('memory-deferred', reads
116
+ // MemAvailable from /proc/meminfo) via a stubbed fs.readFileSync for that one path.
117
+ const realReadFileSync = fs.readFileSync;
118
+ fs.readFileSync = function patchedReadFileSync(file, ...rest) {
119
+ if (file === '/proc/meminfo') return 'MemAvailable: 67108864 kB\n';
120
+ return realReadFileSync.call(this, file, ...rest);
121
+ };
122
+ let fresh;
123
+ try {
124
+ fresh = await scheduler.tickQueue({ bypassLoadGate: true });
125
+ } finally {
126
+ fs.readFileSync = realReadFileSync;
127
+ }
128
+ expect(fresh.reason).not.toBe('wedged');
129
+
130
+ // Let the fresh tick's own spawn (if any) finish, then clear the marker.
131
+ // The fresh tick must have dispatched: wait for the stub's marker, then for
132
+ // the row to leave running/pending (spawnJob is fire-and-forget).
133
+ const deadline = Date.now() + 10_000;
134
+ while (Date.now() < deadline) {
135
+ const row = queueStore.readMergedSync().jobs.find((j) => j.slug === slug);
136
+ if (fs.existsSync(spawnMarker) && (!row || (row.status !== 'running' && row.status !== 'pending'))) break;
137
+ await new Promise((r) => setTimeout(r, 25));
138
+ }
139
+ expect(fs.existsSync(spawnMarker)).toBe(true);
140
+ fs.rmSync(spawnMarker, { force: true });
141
+
142
+ // Late-resume the stale body with a state that WOULD dispatch a pending job.
143
+ const before = queueStore.readMergedSync();
144
+ const staleState = {
145
+ ...before,
146
+ jobs: [{ slug: `9004-tick-wedge-stale-${process.pid}`, title: 'y', cwd, status: 'pending', dependsOn: [] }],
147
+ config: { ...(before.config || {}) },
148
+ };
149
+ release(staleState);
150
+ await new Promise((r) => setTimeout(r, 300));
151
+ expect(fs.existsSync(spawnMarker)).toBe(false);
152
+ } finally {
153
+ queueStore.readMerged = realRead;
154
+ }
155
+ });
156
+
157
+ test('a slow-but-healthy tick under the budget is not flagged as wedged', async () => {
158
+ process.env.SM_TICK_WATCHDOG_MS = '2000';
159
+ const realRead = queueStore.readMerged;
160
+ const before = auditKinds().filter((k) => k === 'tick_wedged').length;
161
+ queueStore.readMerged = async function slow(...args) {
162
+ await new Promise((r) => setTimeout(r, 100));
163
+ return realRead.apply(this, args);
164
+ };
165
+ try {
166
+ const r = await scheduler.tickQueue();
167
+ expect(r?.reason).not.toBe('wedged');
168
+ expect(auditKinds().filter((k) => k === 'tick_wedged').length).toBe(before);
169
+ } finally {
170
+ queueStore.readMerged = realRead;
171
+ }
172
+ });
@@ -4,12 +4,12 @@
4
4
  * requeue instead of burning a whole run + an auto-fix investigation on an
5
5
  * outage that has nothing to do with the code.
6
6
  *
7
- * Run: timeout 120 node --test src/main/__tests__/scheduler-transient-failure.test.cjs
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-transient-failure.test.cjs
8
8
  */
9
9
 
10
10
  'use strict';
11
11
 
12
- const { test } = require('node:test');
12
+ import { test } from 'vitest';
13
13
  const assert = require('node:assert/strict');
14
14
  const fs = require('node:fs');
15
15
  const os = require('node:os');
@@ -18,6 +18,7 @@ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
18
18
  const fs = require('node:fs');
19
19
  const os = require('node:os');
20
20
  const path = require('node:path');
21
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
21
22
  const { execFileSync } = require('node:child_process');
22
23
 
23
24
  let tmpHome;
@@ -30,6 +31,24 @@ function git(args, cwd) {
30
31
  return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
32
  }
32
33
 
34
+ // A completed job's PRD is archived immediately (archiveCompletedPrd, called
35
+ // for EVERY job that reaches effectiveStatus:'completed' — not just a
36
+ // retention-aged one), and the very next reconcile() pass (spawnJob's own
37
+ // broadcast({flush:true}) right after finishing) then finds the row's PRD
38
+ // gone and backfills+drops it from queue.json into history.jsonl in that
39
+ // SAME pass — see scheduler-reconcile-history-backfill.test.cjs. So a job
40
+ // that actually completed is found in EITHER queue.json OR history.jsonl,
41
+ // never guaranteed to still be the former.
42
+ function readTerminalRow(queuePath, projectCwd, slug) {
43
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
44
+ const row = jobs.find((j) => j.slug === slug);
45
+ if (row) return row;
46
+ const historyPath = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'state', 'history.jsonl');
47
+ if (!fs.existsSync(historyPath)) return undefined;
48
+ const lines = fs.readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
49
+ return lines.map((l) => JSON.parse(l)).reverse().find((j) => j.slug === slug);
50
+ }
51
+
33
52
  function initRepo(dir) {
34
53
  fs.mkdirSync(dir, { recursive: true });
35
54
  git(['init', '-q'], dir);
@@ -61,8 +80,9 @@ function writeProjectQueue(cwd, jobs) {
61
80
  // test can prove whether — and where — it was actually spawned, then emits a
62
81
  // clean stream-json success result.
63
82
  function writeClaudeStub() {
64
- const stubPath = path.join(os.tmpdir(), `sm-claude-stub-capdefer-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
65
- const body = `
83
+ return claudeStub.writeClaudeStub({
84
+ allowCommitUnder: os.tmpdir(),
85
+ body: `
66
86
  const fs = require('fs');
67
87
  const path = require('path');
68
88
  const { execFileSync } = require('child_process');
@@ -73,14 +93,13 @@ function writeClaudeStub() {
73
93
  // fallback case), where the run parks in needs_review instead, which is
74
94
  // fine — this test only cares whether it ran in place, not its verdict.
75
95
  try {
76
- execFileSync('git', ['add', '-A'], { cwd: process.cwd() });
77
- execFileSync('git', ['commit', '-q', '-m', 'stub commit'], { cwd: process.cwd() });
96
+ runGit(['add', '-A']);
97
+ runGit(['commit', '-q', '-m', 'stub commit']);
78
98
  } catch { /* not a git repo — best-effort only */ }
79
99
  process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
80
100
  process.exit(0);
81
- `;
82
- fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
83
- return stubPath;
101
+ `,
102
+ });
84
103
  }
85
104
 
86
105
  beforeAll(() => {
@@ -111,7 +130,13 @@ test('a job held behind the worktree cap stays pending with a heldReason, is nev
111
130
  registerActiveProject(projectCwd);
112
131
 
113
132
  const slug = `1112-test-held-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
114
- const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
133
+ // Epic-scoped, not the legacy flat prds/ dir: the flat dir is retired and
134
+ // swept into prds-archived/ by consolidateFlatPrds on every reconcile()
135
+ // pass once a row is no longer 'pending'/'running' — which would strand
136
+ // this test's row the moment it completes (reconcile()'s terminal-job
137
+ // "PRD gone -> archived on purpose" drop, see
138
+ // scheduler-reconcile-history-backfill.test.cjs).
139
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
115
140
  fs.mkdirSync(prdsDir, { recursive: true });
116
141
  fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Held behind the worktree cap.', 'utf8');
117
142
  const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
@@ -147,8 +172,8 @@ test('a job held behind the worktree cap stays pending with a heldReason, is nev
147
172
 
148
173
  await spawnJob({ slug, cwd: projectCwd }, `${runId}-2`, runDir, projectCwd);
149
174
 
150
- jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
151
- row = jobs.find((j) => j.slug === slug);
175
+ row = readTerminalRow(queuePath, projectCwd, slug);
176
+ expect(row).toBeTruthy();
152
177
  expect(row.status).toBe('completed');
153
178
  expect(row.heldReason).toBeUndefined();
154
179
  } finally {
@@ -163,7 +188,8 @@ test('a non-git cwd still falls back to running in place (unchanged behaviour
163
188
  registerActiveProject(projectCwd);
164
189
 
165
190
  const slug = `1112-test-nongit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
166
- const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
191
+ // Epic-scoped — see the sibling test above for why not the flat prds/ dir.
192
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
167
193
  fs.mkdirSync(prdsDir, { recursive: true });
168
194
  fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Non-git cwd, must run in place.', 'utf8');
169
195
  const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
@@ -23,6 +23,7 @@ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
23
23
  const fs = require('node:fs');
24
24
  const os = require('node:os');
25
25
  const path = require('node:path');
26
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
26
27
 
27
28
  let tmpHome;
28
29
  let originalHome;
@@ -48,16 +49,13 @@ afterEach(() => {
48
49
  // where it actually ran), captures the '-p' prompt, then emits a stream-json
49
50
  // success result and exits 0.
50
51
  function writeClaudeStub() {
51
- const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
52
- const body = `
52
+ return claudeStub.writeClaudeStub({ body: `
53
53
  const fs = require('fs');
54
54
  const path = require('path');
55
55
  fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
56
56
  process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
57
57
  process.exit(0);
58
- `;
59
- fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
60
- return stubPath;
58
+ ` });
61
59
  }
62
60
 
63
61
  test('executeJob spawns the child in execCwd (worktree) while resolving the PRD from job.cwd (main tree)', async () => {