claude-code-session-manager 0.97.0 → 0.100.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 (430) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.cjs +16 -4
  3. package/bin/node-floor.cjs +41 -0
  4. package/dist/assets/{DataModel-D93FoaOm.js → DataModel-HdR0AxFq.js} +1 -1
  5. package/dist/assets/{History-CXxxqNP1.js → History-Dr4Jr_5z.js} +2 -2
  6. package/dist/assets/{Hooks-CzeYFQjq.js → Hooks-NhrvH3JP.js} +3 -3
  7. package/dist/assets/{Library-D2zPkwOC.js → Library-Dv_EtIMr.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DbinP78A.js → MarkdownEditor-DTKDWV_n.js} +1 -1
  9. package/dist/assets/{McpServers-Bu_sVGLr.js → McpServers-M034G9B3.js} +2 -2
  10. package/dist/assets/{Memory-BgiI6LZ_.js → Memory-DzF1B4gx.js} +6 -6
  11. package/dist/assets/{Permissions-C1MMzZRm.js → Permissions-DrgbSGWl.js} +3 -3
  12. package/dist/assets/{Plugins-Dhu6wAZi.js → Plugins-Bd3ef35w.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C3AcWKMG.js → ProvenanceBadge-DcBHT1Iy.js} +1 -1
  14. package/dist/assets/{SaveBar-plwYm0iO.js → SaveBar-rimJem-Y.js} +1 -1
  15. package/dist/assets/Scheduler-DjgFJl2w.js +16 -0
  16. package/dist/assets/{ScopeSwitcher-DFy0vv_o.js → ScopeSwitcher-CnAzTBrl.js} +1 -1
  17. package/dist/assets/Settings-Dc1F_sSg.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-DsI7hRPB.js → SkillReferenceGraph-B9aZDecM.js} +1 -1
  19. package/dist/assets/{Skills-3E748UfB.js → Skills-DRXd18gu.js} +2 -2
  20. package/dist/assets/{SystemPrompt-BqmfUJOd.js → SystemPrompt-ahy5-Fwe.js} +1 -1
  21. package/dist/assets/{TagLibrary-CPz6fO8Q.js → TagLibrary-C_tToDeL.js} +1 -1
  22. package/dist/assets/{TiptapBody-D9EoBQ1P.js → TiptapBody-DvdefS6K.js} +1 -1
  23. package/dist/assets/{Toggle-BHAmZNDn.js → Toggle-6Jl5Njc6.js} +1 -1
  24. package/dist/assets/{index-CKH5Uxik.css → index-Bta-hwud.css} +1 -1
  25. package/dist/assets/{index-tn5JIVPj.js → index-vx8O73l8.js} +497 -502
  26. package/dist/assets/{settingsSchema-Crqy_aNz.js → settingsSchema-D6iRguNV.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +26 -29
  29. package/plugins/CLAUDE.md +6 -6
  30. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +23 -19
  31. package/plugins/session-manager-dev/skills/builder/SKILL.md +3 -3
  32. package/plugins/session-manager-dev/skills/develop/SKILL.md +254 -510
  33. package/plugins/session-manager-dev/skills/develop/standards.md +17 -22
  34. package/scripts/README.md +3 -11
  35. package/scripts/audit-ops-hygiene.cjs +3 -3
  36. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +1 -1
  37. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +1 -1
  38. package/scripts/mint-epic.cjs +2 -3
  39. package/scripts/scheduler-mcp-server.cjs +36 -7
  40. package/src/main/agentLibrary.cjs +22 -2
  41. package/src/main/build-info.json +4 -4
  42. package/src/main/config.cjs +41 -38
  43. package/src/main/docEdit.cjs +4 -1
  44. package/src/main/files.cjs +2 -5
  45. package/src/main/health.cjs +1 -1
  46. package/src/main/index.cjs +23 -10
  47. package/src/main/ipcSchemas.cjs +28 -35
  48. package/src/main/lib/agentPersonaSchema.cjs +13 -2
  49. package/src/main/lib/atomicFs.cjs +116 -0
  50. package/src/main/lib/branchSweep.cjs +13 -12
  51. package/src/main/lib/buildTarget.cjs +3 -3
  52. package/src/main/lib/claudeCliCaps.cjs +129 -0
  53. package/src/main/lib/credentials.cjs +2 -4
  54. package/src/main/lib/crossProjectFeedback.cjs +3 -3
  55. package/src/main/lib/cwdClassify.cjs +15 -3
  56. package/src/main/lib/definitionOfDone.cjs +207 -125
  57. package/src/main/lib/dodDrainHook.cjs +11 -5
  58. package/src/main/lib/epicMint.cjs +2 -4
  59. package/src/main/lib/epicStatusMirror.cjs +8 -10
  60. package/src/main/lib/epicWorktreeProjectConfig.cjs +2 -4
  61. package/src/main/lib/gateAuthority.cjs +96 -0
  62. package/src/main/lib/gitExec.cjs +69 -0
  63. package/src/main/lib/gitWorktree.cjs +656 -92
  64. package/src/main/lib/instanceLock.cjs +5 -8
  65. package/src/main/lib/launchFailure.cjs +2 -3
  66. package/src/main/lib/macroLibrary.cjs +386 -0
  67. package/src/main/lib/mcpToolCatalog.cjs +26 -17
  68. package/src/main/lib/needsReviewLedger.cjs +1 -1
  69. package/src/main/lib/opsErrorLog.cjs +11 -1
  70. package/src/main/lib/opsOwnership.cjs +0 -5
  71. package/src/main/lib/pidAlive.cjs +32 -0
  72. package/src/main/lib/prdCreate.cjs +153 -19
  73. package/src/main/lib/prdDisposition.cjs +2 -2
  74. package/src/main/lib/prdGateFiles.cjs +284 -0
  75. package/src/main/lib/prdLocations.cjs +61 -0
  76. package/src/main/lib/prdMigration.cjs +2 -3
  77. package/src/main/lib/prdSizing.cjs +2 -1
  78. package/src/main/lib/promptSessionSchema.cjs +10 -2
  79. package/src/main/lib/rcaReport.cjs +4 -6
  80. package/src/main/lib/reaperHelpers.cjs +8 -1
  81. package/src/main/lib/reservationExpiry.cjs +6 -1
  82. package/src/main/lib/reviewNotice.cjs +263 -0
  83. package/src/main/lib/runClaudeP.cjs +4 -1
  84. package/src/main/lib/schedulerPaths.cjs +22 -2
  85. package/src/main/lib/sessionSlots.cjs +2 -4
  86. package/src/main/lib/shippedPersonaSeeds.cjs +179 -0
  87. package/src/main/lib/timeoutShim.cjs +123 -0
  88. package/src/main/lib/timeoutShimScript.cjs +275 -0
  89. package/src/main/lib/upgradeDrain.cjs +4 -10
  90. package/src/main/lib/watchdogHelpers.cjs +33 -30
  91. package/src/main/lib/workTypeLibrary.cjs +7 -1
  92. package/src/main/promptSessionEvents.cjs +37 -13
  93. package/src/main/queueOps.cjs +17 -6
  94. package/src/main/scheduler.cjs +975 -203
  95. package/src/main/seedAgentPersonas.cjs +278 -23
  96. package/src/main/supervisor.cjs +4 -1
  97. package/src/main/templates/PRD_AUTHORING.md +126 -406
  98. package/src/preload/__tests__/preload-surface.test.cjs +68 -0
  99. package/src/preload/api.d.ts +41 -94
  100. package/src/preload/index.cjs +14 -9
  101. package/src/seed/agents/architect.md +1 -0
  102. package/src/seed/agents/dev-lead.md +17 -18
  103. package/src/seed/agents/project-home-builder.md +1 -0
  104. package/src/seed/agents/validator.md +10 -4
  105. package/dist/assets/HostBilko-nESrTRIg.js +0 -1
  106. package/dist/assets/Scheduler-BVYTeh39.js +0 -16
  107. package/dist/assets/Settings-DIhGgdfs.js +0 -3
  108. package/src/main/__tests__/activeIndexMerge.test.cjs +0 -235
  109. package/src/main/__tests__/agentEffortResolve.test.cjs +0 -117
  110. package/src/main/__tests__/agentLibrary.test.cjs +0 -276
  111. package/src/main/__tests__/agentModelResolve.test.cjs +0 -311
  112. package/src/main/__tests__/agentOverlayWrite.test.cjs +0 -95
  113. package/src/main/__tests__/bilkoHost-deriveSlug.test.cjs +0 -26
  114. package/src/main/__tests__/bilkoHost-integration.test.cjs +0 -118
  115. package/src/main/__tests__/bilkoHostCore.test.cjs +0 -72
  116. package/src/main/__tests__/broadcastCoalescer.test.cjs +0 -122
  117. package/src/main/__tests__/chat-cancel-terminal.test.cjs +0 -120
  118. package/src/main/__tests__/chat-dead-channels.test.cjs +0 -63
  119. package/src/main/__tests__/chat-exit-close-race.test.cjs +0 -146
  120. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +0 -139
  121. package/src/main/__tests__/chat-preamble-anchors.test.cjs +0 -100
  122. package/src/main/__tests__/chat-queue.test.cjs +0 -97
  123. package/src/main/__tests__/chat-stop-signal.test.cjs +0 -89
  124. package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +0 -125
  125. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +0 -252
  126. package/src/main/__tests__/classifyPromptTicket.test.cjs +0 -101
  127. package/src/main/__tests__/classifyTranscriptLine.test.cjs +0 -201
  128. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +0 -66
  129. package/src/main/__tests__/config-readText-bounded.test.cjs +0 -84
  130. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +0 -59
  131. package/src/main/__tests__/crossProjectFeedback.test.cjs +0 -334
  132. package/src/main/__tests__/crossProjectFeedbackRoutes.test.cjs +0 -161
  133. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +0 -77
  134. package/src/main/__tests__/develop-skill-failure-modes.test.cjs +0 -70
  135. package/src/main/__tests__/docEdit.test.cjs +0 -244
  136. package/src/main/__tests__/dod-batchkey.test.cjs +0 -183
  137. package/src/main/__tests__/dod-drain-hook.test.cjs +0 -302
  138. package/src/main/__tests__/dod-report.test.cjs +0 -304
  139. package/src/main/__tests__/dod-reverify.test.cjs +0 -285
  140. package/src/main/__tests__/epicContextDigest.test.cjs +0 -174
  141. package/src/main/__tests__/epicMint.test.cjs +0 -332
  142. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +0 -64
  143. package/src/main/__tests__/epicStatusMirror.test.cjs +0 -110
  144. package/src/main/__tests__/epicValidationHook.test.cjs +0 -291
  145. package/src/main/__tests__/exchanges.test.cjs +0 -122
  146. package/src/main/__tests__/exchangesPromptId.test.cjs +0 -61
  147. package/src/main/__tests__/extractJson.test.cjs +0 -51
  148. package/src/main/__tests__/files-reject-credentials.test.cjs +0 -40
  149. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +0 -556
  150. package/src/main/__tests__/flatPrdTickSweep.test.cjs +0 -110
  151. package/src/main/__tests__/health-build-freshness.test.cjs +0 -39
  152. package/src/main/__tests__/health-claude-md-budget.test.cjs +0 -57
  153. package/src/main/__tests__/health-credentials.test.cjs +0 -81
  154. package/src/main/__tests__/health-delegation-chain.test.cjs +0 -124
  155. package/src/main/__tests__/health-per-project-stall.test.cjs +0 -84
  156. package/src/main/__tests__/health-prd-migration.test.cjs +0 -37
  157. package/src/main/__tests__/health-queue-dispatch.test.cjs +0 -135
  158. package/src/main/__tests__/health-starve-escalation.test.cjs +0 -94
  159. package/src/main/__tests__/health-tick-liveness.test.cjs +0 -179
  160. package/src/main/__tests__/health-usage-poller.test.cjs +0 -144
  161. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +0 -65
  162. package/src/main/__tests__/heapSnapshot.test.cjs +0 -121
  163. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +0 -313
  164. package/src/main/__tests__/historyDashboard.test.cjs +0 -163
  165. package/src/main/__tests__/historyRollup.test.cjs +0 -333
  166. package/src/main/__tests__/intradayRefresh.test.cjs +0 -39
  167. package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +0 -39
  168. package/src/main/__tests__/kg-augment.test.cjs +0 -195
  169. package/src/main/__tests__/loadGateDetailTick.test.cjs +0 -31
  170. package/src/main/__tests__/machineProfile.test.cjs +0 -152
  171. package/src/main/__tests__/mcpStatus.test.cjs +0 -61
  172. package/src/main/__tests__/memoryAggregate.test.cjs +0 -109
  173. package/src/main/__tests__/memoryStale.test.cjs +0 -88
  174. package/src/main/__tests__/needsReviewLedger.test.cjs +0 -162
  175. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +0 -25
  176. package/src/main/__tests__/opsErrorLog.test.cjs +0 -109
  177. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +0 -173
  178. package/src/main/__tests__/personaMerge.test.cjs +0 -169
  179. package/src/main/__tests__/planValidator.test.cjs +0 -125
  180. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +0 -176
  181. package/src/main/__tests__/prd-group-allocator.test.cjs +0 -119
  182. package/src/main/__tests__/prdAdminRouteParity.test.cjs +0 -70
  183. package/src/main/__tests__/prdAdminRoutes.test.cjs +0 -718
  184. package/src/main/__tests__/prdAgentType.test.cjs +0 -103
  185. package/src/main/__tests__/prdAuthoringSeed.test.cjs +0 -39
  186. package/src/main/__tests__/prdCreate.test.cjs +0 -979
  187. package/src/main/__tests__/prdCreateDisposition.test.cjs +0 -201
  188. package/src/main/__tests__/prdCreatePlanId.test.cjs +0 -132
  189. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +0 -117
  190. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +0 -136
  191. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +0 -125
  192. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +0 -108
  193. package/src/main/__tests__/prdLocations.test.cjs +0 -195
  194. package/src/main/__tests__/prdLocationsArchived.test.cjs +0 -201
  195. package/src/main/__tests__/prdMigration.test.cjs +0 -349
  196. package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +0 -91
  197. package/src/main/__tests__/prdParserHighWater.test.cjs +0 -74
  198. package/src/main/__tests__/prdParserSourcePromptId.test.cjs +0 -65
  199. package/src/main/__tests__/prdSetDisposition.test.cjs +0 -222
  200. package/src/main/__tests__/prdSizing.test.cjs +0 -106
  201. package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +0 -118
  202. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +0 -160
  203. package/src/main/__tests__/proc-role-env.test.cjs +0 -125
  204. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +0 -304
  205. package/src/main/__tests__/procname-sm-processes.test.cjs +0 -127
  206. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +0 -177
  207. package/src/main/__tests__/projectPages.test.cjs +0 -137
  208. package/src/main/__tests__/promptSessionEvents.test.cjs +0 -234
  209. package/src/main/__tests__/promptSessionSchema.test.cjs +0 -101
  210. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  211. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +0 -159
  212. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +0 -282
  213. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +0 -96
  214. package/src/main/__tests__/pty-write-result.test.cjs +0 -46
  215. package/src/main/__tests__/queue-health-verdict.test.cjs +0 -170
  216. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +0 -286
  217. package/src/main/__tests__/queue-starvation-per-project.test.cjs +0 -147
  218. package/src/main/__tests__/queueHistory.test.cjs +0 -355
  219. package/src/main/__tests__/queueOps-interactive-ac-lint.test.cjs +0 -65
  220. package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +0 -65
  221. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +0 -154
  222. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +0 -128
  223. package/src/main/__tests__/rcaReport.test.cjs +0 -266
  224. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +0 -119
  225. package/src/main/__tests__/reconcileTiming.test.cjs +0 -135
  226. package/src/main/__tests__/runLogRetention.test.cjs +0 -489
  227. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +0 -26
  228. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +0 -58
  229. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +0 -191
  230. package/src/main/__tests__/runVerify-policy-denial.test.cjs +0 -89
  231. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +0 -225
  232. package/src/main/__tests__/runVerify.test.cjs +0 -1784
  233. package/src/main/__tests__/scheduleJobSchema.test.cjs +0 -127
  234. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +0 -65
  235. package/src/main/__tests__/scheduleJobTransitions.test.cjs +0 -277
  236. package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +0 -59
  237. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +0 -72
  238. package/src/main/__tests__/scheduler-admin-routes.test.cjs +0 -199
  239. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +0 -143
  240. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +0 -105
  241. package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +0 -102
  242. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +0 -155
  243. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +0 -188
  244. package/src/main/__tests__/scheduler-autofix-select.test.cjs +0 -439
  245. package/src/main/__tests__/scheduler-autopromote.test.cjs +0 -51
  246. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +0 -101
  247. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +0 -107
  248. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +0 -153
  249. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +0 -121
  250. package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +0 -134
  251. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +0 -244
  252. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +0 -182
  253. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +0 -43
  254. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +0 -168
  255. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +0 -58
  256. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +0 -81
  257. package/src/main/__tests__/scheduler-epic-digest.test.cjs +0 -227
  258. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +0 -121
  259. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +0 -229
  260. package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +0 -75
  261. package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +0 -119
  262. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +0 -46
  263. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +0 -78
  264. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +0 -119
  265. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +0 -390
  266. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +0 -61
  267. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +0 -80
  268. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +0 -323
  269. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +0 -41
  270. package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +0 -63
  271. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +0 -123
  272. package/src/main/__tests__/scheduler-job-budget.test.cjs +0 -172
  273. package/src/main/__tests__/scheduler-job-overrun.test.cjs +0 -175
  274. package/src/main/__tests__/scheduler-launch-failure.test.cjs +0 -199
  275. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +0 -52
  276. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +0 -199
  277. package/src/main/__tests__/scheduler-looks-done.test.cjs +0 -537
  278. package/src/main/__tests__/scheduler-manual-pause.test.cjs +0 -118
  279. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +0 -245
  280. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +0 -46
  281. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +0 -197
  282. package/src/main/__tests__/scheduler-never-stop.test.cjs +0 -157
  283. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +0 -152
  284. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +0 -81
  285. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +0 -87
  286. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +0 -343
  287. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +0 -217
  288. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +0 -164
  289. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +0 -161
  290. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +0 -178
  291. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +0 -165
  292. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +0 -257
  293. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +0 -123
  294. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +0 -152
  295. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +0 -156
  296. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +0 -754
  297. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +0 -87
  298. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +0 -100
  299. package/src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs +0 -105
  300. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +0 -203
  301. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +0 -247
  302. package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +0 -77
  303. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +0 -254
  304. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +0 -115
  305. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +0 -380
  306. package/src/main/__tests__/scheduler-sigterm-commit.test.cjs +0 -43
  307. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +0 -126
  308. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +0 -154
  309. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +0 -252
  310. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +0 -185
  311. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +0 -136
  312. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +0 -81
  313. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +0 -54
  314. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +0 -172
  315. package/src/main/__tests__/scheduler-transient-failure.test.cjs +0 -141
  316. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +0 -62
  317. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +0 -89
  318. package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +0 -109
  319. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +0 -234
  320. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +0 -120
  321. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +0 -105
  322. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +0 -117
  323. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +0 -110
  324. package/src/main/__tests__/seedAgentPersonas.test.cjs +0 -184
  325. package/src/main/__tests__/seedSchedulerMcp.test.cjs +0 -211
  326. package/src/main/__tests__/seedStatus.test.cjs +0 -100
  327. package/src/main/__tests__/seedValidatorPersona.test.cjs +0 -116
  328. package/src/main/__tests__/stop-signal-anchor.test.cjs +0 -75
  329. package/src/main/__tests__/telemetryClient.test.cjs +0 -1055
  330. package/src/main/__tests__/telemetryContract.test.cjs +0 -930
  331. package/src/main/__tests__/telemetrySettings.test.cjs +0 -210
  332. package/src/main/__tests__/transcripts-batch-flush.test.cjs +0 -249
  333. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +0 -124
  334. package/src/main/__tests__/transcripts-paged-reads.test.cjs +0 -241
  335. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +0 -154
  336. package/src/main/__tests__/transcriptsUsageFor.test.cjs +0 -206
  337. package/src/main/__tests__/uniquePrdNumbers.test.cjs +0 -153
  338. package/src/main/__tests__/usageSingleFlight.test.cjs +0 -169
  339. package/src/main/__tests__/validationSentinels.test.cjs +0 -84
  340. package/src/main/__tests__/workTypeLibrary.test.cjs +0 -89
  341. package/src/main/bilkoHost.cjs +0 -314
  342. package/src/main/bilkoHostCore.cjs +0 -89
  343. package/src/main/lib/__tests__/active-sessions.test.cjs +0 -251
  344. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +0 -179
  345. package/src/main/lib/__tests__/agentPersonaSchema.test.cjs +0 -67
  346. package/src/main/lib/__tests__/auditLog.test.cjs +0 -38
  347. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +0 -107
  348. package/src/main/lib/__tests__/branchSweep.test.cjs +0 -164
  349. package/src/main/lib/__tests__/buildIdentity.test.cjs +0 -121
  350. package/src/main/lib/__tests__/buildTarget.test.cjs +0 -52
  351. package/src/main/lib/__tests__/childWithLog.test.cjs +0 -321
  352. package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +0 -87
  353. package/src/main/lib/__tests__/crashTelemetry.test.cjs +0 -103
  354. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +0 -115
  355. package/src/main/lib/__tests__/cwdClassify.test.cjs +0 -111
  356. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +0 -95
  357. package/src/main/lib/__tests__/delegationReadiness.test.cjs +0 -1175
  358. package/src/main/lib/__tests__/dispatchLoop.test.cjs +0 -63
  359. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +0 -244
  360. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +0 -91
  361. package/src/main/lib/__tests__/epicDelegationStats.test.cjs +0 -137
  362. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +0 -283
  363. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +0 -196
  364. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +0 -163
  365. package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +0 -136
  366. package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +0 -130
  367. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +0 -117
  368. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +0 -137
  369. package/src/main/lib/__tests__/fixChainDepth.test.cjs +0 -40
  370. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +0 -13
  371. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +0 -34
  372. package/src/main/lib/__tests__/gateFixtures.json +0 -20
  373. package/src/main/lib/__tests__/gitCacheBound.test.cjs +0 -69
  374. package/src/main/lib/__tests__/gitWorktree.test.cjs +0 -1478
  375. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +0 -107
  376. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +0 -153
  377. package/src/main/lib/__tests__/guardShims.test.cjs +0 -158
  378. package/src/main/lib/__tests__/importReferences.spec.cjs +0 -56
  379. package/src/main/lib/__tests__/instanceLock.test.cjs +0 -173
  380. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +0 -78
  381. package/src/main/lib/__tests__/jobWorktree.test.cjs +0 -199
  382. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +0 -82
  383. package/src/main/lib/__tests__/landedSinceRun.test.cjs +0 -133
  384. package/src/main/lib/__tests__/launchFailure.test.cjs +0 -220
  385. package/src/main/lib/__tests__/loadGate.test.cjs +0 -303
  386. package/src/main/lib/__tests__/localAdminHttp.test.cjs +0 -214
  387. package/src/main/lib/__tests__/loopDelay.test.cjs +0 -68
  388. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +0 -107
  389. package/src/main/lib/__tests__/modelCatalog.test.cjs +0 -202
  390. package/src/main/lib/__tests__/opsOwnership.test.cjs +0 -113
  391. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +0 -328
  392. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +0 -51
  393. package/src/main/lib/__tests__/opsRootResolve.test.cjs +0 -149
  394. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +0 -82
  395. package/src/main/lib/__tests__/prdDisposition.test.cjs +0 -224
  396. package/src/main/lib/__tests__/procIdentity.test.cjs +0 -119
  397. package/src/main/lib/__tests__/procName.test.cjs +0 -92
  398. package/src/main/lib/__tests__/projectBriefCore.test.cjs +0 -216
  399. package/src/main/lib/__tests__/projectRootResolve.test.cjs +0 -148
  400. package/src/main/lib/__tests__/queueHealth.test.cjs +0 -58
  401. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +0 -88
  402. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +0 -190
  403. package/src/main/lib/__tests__/quietMachineLease.test.cjs +0 -39
  404. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +0 -88
  405. package/src/main/lib/__tests__/reaperHelpers.test.cjs +0 -577
  406. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +0 -312
  407. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +0 -213
  408. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +0 -125
  409. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +0 -127
  410. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +0 -109
  411. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +0 -71
  412. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +0 -216
  413. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +0 -183
  414. package/src/main/lib/__tests__/schedulerPaths.test.cjs +0 -226
  415. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +0 -133
  416. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +0 -56
  417. package/src/main/lib/__tests__/sessionSlots.test.cjs +0 -144
  418. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +0 -626
  419. package/src/main/lib/__tests__/telemetryBoot.test.cjs +0 -134
  420. package/src/main/lib/__tests__/telemetryConsent.test.cjs +0 -136
  421. package/src/main/lib/__tests__/telemetryCounters.test.cjs +0 -57
  422. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +0 -98
  423. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +0 -200
  424. package/src/main/lib/__tests__/toolUseClassify.test.cjs +0 -53
  425. package/src/main/lib/__tests__/updateCheck.test.cjs +0 -63
  426. package/src/main/lib/__tests__/upgradeDrain.test.cjs +0 -130
  427. package/src/main/lib/__tests__/usageCircuit.test.cjs +0 -354
  428. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +0 -375
  429. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +0 -266
  430. package/src/main/lib/kgExchangePairing.cjs +0 -75
@@ -1,1478 +0,0 @@
1
- /**
2
- * gitWorktree.test.cjs — PRD 1032: generalized `git worktree` isolation
3
- * primitives, backing both the job kind (unchanged behavior, mirrored from
4
- * jobWorktree.test.cjs) and the new epic kind.
5
- *
6
- * Exercises the real `git worktree` plumbing against throwaway repos under
7
- * os.tmpdir() (fast — no network, no Electron).
8
- *
9
- * Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktree.test.cjs
10
- */
11
-
12
- 'use strict';
13
-
14
- import { test, expect, beforeEach, afterEach, vi } from 'vitest';
15
- const fs = require('node:fs');
16
- const os = require('node:os');
17
- const path = require('node:path');
18
- const { execFileSync, spawn } = require('node:child_process');
19
- const gitWorktree = require('../gitWorktree.cjs');
20
-
21
- let tmpRoot;
22
- let repoCwd;
23
- let originalJobDisable;
24
- let originalJobMax;
25
- let originalEpicDisable;
26
- let originalEpicMax;
27
- let originalWorktreeRoot;
28
-
29
- function git(args, cwd) {
30
- return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
- }
32
-
33
- function initRepo(dir) {
34
- fs.mkdirSync(dir, { recursive: true });
35
- git(['init', '-q'], dir);
36
- git(['config', 'user.email', 'test@example.com'], dir);
37
- git(['config', 'user.name', 'Test'], dir);
38
- fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
- git(['add', '-A'], dir);
40
- git(['commit', '-q', '-m', 'initial'], dir);
41
- }
42
-
43
- beforeEach(() => {
44
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-'));
45
- repoCwd = path.join(tmpRoot, 'repo');
46
- initRepo(repoCwd);
47
- // Every sweep below runs against THIS throwaway root, never the live job-worktree root.
48
- originalWorktreeRoot = process.env.SM_WORKTREE_ROOT;
49
- process.env.SM_WORKTREE_ROOT = path.join(tmpRoot, 'worktrees');
50
- originalJobDisable = process.env.SM_JOB_WORKTREE_DISABLE;
51
- originalJobMax = process.env.SM_JOB_WORKTREE_MAX;
52
- originalEpicDisable = process.env.SM_EPIC_WORKTREE_DISABLE;
53
- originalEpicMax = process.env.SM_EPIC_WORKTREE_MAX;
54
- delete process.env.SM_JOB_WORKTREE_DISABLE;
55
- delete process.env.SM_JOB_WORKTREE_MAX;
56
- delete process.env.SM_EPIC_WORKTREE_DISABLE;
57
- delete process.env.SM_EPIC_WORKTREE_MAX;
58
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
59
- gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
60
- gitWorktree._resetObservedWorktreeCountCacheForTests();
61
- });
62
-
63
- afterEach(async () => {
64
- const restore = (name, val) => { if (val === undefined) delete process.env[name]; else process.env[name] = val; };
65
- restore('SM_JOB_WORKTREE_DISABLE', originalJobDisable);
66
- restore('SM_JOB_WORKTREE_MAX', originalJobMax);
67
- restore('SM_EPIC_WORKTREE_DISABLE', originalEpicDisable);
68
- restore('SM_EPIC_WORKTREE_MAX', originalEpicMax);
69
- // Best-effort: prune any worktree either kind's tests created before removing the repo.
70
- try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' }); } catch { /* ignore */ }
71
- try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' }); } catch { /* ignore */ }
72
- restore('SM_WORKTREE_ROOT', originalWorktreeRoot);
73
- fs.rmSync(tmpRoot, { recursive: true, force: true });
74
- });
75
-
76
- // ──────────────────────────────────────────── job kind (mirrors jobWorktree.test.cjs)
77
-
78
- test('[job] createJobWorktree creates a linked worktree on a fresh branch from HEAD', async () => {
79
- const slug = 'test-slug-create';
80
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
81
- expect(result.ok).toBe(true);
82
- expect(fs.existsSync(result.dir)).toBe(true);
83
- expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
84
- expect(result.branch).toBe(`sm-job/${slug}`);
85
-
86
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
87
- expect(fs.existsSync(result.dir)).toBe(false);
88
- });
89
-
90
- test('[job] a commit made inside the worktree becomes visible on the main tree HEAD after integrateJobBranch', async () => {
91
- const slug = 'test-slug-integrate';
92
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
93
- expect(worktree.ok).toBe(true);
94
-
95
- fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
96
- git(['add', '-A'], worktree.dir);
97
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
98
- const jobCommitSha = git(['rev-parse', 'HEAD'], worktree.dir).trim();
99
-
100
- const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
101
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
102
- expect(outcome.ok).toBe(true);
103
- expect(outcome.integrated).toBe(true);
104
-
105
- const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
106
- expect(headAfter).not.toBe(headBefore);
107
- expect(headAfter).toBe(jobCommitSha);
108
- expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
109
-
110
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
111
- expect(fs.existsSync(worktree.dir)).toBe(false);
112
- });
113
-
114
- test('[job] integrateJobBranch is a no-op (integrated:false) when the branch has no new commits', async () => {
115
- const slug = 'test-slug-noop';
116
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
117
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
118
- expect(outcome.ok).toBe(true);
119
- expect(outcome.integrated).toBe(false);
120
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
121
- });
122
-
123
- test('[job] a real merge conflict surfaces as an explicit integration failure and preserves the branch', async () => {
124
- const slug = 'test-slug-conflict';
125
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
126
-
127
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
128
- git(['add', '-A'], worktree.dir);
129
- git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
130
-
131
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
132
- git(['add', '-A'], repoCwd);
133
- git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
134
-
135
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
136
- expect(outcome.ok).toBe(false);
137
- expect(outcome.reason).toMatch(/conflict|merge failed/i);
138
-
139
- const status = git(['status', '--porcelain'], repoCwd);
140
- expect(status.trim()).toBe('');
141
-
142
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
143
- const branches = git(['branch', '--list', worktree.branch], repoCwd);
144
- expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
145
- });
146
-
147
- test('[job] merge-commit message text is byte-for-byte unchanged from the pre-generalization jobWorktree.cjs', async () => {
148
- const slug = 'test-slug-merge-message';
149
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
150
-
151
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
152
- git(['add', '-A'], worktree.dir);
153
- git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
154
-
155
- fs.writeFileSync(path.join(repoCwd, 'other.txt'), 'main tree edit\n', 'utf8');
156
- git(['add', '-A'], repoCwd);
157
- git(['commit', '-q', '-m', 'main tree adds other.txt'], repoCwd);
158
-
159
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
160
- expect(outcome.ok).toBe(true);
161
- expect(outcome.mergeCommit).toBe(true);
162
-
163
- const lastMessage = git(['log', '-1', '--format=%s'], repoCwd).trim();
164
- expect(lastMessage).toBe(`merge scheduler job ${slug}`);
165
-
166
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
167
- });
168
-
169
- test('[job] integration is skipped (carried-wip-only) when the branch only commits the carried base WIP, and the base tree stays untouched', async () => {
170
- const slug = 'test-slug-carried-wip-only';
171
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
172
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
173
- expect(worktree.ok).toBe(true);
174
- expect(worktree.carriedPaths).toEqual(['README.md']);
175
-
176
- // The job commits exactly the carried content, doing no other work.
177
- git(['add', '-A'], worktree.dir);
178
- git(['commit', '-q', '-m', 'commit carried README'], worktree.dir);
179
-
180
- const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
181
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
182
- expect(outcome.ok).toBe(true);
183
- expect(outcome.integrated).toBe(false);
184
- expect(outcome.reason).toBe('carried-wip-only');
185
-
186
- const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
187
- expect(headAfter).toBe(headBefore);
188
- // The base tree's own uncommitted README edit is untouched — never
189
- // discarded, never re-applied.
190
- expect(git(['status', '--porcelain'], repoCwd)).toContain('README.md');
191
-
192
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
193
- });
194
-
195
- test('[job] a branch that commits carried paths ALONGSIDE its own work auto-resolves the merge (PRD 1125: carried path is byte-identical duplicate, real work still lands)', async () => {
196
- const slug = 'test-slug-carried-plus-own-work';
197
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
198
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
199
- expect(worktree.ok).toBe(true);
200
- expect(worktree.carriedPaths).toEqual(['README.md']);
201
-
202
- fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
203
- git(['add', '-A'], worktree.dir);
204
- git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
205
-
206
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
207
- // The base tree still holds README.md dirty, but its content is BYTE
208
- // IDENTICAL to the branch's committed copy (the branch never edited it,
209
- // only carried it through) — this is exactly the shape PRD 1125 teaches
210
- // integrateBranch to auto-resolve: discard the duplicate, retry once, land
211
- // the job's real new-file.txt commit too. Not the carried-wip-only
212
- // shortcut (that only fires when the branch's ONLY changes are carried
213
- // paths) — here the merge itself is what resolves.
214
- expect(outcome.ok).toBe(true);
215
- expect(outcome.integrated).toBe(true);
216
- expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
217
- expect(outcome.resolvedPaths).toEqual(['README.md']);
218
- expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
219
-
220
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
221
- });
222
-
223
- test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
224
- const slug = 'test-slug-carried-same-path-plus-edit';
225
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
226
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
227
- expect(worktree.ok).toBe(true);
228
- expect(worktree.carriedPaths).toEqual(['README.md']);
229
-
230
- // The job's real work lands on the SAME path the base tree had WIP on —
231
- // committing MORE than just the carried content. A path-only check would
232
- // wrongly see "only README.md changed, and README.md was carried" and
233
- // classify this as carried-wip-only, causing the caller to delete this
234
- // branch (and the job's real commit with it) instead of attempting a
235
- // normal merge.
236
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'uncommitted tracked edit\njob added this line\n', 'utf8');
237
- git(['add', '-A'], worktree.dir);
238
- git(['commit', '-q', '-m', 'job extends the carried README further'], worktree.dir);
239
-
240
- const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
241
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
242
- // The base tree still holds README.md dirty, so a real merge attempt here
243
- // is expected to fail exactly like the existing worktreeIntegrationFailure
244
- // path — branch preserved, not merged, not silently discarded.
245
- expect(outcome.ok).toBe(false);
246
- expect(outcome.reason).toBeTruthy();
247
- expect(outcome.reason).not.toBe('carried-wip-only');
248
-
249
- const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
250
- expect(headAfter).toBe(headBefore);
251
- const branches = git(['branch', '--list', worktree.branch], repoCwd);
252
- expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
253
-
254
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
255
- });
256
-
257
- test('[job] falls back with a reason for a non-git cwd', async () => {
258
- const nonGitDir = path.join(tmpRoot, 'not-a-repo');
259
- fs.mkdirSync(nonGitDir, { recursive: true });
260
- const result = await gitWorktree.createJobWorktree({ cwd: nonGitDir, slug: 'x' });
261
- expect(result.ok).toBe(false);
262
- expect(result.reason).toMatch(/not a git repository/);
263
- });
264
-
265
- test('[job] a dirty tracked base file is carried into the worktree with identical content, and the base tree is never written to', async () => {
266
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
267
- const statusBefore = git(['status', '--porcelain'], repoCwd);
268
- const stashBefore = git(['stash', 'list'], repoCwd);
269
-
270
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
271
- expect(result.ok).toBe(true);
272
- expect(result.carriedPaths).toEqual(['README.md']);
273
- expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
274
-
275
- const statusAfter = git(['status', '--porcelain'], repoCwd);
276
- const stashAfter = git(['stash', 'list'], repoCwd);
277
- expect(statusAfter).toBe(statusBefore);
278
- expect(stashAfter).toBe(stashBefore);
279
-
280
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
281
- });
282
-
283
- test('[job] an untracked-only dirty base file is NOT carried and stays on the existing clean-create path', async () => {
284
- fs.writeFileSync(path.join(repoCwd, 'scratch.txt'), 'stray scratch file\n', 'utf8');
285
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
286
- expect(result.ok).toBe(true);
287
- expect(result.carriedPaths).toEqual([]);
288
- expect(fs.existsSync(path.join(result.dir, 'scratch.txt'))).toBe(false);
289
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
290
- });
291
-
292
- test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktree torn down, ok:false, active count released', async () => {
293
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
294
- const spy = vi.spyOn(gitWorktree, 'captureAndCarryBaseDiff').mockResolvedValue({ ok: false, reason: 'simulated git apply failure' });
295
- try {
296
- const before = gitWorktree._getActiveWorktreeCountForTests('job');
297
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
298
- expect(result.ok).toBe(false);
299
- expect(result.reason).toMatch(/carry-over of base WIP failed/);
300
- expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(before);
301
- const list = git(['worktree', 'list', '--porcelain'], repoCwd);
302
- expect(list).not.toContain('sm-job/x');
303
- } finally {
304
- spy.mockRestore();
305
- }
306
- });
307
-
308
- // ──────────────────────────────────────────── PRD: stream the base-WIP diff capture (job 1192)
309
-
310
- test('[job] a wide dirty tree (300+ changed paths incl. a multi-MB file) captures and carries over without a maxBuffer overflow', async () => {
311
- const files = [];
312
- for (let i = 0; i < 300; i++) {
313
- const rel = `wide-${i}.txt`;
314
- fs.writeFileSync(path.join(repoCwd, rel), `initial ${i}\n`, 'utf8');
315
- files.push(rel);
316
- }
317
- const bigRel = 'big-file.bin';
318
- fs.writeFileSync(path.join(repoCwd, bigRel), Buffer.alloc(1024, 1));
319
- git(['add', '-A'], repoCwd);
320
- git(['commit', '-q', '-m', 'wide baseline'], repoCwd);
321
-
322
- // Dirty every tracked file (uncommitted tracked edits) and grow the binary
323
- // file well past Node's execFile default maxBuffer (1MB) — the exact shape
324
- // that overflowed the old buffered capture (job 1192: ~240 dirty paths).
325
- for (const rel of files) fs.appendFileSync(path.join(repoCwd, rel), 'dirtied\n', 'utf8');
326
- const bigBuf = Buffer.alloc(3 * 1024 * 1024); // 3MB of NUL bytes — git detects this as binary
327
- fs.writeFileSync(path.join(repoCwd, bigRel), bigBuf);
328
-
329
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'wide-tree' });
330
- expect(result.ok).toBe(true);
331
- expect(result.carriedPaths.length).toBe(files.length + 1);
332
- expect(fs.readFileSync(path.join(result.dir, files[0]), 'utf8')).toContain('dirtied');
333
- expect(Buffer.compare(fs.readFileSync(path.join(result.dir, bigRel)), bigBuf)).toBe(0);
334
-
335
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
336
- }, 30_000);
337
-
338
- test('[job] a renamed file and a mutated binary file survive the capture/apply round trip using the same flags as production', async () => {
339
- const binRel = 'asset.bin';
340
- const binInitial = Buffer.from([0, 1, 2, 3, 4, 0, 255, 254]);
341
- fs.writeFileSync(path.join(repoCwd, binRel), binInitial);
342
- fs.writeFileSync(path.join(repoCwd, 'to-rename.txt'), 'original name content\n', 'utf8');
343
- git(['add', '-A'], repoCwd);
344
- git(['commit', '-q', '-m', 'binary + rename baseline'], repoCwd);
345
-
346
- // Dirty tree: a tracked rename plus a mutated binary file, both uncommitted.
347
- git(['mv', 'to-rename.txt', 'renamed.txt'], repoCwd);
348
- const binChanged = Buffer.from([9, 8, 7, 0, 6, 5, 0, 4]);
349
- fs.writeFileSync(path.join(repoCwd, binRel), binChanged);
350
-
351
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'rename-binary' });
352
- expect(result.ok).toBe(true);
353
- expect(fs.existsSync(path.join(result.dir, 'to-rename.txt'))).toBe(false);
354
- expect(fs.readFileSync(path.join(result.dir, 'renamed.txt'), 'utf8')).toBe('original name content\n');
355
- expect(Buffer.compare(fs.readFileSync(path.join(result.dir, binRel)), binChanged)).toBe(0);
356
-
357
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
358
- });
359
-
360
- test('[job] a capture that cannot write its diff file (target dir missing) falls back with a specific reason, never the old generic maxBuffer message', async () => {
361
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
362
- const bogusDir = path.join(tmpRoot, 'does-not-exist');
363
- const result = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: bogusDir });
364
- expect(result.ok).toBe(false);
365
- expect(result.reason).toMatch(/capturing base diff failed/);
366
- expect(result.reason).not.toMatch(/maxBuffer/i);
367
- expect(fs.existsSync(bogusDir)).toBe(false); // nothing leaked into a directory that never existed
368
- });
369
-
370
- test('[job] the temp diff file is removed after carry-over on both the success and the git-apply-failure paths', async () => {
371
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
372
- const success = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-ok' });
373
- expect(success.ok).toBe(true);
374
- expect(fs.readdirSync(success.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
375
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: success.dir, branch: success.branch });
376
-
377
- // Force a git-apply failure on a genuine clean-base worktree by making the
378
- // target file read-only before the capture/apply call, so the patch writes
379
- // fine but applying it fails — the finally block must still remove the temp
380
- // patch file even though apply itself errored.
381
- const clean = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-fail' });
382
- expect(clean.ok).toBe(true);
383
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'another uncommitted tracked edit\n', 'utf8');
384
- const targetFile = path.join(clean.dir, 'README.md');
385
- fs.chmodSync(targetFile, 0o444);
386
- try {
387
- const fail = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: clean.dir });
388
- expect(fail.ok).toBe(false);
389
- expect(fail.reason).toMatch(/git apply failed/);
390
- } finally {
391
- fs.chmodSync(targetFile, 0o644);
392
- }
393
- expect(fs.readdirSync(clean.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
394
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: clean.dir, branch: clean.branch });
395
- }, 30_000);
396
-
397
- test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
398
- process.env.SM_JOB_WORKTREE_MAX = '1';
399
- const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
400
- expect(first.ok).toBe(true);
401
-
402
- const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-2' });
403
- expect(second.ok).toBe(false);
404
- expect(second.reason).toMatch(/cap reached/);
405
-
406
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
407
-
408
- const third = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-3' });
409
- expect(third.ok).toBe(true);
410
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
411
- });
412
-
413
- test('[job] a leaked cap count (checkout removed from disk without cleanupWorktree) self-heals on the next createWorktree', async () => {
414
- process.env.SM_JOB_WORKTREE_MAX = '1';
415
- const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-1' });
416
- expect(first.ok).toBe(true);
417
-
418
- // Simulate the crash/reap path: the checkout is torn off disk directly
419
- // (not via cleanupJobWorktree), exactly like a job that got SIGTERM'd or
420
- // reaped before its own teardown ran — activeWorktreeCount never gets
421
- // decremented, so the in-memory counter is now permanently stuck at the
422
- // cap even though nothing real is holding it.
423
- await execFileSync('git', ['worktree', 'remove', '--force', first.dir], { cwd: repoCwd, encoding: 'utf8' });
424
- fs.rmSync(first.dir, { recursive: true, force: true });
425
- expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(1);
426
-
427
- // Against unpatched main this next call stays wedged forever: the counter
428
- // never reflects that nothing is actually checked out anymore.
429
- const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-2' });
430
- expect(second.ok).toBe(true);
431
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
432
- });
433
-
434
- test('[job] reclaimTerminalJobOrphans removes a completed row\'s clean-of-real-work orphan checkout', async () => {
435
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-clean' });
436
- expect(worktree.ok).toBe(true);
437
- // Zero commits ahead of main and nothing dirty at all in the checkout —
438
- // the simplest "safe to reclaim" shape.
439
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
440
- cwd: repoCwd,
441
- terminalSlugs: new Set(['orphan-clean']),
442
- });
443
- expect(reclaimed).toEqual(['orphan-clean']);
444
- expect(fs.existsSync(worktree.dir)).toBe(false);
445
- });
446
-
447
- test('[job] reclaimTerminalJobOrphans reclaims a checkout carrying only ops-folder dirty noise', async () => {
448
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-ops-noise' });
449
- expect(worktree.ok).toBe(true);
450
- fs.mkdirSync(path.join(worktree.dir, 'session-manager-operations'), { recursive: true });
451
- fs.writeFileSync(path.join(worktree.dir, 'session-manager-operations', 'queue.json'), '{}\n', 'utf8');
452
-
453
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
454
- cwd: repoCwd,
455
- terminalSlugs: new Set(['orphan-ops-noise']),
456
- });
457
- expect(reclaimed).toEqual(['orphan-ops-noise']);
458
- expect(fs.existsSync(worktree.dir)).toBe(false);
459
- });
460
-
461
- test('[job] reclaimTerminalJobOrphans leaves a checkout with real (non-ops) dirty content alone', async () => {
462
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-real-work' });
463
- expect(worktree.ok).toBe(true);
464
- fs.writeFileSync(path.join(worktree.dir, 'real-output.txt'), 'not ops noise\n', 'utf8');
465
-
466
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
467
- cwd: repoCwd,
468
- terminalSlugs: new Set(['orphan-real-work']),
469
- });
470
- expect(reclaimed).toEqual([]);
471
- expect(fs.existsSync(worktree.dir)).toBe(true);
472
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
473
- });
474
-
475
- test('[job] reclaimTerminalJobOrphans leaves a checkout with unmerged commits alone', async () => {
476
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-unmerged' });
477
- expect(worktree.ok).toBe(true);
478
- fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'real committed work\n', 'utf8');
479
- git(['add', '-A'], worktree.dir);
480
- git(['commit', '-q', '-m', 'unmerged job commit'], worktree.dir);
481
-
482
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
483
- cwd: repoCwd,
484
- terminalSlugs: new Set(['orphan-unmerged']),
485
- });
486
- expect(reclaimed).toEqual([]);
487
- expect(fs.existsSync(worktree.dir)).toBe(true);
488
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
489
- });
490
-
491
- test('[job] reclaimTerminalJobOrphans ignores a worktree whose slug is not in terminalSlugs', async () => {
492
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'not-terminal' });
493
- expect(worktree.ok).toBe(true);
494
-
495
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
496
- cwd: repoCwd,
497
- terminalSlugs: new Set(['some-other-slug']),
498
- });
499
- expect(reclaimed).toEqual([]);
500
- expect(fs.existsSync(worktree.dir)).toBe(true);
501
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
502
- });
503
-
504
- // ──────────────────────────────────────────── PRD 1163: liveness gate in front of reclaimTerminalJobOrphans
505
-
506
- test('[job] reclaimTerminalJobOrphans does NOT remove a checkout when an injected isLive predicate reports it live', async () => {
507
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-but-live' });
508
- expect(worktree.ok).toBe(true);
509
- // Clean-of-real-work shape (zero commits ahead, nothing dirty) — would be
510
- // reclaimed by the existing two proofs alone. The liveness gate must be
511
- // checked BEFORE those proofs and override them.
512
- const isLive = vi.fn(async () => true);
513
-
514
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
515
- cwd: repoCwd,
516
- terminalSlugs: new Set(['terminal-but-live']),
517
- isLive,
518
- });
519
-
520
- expect(reclaimed).toEqual([]);
521
- expect(fs.existsSync(worktree.dir)).toBe(true);
522
- expect(isLive).toHaveBeenCalledWith('terminal-but-live', expect.objectContaining({ worktree: worktree.dir }));
523
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
524
- });
525
-
526
- test('[job] reclaimTerminalJobOrphans still removes a terminal, dead, merged, ops-only-dirty checkout when isLive reports it not live', async () => {
527
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-and-dead' });
528
- expect(worktree.ok).toBe(true);
529
- const isLive = vi.fn(async () => false);
530
-
531
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
532
- cwd: repoCwd,
533
- terminalSlugs: new Set(['terminal-and-dead']),
534
- isLive,
535
- });
536
-
537
- expect(reclaimed).toEqual(['terminal-and-dead']);
538
- expect(fs.existsSync(worktree.dir)).toBe(false);
539
- expect(isLive).toHaveBeenCalled();
540
- });
541
-
542
- test('[job] reclaimTerminalJobOrphans treats an isLive that throws as fail-safe (assume live, do not delete)', async () => {
543
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'isLive-throws' });
544
- expect(worktree.ok).toBe(true);
545
- const isLive = vi.fn(async () => { throw new Error('boom'); });
546
-
547
- const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
548
- cwd: repoCwd,
549
- terminalSlugs: new Set(['isLive-throws']),
550
- isLive,
551
- });
552
-
553
- expect(reclaimed).toEqual([]);
554
- expect(fs.existsSync(worktree.dir)).toBe(true);
555
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
556
- });
557
-
558
- test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive skips on a live cwd holder BEFORE checking pid, and reports the reason via onLive', async () => {
559
- const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
560
- const claudePidAlive = vi.fn(() => false); // pid check must never even matter here
561
- const hasLiveHolder = vi.fn(() => true);
562
- const onLive = vi.fn();
563
- const isLive = buildTerminalOrphanIsLive({
564
- terminalJobs: [{ slug: 'held-slug', runtime: { pid: 4242 } }],
565
- claudePidAlive,
566
- hasLiveHolder,
567
- cwdHolders: new Set(['/tmp/fake']),
568
- onLive,
569
- });
570
-
571
- const result = await isLive('held-slug', { worktree: '/tmp/fake/checkout' });
572
-
573
- expect(result).toBe(true);
574
- expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/fake/checkout', expect.any(Set));
575
- expect(onLive).toHaveBeenCalledWith('held-slug', expect.stringContaining('live cwd holder'));
576
- });
577
-
578
- test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive falls back to the recorded pid when there is no live holder', async () => {
579
- const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
580
- const claudePidAlive = vi.fn((pid) => pid === 4242);
581
- const hasLiveHolder = vi.fn(() => false);
582
- const onLive = vi.fn();
583
- const isLive = buildTerminalOrphanIsLive({
584
- terminalJobs: [{ slug: 'pid-slug', runtime: { pid: 4242 } }],
585
- claudePidAlive,
586
- hasLiveHolder,
587
- onLive,
588
- });
589
-
590
- expect(await isLive('pid-slug', { worktree: '/tmp/whatever' })).toBe(true);
591
- expect(onLive).toHaveBeenCalledWith('pid-slug', expect.stringContaining('pid=4242'));
592
-
593
- expect(await isLive('unknown-slug', { worktree: '/tmp/whatever' })).toBe(false);
594
- });
595
-
596
- test('[job] getMaxConcurrentWorktrees floors the job cap at the sessionSlots pool size (PRD 1112)', () => {
597
- const originalSlots = process.env.SM_SESSION_SLOTS;
598
- try {
599
- process.env.SM_SESSION_SLOTS = '10';
600
- expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(10);
601
-
602
- // The floor never drops BELOW the existing default (4) even when the
603
- // slot pool is smaller — this cap only ever widens for isolation, never
604
- // narrows it below what already shipped.
605
- process.env.SM_SESSION_SLOTS = '3';
606
- expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(4);
607
- } finally {
608
- if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
609
- else process.env.SM_SESSION_SLOTS = originalSlots;
610
- }
611
- });
612
-
613
- test('[job] an explicit SM_JOB_WORKTREE_MAX wins verbatim over the sessionSlots floor', () => {
614
- const originalSlots = process.env.SM_SESSION_SLOTS;
615
- try {
616
- process.env.SM_SESSION_SLOTS = '10';
617
- process.env.SM_JOB_WORKTREE_MAX = '2';
618
- expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(2);
619
- } finally {
620
- if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
621
- else process.env.SM_SESSION_SLOTS = originalSlots;
622
- }
623
- });
624
-
625
- test('[epic] getMaxConcurrentWorktrees is unaffected by the sessionSlots pool size', () => {
626
- const originalSlots = process.env.SM_SESSION_SLOTS;
627
- try {
628
- process.env.SM_SESSION_SLOTS = '10';
629
- expect(gitWorktree.getMaxConcurrentWorktrees('epic')).toBe(50);
630
- } finally {
631
- if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
632
- else process.env.SM_SESSION_SLOTS = originalSlots;
633
- }
634
- });
635
-
636
- test('[job] SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
637
- process.env.SM_JOB_WORKTREE_DISABLE = '1';
638
- const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
639
- expect(result.ok).toBe(false);
640
- expect(result.reason).toMatch(/SM_JOB_WORKTREE_DISABLE/);
641
- });
642
-
643
- test('[job] reconcileWorktreesOnBoot removes a leaked worktree (simulated crash) and its branch', async () => {
644
- const slug = 'test-slug-leak';
645
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
646
- expect(fs.existsSync(worktree.dir)).toBe(true);
647
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
648
-
649
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
650
-
651
- expect(fs.existsSync(worktree.dir)).toBe(false);
652
- const branches = git(['branch', '--list', worktree.branch], repoCwd);
653
- expect(branches.trim()).toBe('');
654
- });
655
-
656
- test('[job] reconcileWorktreesOnBoot defaults to kind:job when no opts are given', async () => {
657
- const slug = 'test-slug-default-kind';
658
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
659
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd]);
660
- expect(fs.existsSync(worktree.dir)).toBe(false);
661
- });
662
-
663
- test('[job] reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_ROOT', async () => {
664
- const humanWorktreeDir = path.join(tmpRoot, 'human-made-worktree');
665
- git(['worktree', 'add', '-b', 'human-branch', humanWorktreeDir, 'HEAD'], repoCwd);
666
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
667
- expect(fs.existsSync(humanWorktreeDir)).toBe(true);
668
- git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
669
- });
670
-
671
- // ──────────────────────────────────────────── PRD 1162: job-kind isLive gate
672
- // (a job survives an app restart via detached:true, so a worktree found at
673
- // boot is not by itself proof its run already died — see scheduler.cjs's
674
- // boot sweep + jobWorktreeBootLive.cjs, exercised here at the
675
- // reconcileWorktreesOnBoot layer with a fully injected isLive predicate).
676
-
677
- test('[job] reconcileWorktreesOnBoot removes a job worktree with no live holder and no running row', async () => {
678
- const slug = 'test-slug-dead';
679
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
680
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
681
-
682
- const isLive = vi.fn(() => false);
683
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
684
-
685
- expect(fs.existsSync(worktree.dir)).toBe(false);
686
- expect(isLive).toHaveBeenCalledWith(slug, expect.objectContaining({ worktree: worktree.dir }));
687
- });
688
-
689
- test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is running with a live pid', async () => {
690
- const slug = 'test-slug-running-live';
691
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
692
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
693
-
694
- const isLive = (key) => key === slug;
695
- const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
696
-
697
- expect(fs.existsSync(worktree.dir)).toBe(true);
698
- expect(result.checkoutsRemoved).toBe(0);
699
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
700
- });
701
-
702
- test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is absent/terminal but a live cwd holder is reported', async () => {
703
- const slug = 'test-slug-terminal-holder';
704
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
705
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
706
-
707
- // Row is absent from the caller's bookkeeping entirely (as if the queue row
708
- // already went terminal) — only the injected cwd-holder signal says live.
709
- const isLive = (key, entry) => entry.worktree === worktree.dir;
710
- const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
711
-
712
- expect(fs.existsSync(worktree.dir)).toBe(true);
713
- expect(result.checkoutsRemoved).toBe(0);
714
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
715
- });
716
-
717
- // ──────────────────────────────────────────── PRD 1162: hasLiveHolder / listCwdHolders
718
-
719
- test('hasLiveHolder returns true for the current process own cwd', () => {
720
- if (process.platform !== 'linux') return; // darwin has no /proc — covered by the pid-based gate instead
721
- expect(gitWorktree.hasLiveHolder(process.cwd())).toBe(true);
722
- });
723
-
724
- test('hasLiveHolder returns false for a freshly-created empty temp dir nothing holds as cwd', () => {
725
- const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-empty-'));
726
- try {
727
- expect(gitWorktree.hasLiveHolder(emptyDir)).toBe(false);
728
- } finally {
729
- fs.rmSync(emptyDir, { recursive: true, force: true });
730
- }
731
- });
732
-
733
- test('hasLiveHolder never throws for a non-existent path', () => {
734
- expect(() => gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).not.toThrow();
735
- expect(gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).toBe(false);
736
- });
737
-
738
- test('hasLiveHolder still matches a checkout dir whose /proc readlink shows the "(deleted)" suffix (unlinked while a process still held it as cwd)', async () => {
739
- if (process.platform !== 'linux') return;
740
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-deleted-'));
741
- const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
742
- try {
743
- // Give the child a moment to actually chdir before the dir is unlinked.
744
- await new Promise((resolve) => setTimeout(resolve, 150));
745
- fs.rmSync(dir, { recursive: true, force: true });
746
- expect(gitWorktree.hasLiveHolder(dir)).toBe(true);
747
- } finally {
748
- child.kill();
749
- }
750
- });
751
-
752
- test('hasLiveHolder accepts a precomputed holders Set from listCwdHolders() and matches a path nested under a holder', () => {
753
- if (process.platform !== 'linux') return; // darwin: hasLiveHolder always false, per its own contract
754
- const holders = new Set(['/tmp/some-worktree-checkout']);
755
- expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout/nested/deep', holders)).toBe(false);
756
- expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout', holders)).toBe(true);
757
- });
758
-
759
- test('listCwdHolders returns a Set and includes the current process own cwd on linux', () => {
760
- const holders = gitWorktree.listCwdHolders();
761
- expect(holders).toBeInstanceOf(Set);
762
- if (process.platform === 'linux') {
763
- expect(holders.has(path.resolve(process.cwd()))).toBe(true);
764
- } else {
765
- expect(holders.size).toBe(0);
766
- }
767
- });
768
-
769
- // ──────────────────────────────────────────── epic kind (new)
770
-
771
- test('[epic] createEpicWorktree names the branch sm-epic/<id>, distinct from the job root', async () => {
772
- const epicId = 'my-epic-abc123';
773
- const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
774
- expect(result.ok).toBe(true);
775
- expect(result.branch).toBe(`sm-epic/${epicId}`);
776
- expect(result.dir.startsWith(gitWorktree.worktreeRootFor('epic'))).toBe(true);
777
- expect(result.dir.startsWith(gitWorktree.worktreeRootFor('job'))).toBe(false);
778
- expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
779
-
780
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
781
- expect(fs.existsSync(result.dir)).toBe(false);
782
- });
783
-
784
- test('[epic] a commit made inside an epic worktree integrates onto the main tree HEAD', async () => {
785
- const epicId = 'epic-integrate';
786
- const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
787
- expect(worktree.ok).toBe(true);
788
-
789
- fs.writeFileSync(path.join(worktree.dir, 'epic-file.txt'), 'epic output\n', 'utf8');
790
- git(['add', '-A'], worktree.dir);
791
- git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
792
-
793
- const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
794
- expect(outcome.ok).toBe(true);
795
- expect(outcome.integrated).toBe(true);
796
- expect(fs.existsSync(path.join(repoCwd, 'epic-file.txt'))).toBe(true);
797
-
798
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
799
- });
800
-
801
- test('[epic] no premature cap eviction — many concurrent epic worktrees stay under the default epic cap', async () => {
802
- const created = [];
803
- // The job kind's default cap is 4; prove the epic kind doesn't inherit that
804
- // ceiling by creating more than 4 concurrent epic worktrees successfully.
805
- for (let i = 0; i < 6; i++) {
806
- const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: `epic-cap-${i}` });
807
- expect(result.ok).toBe(true);
808
- created.push(result);
809
- }
810
- expect(gitWorktree._getActiveWorktreeCountForTests('epic')).toBe(6);
811
- // The job counter must be completely unaffected by epic-kind activity.
812
- expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(0);
813
-
814
- for (const worktree of created) {
815
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
816
- }
817
- });
818
-
819
- test('[epic] SM_EPIC_WORKTREE_MAX raises/lowers the epic cap independently of the job cap', async () => {
820
- process.env.SM_EPIC_WORKTREE_MAX = '1';
821
- const first = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-1' });
822
- expect(first.ok).toBe(true);
823
-
824
- const second = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-2' });
825
- expect(second.ok).toBe(false);
826
- expect(second.reason).toMatch(/cap reached/);
827
-
828
- // The job cap (default 4) is untouched by SM_EPIC_WORKTREE_MAX=1.
829
- const job1 = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'job-max-1' });
830
- expect(job1.ok).toBe(true);
831
-
832
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
833
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: job1.dir, branch: job1.branch });
834
- });
835
-
836
- test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independently of the job kill-switch', async () => {
837
- process.env.SM_EPIC_WORKTREE_DISABLE = '1';
838
- const epicResult = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'x' });
839
- expect(epicResult.ok).toBe(false);
840
- expect(epicResult.reason).toMatch(/SM_EPIC_WORKTREE_DISABLE/);
841
-
842
- // The job kind must still work — the two kill-switches are independent.
843
- const jobResult = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
844
- expect(jobResult.ok).toBe(true);
845
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobResult.dir, branch: jobResult.branch });
846
- });
847
-
848
- test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
849
- const projectConfig = require('../epicWorktreeProjectConfig.cjs');
850
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
851
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
852
-
853
- const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
854
- expect(result.ok).toBe(false);
855
- expect(result.reason).toMatch(/isolation toggle/);
856
-
857
- // A different, untouched project cwd is unaffected — the toggle is
858
- // per-project (its own <cwd>/session-manager-operations/prompt-sessions/
859
- // epic-worktree-config.json), not machine-wide like the env var.
860
- const otherCwd = path.join(tmpRoot, 'other-repo');
861
- initRepo(otherCwd);
862
- const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
863
- expect(otherResult.ok).toBe(true);
864
- await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
865
-
866
- // Turning it back off restores isolation for the original project.
867
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
868
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
869
- const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
870
- expect(restored.ok).toBe(true);
871
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
872
- });
873
-
874
- test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
875
- const epicId = 'epic-leak';
876
- const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
877
- expect(fs.existsSync(worktree.dir)).toBe(true);
878
- gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
879
-
880
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
881
-
882
- expect(fs.existsSync(worktree.dir)).toBe(false);
883
- const branches = git(['branch', '--list', worktree.branch], repoCwd);
884
- expect(branches.trim()).toBe('');
885
- });
886
-
887
- test('[epic] reconcileWorktreesOnBoot does NOT remove a worktree whose owning Epic is still active, per the isLive predicate', async () => {
888
- const liveEpicId = 'epic-still-active';
889
- const deadEpicId = 'epic-orphaned';
890
- const liveWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: liveEpicId });
891
- const deadWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: deadEpicId });
892
- gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
893
-
894
- // Stub for a real active-index.json read (wired in the next PRD in this
895
- // chain) — this only needs to prove reconcileWorktreesOnBoot accepts and
896
- // respects a liveness predicate.
897
- const activeIndexStub = new Set([liveEpicId]);
898
- const isLive = async (epicId) => activeIndexStub.has(epicId);
899
-
900
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic', isLive });
901
-
902
- expect(fs.existsSync(liveWorktree.dir)).toBe(true);
903
- expect(fs.existsSync(deadWorktree.dir)).toBe(false);
904
- const liveBranches = git(['branch', '--list', liveWorktree.branch], repoCwd);
905
- expect(liveBranches).toContain(liveEpicId);
906
- const deadBranches = git(['branch', '--list', deadWorktree.branch], repoCwd);
907
- expect(deadBranches.trim()).toBe('');
908
-
909
- // Cleanup: the live one is left registered by design; tear it down manually
910
- // so afterEach's rm doesn't leave a dangling worktree registration.
911
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: liveWorktree.dir, branch: liveWorktree.branch });
912
- });
913
-
914
- test('[epic] reconcileWorktreesOnBoot for kind:epic never touches a job-kind worktree, and vice versa', async () => {
915
- const jobWorktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cross-kind-job' });
916
- const epicWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'cross-kind-epic' });
917
-
918
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
919
- expect(fs.existsSync(jobWorktree.dir)).toBe(true);
920
- expect(fs.existsSync(epicWorktree.dir)).toBe(false);
921
-
922
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobWorktree.dir, branch: jobWorktree.branch });
923
- });
924
-
925
- test('keyFromBranch strips the kind-specific prefix and returns null for a foreign branch', () => {
926
- expect(gitWorktree.keyFromBranch('epic', 'sm-epic/abc-123')).toBe('abc-123');
927
- expect(gitWorktree.keyFromBranch('job', 'sm-job/my-slug')).toBe('my-slug');
928
- expect(gitWorktree.keyFromBranch('epic', 'sm-job/my-slug')).toBe(null);
929
- expect(gitWorktree.keyFromBranch('job', 'main')).toBe(null);
930
- });
931
-
932
- test('createWorktree throws on an unknown kind', async () => {
933
- await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
934
- });
935
-
936
- // ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
937
-
938
- test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
939
- fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
940
- expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
941
- });
942
-
943
- test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
944
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
945
- expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
946
- });
947
-
948
- // ──────────────────────────────────────────── orphaned on-disk checkouts (PRD 1108)
949
-
950
- test('[job] cleanupWorktree rmdirs the now-empty parent hash dir, but a sibling checkout in the same hash dir survives', async () => {
951
- const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-a' });
952
- const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-b' });
953
- expect(first.ok).toBe(true);
954
- expect(second.ok).toBe(true);
955
- const hashDir = path.dirname(first.dir);
956
- expect(path.dirname(second.dir)).toBe(hashDir);
957
-
958
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
959
- expect(fs.existsSync(first.dir)).toBe(false);
960
- // The hash dir must survive — the sibling checkout still lives under it.
961
- expect(fs.existsSync(hashDir)).toBe(true);
962
- expect(fs.existsSync(second.dir)).toBe(true);
963
-
964
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
965
- expect(fs.existsSync(second.dir)).toBe(false);
966
- // Now that the last sibling is gone, the empty hash dir is reclaimed too.
967
- expect(fs.existsSync(hashDir)).toBe(false);
968
- });
969
-
970
- test('[job] reconcileWorktreesOnBoot reclaims an orphaned on-disk checkout for a project cwd it was never handed', async () => {
971
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-old' });
972
- expect(worktree.ok).toBe(true);
973
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
974
- // Simulate age: back-date the checkout dir's mtime well past the 1s
975
- // threshold used below, without touching anything about its content.
976
- const old = new Date(Date.now() - 60_000);
977
- fs.utimesSync(worktree.dir, old, old);
978
-
979
- // No cwd handed in at all — this is the "project no longer tracked" case
980
- // the per-cwd loop can never reach on its own.
981
- await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 1_000 });
982
-
983
- expect(fs.existsSync(worktree.dir)).toBe(false);
984
- // The hash dir it lived under is empty now and must be reclaimed too.
985
- expect(fs.existsSync(path.dirname(worktree.dir))).toBe(false);
986
- // The main tree's own worktree registration must not be left dangling.
987
- const list = git(['worktree', 'list', '--porcelain'], repoCwd);
988
- expect(list).not.toContain(worktree.dir);
989
- });
990
-
991
- test('[job] reconcileWorktreesOnBoot never reclaims a fresh, in-use checkout younger than the age threshold', async () => {
992
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'fresh-in-use' });
993
- expect(worktree.ok).toBe(true);
994
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
995
-
996
- await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 24 * 60 * 60 * 1000 });
997
-
998
- expect(fs.existsSync(worktree.dir)).toBe(true);
999
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1000
- });
1001
-
1002
- test('[epic] the orphan sweep age threshold defaults much higher (7 days) than the job kind (24h)', () => {
1003
- expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
1004
- expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(7 * 24 * 60 * 60 * 1000);
1005
- });
1006
-
1007
- test('[epic] SM_EPIC_WORKTREE_STALE_MS overrides the epic orphan-sweep age threshold independently of the job env var', () => {
1008
- const original = process.env.SM_EPIC_WORKTREE_STALE_MS;
1009
- try {
1010
- process.env.SM_EPIC_WORKTREE_STALE_MS = '12345';
1011
- expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(12345);
1012
- expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
1013
- } finally {
1014
- if (original === undefined) delete process.env.SM_EPIC_WORKTREE_STALE_MS;
1015
- else process.env.SM_EPIC_WORKTREE_STALE_MS = original;
1016
- }
1017
- });
1018
-
1019
- test('[job] sweepStaleWorktreeCheckouts treats staleAgeMs: 0 as "reclaim immediately", not as "unset — use the default"', async () => {
1020
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'zero-age' });
1021
- expect(worktree.ok).toBe(true);
1022
- gitWorktree._resetActiveWorktreeCountForTests('job', 0);
1023
-
1024
- const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
1025
-
1026
- expect(result.checkoutsRemoved).toBe(1);
1027
- expect(fs.existsSync(worktree.dir)).toBe(false);
1028
- });
1029
-
1030
- test('[job] sweepStaleWorktreeCheckouts does NOT remove an old-mtime checkout with a live holder, regardless of age', async () => {
1031
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-but-live' });
1032
- expect(worktree.ok).toBe(true);
1033
- const holders = new Set([path.resolve(worktree.dir)]);
1034
-
1035
- const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
1036
-
1037
- expect(result.checkoutsRemoved).toBe(0);
1038
- expect(fs.existsSync(worktree.dir)).toBe(true);
1039
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1040
- });
1041
-
1042
- test('[job] sweepStaleWorktreeCheckouts removes an old-mtime checkout when there is no live holder', async () => {
1043
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-and-dead' });
1044
- expect(worktree.ok).toBe(true);
1045
- // A populated holders set (some unrelated live cwd) that does not cover this checkout —
1046
- // exercises the liveness gate for real instead of defeating it with an empty set.
1047
- const holders = new Set([path.resolve(tmpRoot, 'unrelated-live-holder')]);
1048
-
1049
- const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
1050
-
1051
- expect(result.checkoutsRemoved).toBe(1);
1052
- expect(fs.existsSync(worktree.dir)).toBe(false);
1053
- });
1054
-
1055
- test('[job] sweepStaleWorktreeCheckouts never deletes anything outside its own kind root, even via a symlink shaped like a traversal', async () => {
1056
- const outsideDir = path.join(tmpRoot, 'outside-root');
1057
- fs.mkdirSync(outsideDir, { recursive: true });
1058
- const markerFile = path.join(outsideDir, 'do-not-delete.txt');
1059
- fs.writeFileSync(markerFile, 'still here\n', 'utf8');
1060
-
1061
- const root = gitWorktree.worktreeRootFor('job');
1062
- const hashDir = path.join(root, 'traversal-hash-dir');
1063
- fs.mkdirSync(hashDir, { recursive: true });
1064
- const escapePath = path.join(hashDir, 'escape');
1065
- fs.symlinkSync(outsideDir, escapePath, 'dir');
1066
-
1067
- const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
1068
-
1069
- expect(fs.existsSync(markerFile)).toBe(true);
1070
- expect(fs.existsSync(outsideDir)).toBe(true);
1071
- expect(result.checkoutsRemoved).toBe(0);
1072
-
1073
- fs.rmSync(hashDir, { recursive: true, force: true });
1074
- });
1075
-
1076
- test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed counts', async () => {
1077
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1078
- try {
1079
- await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
1080
- expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/boot sweep \(kind:job\): reclaimed \d+ checkout\(s\), \d+ empty root dir\(s\)/));
1081
- } finally {
1082
- logSpy.mockRestore();
1083
- }
1084
- });
1085
-
1086
- // ──────────────────────────────────────────── PRD 1125: auto-resolve identical-duplicate merge blocks
1087
-
1088
- test('[job] integrateBranch auto-resolves when every blocking path (tracked + untracked) is byte-identical to the branch', async () => {
1089
- const slug = 'test-slug-identical-duplicates';
1090
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1091
- expect(worktree.ok).toBe(true);
1092
-
1093
- // The job's own real work, committed on its branch.
1094
- fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
1095
- fs.mkdirSync(path.join(worktree.dir, 'newdir'), { recursive: true });
1096
- fs.writeFileSync(path.join(worktree.dir, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
1097
- git(['add', '-A'], worktree.dir);
1098
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1099
-
1100
- // The shared main tree independently ends up with the SAME bytes on both
1101
- // a tracked path (dirty, uncommitted) and an untracked path — exactly the
1102
- // 2026-09-06 starry-night-ships incident shape: a prior in-place run left
1103
- // these duplicates behind uncommitted.
1104
- fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
1105
- fs.mkdirSync(path.join(repoCwd, 'newdir'), { recursive: true });
1106
- fs.writeFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
1107
-
1108
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1109
- expect(outcome.ok).toBe(true);
1110
- expect(outcome.integrated).toBe(true);
1111
- expect(outcome.mergeCommit ?? outcome.fastForward).toBeTruthy();
1112
- expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
1113
- expect(outcome.resolvedPaths.sort()).toEqual(['fileA', 'newdir/fileU'].sort());
1114
-
1115
- expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('shared-value\n');
1116
- expect(fs.readFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'utf8')).toBe('untracked-shared-value\n');
1117
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1118
-
1119
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
1120
- });
1121
-
1122
- test('[job] integrateBranch does NOT auto-resolve when one blocking path differs from the branch — same reason as today', async () => {
1123
- const slug = 'test-slug-one-differing-path';
1124
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1125
- expect(worktree.ok).toBe(true);
1126
-
1127
- fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'branch-value\n', 'utf8');
1128
- git(['add', '-A'], worktree.dir);
1129
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1130
-
1131
- // Main tree's dirty copy diverges from the branch's committed content —
1132
- // real information is at risk, so this must NOT be discarded.
1133
- fs.writeFileSync(path.join(repoCwd, 'fileA'), 'different-local-value\n', 'utf8');
1134
-
1135
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1136
- expect(outcome.ok).toBe(false);
1137
- expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1138
- expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('different-local-value\n');
1139
- expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
1140
-
1141
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1142
- });
1143
-
1144
- test('[job] integrateBranch falls through to today\'s failure when stderr has no parseable blocking-path block (a genuine content conflict)', async () => {
1145
- const slug = 'test-slug-unparseable-stderr';
1146
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1147
- expect(worktree.ok).toBe(true);
1148
-
1149
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1150
- git(['add', '-A'], worktree.dir);
1151
- git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1152
-
1153
- // A committed (not dirty) diverging edit on the main tree produces git's
1154
- // ordinary "CONFLICT (content)" stderr shape, not the "would be
1155
- // overwritten" block parseBlockingMergePaths looks for.
1156
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
1157
- git(['add', '-A'], repoCwd);
1158
- git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
1159
-
1160
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1161
- expect(outcome.ok).toBe(false);
1162
- expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1163
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1164
-
1165
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1166
- });
1167
-
1168
- test('[job] integrateBranch aborts cleanly when the single retry also fails on a genuine conflict elsewhere', async () => {
1169
- const slug = 'test-slug-retry-fails';
1170
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1171
- expect(worktree.ok).toBe(true);
1172
-
1173
- // The branch's own real work: extends fileA (existing, tracked since
1174
- // initRepo's README.md commit doesn't cover it — add it fresh here) and
1175
- // ADDS a brand-new fileB.txt.
1176
- fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
1177
- fs.writeFileSync(path.join(worktree.dir, 'fileB.txt'), 'branch B change\n', 'utf8');
1178
- git(['add', '-A'], worktree.dir);
1179
- git(['commit', '-q', '-m', 'job commit: fileA + fileB'], worktree.dir);
1180
-
1181
- // Main tree independently ADDS the SAME path with DIFFERENT content — a
1182
- // genuine "both added, diverging content" conflict on fileB.txt.
1183
- fs.writeFileSync(path.join(repoCwd, 'fileB.txt'), 'base B change\n', 'utf8');
1184
- git(['add', '-A'], repoCwd);
1185
- git(['commit', '-q', '-m', 'main tree diverges fileB'], repoCwd);
1186
- // ...and ALSO leaves fileA dirty with content byte-identical to the branch
1187
- // — the one blocking path the initial merge attempt refuses on, and the
1188
- // one the auto-resolve retry successfully discards.
1189
- fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
1190
-
1191
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1192
- expect(outcome.ok).toBe(false);
1193
- expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1194
- // The retry's half-applied merge must be aborted, not left mid-merge.
1195
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1196
- expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1197
-
1198
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1199
- });
1200
-
1201
- // ──────────────────────────────────────────── default-branch integration guard (PRD: starry-night-ships wrong-base merges)
1202
-
1203
- test('[guard] integrateBranch proceeds normally when HEAD is on the repo default branch', async () => {
1204
- const slug = 'test-slug-guard-default-ok';
1205
- const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1206
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1207
- expect(worktree.ok).toBe(true);
1208
-
1209
- fs.writeFileSync(path.join(worktree.dir, 'guard-ok.txt'), 'job output\n', 'utf8');
1210
- git(['add', '-A'], worktree.dir);
1211
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1212
-
1213
- expect(await gitWorktree.getCurrentBranch(repoCwd)).toBe(defaultBranch);
1214
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1215
- expect(outcome.ok).toBe(true);
1216
- expect(outcome.integrated).toBe(true);
1217
-
1218
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1219
- });
1220
-
1221
- test('[guard] integrateBranch refuses when HEAD is on a stray sm-job/* branch, naming both branches in the reason, and mutates nothing', async () => {
1222
- const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1223
- const slugB = 'test-slug-guard-stray-b';
1224
- const strayBranch = 'sm-job/leftover-from-a-prior-run';
1225
- const worktreeB = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: slugB });
1226
- fs.writeFileSync(path.join(worktreeB.dir, 'guard-stray.txt'), 'branch b output\n', 'utf8');
1227
- git(['add', '-A'], worktreeB.dir);
1228
- git(['commit', '-q', '-m', 'job commit b'], worktreeB.dir);
1229
-
1230
- // Simulate the live incident: the SHARED tree's own HEAD got checked out
1231
- // onto a leftover job branch instead of staying on the default branch.
1232
- git(['checkout', '-b', strayBranch], repoCwd);
1233
- const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
1234
- const statusBefore = git(['status', '--porcelain'], repoCwd);
1235
-
1236
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktreeB.branch, slug: slugB });
1237
- expect(outcome.ok).toBe(false);
1238
- expect(outcome.reason).toContain(defaultBranch);
1239
- expect(outcome.reason).toContain(strayBranch);
1240
-
1241
- // Refusing must not touch git state: no checkout/reset/switch performed.
1242
- expect(git(['rev-parse', 'HEAD'], repoCwd).trim()).toBe(headBefore);
1243
- expect(git(['status', '--porcelain'], repoCwd)).toBe(statusBefore);
1244
-
1245
- git(['checkout', defaultBranch], repoCwd);
1246
- git(['branch', '-D', strayBranch], repoCwd);
1247
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktreeB.dir, branch: worktreeB.branch, keepBranch: true });
1248
- try { git(['branch', '-D', worktreeB.branch], repoCwd); } catch { /* best-effort */ }
1249
- });
1250
-
1251
- test('[guard] integrateBranch refuses on a detached HEAD, reporting "detached HEAD" rather than treating it as a branch', async () => {
1252
- const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1253
- const slug = 'test-slug-guard-detached';
1254
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1255
- fs.writeFileSync(path.join(worktree.dir, 'guard-detached.txt'), 'output\n', 'utf8');
1256
- git(['add', '-A'], worktree.dir);
1257
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1258
-
1259
- const headSha = git(['rev-parse', 'HEAD'], repoCwd).trim();
1260
- git(['checkout', '--detach', headSha], repoCwd);
1261
- expect(await gitWorktree.getCurrentBranch(repoCwd)).toBeNull();
1262
-
1263
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1264
- expect(outcome.ok).toBe(false);
1265
- expect(outcome.reason).toContain('detached HEAD');
1266
- expect(outcome.reason).toContain(defaultBranch);
1267
-
1268
- git(['checkout', defaultBranch], repoCwd);
1269
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1270
- try { git(['branch', '-D', worktree.branch], repoCwd); } catch { /* best-effort */ }
1271
- });
1272
-
1273
- test('[guard] resolveDefaultBranch resolves a sensible default (the sole local branch) for a repo with no remote, and integration still proceeds', async () => {
1274
- const remoteOut = execFileSync('git', ['remote'], { cwd: repoCwd, encoding: 'utf8' }).trim();
1275
- expect(remoteOut).toBe('');
1276
- const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1277
- expect(await gitWorktree.resolveDefaultBranch(repoCwd)).toBe(defaultBranch);
1278
-
1279
- const slug = 'test-slug-guard-no-remote';
1280
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1281
- fs.writeFileSync(path.join(worktree.dir, 'guard-no-remote.txt'), 'output\n', 'utf8');
1282
- git(['add', '-A'], worktree.dir);
1283
- git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1284
-
1285
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1286
- expect(outcome.ok).toBe(true);
1287
- expect(outcome.integrated).toBe(true);
1288
-
1289
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1290
- });
1291
-
1292
- test('[guard][epic] integrateEpicBranch inherits the same guard and still merges cleanly when HEAD is on the default branch', async () => {
1293
- const epicId = 'test-epic-guard-ok';
1294
- const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
1295
- expect(worktree.ok).toBe(true);
1296
-
1297
- fs.writeFileSync(path.join(worktree.dir, 'epic-guard.txt'), 'epic output\n', 'utf8');
1298
- git(['add', '-A'], worktree.dir);
1299
- git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
1300
-
1301
- const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
1302
- expect(outcome.ok).toBe(true);
1303
- expect(outcome.integrated).toBe(true);
1304
-
1305
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1306
- });
1307
-
1308
- // ── integrateBranch failure subtype classification (mc-01) ──
1309
-
1310
- test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
1311
- const slug = 'test-slug-kind-blocking';
1312
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1313
- expect(worktree.ok).toBe(true);
1314
-
1315
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1316
- git(['add', '-A'], worktree.dir);
1317
- git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1318
- // Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
1319
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
1320
-
1321
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1322
- expect(outcome.ok).toBe(false);
1323
- expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1324
- expect(outcome.failureKind).toBe('blocking_paths');
1325
- expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
1326
- expect(outcome.blockingPaths.untracked).toEqual([]);
1327
-
1328
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1329
- });
1330
-
1331
- test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
1332
- const slug = 'test-slug-kind-content';
1333
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1334
- expect(worktree.ok).toBe(true);
1335
-
1336
- fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1337
- git(['add', '-A'], worktree.dir);
1338
- git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1339
- fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
1340
- git(['add', '-A'], repoCwd);
1341
- git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
1342
-
1343
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1344
- expect(outcome.ok).toBe(false);
1345
- expect(outcome.failureKind).toBe('content_conflict');
1346
- expect(outcome.conflictedPaths).toEqual(['README.md']);
1347
- // The abort still ran.
1348
- expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1349
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1350
-
1351
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1352
- });
1353
-
1354
- test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
1355
- const slug = 'test-slug-kind-append';
1356
- fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
1357
- git(['add', '-A'], repoCwd);
1358
- git(['commit', '-q', '-m', 'add src.js'], repoCwd);
1359
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1360
- expect(worktree.ok).toBe(true);
1361
-
1362
- fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
1363
- git(['add', '-A'], worktree.dir);
1364
- git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
1365
- fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
1366
- git(['add', '-A'], repoCwd);
1367
- git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
1368
-
1369
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1370
- expect(outcome.ok).toBe(false);
1371
- expect(outcome.failureKind).toBe('content_conflict');
1372
- expect(outcome.conflictedPaths).toEqual(['src.js']);
1373
-
1374
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1375
- });
1376
-
1377
- // ── pure-addition merge-conflict auto-resolve (mc-02) ──
1378
-
1379
- async function conflictScenario(slug, { base, side, main }) {
1380
- for (const [f, c] of Object.entries(base)) {
1381
- fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1382
- fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1383
- }
1384
- git(['add', '-A'], repoCwd);
1385
- git(['commit', '-q', '-m', 'base files'], repoCwd);
1386
- const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1387
- expect(worktree.ok).toBe(true);
1388
- for (const [f, c] of Object.entries(side)) {
1389
- fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
1390
- fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
1391
- }
1392
- git(['add', '-A'], worktree.dir);
1393
- git(['commit', '-q', '-m', 'job side'], worktree.dir);
1394
- for (const [f, c] of Object.entries(main)) {
1395
- fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1396
- fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1397
- }
1398
- git(['add', '-A'], repoCwd);
1399
- git(['commit', '-q', '-m', 'main side'], repoCwd);
1400
- return worktree;
1401
- }
1402
-
1403
- const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
1404
-
1405
- test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
1406
- const slug = 'test-slug-pure-add';
1407
- const worktree = await conflictScenario(slug, {
1408
- base: { 'd.md': '# Doc\n\nintro\n' },
1409
- side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
1410
- main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
1411
- });
1412
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1413
- expect(outcome.ok).toBe(true);
1414
- expect(outcome.autoResolved).toBe('pure_addition_concat');
1415
- expect(outcome.resolvedPaths).toEqual(['d.md']);
1416
- const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
1417
- expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
1418
- expect(merged).not.toMatch(MARKER_RE);
1419
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1420
- expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
1421
- expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
1422
-
1423
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1424
- });
1425
-
1426
- test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
1427
- const slug = 'test-slug-pure-add-neg-sameline';
1428
- const worktree = await conflictScenario(slug, {
1429
- base: { 'd.md': 'intro\nORIGINAL LINE\n' },
1430
- side: { 'd.md': 'intro\nSIDE EDIT\n' },
1431
- main: { 'd.md': 'intro\nMAIN EDIT\n' },
1432
- });
1433
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1434
- expect(outcome.ok).toBe(false);
1435
- expect(outcome.autoResolved).toBeUndefined();
1436
- expect(outcome.failureKind).toBe('content_conflict');
1437
- expect(outcome.conflictedPaths).toEqual(['d.md']);
1438
- expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
1439
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1440
-
1441
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1442
- });
1443
-
1444
- test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
1445
- const slug = 'test-slug-pure-add-neg-js';
1446
- const worktree = await conflictScenario(slug, {
1447
- base: { 'src/thing.js': '// intro\n' },
1448
- side: { 'src/thing.js': '// intro\n// A\n' },
1449
- main: { 'src/thing.js': '// intro\n// B\n// C\n' },
1450
- });
1451
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1452
- expect(outcome.ok).toBe(false);
1453
- expect(outcome.autoResolved).toBeUndefined();
1454
- expect(outcome.failureKind).toBe('content_conflict');
1455
- expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
1456
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1457
-
1458
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1459
- });
1460
-
1461
- test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
1462
- const slug = 'test-slug-pure-add-neg-mixed';
1463
- const worktree = await conflictScenario(slug, {
1464
- base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
1465
- side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
1466
- main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
1467
- });
1468
- const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1469
- expect(outcome.ok).toBe(false);
1470
- expect(outcome.autoResolved).toBeUndefined();
1471
- expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
1472
- expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
1473
- expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
1474
- expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1475
- expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1476
-
1477
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1478
- });