claude-code-session-manager 0.98.0 → 0.101.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 (441) 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-CXaH2GED.js +1 -0
  5. package/dist/assets/{History-Bfdoywlh.js → History-Cerii-HA.js} +2 -2
  6. package/dist/assets/{Hooks-CKDJjTJV.js → Hooks-aXMb5LDy.js} +3 -3
  7. package/dist/assets/{Library-7akfgy67.js → Library-WrJFKEOJ.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DDesiqet.js → MarkdownEditor-D1BMCXS7.js} +1 -1
  9. package/dist/assets/{McpServers-D4lZtXof.js → McpServers-Cf0davLd.js} +2 -2
  10. package/dist/assets/{Memory-CeI8G1w5.js → Memory-Dn6NHWPe.js} +6 -6
  11. package/dist/assets/Permissions-CTVuFPjg.js +3 -0
  12. package/dist/assets/{Plugins-P4eCXy_S.js → Plugins-C2TyF8Mn.js} +2 -2
  13. package/dist/assets/ProvenanceBadge-F_jwHSwE.js +1 -0
  14. package/dist/assets/{SaveBar-l9jnEMES.js → SaveBar-DA1KVJST.js} +1 -1
  15. package/dist/assets/Scheduler-Bm6Q_hlu.js +16 -0
  16. package/dist/assets/{ScopeSwitcher-CjJi8ST5.js → ScopeSwitcher-BygZlJca.js} +1 -1
  17. package/dist/assets/Settings-BYCiv5KV.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-CjbO01Sg.js → SkillReferenceGraph-CcLnl9np.js} +1 -1
  19. package/dist/assets/{Skills-BxZ9_fCR.js → Skills-ChffsdTD.js} +2 -2
  20. package/dist/assets/{SystemPrompt-D8vfFb8b.js → SystemPrompt-dNkfHE_M.js} +1 -1
  21. package/dist/assets/{TagLibrary-2vzUo7YL.js → TagLibrary-jVlEuB0m.js} +1 -1
  22. package/dist/assets/{TiptapBody-B8ZfbK90.js → TiptapBody-6_cdwcP0.js} +1 -1
  23. package/dist/assets/{Toggle-BbW4nGGf.js → Toggle-CVXqGpAP.js} +1 -1
  24. package/dist/assets/ViewTabs-DO1YVbpc.js +1 -0
  25. package/dist/assets/{index-CKH5Uxik.css → index-D89EUizB.css} +1 -1
  26. package/dist/assets/{index-Cd98Q1lD.js → index-DbCQPWWY.js} +747 -752
  27. package/dist/assets/{settingsSchema-IhUfucCF.js → settingsSchema-Wrvn-Yz1.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +25 -29
  30. package/plugins/CLAUDE.md +6 -6
  31. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +21 -19
  32. package/plugins/session-manager-dev/skills/builder/SKILL.md +3 -3
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +254 -510
  34. package/plugins/session-manager-dev/skills/develop/standards.md +17 -22
  35. package/scripts/README.md +3 -11
  36. package/scripts/audit-ops-hygiene.cjs +3 -3
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +1 -1
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +1 -1
  39. package/scripts/mint-epic.cjs +2 -3
  40. package/scripts/scheduler-mcp-server.cjs +64 -7
  41. package/src/main/agentLibrary.cjs +22 -2
  42. package/src/main/build-info.json +4 -4
  43. package/src/main/chatRunner.cjs +4 -3
  44. package/src/main/config.cjs +41 -38
  45. package/src/main/docEdit.cjs +4 -1
  46. package/src/main/files.cjs +2 -5
  47. package/src/main/health.cjs +1 -1
  48. package/src/main/index.cjs +40 -11
  49. package/src/main/ipcSchemas.cjs +35 -34
  50. package/src/main/lib/agentPersonaSchema.cjs +13 -2
  51. package/src/main/lib/atomicFs.cjs +116 -0
  52. package/src/main/lib/branchSweep.cjs +13 -12
  53. package/src/main/lib/buildTarget.cjs +3 -3
  54. package/src/main/lib/builtinMacros.cjs +39 -0
  55. package/src/main/lib/claudeCliCaps.cjs +129 -0
  56. package/src/main/lib/credentials.cjs +2 -4
  57. package/src/main/lib/crossProjectFeedback.cjs +3 -3
  58. package/src/main/lib/cwdClassify.cjs +15 -3
  59. package/src/main/lib/definitionOfDone.cjs +207 -125
  60. package/src/main/lib/dodDrainHook.cjs +11 -5
  61. package/src/main/lib/epicMint.cjs +2 -4
  62. package/src/main/lib/epicStatusMirror.cjs +8 -10
  63. package/src/main/lib/epicWorktreeProjectConfig.cjs +2 -4
  64. package/src/main/lib/gateAuthority.cjs +96 -0
  65. package/src/main/lib/gitExec.cjs +69 -0
  66. package/src/main/lib/gitWorktree.cjs +656 -92
  67. package/src/main/lib/instanceLock.cjs +5 -8
  68. package/src/main/lib/launchFailure.cjs +2 -3
  69. package/src/main/lib/macroLibrary.cjs +480 -0
  70. package/src/main/lib/mcpToolCatalog.cjs +49 -17
  71. package/src/main/lib/needsReviewLedger.cjs +1 -1
  72. package/src/main/lib/opsErrorLog.cjs +11 -1
  73. package/src/main/lib/opsOwnership.cjs +5 -9
  74. package/src/main/lib/pidAlive.cjs +32 -0
  75. package/src/main/lib/prdCreate.cjs +121 -12
  76. package/src/main/lib/prdDisposition.cjs +2 -2
  77. package/src/main/lib/prdGateFiles.cjs +284 -0
  78. package/src/main/lib/prdLocations.cjs +61 -0
  79. package/src/main/lib/prdMigration.cjs +2 -3
  80. package/src/main/lib/prdSizing.cjs +2 -1
  81. package/src/main/lib/projectHomeAdminRoutes.cjs +136 -21
  82. package/src/main/lib/promptSessionSchema.cjs +10 -2
  83. package/src/main/lib/rcaReport.cjs +4 -6
  84. package/src/main/lib/reaperHelpers.cjs +8 -1
  85. package/src/main/lib/reservationExpiry.cjs +6 -1
  86. package/src/main/lib/reviewNotice.cjs +263 -0
  87. package/src/main/lib/runClaudeP.cjs +4 -1
  88. package/src/main/lib/schedulerPaths.cjs +22 -2
  89. package/src/main/lib/sessionSlots.cjs +2 -4
  90. package/src/main/lib/shippedPersonaSeeds.cjs +226 -0
  91. package/src/main/lib/smfileHeaders.cjs +66 -0
  92. package/src/main/lib/timeoutShim.cjs +123 -0
  93. package/src/main/lib/timeoutShimScript.cjs +275 -0
  94. package/src/main/lib/upgradeDrain.cjs +4 -10
  95. package/src/main/lib/watchdogHelpers.cjs +12 -25
  96. package/src/main/lib/workTypeLibrary.cjs +7 -1
  97. package/src/main/projectPages.cjs +43 -13
  98. package/src/main/promptSessionEvents.cjs +37 -13
  99. package/src/main/scheduler.cjs +910 -198
  100. package/src/main/seedAgentPersonas.cjs +279 -24
  101. package/src/main/supervisor.cjs +4 -1
  102. package/src/main/templates/PRD_AUTHORING.md +126 -406
  103. package/src/preload/__tests__/preload-surface.test.cjs +68 -0
  104. package/src/preload/api.d.ts +53 -94
  105. package/src/preload/index.cjs +14 -9
  106. package/src/seed/agents/architect.md +2 -0
  107. package/src/seed/agents/demo-video-builder.md +84 -0
  108. package/src/seed/agents/dev-lead.md +18 -18
  109. package/src/seed/agents/project-home-builder.md +66 -35
  110. package/src/seed/agents/validator.md +10 -4
  111. package/dist/assets/DataModel-DCEgMTjH.js +0 -1
  112. package/dist/assets/HostBilko-CDVHE0hT.js +0 -1
  113. package/dist/assets/Permissions-BGleLja4.js +0 -3
  114. package/dist/assets/ProvenanceBadge-BFczzI1B.js +0 -1
  115. package/dist/assets/Scheduler-C_phpeUS.js +0 -16
  116. package/dist/assets/Settings-BIwZ3FGQ.js +0 -3
  117. package/src/main/__tests__/activeIndexMerge.test.cjs +0 -235
  118. package/src/main/__tests__/agentEffortResolve.test.cjs +0 -117
  119. package/src/main/__tests__/agentLibrary.test.cjs +0 -276
  120. package/src/main/__tests__/agentModelResolve.test.cjs +0 -311
  121. package/src/main/__tests__/agentOverlayWrite.test.cjs +0 -95
  122. package/src/main/__tests__/bilkoHost-deriveSlug.test.cjs +0 -26
  123. package/src/main/__tests__/bilkoHost-integration.test.cjs +0 -118
  124. package/src/main/__tests__/bilkoHostCore.test.cjs +0 -72
  125. package/src/main/__tests__/broadcastCoalescer.test.cjs +0 -122
  126. package/src/main/__tests__/chat-cancel-terminal.test.cjs +0 -120
  127. package/src/main/__tests__/chat-dead-channels.test.cjs +0 -63
  128. package/src/main/__tests__/chat-exit-close-race.test.cjs +0 -146
  129. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +0 -139
  130. package/src/main/__tests__/chat-preamble-anchors.test.cjs +0 -100
  131. package/src/main/__tests__/chat-queue.test.cjs +0 -97
  132. package/src/main/__tests__/chat-stop-signal.test.cjs +0 -89
  133. package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +0 -125
  134. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +0 -252
  135. package/src/main/__tests__/classifyPromptTicket.test.cjs +0 -101
  136. package/src/main/__tests__/classifyTranscriptLine.test.cjs +0 -201
  137. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +0 -66
  138. package/src/main/__tests__/config-readText-bounded.test.cjs +0 -84
  139. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +0 -59
  140. package/src/main/__tests__/crossProjectFeedback.test.cjs +0 -334
  141. package/src/main/__tests__/crossProjectFeedbackRoutes.test.cjs +0 -161
  142. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +0 -77
  143. package/src/main/__tests__/develop-skill-failure-modes.test.cjs +0 -70
  144. package/src/main/__tests__/docEdit.test.cjs +0 -244
  145. package/src/main/__tests__/dod-batchkey.test.cjs +0 -183
  146. package/src/main/__tests__/dod-drain-hook.test.cjs +0 -302
  147. package/src/main/__tests__/dod-report.test.cjs +0 -304
  148. package/src/main/__tests__/dod-reverify.test.cjs +0 -285
  149. package/src/main/__tests__/epicContextDigest.test.cjs +0 -174
  150. package/src/main/__tests__/epicMint.test.cjs +0 -332
  151. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +0 -64
  152. package/src/main/__tests__/epicStatusMirror.test.cjs +0 -110
  153. package/src/main/__tests__/epicValidationHook.test.cjs +0 -291
  154. package/src/main/__tests__/exchanges.test.cjs +0 -122
  155. package/src/main/__tests__/exchangesPromptId.test.cjs +0 -61
  156. package/src/main/__tests__/extractJson.test.cjs +0 -51
  157. package/src/main/__tests__/files-reject-credentials.test.cjs +0 -40
  158. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +0 -556
  159. package/src/main/__tests__/flatPrdTickSweep.test.cjs +0 -110
  160. package/src/main/__tests__/health-build-freshness.test.cjs +0 -39
  161. package/src/main/__tests__/health-claude-md-budget.test.cjs +0 -57
  162. package/src/main/__tests__/health-credentials.test.cjs +0 -81
  163. package/src/main/__tests__/health-delegation-chain.test.cjs +0 -124
  164. package/src/main/__tests__/health-per-project-stall.test.cjs +0 -84
  165. package/src/main/__tests__/health-prd-migration.test.cjs +0 -37
  166. package/src/main/__tests__/health-queue-dispatch.test.cjs +0 -135
  167. package/src/main/__tests__/health-starve-escalation.test.cjs +0 -94
  168. package/src/main/__tests__/health-tick-liveness.test.cjs +0 -179
  169. package/src/main/__tests__/health-usage-poller.test.cjs +0 -144
  170. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +0 -65
  171. package/src/main/__tests__/heapSnapshot.test.cjs +0 -121
  172. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +0 -313
  173. package/src/main/__tests__/historyDashboard.test.cjs +0 -163
  174. package/src/main/__tests__/historyRollup.test.cjs +0 -333
  175. package/src/main/__tests__/intradayRefresh.test.cjs +0 -39
  176. package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +0 -39
  177. package/src/main/__tests__/kg-augment.test.cjs +0 -195
  178. package/src/main/__tests__/loadGateDetailTick.test.cjs +0 -31
  179. package/src/main/__tests__/machineProfile.test.cjs +0 -152
  180. package/src/main/__tests__/mcpStatus.test.cjs +0 -61
  181. package/src/main/__tests__/memoryAggregate.test.cjs +0 -109
  182. package/src/main/__tests__/memoryStale.test.cjs +0 -88
  183. package/src/main/__tests__/needsReviewLedger.test.cjs +0 -162
  184. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +0 -25
  185. package/src/main/__tests__/opsErrorLog.test.cjs +0 -109
  186. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +0 -173
  187. package/src/main/__tests__/personaMerge.test.cjs +0 -169
  188. package/src/main/__tests__/planValidator.test.cjs +0 -125
  189. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +0 -176
  190. package/src/main/__tests__/prd-group-allocator.test.cjs +0 -119
  191. package/src/main/__tests__/prdAdminRouteParity.test.cjs +0 -70
  192. package/src/main/__tests__/prdAdminRoutes.test.cjs +0 -718
  193. package/src/main/__tests__/prdAgentType.test.cjs +0 -103
  194. package/src/main/__tests__/prdAuthoringSeed.test.cjs +0 -39
  195. package/src/main/__tests__/prdCreate.test.cjs +0 -979
  196. package/src/main/__tests__/prdCreateAdoption.test.cjs +0 -205
  197. package/src/main/__tests__/prdCreateDisposition.test.cjs +0 -201
  198. package/src/main/__tests__/prdCreatePlanId.test.cjs +0 -132
  199. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +0 -117
  200. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +0 -136
  201. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +0 -125
  202. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +0 -108
  203. package/src/main/__tests__/prdLocations.test.cjs +0 -195
  204. package/src/main/__tests__/prdLocationsArchived.test.cjs +0 -201
  205. package/src/main/__tests__/prdMigration.test.cjs +0 -349
  206. package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +0 -91
  207. package/src/main/__tests__/prdParserHighWater.test.cjs +0 -74
  208. package/src/main/__tests__/prdParserSourcePromptId.test.cjs +0 -65
  209. package/src/main/__tests__/prdSetDisposition.test.cjs +0 -222
  210. package/src/main/__tests__/prdSizing.test.cjs +0 -106
  211. package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +0 -118
  212. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +0 -160
  213. package/src/main/__tests__/proc-role-env.test.cjs +0 -125
  214. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +0 -304
  215. package/src/main/__tests__/procname-sm-processes.test.cjs +0 -127
  216. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +0 -177
  217. package/src/main/__tests__/projectPages.test.cjs +0 -137
  218. package/src/main/__tests__/promptSessionEvents.test.cjs +0 -234
  219. package/src/main/__tests__/promptSessionSchema.test.cjs +0 -101
  220. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  221. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +0 -159
  222. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +0 -282
  223. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +0 -96
  224. package/src/main/__tests__/pty-write-result.test.cjs +0 -46
  225. package/src/main/__tests__/queue-health-verdict.test.cjs +0 -170
  226. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +0 -286
  227. package/src/main/__tests__/queue-starvation-per-project.test.cjs +0 -147
  228. package/src/main/__tests__/queueHistory.test.cjs +0 -355
  229. package/src/main/__tests__/queueOps-interactive-ac-lint.test.cjs +0 -65
  230. package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +0 -65
  231. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +0 -211
  232. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +0 -128
  233. package/src/main/__tests__/rcaReport.test.cjs +0 -266
  234. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +0 -119
  235. package/src/main/__tests__/reconcileTiming.test.cjs +0 -135
  236. package/src/main/__tests__/runLogRetention.test.cjs +0 -489
  237. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +0 -26
  238. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +0 -58
  239. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +0 -191
  240. package/src/main/__tests__/runVerify-policy-denial.test.cjs +0 -89
  241. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +0 -225
  242. package/src/main/__tests__/runVerify.test.cjs +0 -1784
  243. package/src/main/__tests__/scheduleJobSchema.test.cjs +0 -127
  244. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +0 -65
  245. package/src/main/__tests__/scheduleJobTransitions.test.cjs +0 -277
  246. package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +0 -59
  247. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +0 -72
  248. package/src/main/__tests__/scheduler-admin-routes.test.cjs +0 -199
  249. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +0 -143
  250. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +0 -105
  251. package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +0 -102
  252. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +0 -155
  253. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +0 -188
  254. package/src/main/__tests__/scheduler-autofix-select.test.cjs +0 -439
  255. package/src/main/__tests__/scheduler-autopromote.test.cjs +0 -51
  256. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +0 -101
  257. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +0 -107
  258. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +0 -153
  259. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +0 -121
  260. package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +0 -134
  261. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +0 -244
  262. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +0 -182
  263. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +0 -43
  264. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +0 -168
  265. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +0 -58
  266. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +0 -81
  267. package/src/main/__tests__/scheduler-epic-digest.test.cjs +0 -227
  268. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +0 -121
  269. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +0 -229
  270. package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +0 -75
  271. package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +0 -119
  272. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +0 -46
  273. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +0 -78
  274. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +0 -119
  275. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +0 -390
  276. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +0 -61
  277. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +0 -80
  278. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +0 -323
  279. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +0 -41
  280. package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +0 -63
  281. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +0 -123
  282. package/src/main/__tests__/scheduler-job-budget.test.cjs +0 -172
  283. package/src/main/__tests__/scheduler-job-overrun.test.cjs +0 -175
  284. package/src/main/__tests__/scheduler-launch-failure.test.cjs +0 -199
  285. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +0 -52
  286. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +0 -199
  287. package/src/main/__tests__/scheduler-looks-done.test.cjs +0 -537
  288. package/src/main/__tests__/scheduler-manual-pause.test.cjs +0 -118
  289. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +0 -245
  290. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +0 -46
  291. package/src/main/__tests__/scheduler-mutate-reentrancy.test.cjs +0 -117
  292. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +0 -197
  293. package/src/main/__tests__/scheduler-never-stop.test.cjs +0 -157
  294. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +0 -152
  295. package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +0 -81
  296. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +0 -87
  297. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +0 -343
  298. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +0 -217
  299. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +0 -164
  300. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +0 -161
  301. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +0 -178
  302. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +0 -165
  303. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +0 -257
  304. package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +0 -123
  305. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +0 -152
  306. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +0 -156
  307. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +0 -754
  308. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +0 -87
  309. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +0 -100
  310. package/src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs +0 -105
  311. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +0 -203
  312. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +0 -247
  313. package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +0 -77
  314. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +0 -254
  315. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +0 -115
  316. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +0 -380
  317. package/src/main/__tests__/scheduler-sigterm-commit.test.cjs +0 -43
  318. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +0 -126
  319. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +0 -154
  320. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +0 -252
  321. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +0 -185
  322. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +0 -136
  323. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +0 -81
  324. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +0 -54
  325. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +0 -172
  326. package/src/main/__tests__/scheduler-transient-failure.test.cjs +0 -141
  327. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +0 -62
  328. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +0 -89
  329. package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +0 -109
  330. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +0 -234
  331. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +0 -120
  332. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +0 -105
  333. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +0 -117
  334. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +0 -110
  335. package/src/main/__tests__/seedAgentPersonas.test.cjs +0 -184
  336. package/src/main/__tests__/seedSchedulerMcp.test.cjs +0 -211
  337. package/src/main/__tests__/seedStatus.test.cjs +0 -100
  338. package/src/main/__tests__/seedValidatorPersona.test.cjs +0 -116
  339. package/src/main/__tests__/stop-signal-anchor.test.cjs +0 -75
  340. package/src/main/__tests__/telemetryClient.test.cjs +0 -1055
  341. package/src/main/__tests__/telemetryContract.test.cjs +0 -930
  342. package/src/main/__tests__/telemetrySettings.test.cjs +0 -210
  343. package/src/main/__tests__/transcripts-batch-flush.test.cjs +0 -249
  344. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +0 -124
  345. package/src/main/__tests__/transcripts-paged-reads.test.cjs +0 -241
  346. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +0 -154
  347. package/src/main/__tests__/transcriptsUsageFor.test.cjs +0 -206
  348. package/src/main/__tests__/uniquePrdNumbers.test.cjs +0 -153
  349. package/src/main/__tests__/usageSingleFlight.test.cjs +0 -169
  350. package/src/main/__tests__/validationSentinels.test.cjs +0 -84
  351. package/src/main/__tests__/workTypeLibrary.test.cjs +0 -89
  352. package/src/main/bilkoHost.cjs +0 -314
  353. package/src/main/bilkoHostCore.cjs +0 -89
  354. package/src/main/lib/__tests__/active-sessions.test.cjs +0 -251
  355. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +0 -179
  356. package/src/main/lib/__tests__/agentPersonaSchema.test.cjs +0 -67
  357. package/src/main/lib/__tests__/auditLog.test.cjs +0 -38
  358. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +0 -107
  359. package/src/main/lib/__tests__/branchSweep.test.cjs +0 -164
  360. package/src/main/lib/__tests__/buildIdentity.test.cjs +0 -121
  361. package/src/main/lib/__tests__/buildTarget.test.cjs +0 -52
  362. package/src/main/lib/__tests__/childWithLog.test.cjs +0 -321
  363. package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +0 -87
  364. package/src/main/lib/__tests__/crashTelemetry.test.cjs +0 -103
  365. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +0 -115
  366. package/src/main/lib/__tests__/cwdClassify.test.cjs +0 -111
  367. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +0 -95
  368. package/src/main/lib/__tests__/delegationReadiness.test.cjs +0 -1175
  369. package/src/main/lib/__tests__/dispatchLoop.test.cjs +0 -63
  370. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +0 -244
  371. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +0 -91
  372. package/src/main/lib/__tests__/epicDelegationStats.test.cjs +0 -137
  373. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +0 -283
  374. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +0 -196
  375. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +0 -163
  376. package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +0 -136
  377. package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +0 -130
  378. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +0 -117
  379. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +0 -137
  380. package/src/main/lib/__tests__/fixChainDepth.test.cjs +0 -40
  381. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +0 -13
  382. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +0 -34
  383. package/src/main/lib/__tests__/gateFixtures.json +0 -20
  384. package/src/main/lib/__tests__/gitCacheBound.test.cjs +0 -69
  385. package/src/main/lib/__tests__/gitWorktree.test.cjs +0 -1478
  386. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +0 -107
  387. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +0 -153
  388. package/src/main/lib/__tests__/guardShims.test.cjs +0 -158
  389. package/src/main/lib/__tests__/importReferences.spec.cjs +0 -56
  390. package/src/main/lib/__tests__/instanceLock.test.cjs +0 -173
  391. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +0 -78
  392. package/src/main/lib/__tests__/jobWorktree.test.cjs +0 -199
  393. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +0 -82
  394. package/src/main/lib/__tests__/landedSinceRun.test.cjs +0 -133
  395. package/src/main/lib/__tests__/launchFailure.test.cjs +0 -220
  396. package/src/main/lib/__tests__/loadGate.test.cjs +0 -303
  397. package/src/main/lib/__tests__/localAdminHttp.test.cjs +0 -214
  398. package/src/main/lib/__tests__/loopDelay.test.cjs +0 -68
  399. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +0 -107
  400. package/src/main/lib/__tests__/modelCatalog.test.cjs +0 -202
  401. package/src/main/lib/__tests__/opsOwnership.test.cjs +0 -113
  402. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +0 -328
  403. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +0 -51
  404. package/src/main/lib/__tests__/opsRootResolve.test.cjs +0 -149
  405. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +0 -82
  406. package/src/main/lib/__tests__/prdDisposition.test.cjs +0 -224
  407. package/src/main/lib/__tests__/procIdentity.test.cjs +0 -119
  408. package/src/main/lib/__tests__/procName.test.cjs +0 -92
  409. package/src/main/lib/__tests__/projectBriefCore.test.cjs +0 -216
  410. package/src/main/lib/__tests__/projectRootResolve.test.cjs +0 -148
  411. package/src/main/lib/__tests__/queueHealth.test.cjs +0 -58
  412. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +0 -88
  413. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +0 -190
  414. package/src/main/lib/__tests__/quietMachineLease.test.cjs +0 -39
  415. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +0 -88
  416. package/src/main/lib/__tests__/reaperHelpers.test.cjs +0 -577
  417. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +0 -312
  418. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +0 -213
  419. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +0 -125
  420. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +0 -127
  421. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +0 -109
  422. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +0 -71
  423. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +0 -216
  424. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +0 -183
  425. package/src/main/lib/__tests__/schedulerPaths.test.cjs +0 -226
  426. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +0 -133
  427. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +0 -56
  428. package/src/main/lib/__tests__/sessionSlots.test.cjs +0 -144
  429. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +0 -626
  430. package/src/main/lib/__tests__/telemetryBoot.test.cjs +0 -134
  431. package/src/main/lib/__tests__/telemetryConsent.test.cjs +0 -136
  432. package/src/main/lib/__tests__/telemetryCounters.test.cjs +0 -57
  433. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +0 -98
  434. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +0 -200
  435. package/src/main/lib/__tests__/toolUseClassify.test.cjs +0 -53
  436. package/src/main/lib/__tests__/updateCheck.test.cjs +0 -63
  437. package/src/main/lib/__tests__/upgradeDrain.test.cjs +0 -130
  438. package/src/main/lib/__tests__/usageCircuit.test.cjs +0 -354
  439. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +0 -441
  440. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +0 -266
  441. 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
- });