claude-code-session-manager 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-BZ6IY2g1.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-dAoGAODX.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-CF157HyX.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CCPPipBk.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-Dlmz2m06.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-BaXcboWX.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CoUCzG5S.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-DTg3JlOT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-DdjY1sA3.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-BJ3Jlw0C.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-ygRSgc41.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-m0KAnccF.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-CJRl6B3Q.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-w8cnlVvW.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BpyPcUIk.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-D2ppxSfy.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-EyuIwt-j.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-BHSGGFEc.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-BNaZ2JCF.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-Dl3q75QV.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-Bg6F-Cyp.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-BSutflCy.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody-CXih4xrc.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-2qtUHhA9.js} +1 -1
  26. package/dist/assets/{index-fc_JjdxL.js → index-B3emy2oI.js} +356 -356
  27. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-CdzxaqwM.js} +1 -1
  29. package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
  30. package/dist/index.html +2 -2
  31. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
  32. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  33. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
  34. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  35. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
  36. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  37. package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
  38. package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
  39. package/package.json +10 -13
  40. package/scripts/README.md +59 -0
  41. package/scripts/audit-ops-hygiene.cjs +350 -0
  42. package/scripts/hooks/guard-destructive-git.cjs +10 -34
  43. package/scripts/hooks/guard-inline-implementation.cjs +14 -8
  44. package/scripts/hooks/guard-prd-writes.cjs +7 -36
  45. package/scripts/hooks/guard-self-schedule.cjs +175 -0
  46. package/scripts/ops-sweep.cjs +355 -0
  47. package/scripts/scheduler-mcp-server.cjs +28 -71
  48. package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
  49. package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
  50. package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
  51. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
  52. package/src/main/__tests__/chat-queue.test.cjs +2 -2
  53. package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
  54. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
  55. package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
  56. package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
  57. package/src/main/__tests__/dod-report.test.cjs +2 -2
  58. package/src/main/__tests__/dod-reverify.test.cjs +2 -2
  59. package/src/main/__tests__/epicMint.test.cjs +2 -2
  60. package/src/main/__tests__/exchanges.test.cjs +2 -2
  61. package/src/main/__tests__/extractJson.test.cjs +2 -2
  62. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  63. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
  64. package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
  65. package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
  66. package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
  67. package/src/main/__tests__/health-tick-liveness.test.cjs +11 -3
  68. package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
  69. package/src/main/__tests__/historyRollup.test.cjs +2 -2
  70. package/src/main/__tests__/kg-augment.test.cjs +2 -2
  71. package/src/main/__tests__/mcpStatus.test.cjs +2 -2
  72. package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
  73. package/src/main/__tests__/memoryStale.test.cjs +1 -1
  74. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
  75. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
  76. package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
  77. package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
  78. package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
  79. package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
  80. package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
  81. package/src/main/__tests__/proc-role-env.test.cjs +125 -0
  82. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
  83. package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
  84. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
  85. package/src/main/__tests__/projectPages.test.cjs +63 -149
  86. package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
  87. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
  88. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  89. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
  90. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
  91. package/src/main/__tests__/runVerify.test.cjs +5 -5
  92. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
  93. package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
  94. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
  95. package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
  96. package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
  97. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
  98. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
  99. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
  100. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
  101. package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
  102. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
  103. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
  104. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
  105. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
  106. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
  107. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
  108. package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
  109. package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
  110. package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
  111. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
  112. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
  113. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
  114. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
  115. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
  116. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
  117. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
  118. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
  119. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
  120. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
  121. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
  122. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
  123. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
  124. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  125. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  126. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  127. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  128. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  129. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  130. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  131. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  132. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  133. package/src/main/bilkoHost.cjs +16 -7
  134. package/src/main/build-info.json +8 -0
  135. package/src/main/chatRunner.cjs +4 -2
  136. package/src/main/config.cjs +2 -3
  137. package/src/main/docEdit.cjs +4 -2
  138. package/src/main/health.cjs +267 -51
  139. package/src/main/heapSnapshot.cjs +2 -2
  140. package/src/main/historyAggregator.cjs +1 -1
  141. package/src/main/index.cjs +61 -10
  142. package/src/main/ipcSchemas.cjs +6 -17
  143. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  144. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  145. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  146. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  147. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  148. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  149. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  150. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  151. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  152. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  153. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  154. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  155. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  156. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  157. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  158. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  159. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  160. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  161. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  162. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  163. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  164. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  165. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  166. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  167. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  168. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  169. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  170. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  171. package/src/main/lib/activeSessions.cjs +20 -88
  172. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  173. package/src/main/lib/agentModelResolve.cjs +33 -1
  174. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  175. package/src/main/lib/auditLog.cjs +30 -5
  176. package/src/main/lib/buildIdentity.cjs +113 -0
  177. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  178. package/src/main/lib/claudeBin.cjs +37 -2
  179. package/src/main/lib/cleanEnv.cjs +27 -1
  180. package/src/main/lib/credentials.cjs +4 -2
  181. package/src/main/lib/cwdClassify.cjs +185 -0
  182. package/src/main/lib/definitionOfDone.cjs +296 -52
  183. package/src/main/lib/dispatchLoop.cjs +40 -0
  184. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  185. package/src/main/lib/ephemeralCwd.cjs +7 -28
  186. package/src/main/lib/gitWorktree.cjs +95 -8
  187. package/src/main/lib/guardShims.cjs +3 -3
  188. package/src/main/lib/historyRollup.cjs +6 -7
  189. package/src/main/lib/instanceLock.cjs +32 -5
  190. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  191. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  192. package/src/main/lib/localAdminHttp.cjs +9 -27
  193. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  194. package/src/main/lib/opsOwnership.cjs +27 -19
  195. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  196. package/src/main/lib/prdLocations.cjs +48 -1
  197. package/src/main/lib/procIdentity.cjs +126 -0
  198. package/src/main/lib/procName.cjs +98 -0
  199. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  200. package/src/main/lib/queueHistory.cjs +8 -7
  201. package/src/main/lib/queueStore.cjs +57 -37
  202. package/src/main/lib/quietMachineLease.cjs +19 -2
  203. package/src/main/lib/reservationExpiry.cjs +30 -0
  204. package/src/main/lib/runClaudeP.cjs +4 -2
  205. package/src/main/lib/runLogRetention.cjs +3 -2
  206. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  207. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  208. package/src/main/lib/schedulerBatch.cjs +27 -2
  209. package/src/main/lib/schedulerPaths.cjs +175 -0
  210. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  211. package/src/main/lib/sessionSlots.cjs +47 -9
  212. package/src/main/lib/smProcNames.cjs +51 -0
  213. package/src/main/lib/upgradeDrain.cjs +188 -0
  214. package/src/main/lib/usageCircuit.cjs +53 -6
  215. package/src/main/lib/watchdogHelpers.cjs +70 -37
  216. package/src/main/lib/withTimeout.cjs +33 -0
  217. package/src/main/mcpStatus.cjs +4 -2
  218. package/src/main/pluginInstall.cjs +6 -2
  219. package/src/main/projectPages.cjs +41 -145
  220. package/src/main/pty.cjs +8 -1
  221. package/src/main/queueOps.cjs +10 -10
  222. package/src/main/runVerify.cjs +69 -3
  223. package/src/main/scheduler.cjs +1598 -381
  224. package/src/main/seedAgentPersonas.cjs +1 -1
  225. package/src/main/seedDevPlugin.cjs +1 -1
  226. package/src/main/seedSchedulerMcp.cjs +7 -6
  227. package/src/main/seedStatus.cjs +1 -1
  228. package/src/main/supervisor.cjs +5 -3
  229. package/src/main/usage.cjs +126 -62
  230. package/src/preload/api.d.ts +18 -64
  231. package/src/preload/index.cjs +2 -4
  232. package/src/seed/agents/project-home-builder.md +31 -48
  233. package/screenshots/.gitkeep +0 -0
  234. package/screenshots/README-screenshots.md +0 -13
  235. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  236. package/src/main/teams.cjs +0 -95
  237. package/src/main/templates/project-pages-catalog.json +0 -741
  238. package/src/main/templates/project-pages-default-home.html +0 -123
  239. package/src/main/templates/project-pages-pipeline.md +0 -417
  240. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  241. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  242. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  243. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  244. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  245. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  246. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  247. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  248. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  249. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  250. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  251. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  252. package/src/seed/prompts/documentation/module-readme.md +0 -8
  253. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  254. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  255. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  256. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  257. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  258. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  259. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  260. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  261. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  262. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  263. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  264. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  265. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  266. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  267. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  268. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  269. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  270. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  271. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  272. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  273. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  274. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  275. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  276. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  277. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  278. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  279. package/src/seed/prompts/security/authz-audit.md +0 -8
  280. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  281. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  282. package/src/seed/prompts/security/dependency-audit.md +0 -8
  283. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  284. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  285. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  286. package/web/README.md +0 -41
  287. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  288. package/web/project-pages/render.cjs +0 -70
  289. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  290. package/web/project-pages/validate-summary.cjs +0 -62
