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
@@ -1,11 +1,7 @@
1
1
  /**
2
- * projectPages.test.cjs — unit tests for projectPages.cjs's read-only get(),
3
- * including the regression this PRD's AC calls out explicitly: adding the
4
- * 5th 'brief' lens must not make every pre-existing 4-lens project suddenly
5
- * read as having no output at all — and the shipped-default fallback added
6
- * by the "project-home-hosted-html-spec" PRD: a project with no generated
7
- * output gets the build-time default home.html (isDefault: true) instead of
8
- * the bare `{ output: null }` empty-state signal.
2
+ * projectPages.test.cjs — projectPages.cjs's read-only surface: get() of the
3
+ * single project-pages/home.html ({html|null, mtimeMs}) plus the refcounted
4
+ * watcher that pushes `project-pages:changed`.
9
5
  *
10
6
  * Run: timeout 120 npx vitest run src/main/__tests__/projectPages.test.cjs
11
7
  */
@@ -20,8 +16,6 @@ const path = require('node:path');
20
16
  const config = require('../config.cjs');
21
17
  const {
22
18
  get,
23
- DEFAULT_HOME_PATH,
24
- readDefaultHomeHtml,
25
19
  attachWindow,
26
20
  watchOutput,
27
21
  unwatchOutput,
@@ -31,6 +25,7 @@ const {
31
25
 
32
26
  const tmpDirs = [];
33
27
  afterEach(async () => {
28
+ closeAllOutputWatchers();
34
29
  while (tmpDirs.length) {
35
30
  const d = tmpDirs.pop();
36
31
  await fsp.rm(d, { recursive: true, force: true });
@@ -44,179 +39,98 @@ async function mkTmpCwd() {
44
39
  return dir;
45
40
  }
46
41
 
47
- function outputDir(cwd) {
48
- return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
42
+ function pagesDir(cwd) {
43
+ return path.join(cwd, 'session-manager-operations', 'project-pages');
49
44
  }
50
45
 
51
- function writeOutput(cwd, lenses, manifest = { generatedAt: '2026-08-02T00:00:00.000Z' }) {
52
- const dir = outputDir(cwd);
53
- fs.mkdirSync(dir, { recursive: true });
54
- fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest));
55
- for (const [lens, html] of Object.entries(lenses)) {
56
- fs.writeFileSync(path.join(dir, `${lens}.html`), html);
57
- }
46
+ function writeHome(cwd, html) {
47
+ fs.mkdirSync(pagesDir(cwd), { recursive: true });
48
+ fs.writeFileSync(path.join(pagesDir(cwd), 'home.html'), html);
58
49
  }
59
50
 
60
- test('the shipped default asset exists on disk at a fixed app-relative path', () => {
61
- expect(fs.existsSync(DEFAULT_HOME_PATH)).toBe(true);
62
- expect(path.isAbsolute(DEFAULT_HOME_PATH)).toBe(true);
63
- });
64
-
65
- test('(a) generated output present: get() returns it with isDefault false', async () => {
66
- const cwd = await mkTmpCwd();
67
- writeOutput(cwd, {
68
- home: 'HOME',
69
- marketing: 'MARKETING',
70
- feature: 'FEATURE',
71
- architecture: 'ARCHITECTURE',
72
- brief: 'BRIEF',
73
- });
74
- const result = await get({ cwd });
75
- expect(result.output).not.toBeNull();
76
- expect(result.output.home).toBe('HOME');
77
- expect(result.output.brief).toBe('BRIEF');
78
- expect(result.output.generatedAt).toBe('2026-08-02T00:00:00.000Z');
79
- expect(result.output.isDefault).toBe(false);
80
- });
51
+ function makeFakeWindow(sent) {
52
+ return {
53
+ isDestroyed: () => false,
54
+ webContents: {
55
+ isDestroyed: () => false,
56
+ isCrashed: () => false,
57
+ send: (channel, payload) => sent.push({ channel, payload }),
58
+ },
59
+ };
60
+ }
81
61
 
82
- // The regression this PRD's AC explicitly guards against: a project that
83
- // generated its output BEFORE the 'brief' lens existed has the original 4
84
- // files but no brief.html — that must still read as "has output", not the
85
- // shipped-default fallback, and `brief` must simply be absent from the payload.
86
- test('4 original lenses present but brief.html missing still returns generated output, with brief omitted', async () => {
87
- const cwd = await mkTmpCwd();
88
- writeOutput(cwd, {
89
- home: 'HOME',
90
- marketing: 'MARKETING',
91
- feature: 'FEATURE',
92
- architecture: 'ARCHITECTURE',
93
- });
94
- const result = await get({ cwd });
95
- expect(result.output).not.toBeNull();
96
- expect(result.output.home).toBe('HOME');
97
- expect(result.output.marketing).toBe('MARKETING');
98
- expect(result.output.feature).toBe('FEATURE');
99
- expect(result.output.architecture).toBe('ARCHITECTURE');
100
- expect(result.output.brief).toBeUndefined();
101
- expect(result.output.isDefault).toBe(false);
102
- });
62
+ async function waitFor(predicate, ms = 5000) {
63
+ const deadline = Date.now() + ms;
64
+ while (!predicate() && Date.now() < deadline) {
65
+ await new Promise((resolve) => setTimeout(resolve, 100));
66
+ }
67
+ }
103
68
 
104
- // (b) output absent → returns the shipped default with isDefault true.
105
- test('(b) no manifest at all: get() returns the shipped default with isDefault true', async () => {
69
+ test('get() returns the home.html text and its mtimeMs', async () => {
106
70
  const cwd = await mkTmpCwd();
71
+ writeHome(cwd, '<html>HOME</html>');
107
72
  const result = await get({ cwd });
108
- expect(result.output).not.toBeNull();
109
- expect(result.output.isDefault).toBe(true);
110
- expect(result.output.generatedAt).toBeNull();
111
- expect(result.output.home).toBe(readDefaultHomeHtml());
112
- expect(result.output.marketing).toBeUndefined();
113
- expect(result.output.feature).toBeUndefined();
114
- expect(result.output.architecture).toBeUndefined();
73
+ expect(result.html).toBe('<html>HOME</html>');
74
+ expect(typeof result.mtimeMs).toBe('number');
75
+ expect(Object.keys(result).sort()).toEqual(['html', 'mtimeMs']);
115
76
  });
116
77
 
117
- test('one of the required 4 lenses missing falls back to the shipped default, even if brief.html exists', async () => {
78
+ test('get() with no home.html returns {html: null, mtimeMs: null} — no shipped default', async () => {
118
79
  const cwd = await mkTmpCwd();
119
- writeOutput(cwd, {
120
- home: 'HOME',
121
- marketing: 'MARKETING',
122
- feature: 'FEATURE',
123
- // architecture.html deliberately missing
124
- brief: 'BRIEF',
125
- });
126
- const result = await get({ cwd });
127
- expect(result.output).not.toBeNull();
128
- expect(result.output.isDefault).toBe(true);
129
- expect(result.output.home).toBe(readDefaultHomeHtml());
80
+ expect(await get({ cwd })).toEqual({ html: null, mtimeMs: null });
130
81
  });
131
82
 
132
- test('manifest with no generatedAt string falls back to the shipped default', async () => {
83
+ test('get() ignores legacy manifest/lens files — only home.html counts', async () => {
133
84
  const cwd = await mkTmpCwd();
134
- writeOutput(
135
- cwd,
136
- { home: 'HOME', marketing: 'MARKETING', feature: 'FEATURE', architecture: 'ARCHITECTURE', brief: 'BRIEF' },
137
- {},
138
- );
139
- const result = await get({ cwd });
140
- expect(result.output).not.toBeNull();
141
- expect(result.output.isDefault).toBe(true);
85
+ const out = path.join(pagesDir(cwd), 'output');
86
+ fs.mkdirSync(out, { recursive: true });
87
+ fs.writeFileSync(path.join(out, 'home.html'), 'OLD');
88
+ fs.writeFileSync(path.join(out, 'manifest.json'), '{"generatedAt":"2026-08-02T00:00:00.000Z"}');
89
+ expect(await get({ cwd })).toEqual({ html: null, mtimeMs: null });
142
90
  });
143
91
 
144
- // (c) the default path resolution does not depend on cwd.
145
- test('(c) default fallback content is identical regardless of which project cwd requests it', async () => {
146
- const cwdA = await mkTmpCwd();
147
- const cwdB = await mkTmpCwd();
148
- const resultA = await get({ cwd: cwdA });
149
- const resultB = await get({ cwd: cwdB });
150
- expect(resultA.output.isDefault).toBe(true);
151
- expect(resultB.output.isDefault).toBe(true);
152
- expect(resultA.output.home).toBe(resultB.output.home);
153
- expect(resultA.output.home).toBe(readDefaultHomeHtml());
154
- });
155
-
156
- test('the shipped default is honest: no fabricated project-specific content, and prompts Generate My Project Home', () => {
157
- const html = readDefaultHomeHtml();
158
- expect(html).toContain('Generate My Project Home');
159
- expect(html.toLowerCase()).not.toContain('lorem ipsum');
160
- });
161
-
162
- // ── ephemeral cwd: get() must fall back, never throw/reject ────────────────
163
- test('get() with an ephemeral cwd (os.tmpdir() itself) returns the shipped default instead of throwing', async () => {
92
+ test('get() with an ephemeral cwd (os.tmpdir() itself) returns null instead of throwing', async () => {
164
93
  config.addAllowedRoot(os.tmpdir());
165
- const result = await get({ cwd: os.tmpdir() });
166
- expect(result.output).not.toBeNull();
167
- expect(result.output.isDefault).toBe(true);
168
- });
169
-
170
- // ── watcher ──────────────────────────────────────────────────────────────
171
- afterEach(() => {
172
- closeAllOutputWatchers();
94
+ expect(await get({ cwd: os.tmpdir() })).toEqual({ html: null, mtimeMs: null });
173
95
  });
174
96
 
175
97
  test('watchOutput() on an ephemeral cwd refuses instead of crashing', async () => {
176
98
  config.addAllowedRoot(os.tmpdir());
177
- const result = await watchOutput(os.tmpdir());
178
- expect(result).toEqual({ ok: false, reason: 'ephemeral' });
99
+ expect(await watchOutput(os.tmpdir())).toEqual({ ok: false, reason: 'ephemeral' });
179
100
  expect(_outputWatchers.size).toBe(0);
180
101
  });
181
102
 
182
- test('watchOutput() pushes project-pages:changed when manifest.json appears, and unwatchOutput() tears it down', async () => {
103
+ test('watchOutput() is refcounted: two watches share one watcher, closed at the last unwatch', async () => {
104
+ const cwd = await mkTmpCwd();
105
+ expect(await watchOutput(cwd)).toEqual({ ok: true });
106
+ expect(await watchOutput(cwd)).toEqual({ ok: true });
107
+ expect(_outputWatchers.size).toBe(1);
108
+ unwatchOutput(cwd);
109
+ expect(_outputWatchers.size).toBe(1);
110
+ unwatchOutput(cwd);
111
+ expect(_outputWatchers.size).toBe(0);
112
+ });
113
+
114
+ test('watchOutput() pushes {cwd, html, mtimeMs} on project-pages:changed when home.html lands, ignoring sibling files', async () => {
183
115
  const cwd = await mkTmpCwd();
184
116
  const sent = [];
185
- const fakeWindow = {
186
- isDestroyed: () => false,
187
- webContents: {
188
- isDestroyed: () => false,
189
- isCrashed: () => false,
190
- send: (channel, payload) => sent.push({ channel, payload }),
191
- },
192
- };
193
- attachWindow(fakeWindow);
117
+ attachWindow(makeFakeWindow(sent));
194
118
 
195
- const watchResult = await watchOutput(cwd);
196
- expect(watchResult).toEqual({ ok: true });
119
+ expect(await watchOutput(cwd)).toEqual({ ok: true });
197
120
  expect(_outputWatchers.size).toBe(1);
198
121
 
199
- writeOutput(cwd, {
200
- home: 'HOME',
201
- marketing: 'MARKETING',
202
- feature: 'FEATURE',
203
- architecture: 'ARCHITECTURE',
204
- });
205
-
206
- // Bounded poll for the debounced (awaitWriteFinish) push instead of a bare
207
- // sleep — chokidar's stability window is 50ms, so this gives it generous
208
- // headroom without hanging the suite on a broken watcher.
209
- const deadline = Date.now() + 5000;
210
- while (sent.length === 0 && Date.now() < deadline) {
211
- await new Promise((resolve) => setTimeout(resolve, 100));
212
- }
122
+ fs.writeFileSync(path.join(pagesDir(cwd), 'summary.json'), '{}');
123
+ writeHome(cwd, '<html>HOME</html>');
124
+ await waitFor(() => sent.length > 0);
213
125
 
214
126
  expect(sent.length).toBeGreaterThan(0);
127
+ for (const msg of sent) expect(msg.channel).toBe('project-pages:changed');
215
128
  const last = sent[sent.length - 1];
216
- expect(last.channel).toBe('project-pages:changed');
217
129
  expect(last.payload.cwd).toBe(fs.realpathSync(cwd));
218
- expect(last.payload.output.isDefault).toBe(false);
219
- expect(last.payload.output.home).toBe('HOME');
130
+ expect(last.payload.html).toBe('<html>HOME</html>');
131
+ expect(typeof last.payload.mtimeMs).toBe('number');
132
+ // summary.json alone must not have produced a push carrying no html
133
+ expect(sent.every((m) => m.payload.html === '<html>HOME</html>')).toBe(true);
220
134
 
221
135
  unwatchOutput(cwd);
222
136
  expect(_outputWatchers.size).toBe(0);
@@ -42,7 +42,7 @@ test('saturated: jobs running, zero free slots', () => {
42
42
  ];
43
43
  const v = classifyQueueHealth({
44
44
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(['1-a']),
45
- freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
45
+ freeSlots: 0, totalSlots: 4, lastRunAtMs: NOW, now: NOW, cwd: CWD,
46
46
  });
47
47
  assert.equal(v.kind, 'saturated');
48
48
  assert.equal(v.runningCount, 1);
@@ -62,7 +62,7 @@ test('blocked: every pending row terminates in a failed dependency', () => {
62
62
  ];
63
63
  const v = classifyQueueHealth({
64
64
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
65
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
65
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
66
66
  });
67
67
  assert.equal(v.kind, 'blocked');
68
68
  assert.equal(v.pending, 1);
@@ -77,7 +77,7 @@ test('idle: nothing pending', () => {
77
77
  ];
78
78
  const v = classifyQueueHealth({
79
79
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
80
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW, now: NOW, cwd: CWD,
80
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW, now: NOW, cwd: CWD,
81
81
  });
82
82
  assert.equal(v.kind, 'idle');
83
83
  assert.equal(v.pending, 0);
@@ -90,7 +90,7 @@ test('stalled: dispatchable work, nothing running, driver idle past threshold',
90
90
  ];
91
91
  const v = classifyQueueHealth({
92
92
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
93
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
93
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
94
94
  });
95
95
  assert.equal(v.kind, 'stalled');
96
96
  assert.equal(v.dispatchable, 1);
@@ -103,7 +103,7 @@ test('running: dispatchable work, nothing running yet, but driver has not been i
103
103
  ];
104
104
  const v = classifyQueueHealth({
105
105
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
106
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
106
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
107
107
  });
108
108
  assert.equal(v.kind, 'running');
109
109
  });
@@ -114,7 +114,7 @@ test('paused: a decision, not a stall — takes priority over everything else',
114
114
  ];
115
115
  const v = classifyQueueHealth({
116
116
  jobs, paused: { reason: 'manual', since: NOW }, launchBlocks: {}, runningSet: new Set(),
117
- freeSlots: 0, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
117
+ freeSlots: 0, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
118
118
  });
119
119
  assert.equal(v.kind, 'paused');
120
120
  assert.equal(v.reason, 'manual');
@@ -131,7 +131,7 @@ test('launch-blocked: an open circuit breaker for a persona a pending row actual
131
131
  };
132
132
  const v = classifyQueueHealth({
133
133
  jobs, paused: null, launchBlocks, runningSet: new Set(),
134
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - LONG, now: NOW, cwd: CWD,
134
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
135
135
  });
136
136
  assert.equal(v.kind, 'launch-blocked');
137
137
  assert.equal(v.agentType, 'dev-lead');
@@ -148,7 +148,7 @@ test('launch-blocked is scoped: a breaker for a persona nothing pending here use
148
148
  };
149
149
  const v = classifyQueueHealth({
150
150
  jobs, paused: null, launchBlocks, runningSet: new Set(),
151
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
151
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
152
152
  });
153
153
  assert.notEqual(v.kind, 'launch-blocked');
154
154
  });
@@ -163,7 +163,7 @@ test('needsReviewCount and cwd scoping: another project\'s rows never leak into
163
163
  ];
164
164
  const v = classifyQueueHealth({
165
165
  jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
166
- freeSlots: 4, totalSlots: 4, lastDispatchAttemptAtMs: NOW - 5_000, now: NOW, cwd: CWD,
166
+ freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
167
167
  });
168
168
  assert.equal(v.pending, 1);
169
169
  assert.equal(v.needsReviewCount, 1);
@@ -23,14 +23,16 @@
23
23
 
24
24
  'use strict';
25
25
 
26
- import { test, expect, beforeAll, afterAll } from 'vitest';
26
+ import { test, expect, beforeAll, afterAll, vi } from 'vitest';
27
27
  const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
+ const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
30
31
 
31
32
  let tmpHome;
32
33
  let originalHome;
33
34
  let originalClaudeBin;
35
+ let originalAutofixDisable;
34
36
  let scheduler;
35
37
  let queueStore;
36
38
 
@@ -41,21 +43,26 @@ let queueStore;
41
43
  // guard then makes runQueueStarvationWatchdog return null for a reason that
42
44
  // has nothing to do with what that test is actually checking.
43
45
  function writeClaudeStub() {
44
- const stubPath = path.join(os.tmpdir(), `sm-claude-stub-starve-driver-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
45
- const body = `
46
+ return claudeStub.writeClaudeStub({ body: `
46
47
  process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
47
48
  process.exit(0);
48
- `;
49
- fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
50
- return stubPath;
49
+ ` });
51
50
  }
52
51
 
53
52
  beforeAll(() => {
54
53
  originalHome = process.env.HOME;
55
54
  originalClaudeBin = process.env.SM_CLAUDE_BIN;
55
+ originalAutofixDisable = process.env.SM_AUTOFIX_DISABLE;
56
56
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-starve-driver-'));
57
57
  process.env.HOME = tmpHome;
58
58
  process.env.SM_CLAUDE_BIN = writeClaudeStub();
59
+ // The forced tick's dispatched job has no PRD file and makes no commit, so
60
+ // it lands 'needs_review' and (without this) immediately fires a
61
+ // fire-and-forget auto-fix investigation — ANOTHER real claude spawn, under
62
+ // its own queue row, that can still be in runningSet by the time the next
63
+ // test in this file reads it (see writeClaudeStub's header comment; this
64
+ // closes the same gap one layer further down the cascade).
65
+ process.env.SM_AUTOFIX_DISABLE = '1';
59
66
  fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
60
67
  fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
61
68
  scheduler = require('../scheduler.cjs');
@@ -66,6 +73,8 @@ afterAll(() => {
66
73
  process.env.HOME = originalHome;
67
74
  if (originalClaudeBin === undefined) delete process.env.SM_CLAUDE_BIN;
68
75
  else process.env.SM_CLAUDE_BIN = originalClaudeBin;
76
+ if (originalAutofixDisable === undefined) delete process.env.SM_AUTOFIX_DISABLE;
77
+ else process.env.SM_AUTOFIX_DISABLE = originalAutofixDisable;
69
78
  fs.rmSync(tmpHome, { recursive: true, force: true });
70
79
  });
71
80
 
@@ -81,7 +90,7 @@ function mkProject() {
81
90
  return { cwd, slug };
82
91
  }
83
92
 
84
- test('the watchdog reads idleness from lastDispatchAttemptAt, not the always-fresh poll timestamp', async () => {
93
+ test('ticks that launch nothing do NOT refresh the clock — idleness reads lastRunAt, not lastDispatchAttemptAt', async () => {
85
94
  const { cwd, slug } = mkProject();
86
95
  await scheduler.writeQueue({
87
96
  jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
@@ -90,18 +99,37 @@ test('the watchdog reads idleness from lastDispatchAttemptAt, not the always-fre
90
99
  });
91
100
 
92
101
  const now = Date.now();
93
- // The exact shape of the live incident: the poll timestamp is seconds old
94
- // (the poll loop itself keeps succeeding), but nothing has actually
95
- // attempted a dispatch in 40 minutes.
102
+ // The structural repeat of f18e161: the loop ticks every 30 s (so
103
+ // lastDispatchAttemptAt is seconds old) but nothing has LAUNCHED in 40
104
+ // minutes (lastRunAt). The boot stamp is aged out so it can't mask it.
105
+ const bootedAtMs = now - 3 * 60 * 60_000;
96
106
  const state = {
97
107
  ...queueStore.readMergedSync(),
98
- lastRunAt: new Date(now - 5_000).toISOString(),
99
- lastDispatchAttemptAt: new Date(now - 40 * 60_000).toISOString(),
108
+ lastRunAt: new Date(now - 40 * 60_000).toISOString(),
109
+ lastDispatchAttemptAt: new Date(now - 5_000).toISOString(),
100
110
  };
101
111
 
102
- const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
112
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, { now, bootedAtMs });
103
113
  expect(verdict).not.toBeNull();
104
114
  expect(verdict.kind).toBe('starved');
115
+
116
+ // The watchdog's forced tick above dispatches this job for real (see
117
+ // writeClaudeStub's header comment) — tickQueue doesn't await the spawned
118
+ // process to completion, so runningSet can still hold this slug for a
119
+ // moment after this test's own `await` returns. Under a fast, unloaded
120
+ // machine the stub exits before the NEXT test's watchdog call ever reads
121
+ // runningSet, which is racy rather than deterministic — under a heavily
122
+ // loaded machine (e.g. the full test:unit run) it reliably loses,
123
+ // stranding a slug in runningSet and making
124
+ // classifyQueueStarvation's `runningCount > 0` guard return null for the
125
+ // next test, which has nothing to do with what that test actually checks.
126
+ // Wait for this job to actually leave 'running' before moving on.
127
+ const deadline = Date.now() + 5_000;
128
+ while (Date.now() < deadline) {
129
+ const row = queueStore.readMergedSync().jobs.find((j) => j.slug === slug);
130
+ if (!row || row.status !== 'running') break;
131
+ await new Promise((r) => setTimeout(r, 25));
132
+ }
105
133
  });
106
134
 
107
135
  test('a wedged cancelToken does not block the watchdog from actually driving a tick', async () => {
@@ -117,21 +145,34 @@ test('a wedged cancelToken does not block the watchdog from actually driving a t
117
145
  // applyPauseCleared reset (a direct state write, not the clearPause()
118
146
  // helper) — leaving the token permanently cancelled with paused: null.
119
147
  await scheduler.setPaused('rate_limit', null);
120
- await scheduler.writeQueue({
121
- jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
122
- config: {},
123
- paused: null,
124
- });
148
+ // setPaused's own broadcast({flush:true}) and a still-draining
149
+ // fire-and-forget mutate() from the PRIOR test's forced dispatch (e.g.
150
+ // tickQueue's own finally-chained call) both write through the SAME
151
+ // serialized mutate() queue this raw writeQueue() call bypasses — one of
152
+ // them can land AFTER this write and re-persist the stale paused:{...}
153
+ // object it read before this clear, purely by losing the race. Re-assert
154
+ // paused:null until it actually sticks, rather than trusting one write to
155
+ // win a race against mutations this test doesn't control the timing of.
156
+ const pausedClearDeadline = Date.now() + 5_000;
157
+ do {
158
+ await scheduler.writeQueue({
159
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
160
+ config: {},
161
+ paused: null,
162
+ });
163
+ if (!queueStore.readMergedSync().paused) break;
164
+ await new Promise((r) => setTimeout(r, 25));
165
+ } while (Date.now() < pausedClearDeadline);
125
166
 
126
167
  const before = queueStore.readMergedSync().lastDispatchAttemptAt;
127
168
 
128
169
  const now = Date.now();
129
170
  const state = {
130
171
  ...queueStore.readMergedSync(),
131
- lastRunAt: null,
172
+ lastRunAt: new Date(now - 40 * 60_000).toISOString(),
132
173
  lastDispatchAttemptAt: new Date(now - 40 * 60_000).toISOString(),
133
174
  };
134
- const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
175
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, { now, bootedAtMs: now - 3 * 60 * 60_000 });
135
176
  expect(verdict.kind).toBe('starved');
136
177
 
137
178
  // tickQueue() only stamps lastDispatchAttemptAt once it gets PAST the
@@ -141,3 +182,59 @@ test('a wedged cancelToken does not block the watchdog from actually driving a t
141
182
  expect(after).not.toBe(before);
142
183
  expect(after).not.toBeNull();
143
184
  });
185
+
186
+ const NOW = Date.parse('2026-09-18T12:00:00.000Z');
187
+ const CWD = '/tmp/sm-starve-clock-project';
188
+ const pendingRow = (slug, extra = {}) => ({ slug, cwd: CWD, status: 'pending', dependsOn: [], ...extra });
189
+ const idleFor = (ms) => NOW - ms;
190
+
191
+ test('a real launch (fresh lastRunAt) refreshes the clock; so do a pause clear and a boot', () => {
192
+ const jobs = [pendingRow('a')];
193
+ const base = { jobs, paused: null, runningCount: 0, now: NOW };
194
+ const stale = idleFor(3 * 60 * 60_000);
195
+ expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale }).kind).toBe('starved');
196
+ expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: idleFor(5_000) })).toBeNull();
197
+ expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale, lastPauseClearedAtMs: idleFor(5_000) })).toBeNull();
198
+ expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale, schedulerBootedAtMs: idleFor(5_000) })).toBeNull();
199
+ expect(scheduler.dispatchIdleMs({ lastRunAtMs: 1000, lastPauseClearedAtMs: 4000, schedulerBootedAtMs: 2000, now: 10_000 })).toBe(6000);
200
+ expect(scheduler.dispatchIdleMs({ now: 10_000 })).toBe(Infinity);
201
+ });
202
+
203
+ test('rows held by a launch breaker / lease read blocked, not starved (per project too)', () => {
204
+ const jobs = [pendingRow('a'), pendingRow('b')];
205
+ const args = { jobs, paused: null, runningCount: 0, lastRunAtMs: idleFor(3 * 60 * 60_000), now: NOW };
206
+ expect(scheduler.classifyQueueStarvation(args).kind).toBe('starved');
207
+ const partial = scheduler.classifyQueueStarvation({ ...args, heldSlugs: new Set(['a']) });
208
+ expect(partial.kind).toBe('starved');
209
+ expect(partial.dispatchable).toBe(1);
210
+ const all = scheduler.classifyQueueStarvation({ ...args, heldSlugs: new Map([['a', 'x'], ['b', 'y']]) });
211
+ expect(all.kind).toBe('blocked');
212
+ expect(all.dispatchable).toBe(0);
213
+ const byProject = scheduler.classifyQueueStarvationByProject({
214
+ jobs, paused: null, runningSet: new Set(), lastRunAtMs: args.lastRunAtMs, heldSlugs: new Set(['a', 'b']), now: NOW,
215
+ });
216
+ expect(byProject.map((v) => v.kind)).toEqual(['blocked']);
217
+ });
218
+
219
+ test('the watchdog latches: one forced tick / audit per starve episode, re-armed by threshold or running-count change', async () => {
220
+ const cwd = '/tmp/sm-starve-latch-project';
221
+ const jobs = [{ slug: 'latch-a', cwd, status: 'pending', dependsOn: [] }];
222
+ const opts = { bootedAtMs: NOW - 3 * 60 * 60_000 };
223
+ const state = { jobs, paused: null, lastRunAt: new Date(NOW - 40 * 60_000).toISOString() };
224
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
225
+ const starvedLogs = () => warn.mock.calls.filter((c) => String(c[0]).includes('QUEUE STARVED')).length;
226
+ try {
227
+ // The row exists only in this hand-built state, never in queue.json, so
228
+ // the forced tick has nothing real to launch.
229
+ expect((await scheduler.runQueueStarvationWatchdog(state, { now: NOW, ...opts })).kind).toBe('starved');
230
+ expect(starvedLogs()).toBe(1);
231
+ // Same episode, well inside QUEUE_STARVATION_MS: still reported, not re-forced.
232
+ expect((await scheduler.runQueueStarvationWatchdog(state, { now: NOW + 60_000, ...opts })).kind).toBe('starved');
233
+ expect(starvedLogs()).toBe(1);
234
+ // QUEUE_STARVATION_MS elapsed again: a new episode.
235
+ await scheduler.runQueueStarvationWatchdog(state, { now: NOW + scheduler.QUEUE_STARVATION_MS + 1, ...opts });
236
+ expect(starvedLogs()).toBe(2);
237
+ } finally {
238
+ warn.mockRestore();
239
+ }
240
+ });
@@ -53,11 +53,11 @@ beforeEach(() => {
53
53
  // Fresh require per test so HISTORY_PATH's target file starts clean.
54
54
  delete require.cache[require.resolve('../lib/queueHistory.cjs')];
55
55
  queueHistory = require('../lib/queueHistory.cjs');
56
- try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
56
+ try { fs.unlinkSync(queueHistory.historyPath()); } catch { /* ok if absent */ }
57
57
  });
58
58
 
59
59
  afterEach(() => {
60
- try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
60
+ try { fs.unlinkSync(queueHistory.historyPath()); } catch { /* ok if absent */ }
61
61
  if (prevOverride === undefined) delete process.env.SM_HISTORY_PATH_OVERRIDE;
62
62
  else process.env.SM_HISTORY_PATH_OVERRIDE = prevOverride;
63
63
  });
@@ -4,9 +4,11 @@
4
4
  * pollLoop): the 'meter_rate_limited' branch used to never back off and
5
5
  * never call persistSchedulerState(), letting scheduler-state.json freeze
6
6
  * stale while the loop kept failing every POLL_INTERVAL_MS underneath it.
7
- * This suite covers the two pure pieces of the fix: the shared backoff
8
- * curve/cap (nextBackoffMs) and the once-per-streak WARN gate
9
- * (shouldWarnFailureStreak).
7
+ * This suite covers the pure pieces of the fix: the shared backoff
8
+ * curve/cap (nextBackoffMs), the once-per-streak WARN gate
9
+ * (shouldWarnFailureStreak), and the periodic escalation ladder that fires
10
+ * every FAILURE_STREAK_ESCALATION_MS while a streak PERSISTS past the
11
+ * initial WARN, re-arming once the streak clears (shouldEscalateFailureStreak).
10
12
  *
11
13
  * Run: timeout 120 npx vitest run src/main/__tests__/rateLimitPollerStreak.test.cjs
12
14
  */
@@ -17,8 +19,10 @@ import { test, expect } from 'vitest';
17
19
  const {
18
20
  nextBackoffMs,
19
21
  shouldWarnFailureStreak,
22
+ shouldEscalateFailureStreak,
20
23
  BACKOFF_MAX_MS,
21
24
  FAILURE_STREAK_WARN_THRESHOLD,
25
+ FAILURE_STREAK_ESCALATION_MS,
22
26
  } = require('../scheduler.cjs');
23
27
 
24
28
  test('nextBackoffMs starts at 30s from a zero/falsy backoff', () => {
@@ -77,3 +81,34 @@ test('shouldWarnFailureStreak respects a custom threshold', () => {
77
81
  expect(shouldWarnFailureStreak(3, false, 3)).toBe(true);
78
82
  expect(shouldWarnFailureStreak(2, false, 3)).toBe(false);
79
83
  });
84
+
85
+ test('shouldEscalateFailureStreak: false below the WARN threshold, regardless of elapsed time', () => {
86
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD - 1, null, Date.now())).toBe(false);
87
+ });
88
+
89
+ test('shouldEscalateFailureStreak: fires immediately the first time (lastEscalatedAtMs null) once past the WARN threshold', () => {
90
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, Date.now())).toBe(true);
91
+ });
92
+
93
+ test('shouldEscalateFailureStreak: does not re-fire before FAILURE_STREAK_ESCALATION_MS has elapsed since the last one', () => {
94
+ const lastEscalatedAtMs = 1_000_000;
95
+ const tooSoon = lastEscalatedAtMs + FAILURE_STREAK_ESCALATION_MS - 1;
96
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, lastEscalatedAtMs, tooSoon)).toBe(false);
97
+ });
98
+
99
+ test('shouldEscalateFailureStreak: fires again once FAILURE_STREAK_ESCALATION_MS has elapsed — the persisting-streak ladder', () => {
100
+ const lastEscalatedAtMs = 1_000_000;
101
+ const dueNow = lastEscalatedAtMs + FAILURE_STREAK_ESCALATION_MS;
102
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, lastEscalatedAtMs, dueNow)).toBe(true);
103
+ });
104
+
105
+ test('shouldEscalateFailureStreak: re-arms after a streak clears (lastEscalatedAtMs reset to null) — a later independent streak escalates again', () => {
106
+ const firstStreakNow = 1_000_000;
107
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, firstStreakNow)).toBe(true);
108
+ // Streak clears (a success resets both failureStreakWarned and the
109
+ // escalation timestamp) — a LATER, independent streak crossing the WARN
110
+ // threshold again must escalate again immediately, not wait out a stale
111
+ // 30-minute window from the previous incident.
112
+ const laterStreakNow = firstStreakNow + 5 * 60_000; // well under 30 min later
113
+ expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, laterStreakNow)).toBe(true);
114
+ });