@@ -31,14 +31,13 @@
31
31
  const fs = require('node:fs');
32
32
  const fsp = require('node:fs/promises');
33
33
  const path = require('node:path');
34
- const os = require('node:os');
35
34
  const crypto = require('node:crypto');
36
35
  const { allProjectCwds, activeProjectCwds, bustProjectCwdCache } = require('./activeSessions.cjs');
36
+ const { classifyCwd } = require('./cwdClassify.cjs');
37
37
  const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
38
38
  const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
39
+ const schedulerPaths = require('./schedulerPaths.cjs');
39
40
 
40
- const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
41
- const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
42
41
  const OPS_DIRNAME = OPS_ROOT_DIR;
43
42
  const STATE_SUBPATH = ['scheduler', 'state'];
44
43
 
@@ -118,7 +117,12 @@ function projectHistoryPath(cwd) {
118
117
  */
119
118
  function queuePathOrSkip(cwd, context) {
120
119
  try {
121
- return projectQueuePath(cwd);
120
+ const file = projectQueuePath(cwd);
121
+ // Unprovable project identity: the row stays dispatchable and is never
122
+ // dropped or emptied — the write itself is refused by assertOpsWrite.
123
+ const { kind, reason } = classifyCwd(cwd);
124
+ if (kind === 'unknown') console.warn(`[queueStore] ${context}: project identity unprovable for "${cwd}" (${reason}) — reads allowed, writes refused`);
125
+ return file;
122
126
  } catch (e) {
123
127
  if (e?.ephemeral) {
124
128
  console.warn(`[queueStore] ${context}: refusing ephemeral cwd "${cwd}" — ${e.message}`);
@@ -190,8 +194,10 @@ async function writeJsonAtomic(file, value) {
190
194
  // list briefly. Correctness fallback: a brand-new project appears at worst
191
195
  // CACHE_MS late, and its first write goes through writeSplit which busts the
192
196
  // cache.
193
- const CACHE_MS = 30_000;
194
- let cwdCache = { at: 0, cwds: [] };
197
+ // Above the 60 s dispatch-loop interval so one pass never re-scans cold; the
198
+ // cached array is frozen (shared, never mutated).
199
+ const CACHE_MS = 120_000;
200
+ let cwdCache = { at: 0, cwds: Object.freeze([]) };
195
201
 
196
202
  function stateCwds(opts) {
197
203
  const now = Date.now();
@@ -205,12 +211,13 @@ function stateCwds(opts) {
205
211
  }
206
212
  // ...and active projects are included even before their first write.
207
213
  for (const cwd of activeProjectCwds(undefined, opts)) add(cwd);
214
+ Object.freeze(cwds);
208
215
  if (!opts) cwdCache = { at: now, cwds };
209
216
  return cwds;
210
217
  }
211
218
 
212
219
  function bustCwdCache() {
213
- cwdCache = { at: 0, cwds: [] };
220
+ cwdCache = { at: 0, cwds: Object.freeze([]) };
214
221
  // Without this chain, activeSessions' own scan cache hands stateCwds a
215
222
  // stale list even after this bust — turning a caller's "I just wrote a
216
223
  // brand-new project's first state file, make it visible now" into a
@@ -231,6 +238,10 @@ function shapeMachine(data) {
231
238
  // classifyQueueStarvation's header for why the two must never merge.
232
239
  lastDispatchAttemptAt: data.lastDispatchAttemptAt ?? null,
233
240
  paused: data.paused ?? null,
241
+ // Upgrade-drain state (lib/upgradeDrain.cjs): deliberately NOT part of
242
+ // `paused`, so a rate-limit pause can neither overwrite it nor have the
243
+ // manual-clear cooldown swallow it.
244
+ drain: data.drain && typeof data.drain === 'object' ? data.drain : null,
234
245
  // Launch circuit breaker (lib/launchFailure.cjs): per-persona blocks and
235
246
  // the degraded-mode env a persona is currently launching with. Machine
236
247
  // state, not per-project: the broken thing is the installed CLI.
@@ -291,7 +302,6 @@ function findLongestValidJsonPrefix(raw) {
291
302
  // project's own ops root is the natural home for a machine-level log entry,
292
303
  // mirroring scheduler.cjs's own `job.cwd || DEFAULT_PROJECT_CWD` fallback for
293
304
  // cwd-less machine errors (schedulerBatch.cjs's DEFAULT_PROJECT_CWD).
294
- const MACHINE_STATE_LOG_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
295
305
 
296
306
  // The truncated tail in the real 2026-09-11 tear contained an orphaned
297
307
  // `resumeAt` fragment with no way to reconstruct the `paused` object it
@@ -306,7 +316,7 @@ const PAUSED_HINT_RE = /"paused"|resumeAt/;
306
316
  function logMachineStateRecovery({ level, message, meta }) {
307
317
  try {
308
318
  const { appendError } = require('./opsErrorLog.cjs');
309
- appendError({ cwd: MACHINE_STATE_LOG_CWD, scope: 'scheduler', level, message, meta });
319
+ appendError({ cwd: schedulerPaths.machineStateLogCwd(), scope: 'scheduler', level, message, meta });
310
320
  } catch { /* durable logging must never block recovery */ }
311
321
  console.error(`[queueStore] ${message}`);
312
322
  }
@@ -331,7 +341,7 @@ function buildMachineStateRecovery(raw, parseError) {
331
341
  ? '; the discarded tail looks like it contained paused/resumeAt data that could not '
332
342
  + 'be reconstructed — verify pause status manually'
333
343
  : ''),
334
- meta: { path: MACHINE_STATE_PATH, prefixLength: prefix.prefixLength, totalLength: raw.length, pausedHint },
344
+ meta: { path: schedulerPaths.machineStatePath(), prefixLength: prefix.prefixLength, totalLength: raw.length, pausedHint },
335
345
  };
336
346
  }
337
347
  // No valid JSON prefix at all: fall back to defaults (shapeMachine({}) —
@@ -344,7 +354,7 @@ function buildMachineStateRecovery(raw, parseError) {
344
354
  message:
345
355
  `scheduler-machine.json unrecoverable (${parseError?.message}) — falling back to defaults `
346
356
  + 'so dispatch does not silently halt',
347
- meta: { path: MACHINE_STATE_PATH, totalLength: raw.length },
357
+ meta: { path: schedulerPaths.machineStatePath(), totalLength: raw.length },
348
358
  };
349
359
  }
350
360
 
@@ -352,7 +362,7 @@ function recoverTornMachineStateSync(raw, parseError) {
352
362
  const recovery = buildMachineStateRecovery(raw, parseError);
353
363
  logMachineStateRecovery(recovery);
354
364
  try {
355
- writeJsonAtomicSync(MACHINE_STATE_PATH, recovery.value);
365
+ writeJsonAtomicSync(schedulerPaths.machineStatePath(), recovery.value);
356
366
  } catch (e) {
357
367
  console.error(`[queueStore] failed to persist recovered machine state: ${e?.message}`);
358
368
  }
@@ -363,7 +373,7 @@ async function recoverTornMachineState(raw, parseError) {
363
373
  const recovery = buildMachineStateRecovery(raw, parseError);
364
374
  logMachineStateRecovery(recovery);
365
375
  try {
366
- await writeJsonAtomic(MACHINE_STATE_PATH, recovery.value);
376
+ await writeJsonAtomic(schedulerPaths.machineStatePath(), recovery.value);
367
377
  } catch (e) {
368
378
  console.error(`[queueStore] failed to persist recovered machine state: ${e?.message}`);
369
379
  }
@@ -378,10 +388,10 @@ async function recoverTornMachineState(raw, parseError) {
378
388
  function loadMachineStateSync() {
379
389
  let raw;
380
390
  try {
381
- raw = fs.readFileSync(MACHINE_STATE_PATH, 'utf8');
391
+ raw = fs.readFileSync(schedulerPaths.machineStatePath(), 'utf8');
382
392
  } catch (e) {
383
393
  if (e?.code === 'ENOENT') return {};
384
- return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: MACHINE_STATE_PATH };
394
+ return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: schedulerPaths.machineStatePath() };
385
395
  }
386
396
  try {
387
397
  return { shaped: shapeMachine(JSON.parse(raw)) };
@@ -394,10 +404,10 @@ function loadMachineStateSync() {
394
404
  async function loadMachineState() {
395
405
  let raw;
396
406
  try {
397
- raw = await fsp.readFile(MACHINE_STATE_PATH, 'utf8');
407
+ raw = await fsp.readFile(schedulerPaths.machineStatePath(), 'utf8');
398
408
  } catch (e) {
399
409
  if (e?.code === 'ENOENT') return {};
400
- return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: MACHINE_STATE_PATH };
410
+ return { unreadable: `machine state unreadable: ${e?.message}`, unreadablePath: schedulerPaths.machineStatePath() };
401
411
  }
402
412
  try {
403
413
  return { shaped: shapeMachine(JSON.parse(raw)) };
@@ -442,15 +452,17 @@ function shapeJobs(raw, file) {
442
452
 
443
453
  /**
444
454
  * readMergedSync(opts?) → { config, jobs, scheduledFor, lastRunAt, paused,
445
- * unreadable?, unreadablePath?, sourceCwds }.
455
+ * unreadable?, unreadablePath?, unreadableCwds, sourceCwds }.
446
456
  *
447
- * `unreadable` mirrors the old single-file semantics: ANY source file that
448
- * exists but fails to parse halts scheduling (never treat a project's queue
449
- * as empty because it read corrupt). `sourceCwds` records every project file
450
- * consulted so writeSplit can persist "this project now has zero jobs".
457
+ * `unreadable` is reserved for the machine-runtime file alone (a machine-wide
458
+ * halt). A project shard that exists but fails to read/parse is quarantined
459
+ * per cwd in `unreadableCwds: [{cwd, file, error}]` — other projects keep
460
+ * dispatching, and the torn shard is never treated as empty, recovered, or
461
+ * written. `sourceCwds` records every healthy project file consulted so
462
+ * writeSplit can persist "this project now has zero jobs".
451
463
  */
452
464
  function readMergedSync(opts) {
453
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
465
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, drain: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [], unreadableCwds: [] };
454
466
  const sourceCwds = [];
455
467
  const machine = loadMachineStateSync();
456
468
  if (machine.shaped) {
@@ -473,8 +485,9 @@ function readMergedSync(opts) {
473
485
  sourceCwds.push(cwd);
474
486
  } catch (e) {
475
487
  if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
476
- out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
477
- out.unreadablePath = out.unreadablePath || file;
488
+ // Quarantine this ONE shard; never recover or rewrite it (a job shard is a
489
+ // ledger — a prefix could time-travel a running row back to pending).
490
+ out.unreadableCwds.push({ cwd, file, error: e?.message ?? String(e) });
478
491
  }
479
492
  }
480
493
  defineSources(out, sourceCwds);
@@ -483,7 +496,7 @@ function readMergedSync(opts) {
483
496
 
484
497
  /** Async twin of readMergedSync for IPC hot paths. */
485
498
  async function readMerged(opts) {
486
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
499
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, drain: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [], unreadableCwds: [] };
487
500
  const sourceCwds = [];
488
501
  const machine = await loadMachineState();
489
502
  if (machine.shaped) {
@@ -506,8 +519,9 @@ async function readMerged(opts) {
506
519
  sourceCwds.push(cwd);
507
520
  } catch (e) {
508
521
  if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
509
- out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
510
- out.unreadablePath = out.unreadablePath || file;
522
+ // Quarantine this ONE shard; never recover or rewrite it (a job shard is a
523
+ // ledger — a prefix could time-travel a running row back to pending).
524
+ out.unreadableCwds.push({ cwd, file, error: e?.message ?? String(e) });
511
525
  }
512
526
  }
513
527
  defineSources(out, sourceCwds);
@@ -531,21 +545,25 @@ function defineSources(state, sourceCwds) {
531
545
  * deletions stick.
532
546
  */
533
547
  async function writeSplit(state, defaultCwd) {
534
- await writeJsonAtomic(MACHINE_STATE_PATH, {
548
+ await writeJsonAtomic(schedulerPaths.machineStatePath(), {
535
549
  config: state.config,
536
550
  scheduledFor: state.scheduledFor ?? null,
537
551
  lastRunAt: state.lastRunAt ?? null,
538
552
  lastDispatchAttemptAt: state.lastDispatchAttemptAt ?? null,
539
553
  paused: state.paused ?? null,
554
+ drain: state.drain ?? null,
540
555
  launchBlocks: state.launchBlocks ?? {},
541
556
  launchMitigations: state.launchMitigations ?? {},
542
557
  });
543
558
 
559
+ // A quarantined shard is left byte-identical: never re-created or clobbered,
560
+ // even if merged rows carry that cwd.
561
+ const quarantined = new Set((state.unreadableCwds ?? []).map((u) => u.cwd));
544
562
  const byCwd = new Map();
545
- for (const cwd of state.sourceCwds ?? []) byCwd.set(cwd, []);
563
+ for (const cwd of state.sourceCwds ?? []) if (!quarantined.has(cwd)) byCwd.set(cwd, []);
546
564
  for (const job of state.jobs ?? []) {
547
565
  const cwd = job.cwd || defaultCwd;
548
- if (!cwd) continue; // nowhere to put it; job is dropped from persistence rather than crashing
566
+ if (!cwd || quarantined.has(cwd)) continue; // nowhere to put it; job is dropped from persistence rather than crashing
549
567
  if (!byCwd.has(cwd)) byCwd.set(cwd, []);
550
568
  byCwd.get(cwd).push(job);
551
569
  }
@@ -576,7 +594,7 @@ async function writeSplit(state, defaultCwd) {
576
594
  async function migrateLegacyGlobalQueue(defaultCwd) {
577
595
  let raw;
578
596
  try {
579
- raw = await fsp.readFile(LEGACY_QUEUE_PATH, 'utf8');
597
+ raw = await fsp.readFile(schedulerPaths.legacyQueuePath(), 'utf8');
580
598
  } catch {
581
599
  return { migrated: false };
582
600
  }
@@ -588,8 +606,8 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
588
606
  return { migrated: false, error: e?.message };
589
607
  }
590
608
 
591
- if (!fs.existsSync(MACHINE_STATE_PATH)) {
592
- await writeJsonAtomic(MACHINE_STATE_PATH, {
609
+ if (!fs.existsSync(schedulerPaths.machineStatePath())) {
610
+ await writeJsonAtomic(schedulerPaths.machineStatePath(), {
593
611
  config: legacy.config || {},
594
612
  scheduledFor: legacy.scheduledFor ?? null,
595
613
  lastRunAt: legacy.lastRunAt ?? null,
@@ -622,14 +640,12 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
622
640
  }
623
641
  }
624
642
 
625
- await fsp.rename(LEGACY_QUEUE_PATH, `${LEGACY_QUEUE_PATH}.retired-${Date.now()}`);
643
+ await fsp.rename(schedulerPaths.legacyQueuePath(), `${schedulerPaths.legacyQueuePath()}.retired-${Date.now()}`);
626
644
  bustCwdCache();
627
645
  return { migrated: true, moved, projects: byCwd.size };
628
646
  }
629
647
 
630
648
  module.exports = {
631
- MACHINE_STATE_PATH,
632
- LEGACY_QUEUE_PATH,
633
649
  STATE_SUBPATH,
634
650
  projectStateDir,
635
651
  projectQueuePath,
@@ -645,3 +661,7 @@ module.exports = {
645
661
  writeJsonAtomicSync,
646
662
  findLongestValidJsonPrefix,
647
663
  };
664
+
665
+ // Lazy path getters (SM_SCHEDULER_HOME resolved at read, never at require).
666
+ Object.defineProperty(module.exports, 'MACHINE_STATE_PATH', { get: schedulerPaths.machineStatePath, enumerable: true });
667
+ Object.defineProperty(module.exports, 'LEGACY_QUEUE_PATH', { get: schedulerPaths.legacyQueuePath, enumerable: true });
@@ -12,17 +12,22 @@
12
12
  */
13
13
  'use strict';
14
14
 
15
+ const { appendAuditEvent } = require('./auditLog.cjs');
16
+ const { isProvablyDead, DEFAULT_GRACE_MS } = require('./reservationExpiry.cjs');
17
+
15
18
  // slug of the job currently holding the lease, or null.
16
19
  let heldBySlug = null;
20
+ let heldClaimedAt = 0;
17
21
 
18
22
  function isHeld() {
19
23
  return heldBySlug !== null;
20
24
  }
21
25
 
22
26
  /** acquire(slug) → true if the lease was free and is now held by `slug`. */
23
- function acquire(slug) {
27
+ function acquire(slug, { claimedAt = Date.now() } = {}) {
24
28
  if (heldBySlug !== null) return false;
25
29
  heldBySlug = String(slug);
30
+ heldClaimedAt = claimedAt;
26
31
  return true;
27
32
  }
28
33
 
@@ -36,6 +41,17 @@ function release(slug) {
36
41
  return true;
37
42
  }
38
43
 
44
+ /** expireDead — free a lease whose holder has no running row and is past the grace window
45
+ * (the lease never carries a pid; the row-liveness + grace proof is the whole test). */
46
+ function expireDead({ liveSlugs, now = Date.now(), graceMs = DEFAULT_GRACE_MS } = {}) {
47
+ if (heldBySlug === null) return false;
48
+ const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || []);
49
+ if (!isProvablyDead({ claimedAt: heldClaimedAt, pid: null }, { live: live.has(heldBySlug), now, graceMs })) return false;
50
+ appendAuditEvent('quiet_lease_expired', { slug: heldBySlug });
51
+ heldBySlug = null;
52
+ return true;
53
+ }
54
+
39
55
  function holder() {
40
56
  return heldBySlug;
41
57
  }
@@ -43,6 +59,7 @@ function holder() {
43
59
  /** Test hook: force the lease back to free. */
44
60
  function __resetForTests() {
45
61
  heldBySlug = null;
62
+ heldClaimedAt = 0;
46
63
  }
47
64
 
48
- module.exports = { isHeld, acquire, release, holder, __resetForTests };
65
+ module.exports = { isHeld, acquire, release, expireDead, holder, __resetForTests };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * reservationExpiry.cjs — the single fail-CLOSED "is this reservation's owner
3
+ * provably dead?" predicate shared by sessionSlots, quietMachineLease, the
4
+ * investigation set and gitWorktree's accounting. A reservation is released
5
+ * only on proof: no live row for its slug AND (a stamped pid that is dead, or
6
+ * no pid ever stamped and claimedAt older than graceMs). Anything ambiguous
7
+ * (live row, live pid, inside the grace window, claimed after the pass began)
8
+ * is kept. Never signals a process.
9
+ */
10
+ 'use strict';
11
+
12
+ const DEFAULT_GRACE_MS = 10 * 60 * 1000;
13
+
14
+ function defaultPidAlive(pid) {
15
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
16
+ }
17
+
18
+ /**
19
+ * @param {{ claimedAt: number, pid: number|null }} res
20
+ * @param {{ live: boolean, pidAlive?: (pid:number)=>boolean, now: number, graceMs?: number }} ctx
21
+ */
22
+ function isProvablyDead(res, { live, pidAlive = defaultPidAlive, now, graceMs = DEFAULT_GRACE_MS }) {
23
+ if (live) return false;
24
+ // Made after the pass began (i.e. this tick) — never expire it.
25
+ if (!Number.isFinite(res.claimedAt) || res.claimedAt > now) return false;
26
+ if (Number.isInteger(res.pid) && res.pid > 0) return !pidAlive(res.pid);
27
+ return now - res.claimedAt > graceMs;
28
+ }
29
+
30
+ module.exports = { isProvablyDead, defaultPidAlive, DEFAULT_GRACE_MS };
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  const { spawn } = require('node:child_process');
18
- const { resolveClaudeBin } = require('./claudeBin.cjs');
18
+ const { resolveClaudeBin, claudeSpawnTarget } = require('./claudeBin.cjs');
19
+ const { withProcRole } = require('./cleanEnv.cjs');
19
20
 
20
21
  const MAX_OUT_BYTES = 8 * 1024 * 1024;
21
22
 
@@ -31,7 +32,8 @@ function runClaudeP(prompt, { model = 'sonnet', timeoutMs = 180_000, systemPromp
31
32
  '--output-format', 'text',
32
33
  ];
33
34
  if (systemPrompt) args.push('--append-system-prompt', systemPrompt);
34
- const child = spawn(bin, args, { env: { ...process.env, SM_KG_INTERNAL: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
35
+ const target = claudeSpawnTarget('aux', 'runclaudep', bin);
36
+ const child = spawn(target.command, args, { env: withProcRole({ ...process.env, SM_KG_INTERNAL: '1' }, 'aux'), stdio: ['ignore', 'pipe', 'pipe'], ...(target.argv0 ? { argv0: target.argv0 } : {}) });
35
37
  let out = '';
36
38
  let err = '';
37
39
  let killedForSize = false;
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Directory shape: one subdirectory per tick (`RUNS_DIR/<iso-ts>/`, minted by
14
14
  * scheduler.cjs's pickRunDir), which commonly holds MANY different PRD
15
- * slugs' artifacts side by side — `<slug>.log`, `<slug>.meta.json`, and an
15
+ * slugs' artifacts side by side — `<slug>.log`, `<slug>.meta.json`, the
16
+ * `<slug>.supervisor.json` dispatch record (jobSupervisorRecord.cjs), and an
16
17
  * optional `root-cause-<slug>.md` (rcaReport.cjs) per slug. Retention is
17
18
  * therefore computed per (runId, slug) ENTRY, not per directory: a directory
18
19
  * is only "fully removable" once every entry it holds is independently
@@ -131,7 +132,7 @@ function scanRunEntries(runsDir) {
131
132
  for (const metaFile of metaFiles) {
132
133
  const slug = metaFile.slice(0, -META_SUFFIX.length);
133
134
  const relatedNames = files.filter(
134
- (f) => f === metaFile || f === `${slug}.log` || f === `root-cause-${slug}.md`
135
+ (f) => f === metaFile || f === `${slug}.log` || f === `${slug}.supervisor.json` || f === `root-cause-${slug}.md`
135
136
  );
136
137
 
137
138
  let sizeBytes = 0;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Canonical runtime schema for a ScheduleJob record, mirroring the TS
3
- * `ScheduleJob` interface at src/preload/api.d.ts:375-422 field-for-field.
3
+ * `ScheduleJob` interface (`ScheduleJob` in src/preload/api.d.ts) field-for-field.
4
4
  *
5
5
  * Modelled directly on promptSessionSchema.cjs's "zod schema asserted at the
6
6
  * main-process boundary" pattern: the Epic entity has had this guarantee
@@ -52,7 +52,7 @@ const ScheduleJobStatusHistoryEntrySchema = z
52
52
  .passthrough();
53
53
 
54
54
  // Only `slug` and `status` are required. Every other ScheduleJob field
55
- // (src/preload/api.d.ts:375-422) is declared here for documentation and type
55
+ // (`ScheduleJob` in src/preload/api.d.ts) is declared here for documentation and type
56
56
  // checking when present, but kept optional: a job row moves through several
57
57
  // legitimate partial shapes across its life (freshly minted with nulls,
58
58
  // mid-run with runtime fields, terminal with exitCode/error) and plenty of
@@ -207,7 +207,23 @@ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } =
207
207
  // Fire-and-forget, exactly like every other best-effort side-channel write
208
208
  // in this codebase (rcaReport.cjs, auditLog.cjs's own posture) — a ledger
209
209
  // write failure must never fail or delay the transition it's recording.
210
- if (toStatus === 'needs_review' && from !== 'needs_review') {
210
+ // 'investigating' is a PAUSE, not a resolution, when it's reached FROM
211
+ // needs_review: spawnInvestigation always restores the job to exactly the
212
+ // status it left (a snapshot taken before the pause), so
213
+ // needs_review -> investigating -> needs_review is one continuous episode,
214
+ // not two. Without this check, the pause mints a spurious 'auto-fix'
215
+ // resolution mid-episode, then the resume mints a spurious fresh entry
216
+ // that overwrites the original episode's runId/enteredAt/reason in the
217
+ // ledger's by-key maps (keyed on slug+runId) — corrupting byLadderRung and
218
+ // understating dwell time. Detected via the stamped episode fields
219
+ // themselves rather than the transition's source string, since the same
220
+ // 'investigating' status is also reached fresh from 'failed' (a job that
221
+ // was never parked needs_review at all, and has no episode to pause).
222
+ const isNeedsReviewPause = from === 'needs_review' && toStatus === 'investigating';
223
+ const pausedEpisodeOpen = job.needsReviewEntryRunId !== undefined;
224
+ const isNeedsReviewResume = from === 'investigating' && pausedEpisodeOpen;
225
+
226
+ if (toStatus === 'needs_review' && from !== 'needs_review' && !isNeedsReviewResume) {
211
227
  // ENTRY: stamp the episode's runId/enteredAt on the job itself so the
212
228
  // RESOLUTION line (below) can reference the SAME runId even if the job's
213
229
  // own `runId` is reassigned in between (e.g. a resume-recovery
@@ -217,9 +233,17 @@ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } =
217
233
  queueHistory.appendHistory([buildNeedsReviewEntryLine(job, entry)]).catch((e) => {
218
234
  console.error('[scheduleJobTransitions] failed to append needs_review entry', e?.message ?? String(e));
219
235
  });
220
- } else if (from === 'needs_review' && toStatus !== 'needs_review') {
236
+ } else if (
237
+ (from === 'needs_review' && toStatus !== 'needs_review' && !isNeedsReviewPause)
238
+ || (isNeedsReviewResume && toStatus !== 'needs_review')
239
+ ) {
221
240
  // RESOLUTION: read the episode stamped at entry, then clear it — the
222
241
  // episode is over regardless of what this resolution line looks like.
242
+ // The `isNeedsReviewResume` arm covers an episode that paused for
243
+ // investigation and is now resolving to something other than
244
+ // needs_review (e.g. the investigation actually fixed it) — the episode
245
+ // stamps were deliberately left untouched across the pause, so this
246
+ // still references the ORIGINAL entry time, not the pause's.
223
247
  const episode = { runId: job.needsReviewEntryRunId ?? null, enteredAt: job.needsReviewEnteredAt ?? null };
224
248
  delete job.needsReviewEntryRunId;
225
249
  delete job.needsReviewEnteredAt;
@@ -227,6 +251,9 @@ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } =
227
251
  console.error('[scheduleJobTransitions] failed to append needs_review resolution', e?.message ?? String(e));
228
252
  });
229
253
  }
254
+ // else: isNeedsReviewPause (needs_review -> investigating), or
255
+ // isNeedsReviewResume && toStatus === 'needs_review' (investigating ->
256
+ // needs_review, still the same open episode) — intentionally a no-op.
230
257
 
231
258
  return true;
232
259
  }
@@ -75,7 +75,7 @@ function dedupeCycles(cycles) {
75
75
  return out;
76
76
  }
77
77
 
78
- function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
78
+ function makeDepResolver(projectJobs, satisfiedSlugs = new Set()) {
79
79
  const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
80
80
  const rowsByBareSlug = new Map();
81
81
  for (const j of projectJobs) {
@@ -102,6 +102,31 @@ function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
102
102
  }
103
103
  return satisfiedBareSlugs.has(bareSlug(slug));
104
104
  };
105
+ return { rowsForDep, isKnownSatisfied };
106
+ }
107
+
108
+ /**
109
+ * findUnresolvableDepRoots(projectJobs, satisfiedSlugs) → string[]
110
+ *
111
+ * Pure. The dep slugs that name NO live row and NO completion/archive record
112
+ * — the roots findBlockingDep holds as 'unresolved'. Unlike a failed/pending
113
+ * dep row, nothing in the queue will ever change one of these; only a human
114
+ * (or a restored record) can. Sorted, de-duplicated. O(jobs + deps).
115
+ */
116
+ function findUnresolvableDepRoots(projectJobs, satisfiedSlugs = new Set()) {
117
+ const { rowsForDep, isKnownSatisfied } = makeDepResolver(projectJobs, satisfiedSlugs);
118
+ const roots = new Set();
119
+ for (const j of projectJobs) {
120
+ if (j.status !== 'pending') continue;
121
+ for (const dep of j.dependsOn ?? []) {
122
+ if (rowsForDep(dep).length === 0 && !isKnownSatisfied(dep)) roots.add(dep);
123
+ }
124
+ }
125
+ return [...roots].sort();
126
+ }
127
+
128
+ function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
129
+ const { rowsForDep, isKnownSatisfied } = makeDepResolver(projectJobs, satisfiedSlugs);
105
130
  // A dep row is blocking unless it's 'completed', OR it's a 'skipped' row
106
131
  // stamped `needsReviewAutoResolvedSkip` — the bounded needs_review
107
132
  // auto-resolve terminal decision (scheduler.cjs's
@@ -658,6 +683,6 @@ function findStarvedProjects(jobs, now, thresholdMs) {
658
683
  }
659
684
 
660
685
  module.exports = {
661
- pickForProject, pickNextBatch, enqueueTimestamp, findStarvedProjects, findBlockingDep, DEFAULT_PROJECT_CWD,
686
+ pickForProject, pickNextBatch, enqueueTimestamp, findStarvedProjects, findBlockingDep, findUnresolvableDepRoots, DEFAULT_PROJECT_CWD,
662
687
  DEP_HISTORY_FAIL_OPEN,
663
688
  };