@paperclipai/server 2026.824.0 → 2026.825.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (519) hide show
  1. package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
  2. package/dist/adapters/builtin-adapter-types.js +1 -0
  3. package/dist/adapters/builtin-adapter-types.js.map +1 -1
  4. package/dist/adapters/plugin-loader.d.ts +1 -0
  5. package/dist/adapters/plugin-loader.d.ts.map +1 -1
  6. package/dist/adapters/plugin-loader.js +12 -1
  7. package/dist/adapters/plugin-loader.js.map +1 -1
  8. package/dist/adapters/plugin-loader.test.d.ts +2 -0
  9. package/dist/adapters/plugin-loader.test.d.ts.map +1 -0
  10. package/dist/adapters/plugin-loader.test.js +37 -0
  11. package/dist/adapters/plugin-loader.test.js.map +1 -0
  12. package/dist/adapters/registry.d.ts.map +1 -1
  13. package/dist/adapters/registry.js +68 -5
  14. package/dist/adapters/registry.js.map +1 -1
  15. package/dist/adapters/registry.test.d.ts +2 -0
  16. package/dist/adapters/registry.test.d.ts.map +1 -0
  17. package/dist/adapters/registry.test.js +35 -0
  18. package/dist/adapters/registry.test.js.map +1 -0
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +5 -7
  21. package/dist/app.js.map +1 -1
  22. package/dist/auth/better-auth.d.ts +10 -0
  23. package/dist/auth/better-auth.d.ts.map +1 -1
  24. package/dist/auth/better-auth.js +47 -0
  25. package/dist/auth/better-auth.js.map +1 -1
  26. package/dist/auth/workspace-login-handoff-exchange.d.ts +75 -0
  27. package/dist/auth/workspace-login-handoff-exchange.d.ts.map +1 -0
  28. package/dist/auth/workspace-login-handoff-exchange.js +73 -0
  29. package/dist/auth/workspace-login-handoff-exchange.js.map +1 -0
  30. package/dist/auth/workspace-login-handoff-plugin.d.ts +40 -0
  31. package/dist/auth/workspace-login-handoff-plugin.d.ts.map +1 -0
  32. package/dist/auth/workspace-login-handoff-plugin.js +140 -0
  33. package/dist/auth/workspace-login-handoff-plugin.js.map +1 -0
  34. package/dist/auth/workspace-login-handoff.d.ts +189 -0
  35. package/dist/auth/workspace-login-handoff.d.ts.map +1 -0
  36. package/dist/auth/workspace-login-handoff.js +319 -0
  37. package/dist/auth/workspace-login-handoff.js.map +1 -0
  38. package/dist/build-info.json +3 -0
  39. package/dist/config.d.ts +1 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +14 -0
  42. package/dist/config.js.map +1 -1
  43. package/dist/dev-runner-worktree.d.ts.map +1 -1
  44. package/dist/dev-runner-worktree.js +5 -0
  45. package/dist/dev-runner-worktree.js.map +1 -1
  46. package/dist/duplex-aggregate-ceiling-env.d.ts +21 -0
  47. package/dist/duplex-aggregate-ceiling-env.d.ts.map +1 -0
  48. package/dist/duplex-aggregate-ceiling-env.js +23 -0
  49. package/dist/duplex-aggregate-ceiling-env.js.map +1 -0
  50. package/dist/embedded-postgres-supervisor.d.ts +27 -0
  51. package/dist/embedded-postgres-supervisor.d.ts.map +1 -0
  52. package/dist/embedded-postgres-supervisor.js +72 -0
  53. package/dist/embedded-postgres-supervisor.js.map +1 -0
  54. package/dist/errors.d.ts +2 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +6 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +115 -7
  60. package/dist/index.js.map +1 -1
  61. package/dist/instrumentation.d.ts +33 -0
  62. package/dist/instrumentation.d.ts.map +1 -1
  63. package/dist/instrumentation.js +68 -1
  64. package/dist/instrumentation.js.map +1 -1
  65. package/dist/middleware/auth.d.ts.map +1 -1
  66. package/dist/middleware/auth.js +35 -10
  67. package/dist/middleware/auth.js.map +1 -1
  68. package/dist/middleware/error-handler.d.ts.map +1 -1
  69. package/dist/middleware/error-handler.js +8 -1
  70. package/dist/middleware/error-handler.js.map +1 -1
  71. package/dist/middleware/logger.d.ts.map +1 -1
  72. package/dist/middleware/logger.js +5 -2
  73. package/dist/middleware/logger.js.map +1 -1
  74. package/dist/middleware/redact-sensitive.d.ts.map +1 -1
  75. package/dist/middleware/redact-sensitive.js +4 -0
  76. package/dist/middleware/redact-sensitive.js.map +1 -1
  77. package/dist/middleware/validate.d.ts +3 -1
  78. package/dist/middleware/validate.d.ts.map +1 -1
  79. package/dist/middleware/validate.js +37 -0
  80. package/dist/middleware/validate.js.map +1 -1
  81. package/dist/routes/access.d.ts.map +1 -1
  82. package/dist/routes/access.js +20 -0
  83. package/dist/routes/access.js.map +1 -1
  84. package/dist/routes/activity.js +4 -4
  85. package/dist/routes/activity.js.map +1 -1
  86. package/dist/routes/adapter-login-route-spine.d.ts +81 -0
  87. package/dist/routes/adapter-login-route-spine.d.ts.map +1 -0
  88. package/dist/routes/adapter-login-route-spine.js +40 -0
  89. package/dist/routes/adapter-login-route-spine.js.map +1 -0
  90. package/dist/routes/adapters.d.ts +33 -0
  91. package/dist/routes/adapters.d.ts.map +1 -1
  92. package/dist/routes/adapters.js +35 -1
  93. package/dist/routes/adapters.js.map +1 -1
  94. package/dist/routes/adapters.test.d.ts +2 -0
  95. package/dist/routes/adapters.test.d.ts.map +1 -0
  96. package/dist/routes/adapters.test.js +58 -0
  97. package/dist/routes/adapters.test.js.map +1 -0
  98. package/dist/routes/agents.d.ts.map +1 -1
  99. package/dist/routes/agents.js +281 -91
  100. package/dist/routes/agents.js.map +1 -1
  101. package/dist/routes/cases.d.ts +11 -22
  102. package/dist/routes/cases.d.ts.map +1 -1
  103. package/dist/routes/cases.js +13 -13
  104. package/dist/routes/companies.js +3 -3
  105. package/dist/routes/decision-training.js +6 -6
  106. package/dist/routes/decisions.js +4 -4
  107. package/dist/routes/decisions.js.map +1 -1
  108. package/dist/routes/environments.d.ts.map +1 -1
  109. package/dist/routes/environments.js +79 -6
  110. package/dist/routes/environments.js.map +1 -1
  111. package/dist/routes/execution-workspaces.d.ts.map +1 -1
  112. package/dist/routes/execution-workspaces.js +384 -16
  113. package/dist/routes/execution-workspaces.js.map +1 -1
  114. package/dist/routes/file-resources.js +2 -2
  115. package/dist/routes/health.d.ts.map +1 -1
  116. package/dist/routes/health.js +58 -4
  117. package/dist/routes/health.js.map +1 -1
  118. package/dist/routes/instance-settings.d.ts.map +1 -1
  119. package/dist/routes/instance-settings.js +40 -0
  120. package/dist/routes/instance-settings.js.map +1 -1
  121. package/dist/routes/issues.d.ts +10 -0
  122. package/dist/routes/issues.d.ts.map +1 -1
  123. package/dist/routes/issues.js +495 -65
  124. package/dist/routes/issues.js.map +1 -1
  125. package/dist/routes/openapi.d.ts.map +1 -1
  126. package/dist/routes/openapi.js +241 -122
  127. package/dist/routes/openapi.js.map +1 -1
  128. package/dist/routes/pipelines.js +19 -19
  129. package/dist/routes/plugins.d.ts.map +1 -1
  130. package/dist/routes/plugins.js +22 -0
  131. package/dist/routes/plugins.js.map +1 -1
  132. package/dist/routes/projects.d.ts.map +1 -1
  133. package/dist/routes/projects.js +2 -0
  134. package/dist/routes/projects.js.map +1 -1
  135. package/dist/routes/routines.js +2 -2
  136. package/dist/routes/routines.js.map +1 -1
  137. package/dist/routes/secrets.d.ts +1 -1
  138. package/dist/routes/secrets.d.ts.map +1 -1
  139. package/dist/routes/secrets.js +39 -50
  140. package/dist/routes/secrets.js.map +1 -1
  141. package/dist/routes/setup-token-route.test.js +50 -3
  142. package/dist/routes/setup-token-route.test.js.map +1 -1
  143. package/dist/services/adapter-login-lease.d.ts +50 -0
  144. package/dist/services/adapter-login-lease.d.ts.map +1 -0
  145. package/dist/services/adapter-login-lease.js +26 -0
  146. package/dist/services/adapter-login-lease.js.map +1 -0
  147. package/dist/services/adapter-login-lease.test.d.ts +2 -0
  148. package/dist/services/adapter-login-lease.test.d.ts.map +1 -0
  149. package/dist/services/adapter-login-lease.test.js +68 -0
  150. package/dist/services/adapter-login-lease.test.js.map +1 -0
  151. package/dist/services/agent-action-audit.js +1 -1
  152. package/dist/services/agent-secret-bindings.d.ts +15 -0
  153. package/dist/services/agent-secret-bindings.d.ts.map +1 -1
  154. package/dist/services/agent-secret-bindings.js +2 -2
  155. package/dist/services/agent-secret-bindings.js.map +1 -1
  156. package/dist/services/agents.d.ts +13 -13
  157. package/dist/services/agents.d.ts.map +1 -1
  158. package/dist/services/agents.js +46 -5
  159. package/dist/services/agents.js.map +1 -1
  160. package/dist/services/artifact-review-documents.d.ts +34 -0
  161. package/dist/services/artifact-review-documents.d.ts.map +1 -0
  162. package/dist/services/artifact-review-documents.js +196 -0
  163. package/dist/services/artifact-review-documents.js.map +1 -0
  164. package/dist/services/assets.d.ts +1 -1
  165. package/dist/services/codex-device-login-credential-read.d.ts +58 -0
  166. package/dist/services/codex-device-login-credential-read.d.ts.map +1 -0
  167. package/dist/services/codex-device-login-credential-read.js +118 -0
  168. package/dist/services/codex-device-login-credential-read.js.map +1 -0
  169. package/dist/services/codex-device-login-reaper.d.ts.map +1 -1
  170. package/dist/services/codex-device-login-reaper.js +8 -5
  171. package/dist/services/codex-device-login-reaper.js.map +1 -1
  172. package/dist/services/codex-device-login-service.d.ts +127 -36
  173. package/dist/services/codex-device-login-service.d.ts.map +1 -1
  174. package/dist/services/codex-device-login-service.js +204 -84
  175. package/dist/services/codex-device-login-service.js.map +1 -1
  176. package/dist/services/company-portability.d.ts +3 -1
  177. package/dist/services/company-portability.d.ts.map +1 -1
  178. package/dist/services/company-portability.js +76 -20
  179. package/dist/services/company-portability.js.map +1 -1
  180. package/dist/services/company-skill-policy.d.ts +1 -1
  181. package/dist/services/costs.d.ts +1 -1
  182. package/dist/services/decisions.d.ts +6 -6
  183. package/dist/services/duplex-observability-recorder.d.ts +62 -0
  184. package/dist/services/duplex-observability-recorder.d.ts.map +1 -0
  185. package/dist/services/duplex-observability-recorder.js +54 -0
  186. package/dist/services/duplex-observability-recorder.js.map +1 -0
  187. package/dist/services/environment-config.js +5 -5
  188. package/dist/services/environment-config.js.map +1 -1
  189. package/dist/services/environment-custom-image-runtime.d.ts +116 -0
  190. package/dist/services/environment-custom-image-runtime.d.ts.map +1 -1
  191. package/dist/services/environment-custom-image-runtime.js +255 -1
  192. package/dist/services/environment-custom-image-runtime.js.map +1 -1
  193. package/dist/services/environment-custom-images.d.ts +39 -0
  194. package/dist/services/environment-custom-images.d.ts.map +1 -1
  195. package/dist/services/environment-custom-images.js +219 -17
  196. package/dist/services/environment-custom-images.js.map +1 -1
  197. package/dist/services/environment-driver-traits.d.ts +109 -0
  198. package/dist/services/environment-driver-traits.d.ts.map +1 -0
  199. package/dist/services/environment-driver-traits.js +102 -0
  200. package/dist/services/environment-driver-traits.js.map +1 -0
  201. package/dist/services/environment-execution-target.d.ts +4 -0
  202. package/dist/services/environment-execution-target.d.ts.map +1 -1
  203. package/dist/services/environment-execution-target.js +63 -7
  204. package/dist/services/environment-execution-target.js.map +1 -1
  205. package/dist/services/environment-run-orchestrator.d.ts +27 -1
  206. package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
  207. package/dist/services/environment-run-orchestrator.js +24 -4
  208. package/dist/services/environment-run-orchestrator.js.map +1 -1
  209. package/dist/services/environment-runtime.d.ts +118 -20
  210. package/dist/services/environment-runtime.d.ts.map +1 -1
  211. package/dist/services/environment-runtime.js +436 -91
  212. package/dist/services/environment-runtime.js.map +1 -1
  213. package/dist/services/environments.d.ts.map +1 -1
  214. package/dist/services/environments.js +20 -2
  215. package/dist/services/environments.js.map +1 -1
  216. package/dist/services/execution-workspace-branch-ownership.d.ts +4 -0
  217. package/dist/services/execution-workspace-branch-ownership.d.ts.map +1 -0
  218. package/dist/services/execution-workspace-branch-ownership.js +9 -0
  219. package/dist/services/execution-workspace-branch-ownership.js.map +1 -0
  220. package/dist/services/execution-workspace-policy.d.ts.map +1 -1
  221. package/dist/services/execution-workspace-policy.js +3 -0
  222. package/dist/services/execution-workspace-policy.js.map +1 -1
  223. package/dist/services/execution-workspaces.d.ts +2 -0
  224. package/dist/services/execution-workspaces.d.ts.map +1 -1
  225. package/dist/services/execution-workspaces.js +90 -2
  226. package/dist/services/execution-workspaces.js.map +1 -1
  227. package/dist/services/external-objects.d.ts +19 -19
  228. package/dist/services/finance.d.ts +1 -1
  229. package/dist/services/goals.d.ts +2 -2
  230. package/dist/services/heartbeat-policy.d.ts +3 -0
  231. package/dist/services/heartbeat-policy.d.ts.map +1 -0
  232. package/dist/services/heartbeat-policy.js +10 -0
  233. package/dist/services/heartbeat-policy.js.map +1 -0
  234. package/dist/services/heartbeat-run-runtime-status.test.js +1 -1
  235. package/dist/services/heartbeat-run-runtime-status.test.js.map +1 -1
  236. package/dist/services/heartbeat.d.ts +23 -1
  237. package/dist/services/heartbeat.d.ts.map +1 -1
  238. package/dist/services/heartbeat.js +192 -11
  239. package/dist/services/heartbeat.js.map +1 -1
  240. package/dist/services/index.d.ts +1 -0
  241. package/dist/services/index.d.ts.map +1 -1
  242. package/dist/services/index.js +1 -0
  243. package/dist/services/index.js.map +1 -1
  244. package/dist/services/instance-settings.d.ts.map +1 -1
  245. package/dist/services/instance-settings.js +2 -0
  246. package/dist/services/instance-settings.js.map +1 -1
  247. package/dist/services/issue-dependency-wakeups.d.ts +57 -9
  248. package/dist/services/issue-dependency-wakeups.d.ts.map +1 -1
  249. package/dist/services/issue-dependency-wakeups.js +146 -14
  250. package/dist/services/issue-dependency-wakeups.js.map +1 -1
  251. package/dist/services/issue-dependency-wakeups.test.d.ts +2 -0
  252. package/dist/services/issue-dependency-wakeups.test.d.ts.map +1 -0
  253. package/dist/services/issue-dependency-wakeups.test.js +183 -0
  254. package/dist/services/issue-dependency-wakeups.test.js.map +1 -0
  255. package/dist/services/issue-recovery-actions.d.ts +6 -1
  256. package/dist/services/issue-recovery-actions.d.ts.map +1 -1
  257. package/dist/services/issue-recovery-actions.js +123 -44
  258. package/dist/services/issue-recovery-actions.js.map +1 -1
  259. package/dist/services/issue-thread-interactions.d.ts +8 -0
  260. package/dist/services/issue-thread-interactions.d.ts.map +1 -1
  261. package/dist/services/issue-thread-interactions.js +220 -10
  262. package/dist/services/issue-thread-interactions.js.map +1 -1
  263. package/dist/services/issues.d.ts +8 -7
  264. package/dist/services/issues.d.ts.map +1 -1
  265. package/dist/services/issues.js +84 -12
  266. package/dist/services/issues.js.map +1 -1
  267. package/dist/services/local-service-supervisor.d.ts +61 -1
  268. package/dist/services/local-service-supervisor.d.ts.map +1 -1
  269. package/dist/services/local-service-supervisor.js +221 -25
  270. package/dist/services/local-service-supervisor.js.map +1 -1
  271. package/dist/services/login-command.d.ts +46 -0
  272. package/dist/services/login-command.d.ts.map +1 -0
  273. package/dist/services/login-command.js +87 -0
  274. package/dist/services/login-command.js.map +1 -0
  275. package/dist/services/login-command.test.d.ts +2 -0
  276. package/dist/services/login-command.test.d.ts.map +1 -0
  277. package/dist/services/login-command.test.js +71 -0
  278. package/dist/services/login-command.test.js.map +1 -0
  279. package/dist/services/managed-workspace-identity.d.ts +140 -0
  280. package/dist/services/managed-workspace-identity.d.ts.map +1 -0
  281. package/dist/services/managed-workspace-identity.js +277 -0
  282. package/dist/services/managed-workspace-identity.js.map +1 -0
  283. package/dist/services/plugin-environment-driver.d.ts +15 -1
  284. package/dist/services/plugin-environment-driver.d.ts.map +1 -1
  285. package/dist/services/plugin-environment-driver.js +23 -1
  286. package/dist/services/plugin-environment-driver.js.map +1 -1
  287. package/dist/services/plugin-host-services.d.ts +3 -2
  288. package/dist/services/plugin-host-services.d.ts.map +1 -1
  289. package/dist/services/plugin-host-services.js +11 -2
  290. package/dist/services/plugin-host-services.js.map +1 -1
  291. package/dist/services/plugin-loader.d.ts.map +1 -1
  292. package/dist/services/plugin-loader.js +39 -8
  293. package/dist/services/plugin-loader.js.map +1 -1
  294. package/dist/services/plugin-managed-agents.d.ts.map +1 -1
  295. package/dist/services/plugin-managed-agents.js +50 -5
  296. package/dist/services/plugin-managed-agents.js.map +1 -1
  297. package/dist/services/plugin-manifest-validator.d.ts.map +1 -1
  298. package/dist/services/plugin-manifest-validator.js +5 -2
  299. package/dist/services/plugin-manifest-validator.js.map +1 -1
  300. package/dist/services/plugin-registry.d.ts +5 -5
  301. package/dist/services/plugin-worker-manager.d.ts +158 -11
  302. package/dist/services/plugin-worker-manager.d.ts.map +1 -1
  303. package/dist/services/plugin-worker-manager.js +1139 -79
  304. package/dist/services/plugin-worker-manager.js.map +1 -1
  305. package/dist/services/projects.d.ts +1 -1
  306. package/dist/services/recovery/disposition-repair.d.ts +24 -0
  307. package/dist/services/recovery/disposition-repair.d.ts.map +1 -0
  308. package/dist/services/recovery/disposition-repair.js +125 -0
  309. package/dist/services/recovery/disposition-repair.js.map +1 -0
  310. package/dist/services/recovery/service.d.ts +6 -6
  311. package/dist/services/recovery/service.d.ts.map +1 -1
  312. package/dist/services/recovery/service.js +856 -384
  313. package/dist/services/recovery/service.js.map +1 -1
  314. package/dist/services/recovery/stranded-notice.js +4 -4
  315. package/dist/services/recovery/stranded-notice.js.map +1 -1
  316. package/dist/services/recovery/stranded-notice.test.js +1 -1
  317. package/dist/services/recovery/stranded-notice.test.js.map +1 -1
  318. package/dist/services/recovery/successful-run-handoff.d.ts +1 -1
  319. package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
  320. package/dist/services/recovery/successful-run-handoff.js +6 -4
  321. package/dist/services/recovery/successful-run-handoff.js.map +1 -1
  322. package/dist/services/recovery/successful-run-handoff.test.js +1 -1
  323. package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
  324. package/dist/services/recovery-observability.d.ts +1 -1
  325. package/dist/services/recovery-observability.d.ts.map +1 -1
  326. package/dist/services/recovery-observability.js +4 -1
  327. package/dist/services/recovery-observability.js.map +1 -1
  328. package/dist/services/routines.d.ts.map +1 -1
  329. package/dist/services/routines.js +86 -1
  330. package/dist/services/routines.js.map +1 -1
  331. package/dist/services/runtime-exposure/loopback-listener.js +1 -1
  332. package/dist/services/runtime-exposure/loopback-listener.js.map +1 -1
  333. package/dist/services/runtime-exposure/loopback-listener.test.js +1 -1
  334. package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -1
  335. package/dist/services/scripts/codex-auth-read.cjs +93 -0
  336. package/dist/services/secret-proposal-authorization.d.ts +15 -0
  337. package/dist/services/secret-proposal-authorization.d.ts.map +1 -0
  338. package/dist/services/secret-proposal-authorization.js +29 -0
  339. package/dist/services/secret-proposal-authorization.js.map +1 -0
  340. package/dist/services/secret-proposal-notifications.d.ts +18 -0
  341. package/dist/services/secret-proposal-notifications.d.ts.map +1 -0
  342. package/dist/services/secret-proposal-notifications.js +37 -0
  343. package/dist/services/secret-proposal-notifications.js.map +1 -0
  344. package/dist/services/secret-proposals.d.ts +20 -10
  345. package/dist/services/secret-proposals.d.ts.map +1 -1
  346. package/dist/services/secret-proposals.js +205 -14
  347. package/dist/services/secret-proposals.js.map +1 -1
  348. package/dist/services/secrets.d.ts +3 -3
  349. package/dist/services/settings-visibility.d.ts +17 -0
  350. package/dist/services/settings-visibility.d.ts.map +1 -0
  351. package/dist/services/settings-visibility.js +29 -0
  352. package/dist/services/settings-visibility.js.map +1 -0
  353. package/dist/services/setup-token-reaper.d.ts +36 -0
  354. package/dist/services/setup-token-reaper.d.ts.map +1 -0
  355. package/dist/services/setup-token-reaper.js +38 -0
  356. package/dist/services/setup-token-reaper.js.map +1 -0
  357. package/dist/services/setup-token-reaper.test.d.ts +2 -0
  358. package/dist/services/setup-token-reaper.test.d.ts.map +1 -0
  359. package/dist/services/setup-token-reaper.test.js +123 -0
  360. package/dist/services/setup-token-reaper.test.js.map +1 -0
  361. package/dist/services/setup-token-session.d.ts +57 -45
  362. package/dist/services/setup-token-session.d.ts.map +1 -1
  363. package/dist/services/setup-token-session.js +118 -110
  364. package/dist/services/setup-token-session.js.map +1 -1
  365. package/dist/services/setup-token-session.test.js +88 -47
  366. package/dist/services/setup-token-session.test.js.map +1 -1
  367. package/dist/services/setup-token-transport-binding.d.ts +20 -12
  368. package/dist/services/setup-token-transport-binding.d.ts.map +1 -1
  369. package/dist/services/setup-token-transport-binding.js +59 -32
  370. package/dist/services/setup-token-transport-binding.js.map +1 -1
  371. package/dist/services/setup-token-transport-binding.test.js +50 -17
  372. package/dist/services/setup-token-transport-binding.test.js.map +1 -1
  373. package/dist/services/smoke-lab.d.ts +1 -1
  374. package/dist/services/stalled-review-decisions.d.ts +1 -1
  375. package/dist/services/status-cards.d.ts +168 -168
  376. package/dist/services/tool-access-policy.d.ts +2 -2
  377. package/dist/services/tool-gateway.d.ts +1 -1
  378. package/dist/services/work-products.d.ts +19 -0
  379. package/dist/services/work-products.d.ts.map +1 -1
  380. package/dist/services/work-products.js +50 -3
  381. package/dist/services/work-products.js.map +1 -1
  382. package/dist/services/workspace-git-operation-scheduler.test.js +10 -3
  383. package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -1
  384. package/dist/services/workspace-instance-cleanup.d.ts +9 -0
  385. package/dist/services/workspace-instance-cleanup.d.ts.map +1 -1
  386. package/dist/services/workspace-instance-cleanup.js +29 -3
  387. package/dist/services/workspace-instance-cleanup.js.map +1 -1
  388. package/dist/services/workspace-login-handoff-issuer.d.ts +95 -0
  389. package/dist/services/workspace-login-handoff-issuer.d.ts.map +1 -0
  390. package/dist/services/workspace-login-handoff-issuer.js +147 -0
  391. package/dist/services/workspace-login-handoff-issuer.js.map +1 -0
  392. package/dist/services/workspace-operations.d.ts +4 -1
  393. package/dist/services/workspace-operations.d.ts.map +1 -1
  394. package/dist/services/workspace-operations.js +29 -15
  395. package/dist/services/workspace-operations.js.map +1 -1
  396. package/dist/services/workspace-readiness.d.ts +56 -0
  397. package/dist/services/workspace-readiness.d.ts.map +1 -0
  398. package/dist/services/workspace-readiness.js +250 -0
  399. package/dist/services/workspace-readiness.js.map +1 -0
  400. package/dist/services/workspace-realization.d.ts.map +1 -1
  401. package/dist/services/workspace-realization.js +5 -33
  402. package/dist/services/workspace-realization.js.map +1 -1
  403. package/dist/services/workspace-runtime-exposure.test.js +321 -12
  404. package/dist/services/workspace-runtime-exposure.test.js.map +1 -1
  405. package/dist/services/workspace-runtime-leases.d.ts.map +1 -1
  406. package/dist/services/workspace-runtime-leases.js +1 -1
  407. package/dist/services/workspace-runtime-leases.js.map +1 -1
  408. package/dist/services/workspace-runtime-read-model.d.ts +8 -8
  409. package/dist/services/workspace-runtime-read-model.test.js +20 -0
  410. package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
  411. package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
  412. package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
  413. package/dist/services/workspace-runtime.d.ts +77 -4
  414. package/dist/services/workspace-runtime.d.ts.map +1 -1
  415. package/dist/services/workspace-runtime.js +1020 -126
  416. package/dist/services/workspace-runtime.js.map +1 -1
  417. package/dist/worktree-config.d.ts.map +1 -1
  418. package/dist/worktree-config.js +3 -76
  419. package/dist/worktree-config.js.map +1 -1
  420. package/dist/worktree-seed-manifest.d.ts +8 -0
  421. package/dist/worktree-seed-manifest.d.ts.map +1 -0
  422. package/dist/worktree-seed-manifest.js +47 -0
  423. package/dist/worktree-seed-manifest.js.map +1 -0
  424. package/package.json +34 -30
  425. package/skills/paperclip/SKILL.md +2 -0
  426. package/skills/paperclip/references/api-reference.md +44 -2
  427. package/ui-dist/assets/CompanyExport-DnEsO-St.js +4 -0
  428. package/ui-dist/assets/{abnfDiagram-N423BO3Z-BvgoV-u5.js → abnfDiagram-N423BO3Z-2ff8TsQ0.js} +1 -1
  429. package/ui-dist/assets/{arc-BInnUBUU.js → arc-B2AXsH34.js} +1 -1
  430. package/ui-dist/assets/architectureDiagram-T3A2C74G-BDxtKMxk.js +36 -0
  431. package/ui-dist/assets/{blockDiagram-VBNYF7ZC-D9kzAkLN.js → blockDiagram-VBNYF7ZC-BB8T5Mj5.js} +1 -1
  432. package/ui-dist/assets/{c4Diagram-5PPSVZJV-BwzsgV_0.js → c4Diagram-5PPSVZJV-lMo8qk-x.js} +1 -1
  433. package/ui-dist/assets/channel-DCTVZpyq.js +1 -0
  434. package/ui-dist/assets/{chunk-2GRJ4B5K-D2EQ9vrl.js → chunk-2GRJ4B5K-CsS7EB3G.js} +1 -1
  435. package/ui-dist/assets/{chunk-2Q5K7J3B-DOA_8fSd.js → chunk-2Q5K7J3B-DFyQPjoR.js} +1 -1
  436. package/ui-dist/assets/{chunk-5RXB4S5H-CT5ukRiT.js → chunk-5RXB4S5H-No2SHOQR.js} +1 -1
  437. package/ui-dist/assets/{chunk-5VM5RSS4-D2gOlkyB.js → chunk-5VM5RSS4-DdxQO7V9.js} +1 -1
  438. package/ui-dist/assets/{chunk-6Q2QTUOP-DUb6aG8c.js → chunk-6Q2QTUOP-gbHjh-aK.js} +1 -1
  439. package/ui-dist/assets/{chunk-GF5L2VYU-BPabG3Cx.js → chunk-GF5L2VYU-h-BBl6NY.js} +1 -1
  440. package/ui-dist/assets/{chunk-JWPE2WC7-scNfhlZz.js → chunk-JWPE2WC7-Q3pJdTz5.js} +1 -1
  441. package/ui-dist/assets/{chunk-KBJHAD2P-BcRAzRpq.js → chunk-KBJHAD2P-Ba8LGLSx.js} +1 -1
  442. package/ui-dist/assets/{chunk-RYQCIY6F-BiZ0-EGh.js → chunk-RYQCIY6F-9VcvykBo.js} +1 -1
  443. package/ui-dist/assets/{chunk-XXDRQBXY-lJULxFIE.js → chunk-XXDRQBXY-NVbjDH4W.js} +1 -1
  444. package/ui-dist/assets/classDiagram-JCYQIIEL-CzJ88u6h.js +1 -0
  445. package/ui-dist/assets/classDiagram-v2-OCEON4UE-CzJ88u6h.js +1 -0
  446. package/ui-dist/assets/cose-bilkent-JH36ORCC-Cdz4wtUh.js +1 -0
  447. package/ui-dist/assets/{cynefin-VYW2F7L2-B34jJY1x.js → cynefin-VYW2F7L2-MpzJkW5s.js} +1 -1
  448. package/ui-dist/assets/{cynefinDiagram-MW4NZA55-DQ8QK8JK.js → cynefinDiagram-MW4NZA55-_Nx8av7i.js} +1 -1
  449. package/ui-dist/assets/{dagre-VZM6K2ZE-B7lSKgxH.js → dagre-VZM6K2ZE-DcZTQ4Oh.js} +1 -1
  450. package/ui-dist/assets/{diagram-7IWD3JNH-B3hLAFGE.js → diagram-7IWD3JNH-UyLi2mee.js} +1 -1
  451. package/ui-dist/assets/{diagram-B4RE2ZJO-CH9Ro9e0.js → diagram-B4RE2ZJO-B6p0Ivqg.js} +1 -1
  452. package/ui-dist/assets/{diagram-LBJQPF4R-DdPVMii7.js → diagram-LBJQPF4R-C8u6pa97.js} +1 -1
  453. package/ui-dist/assets/{diagram-Q27KOJAE-f5VXswjc.js → diagram-Q27KOJAE-DbwyagW0.js} +1 -1
  454. package/ui-dist/assets/{diagram-UB23O5K3-DGqxixsR.js → diagram-UB23O5K3-Cn5BMEH7.js} +1 -1
  455. package/ui-dist/assets/{ebnfDiagram-BXEA7PRR-C-1klg_w.js → ebnfDiagram-BXEA7PRR-vE0IZma9.js} +1 -1
  456. package/ui-dist/assets/{erDiagram-JOGREHBK-Dt4qK4gv.js → erDiagram-JOGREHBK-3OZVIlVG.js} +1 -1
  457. package/ui-dist/assets/{flowDiagram-UKHOOZJN-9ZlrTuET.js → flowDiagram-UKHOOZJN-C94FiPyr.js} +1 -1
  458. package/ui-dist/assets/ganttDiagram-PKOTCBZU-DMVaPmF-.js +292 -0
  459. package/ui-dist/assets/{gitGraphDiagram-DS77QQ5N-HH2fSgad.js → gitGraphDiagram-DS77QQ5N-yeFtJ689.js} +1 -1
  460. package/ui-dist/assets/{index-Cm6iEYPt.js → index-B4ZF3vY8.js} +288 -224
  461. package/ui-dist/assets/{index-CHXG4GPL.js → index-Bi_vqkcQ.js} +1 -1
  462. package/ui-dist/assets/{index-Bc9g6q63.js → index-BjDGQg9-.js} +1 -1
  463. package/ui-dist/assets/{index-FHzrJ7iQ.js → index-BnpyUMTb.js} +1 -1
  464. package/ui-dist/assets/{index-C1mpWCcn.js → index-BoqQTVMA.js} +1 -1
  465. package/ui-dist/assets/index-Bq-Mr-4x.js +1 -0
  466. package/ui-dist/assets/{index-DceuCrr8.js → index-Brb2U7N4.js} +1 -1
  467. package/ui-dist/assets/{index-DQIrrn2L.js → index-BsBybVwH.js} +1 -1
  468. package/ui-dist/assets/{index-CQhaM6qd.js → index-C2iv4Wgu.js} +1 -1
  469. package/ui-dist/assets/{index-DV2FOgyD.js → index-CKxIu7uO.js} +1 -1
  470. package/ui-dist/assets/{index-DU4U1fuv.js → index-Cb4VGIwp.js} +1 -1
  471. package/ui-dist/assets/{index-B61ORkIb.js → index-ChpszwcO.js} +1 -1
  472. package/ui-dist/assets/{index-DlVBBF8o.js → index-CoQFdT8N.js} +1 -1
  473. package/ui-dist/assets/index-D6P8_f0W.css +1 -0
  474. package/ui-dist/assets/index-DKdmghUG.js +1 -0
  475. package/ui-dist/assets/{index-CVTL3aAL.js → index-DaBci7Bx.js} +1 -1
  476. package/ui-dist/assets/{index-diFs0CNC.js → index-DjYaNiXZ.js} +1 -1
  477. package/ui-dist/assets/{index-Bf7tyGxE.js → index-DxTBJl4h.js} +1 -1
  478. package/ui-dist/assets/{index-DsdVneqe.js → index-Dz3E8nn0.js} +1 -1
  479. package/ui-dist/assets/{index-DvFUXI-Q.js → index-J7dz_hap.js} +1 -1
  480. package/ui-dist/assets/{index-CLu3Tfpv.js → index-JMp2Gpnz.js} +1 -1
  481. package/ui-dist/assets/{index-CcRTJ93J.js → index-Kp6wavQt.js} +1 -1
  482. package/ui-dist/assets/{index-BvFx-4KW.js → index-LBhYZ0L4.js} +1 -1
  483. package/ui-dist/assets/{index-BbAhzCq8.js → index-c3C0P6l8.js} +4 -4
  484. package/ui-dist/assets/{index-BY5ITKmG.js → index-pBEPheTW.js} +1 -1
  485. package/ui-dist/assets/{infoDiagram-6WML65LV-2jai2fw-.js → infoDiagram-6WML65LV-BiL7jKLA.js} +1 -1
  486. package/ui-dist/assets/{ishikawaDiagram-WSZJBQD7-CUmGhmEb.js → ishikawaDiagram-WSZJBQD7-DYcQy41D.js} +1 -1
  487. package/ui-dist/assets/{journeyDiagram-NVQOT4AX-CGQuPDO1.js → journeyDiagram-NVQOT4AX-Bxva6JgA.js} +1 -1
  488. package/ui-dist/assets/{kanban-definition-27J2QSJJ-zgEPcbHl.js → kanban-definition-27J2QSJJ-B2HpN34Q.js} +1 -1
  489. package/ui-dist/assets/{linear-BVf5VtLS.js → linear-Dq8qSESw.js} +1 -1
  490. package/ui-dist/assets/{mermaid.core-D2TFkGfI.js → mermaid.core-Ikay8Cpl.js} +35 -35
  491. package/ui-dist/assets/{mindmap-definition-FAOFIHXS-xoAQ0Lb_.js → mindmap-definition-FAOFIHXS-C-SIdYSY.js} +1 -1
  492. package/ui-dist/assets/{pegDiagram-VL7TDLO6-CBR1PWLr.js → pegDiagram-VL7TDLO6-C7rIJ_13.js} +1 -1
  493. package/ui-dist/assets/{pieDiagram-7S7Q4E2Y-BMPnzK_e.js → pieDiagram-7S7Q4E2Y-3gx0a0ve.js} +1 -1
  494. package/ui-dist/assets/{quadrantDiagram-CIZ2JOQS-CPtVUPkR.js → quadrantDiagram-CIZ2JOQS-BiwM9QGv.js} +1 -1
  495. package/ui-dist/assets/{railroadDiagram-AXF67PYL-DC0lsZV1.js → railroadDiagram-AXF67PYL-rFS8XcFA.js} +1 -1
  496. package/ui-dist/assets/{requirementDiagram-LRYGKXZP-BF6k05SB.js → requirementDiagram-LRYGKXZP-CEAUDUTQ.js} +1 -1
  497. package/ui-dist/assets/{sankeyDiagram-W5VNT64P-CRN0ibiP.js → sankeyDiagram-W5VNT64P-B7anpjlM.js} +1 -1
  498. package/ui-dist/assets/{sequenceDiagram-SI44F4Z6-CQtTiPWm.js → sequenceDiagram-SI44F4Z6-CJEdJvm_.js} +1 -1
  499. package/ui-dist/assets/{sizeCapture-X5ZJPWSS-BdjuGZd1.js → sizeCapture-X5ZJPWSS-CU1Vm6c1.js} +1 -1
  500. package/ui-dist/assets/{stateDiagram-OKZ733FA-B99onxyh.js → stateDiagram-OKZ733FA-wxVV9WIi.js} +1 -1
  501. package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-BIFLqlqR.js +1 -0
  502. package/ui-dist/assets/{swimlanes-SLNWSIFB-BIByPbrL.js → swimlanes-SLNWSIFB-Cd-C1Z_k.js} +2 -2
  503. package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-CoBROkkd.js +8 -0
  504. package/ui-dist/assets/{timeline-definition-Z64GVDOM-I1NgOcn9.js → timeline-definition-Z64GVDOM-SWngk4aR.js} +1 -1
  505. package/ui-dist/assets/{vennDiagram-T6HMQDX7-D6t2Njcl.js → vennDiagram-T6HMQDX7-DFdPUr36.js} +1 -1
  506. package/ui-dist/assets/{wardleyDiagram-T6FBY63Y-BiYKm9gh.js → wardleyDiagram-T6FBY63Y-C6IlrZ7t.js} +1 -1
  507. package/ui-dist/assets/{xychartDiagram-ELKLHX3M-BhwXKWIa.js → xychartDiagram-ELKLHX3M-BNm6hHGl.js} +1 -1
  508. package/ui-dist/index.html +2 -2
  509. package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +0 -36
  510. package/ui-dist/assets/channel-qoSYbCn0.js +0 -1
  511. package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +0 -1
  512. package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +0 -1
  513. package/ui-dist/assets/cose-bilkent-JH36ORCC-Do8pe8JB.js +0 -1
  514. package/ui-dist/assets/ganttDiagram-PKOTCBZU-CE3lyvFq.js +0 -292
  515. package/ui-dist/assets/index-BHiUiToL.css +0 -1
  516. package/ui-dist/assets/index-hkanj4t6.js +0 -1
  517. package/ui-dist/assets/index-yRJnh4vx.js +0 -1
  518. package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +0 -1
  519. package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +0 -8
@@ -12,16 +12,19 @@ import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
12
12
  import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
13
13
  import { conflict } from "../errors.js";
14
14
  import { resolveHomeAwarePath } from "../home-paths.js";
15
- import { createLocalServiceKey, findLocalServiceRegistryRecordByRuntimeServiceId, findAdoptableLocalService, isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, readLocalServiceProcessCwd, readLocalServicePortOwner, removeLocalServiceRegistryRecord, terminateLocalService, touchLocalServiceRegistryRecord, writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js";
15
+ import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
16
+ import { buildManagedWorkspaceGuestEnv, logManagedWorkspaceReadinessRejection, probeManagedWorkspaceHandoffSubjects, probeManagedWorkspaceReadiness, resolveManagedWorkspaceIdentity, shouldBlockPublicationOnReadiness, waitForManagedWorkspaceReadiness, } from "./managed-workspace-identity.js";
17
+ import { createLocalServiceKey, findLocalServiceRegistryRecordByRuntimeServiceId, findAdoptableLocalService, isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, openLocalServiceLogFile, readLocalServiceProcessCwd, readLocalServiceProcessGroupId, readLocalServicePortOwner, removeLocalServiceRegistryRecord, terminateLocalService, touchLocalServiceRegistryRecord, writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js";
16
18
  import { workspaceOperationService } from "./workspace-operations.js";
17
19
  import { executionWorkspaceService, readExecutionWorkspaceConfig } from "./execution-workspaces.js";
20
+ import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
18
21
  import { logActivity } from "./activity-log.js";
19
22
  import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
20
23
  import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
21
24
  import { cleanupWorktreeInstanceArtifacts, deriveWorktreeInstanceId, readWorktreeInstancePointer, WORKTREE_INSTANCE_ROOT_METADATA_KEY, } from "./workspace-instance-cleanup.js";
22
25
  import { UnixBrokerClient } from "./runtime-exposure/broker-client.js";
23
26
  import { deprovisionExposure, provisionExposure, reserveExposure, } from "./runtime-exposure/exposure-manager.js";
24
- import { diagnoseRuntimeListenerBinds } from "./runtime-exposure/loopback-listener.js";
27
+ import { diagnoseRuntimeListenerBinds, readListenerBindFacts } from "./runtime-exposure/loopback-listener.js";
25
28
  import { allocateExposurePortPair } from "./runtime-exposure/port-pair.js";
26
29
  import { buildExposureReservationLedger, collectRowExposurePorts, describeExposureReservationDrift, ExposurePortOwnershipConflictError, ExposurePortPairClaims, findExposurePairConflict, findExposureReservationDrift, isExposureAdoptionPermitted, } from "./runtime-exposure/port-reservation.js";
27
30
  import { resolveTailscaleDnsName } from "./runtime-exposure/tailscale-hostname.js";
@@ -47,6 +50,8 @@ const runtimeServicesById = new Map();
47
50
  const runtimeServicesByReuseKey = new Map();
48
51
  const runtimeServiceLeasesByRun = new Map();
49
52
  const runtimeProvisionByWorkspace = new Map();
53
+ const runtimeControlStartByOwner = new Map();
54
+ const runtimeReplacementClaimsByReuseKey = new Map();
50
55
  const quarantinedRuntimeExposurePorts = new Set();
51
56
  /**
52
57
  * Pair-atomic in-process claims for exposure allocations that have not bound a
@@ -67,10 +72,24 @@ const ACTIVE_RUNTIME_PORT_RESERVATION_STATUSES = ["provisioning", "starting", "r
67
72
  const DEFAULT_TAILSCALE_BROKER_SOCKET = "/run/paperclip-tailscale-broker/broker.sock";
68
73
  class RuntimeServicePortBindCollision extends Error {
69
74
  port;
70
- constructor(port) {
71
- super(`Runtime service could not bind allocated port ${port}`);
75
+ /**
76
+ * Who held the port when the collision was seen, captured at failure time.
77
+ * Null when no owner remained (a transient racer that already released it).
78
+ */
79
+ diagnosis;
80
+ /**
81
+ * True when the port is only a preference and the caller may re-allocate a
82
+ * different one. Exposed runtimes always draw from the dedicated broker range,
83
+ * so a collision on the assigned port is recoverable by taking the next pair.
84
+ */
85
+ exposureReallocatable;
86
+ constructor(port, diagnosis = null, exposureReallocatable = false) {
87
+ super(`Runtime service could not bind allocated port ${port}` +
88
+ (diagnosis ? ` (${diagnosis})` : ""));
72
89
  this.name = "RuntimeServicePortBindCollision";
73
90
  this.port = port;
91
+ this.diagnosis = diagnosis;
92
+ this.exposureReallocatable = exposureReallocatable;
74
93
  }
75
94
  }
76
95
  async function isLoopbackPortAvailable(port) {
@@ -221,11 +240,20 @@ export async function resetRuntimeServicesForTests(opts = {}) {
221
240
  }
222
241
  for (const record of runtimeServicesById.values()) {
223
242
  clearIdleTimer(record);
243
+ if (opts.simulateSupervisorExit) {
244
+ // A real supervisor exit closes its side of every inherited pipe. Tests
245
+ // use this to prove surviving request-logging services do not depend on
246
+ // Paperclip keeping an anonymous stdio peer alive.
247
+ record.child?.stdout?.destroy();
248
+ record.child?.stderr?.destroy();
249
+ }
224
250
  }
225
251
  runtimeServicesById.clear();
226
252
  runtimeServicesByReuseKey.clear();
227
253
  runtimeServiceLeasesByRun.clear();
228
254
  runtimeProvisionByWorkspace.clear();
255
+ runtimeControlStartByOwner.clear();
256
+ runtimeReplacementClaimsByReuseKey.clear();
229
257
  quarantinedRuntimeExposurePorts.clear();
230
258
  exposurePortPairClaims.clear();
231
259
  workspaceRuntimeExposureDeps = defaultWorkspaceRuntimeExposureDeps();
@@ -1709,20 +1737,83 @@ export async function ensureGitWorktreeBranchCoherent(input) {
1709
1737
  ],
1710
1738
  };
1711
1739
  }
1740
+ // A configured base ref that does not resolve to a commit, even after an
1741
+ // authenticated fetch of its `origin/<branch>` counterpart. The caller must
1742
+ // stop before `git worktree add` and raise a pre-dispatch configuration
1743
+ // failure. `requestedRef` keeps the operator spelling for the human notice.
1744
+ // `recoveryIdentityRef` is the canonical remote ref the resolver probed, so two
1745
+ // equivalent spellings of one remote branch map to one recovery identity.
1746
+ // `attemptedRefs` names each ref the resolver tried, and `fetchError` carries
1747
+ // the first fetch warning (masked) when the fetch itself failed.
1748
+ export class UnresolvedWorkspaceBaseRefError extends Error {
1749
+ requestedRef;
1750
+ recoveryIdentityRef;
1751
+ attemptedRefs;
1752
+ fetchError;
1753
+ constructor(input) {
1754
+ super(`Configured workspace base ref "${input.requestedRef}" did not resolve to a commit on origin after an authenticated fetch.`);
1755
+ this.name = "UnresolvedWorkspaceBaseRefError";
1756
+ this.requestedRef = input.requestedRef;
1757
+ this.recoveryIdentityRef = input.recoveryIdentityRef;
1758
+ this.attemptedRefs = input.attemptedRefs;
1759
+ this.fetchError = input.fetchError ?? null;
1760
+ }
1761
+ }
1762
+ export function isUnresolvedWorkspaceBaseRefError(error) {
1763
+ return error instanceof UnresolvedWorkspaceBaseRefError;
1764
+ }
1712
1765
  // Resolve the authoritative base ref for a fresh worktree. A configured local
1713
1766
  // branch is mapped to its `origin/<branch>` counterpart so unpushed local
1714
- // divergence never leaks into the task branch; remote-tracking refs, SHAs, and
1715
- // tags are used verbatim, and an unset/`HEAD` base falls back to the detected
1716
- // default branch (which already prefers `origin/master`).
1767
+ // divergence never leaks into the task branch; SHAs and tags are used verbatim,
1768
+ // and an unset/`HEAD` base falls back to the detected default branch (which
1769
+ // already prefers `origin/master`).
1770
+ //
1771
+ // A remote-only feature branch never has a local ref or a remote-tracking ref
1772
+ // yet. The resolver fetches `origin/<branch>` with the authenticated helper,
1773
+ // then re-checks the commit. This covers both the unqualified form (`fix/foo`)
1774
+ // and the remote-tracking form (`origin/fix/foo`). A ref that still does not
1775
+ // resolve returns `resolved: false`, so the caller stops before the worktree
1776
+ // add instead of passing an invalid reference to git.
1717
1777
  async function resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, resolveGitAuth) {
1718
1778
  const warnings = [];
1719
1779
  const detectOrHead = async () => (await detectDefaultBranch(repoRoot, resolveGitAuth)) ?? "HEAD";
1720
1780
  const configured = configuredBaseRef?.trim();
1721
1781
  if (!configured || configured === "HEAD") {
1722
- return { baseRef: await detectOrHead(), warnings, refreshed: false };
1782
+ return { resolved: true, baseRef: await detectOrHead(), warnings, refreshed: false };
1723
1783
  }
1724
- if (parseRemoteTrackingRef(configured)) {
1725
- return { baseRef: configured, warnings, refreshed: false };
1784
+ // A remote-tracking ref supplied directly (for example `origin/fix/foo`).
1785
+ // Use it verbatim when it already resolves. When it does not, fetch it once
1786
+ // and re-check, then stop if it is still absent on the remote.
1787
+ //
1788
+ // `parseRemoteTrackingRef` only checks the `remote/branch` shape. An
1789
+ // unqualified branch name that contains a slash (for example `fix/foo`) has
1790
+ // the same shape but names no real remote, so it is not a remote-tracking
1791
+ // ref. Gate this branch on the first segment naming an existing remote, and
1792
+ // let a name like `fix/foo` fall through to the remote-only branch handling
1793
+ // below.
1794
+ const remoteTracking = parseRemoteTrackingRef(configured);
1795
+ if (remoteTracking && await resolveBaseRefSha(repoRoot, configured)) {
1796
+ return { resolved: true, baseRef: configured, warnings, refreshed: false };
1797
+ }
1798
+ if (remoteTracking && await remoteExists(repoRoot, remoteTracking.remote)) {
1799
+ const fetchWarnings = await refreshRemoteTrackingBaseRef(repoRoot, configured, resolveGitAuth);
1800
+ warnings.push(...fetchWarnings);
1801
+ if (await resolveBaseRefSha(repoRoot, configured)) {
1802
+ return { resolved: true, baseRef: configured, warnings, refreshed: true };
1803
+ }
1804
+ // Build the recovery identity from the parsed remote and branch. The raw
1805
+ // ref and its remote-tracking spelling (`origin/fix/foo` and
1806
+ // `refs/remotes/origin/fix/foo`) then share one recovery fingerprint, so
1807
+ // recovery treats them as one identity instead of two.
1808
+ const canonicalRemoteRef = `${remoteTracking.remote}/${remoteTracking.branch}`;
1809
+ return {
1810
+ resolved: false,
1811
+ requestedRef: configured,
1812
+ recoveryIdentityRef: canonicalRemoteRef,
1813
+ attemptedRefs: [configured],
1814
+ warnings,
1815
+ fetchError: fetchWarnings[0] ?? null,
1816
+ };
1726
1817
  }
1727
1818
  if (await localBranchExists(repoRoot, configured)) {
1728
1819
  const remoteCandidate = `origin/${configured}`;
@@ -1730,14 +1821,34 @@ async function resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, resolveG
1730
1821
  // the returned ref (see `refreshed`) so we never fetch the same ref twice.
1731
1822
  warnings.push(...await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate, resolveGitAuth));
1732
1823
  if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
1733
- return { baseRef: remoteCandidate, warnings, refreshed: true };
1824
+ return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
1734
1825
  }
1735
1826
  if (await remoteExists(repoRoot, "origin")) {
1736
1827
  warnings.push(`Configured base ref "${configured}" is a local branch with no matching origin/${configured}; basing the execution workspace on the local ref, which may include unpushed commits.`);
1737
1828
  }
1738
- return { baseRef: configured, warnings, refreshed: false };
1829
+ return { resolved: true, baseRef: configured, warnings, refreshed: false };
1739
1830
  }
1740
- return { baseRef: configured, warnings, refreshed: false };
1831
+ // Fall-through: an unqualified ref (for example `fix/foo`) that is not `HEAD`,
1832
+ // not a remote-tracking ref, and not a local branch. A full SHA or a tag that
1833
+ // already resolves stays verbatim. Otherwise treat it as a remote-only branch
1834
+ // name: fetch `origin/<ref>` and base the worktree on the remote counterpart.
1835
+ if (await resolveBaseRefSha(repoRoot, configured)) {
1836
+ return { resolved: true, baseRef: configured, warnings, refreshed: false };
1837
+ }
1838
+ const remoteCandidate = `origin/${configured}`;
1839
+ const fetchWarnings = await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate, resolveGitAuth);
1840
+ warnings.push(...fetchWarnings);
1841
+ if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
1842
+ return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
1843
+ }
1844
+ return {
1845
+ resolved: false,
1846
+ requestedRef: configured,
1847
+ recoveryIdentityRef: remoteCandidate,
1848
+ attemptedRefs: [remoteCandidate],
1849
+ warnings,
1850
+ fetchError: fetchWarnings[0] ?? null,
1851
+ };
1741
1852
  }
1742
1853
  // Auto-refresh a reused worktree to the latest base only when it is provably
1743
1854
  // unstarted: no task commits past the base and a clean tree (including untracked
@@ -2026,6 +2137,15 @@ function buildWorkspaceCommandEnv(input) {
2026
2137
  function quoteShellArg(value) {
2027
2138
  return `'${value.replace(/'/g, `'\\''`)}'`;
2028
2139
  }
2140
+ const BUILTIN_WORKSPACE_PROVISION_COMMAND = "bash ./scripts/provision-worktree.sh";
2141
+ function resolveWorkspaceProvisionCommand(strategy, repoRoot) {
2142
+ const configuredCommand = asString(strategy.provisionCommand, "").trim();
2143
+ if (configuredCommand)
2144
+ return configuredCommand;
2145
+ return existsSync(path.join(repoRoot, "scripts", "provision-worktree.sh"))
2146
+ ? BUILTIN_WORKSPACE_PROVISION_COMMAND
2147
+ : "";
2148
+ }
2029
2149
  function resolveRepoManagedWorkspaceCommand(command, repoRoot) {
2030
2150
  const patterns = [
2031
2151
  /^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
@@ -2131,27 +2251,33 @@ async function recordWorkspaceCommandOperation(recorder, input) {
2131
2251
  cwd: input.cwd,
2132
2252
  env: input.env,
2133
2253
  });
2254
+ const seedEvidence = input.phase === "workspace_seed"
2255
+ ? readWorkspaceSeedOperationEvidence(input.cwd)
2256
+ : null;
2134
2257
  stdout = result.stdout;
2135
- stderr = result.stderr;
2136
- code = result.code;
2258
+ stderr = [result.stderr, seedEvidence?.error].filter(Boolean).join("\n");
2259
+ code = result.code === 0 && seedEvidence && !seedEvidence.verified ? 1 : result.code;
2137
2260
  if (result.stdout && input.onLog)
2138
2261
  await input.onLog("stdout", `[runtime-provision] ${result.stdout}`);
2139
- if (result.stderr && input.onLog)
2140
- await input.onLog("stderr", `[runtime-provision] ${result.stderr}`);
2262
+ if (stderr && input.onLog)
2263
+ await input.onLog("stderr", `[runtime-provision] ${stderr}`);
2264
+ const truncationMetadata = result.stdoutTruncated || result.stderrTruncated
2265
+ ? {
2266
+ stdoutTruncated: result.stdoutTruncated,
2267
+ stderrTruncated: result.stderrTruncated,
2268
+ stdoutBytes: result.stdoutBytes,
2269
+ stderrBytes: result.stderrBytes,
2270
+ }
2271
+ : null;
2141
2272
  return {
2142
- status: result.code === 0 ? "succeeded" : "failed",
2143
- exitCode: result.code,
2273
+ status: code === 0 ? "succeeded" : "failed",
2274
+ exitCode: code,
2144
2275
  stdout: result.stdout,
2145
- stderr: result.stderr,
2146
- system: result.code === 0 ? input.successMessage ?? null : null,
2147
- metadata: result.stdoutTruncated || result.stderrTruncated
2148
- ? {
2149
- stdoutTruncated: result.stdoutTruncated,
2150
- stderrTruncated: result.stderrTruncated,
2151
- stdoutBytes: result.stdoutBytes,
2152
- stderrBytes: result.stderrBytes,
2153
- }
2154
- : null,
2276
+ stderr,
2277
+ system: code === 0 ? input.successMessage ?? null : null,
2278
+ metadata: seedEvidence
2279
+ ? { ...seedEvidence.metadata, ...(truncationMetadata ?? {}) }
2280
+ : truncationMetadata,
2155
2281
  };
2156
2282
  },
2157
2283
  });
@@ -2163,7 +2289,7 @@ async function recordWorkspaceCommandOperation(recorder, input) {
2163
2289
  : `${input.label} failed with exit code ${code ?? -1}`);
2164
2290
  }
2165
2291
  async function provisionExecutionWorktree(input) {
2166
- const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
2292
+ const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot);
2167
2293
  if (!provisionCommand)
2168
2294
  return;
2169
2295
  const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
@@ -2227,7 +2353,17 @@ async function resolveGitRepoRootForWorkspaceCleanup(worktreePath, projectWorksp
2227
2353
  export async function realizeExecutionWorkspace(input) {
2228
2354
  const rawStrategy = parseObject(input.config.workspaceStrategy);
2229
2355
  const strategyType = asString(rawStrategy.type, "project_primary");
2356
+ const requestedExistingBranch = asString(rawStrategy.existingBranch, "").trim();
2230
2357
  if (strategyType !== "git_worktree") {
2358
+ if (requestedExistingBranch) {
2359
+ throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}" but has type "${strategyType}"; an exact-branch workspace requires strategy type "git_worktree". Set workspaceStrategy.type to "git_worktree" or remove existingBranch.`, {
2360
+ workspaceValidation: {
2361
+ reason: "existing_branch_requires_git_worktree",
2362
+ requestedExistingBranch,
2363
+ strategyType,
2364
+ },
2365
+ });
2366
+ }
2231
2367
  return {
2232
2368
  ...input.base,
2233
2369
  strategy: "project_primary",
@@ -2236,28 +2372,66 @@ export async function realizeExecutionWorkspace(input) {
2236
2372
  worktreePath: null,
2237
2373
  warnings: [],
2238
2374
  created: false,
2375
+ branchCreatedByRuntime: false,
2239
2376
  baseRefSha: null,
2240
2377
  };
2241
2378
  }
2242
2379
  const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd);
2243
- const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
2244
- const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
2245
- issue: input.issue,
2246
- agent: input.agent,
2247
- projectId: input.base.projectId,
2248
- repoRef: input.base.repoRef,
2249
- });
2250
- let branchName = sanitizeBranchName(renderedBranch);
2380
+ let branchName;
2381
+ if (requestedExistingBranch) {
2382
+ // Exact-branch mode: attach the requested pre-existing branch verbatim.
2383
+ // The branch must already exist; realization never creates, renames, or
2384
+ // resets it, and any mismatch below fails closed instead of falling back
2385
+ // to a derived branch or the shared checkout.
2386
+ const existingBranchSha = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${requestedExistingBranch}`], repoRoot).catch(() => null);
2387
+ if (!existingBranchSha) {
2388
+ throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", but no local branch with that name exists in "${repoRoot}". Create or fetch the branch first, or remove workspaceStrategy.existingBranch; exact-branch realization never creates a branch.`, {
2389
+ workspaceValidation: {
2390
+ reason: "existing_branch_not_found",
2391
+ requestedExistingBranch,
2392
+ repoRoot,
2393
+ },
2394
+ });
2395
+ }
2396
+ branchName = requestedExistingBranch;
2397
+ }
2398
+ else {
2399
+ const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
2400
+ const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
2401
+ issue: input.issue,
2402
+ agent: input.agent,
2403
+ projectId: input.base.projectId,
2404
+ repoRef: input.base.repoRef,
2405
+ });
2406
+ branchName = sanitizeBranchName(renderedBranch);
2407
+ }
2251
2408
  const configuredParentDir = asString(rawStrategy.worktreeParentDir, "");
2252
2409
  const worktreeParentDir = configuredParentDir
2253
2410
  ? resolveConfiguredPath(configuredParentDir, repoRoot)
2254
2411
  : path.join(repoRoot, ".paperclip", "worktrees");
2255
2412
  const worktreePath = path.join(worktreeParentDir, branchName);
2413
+ if (path.relative(worktreeParentDir, worktreePath).startsWith("..")) {
2414
+ throw new WorkspaceRuntimeValidationFailure(`Workspace branch "${branchName}" resolves to a worktree path outside the managed worktree parent directory "${worktreeParentDir}".`, {
2415
+ workspaceValidation: {
2416
+ reason: "worktree_path_escapes_parent_dir",
2417
+ branchName,
2418
+ worktreeParentDir,
2419
+ },
2420
+ });
2421
+ }
2256
2422
  let pendingForwardBranchReconcile = null;
2257
2423
  const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
2258
2424
  ? rawStrategy.baseRef
2259
2425
  : input.base.repoRef ?? null;
2260
- const { baseRef, warnings: baseRefResolutionWarnings, refreshed: baseRefAlreadyRefreshed, } = await resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, input.resolveGitAuth);
2426
+ const baseRefResolution = await resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, input.resolveGitAuth);
2427
+ // Keep a usable base ref for the reuse and drift paths even when the ref is
2428
+ // unresolved: those paths tolerate a null base-ref SHA and never run
2429
+ // `git worktree add -b <branch> <baseRef>`. Only the fresh-create path below
2430
+ // stops on an unresolved ref. `baseRefAlreadyRefreshed` is true for the
2431
+ // unresolved case because the resolver already attempted the fetch.
2432
+ const baseRef = baseRefResolution.resolved ? baseRefResolution.baseRef : baseRefResolution.requestedRef;
2433
+ const baseRefResolutionWarnings = baseRefResolution.warnings;
2434
+ const baseRefAlreadyRefreshed = baseRefResolution.resolved ? baseRefResolution.refreshed : true;
2261
2435
  const baseRefreshWarnings = [
2262
2436
  ...baseRefResolutionWarnings,
2263
2437
  ...(baseRefAlreadyRefreshed ? [] : await refreshRemoteTrackingBaseRef(repoRoot, baseRef, input.resolveGitAuth)),
@@ -2265,7 +2439,9 @@ export async function realizeExecutionWorkspace(input) {
2265
2439
  const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
2266
2440
  await fs.mkdir(worktreeParentDir, { recursive: true });
2267
2441
  async function reuseExistingWorktree(reusablePath, effectiveBranchName = branchName, extraWarnings = []) {
2268
- const refresh = currentBaseRefSha
2442
+ // An exact-branch attach must never move the requested branch, so skip
2443
+ // the unstarted-worktree fast-forward that template-derived reuse gets.
2444
+ const refresh = currentBaseRefSha && !requestedExistingBranch
2269
2445
  ? await refreshUnstartedWorktreeToBase({
2270
2446
  repoRoot,
2271
2447
  worktreePath: reusablePath,
@@ -2324,6 +2500,14 @@ export async function realizeExecutionWorkspace(input) {
2324
2500
  worktreePath: reusablePath,
2325
2501
  warnings: [...extraWarnings, ...baseRefreshWarnings, ...baseDrift.warnings],
2326
2502
  created: false,
2503
+ // A fresh realization may still land on the worktree recorded by a
2504
+ // previous heartbeat. Preserve that branch's ownership only when the
2505
+ // recorded branch matches the checkout being reused. Exact-branch mode
2506
+ // remains operator-owned by contract; every mismatch likewise fails
2507
+ // safe and leaves the branch behind during terminal cleanup.
2508
+ branchCreatedByRuntime: !requestedExistingBranch
2509
+ && input.recordedBranchOwnership?.branchName === effectiveBranchName
2510
+ && input.recordedBranchOwnership.createdByRuntime === true,
2327
2511
  baseRefSha: refresh.baseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
2328
2512
  pendingForwardBranchReconcile,
2329
2513
  };
@@ -2335,6 +2519,11 @@ export async function realizeExecutionWorkspace(input) {
2335
2519
  expectedBranchName: branchName,
2336
2520
  }).catch(() => null);
2337
2521
  if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") {
2522
+ if (requestedExistingBranch) {
2523
+ // Exact-branch mode never reconciles a mismatched checkout onto
2524
+ // another branch; the caller fails closed with the mismatch reason.
2525
+ return { validation, branchName, warnings: [] };
2526
+ }
2338
2527
  const coherence = await ensureGitWorktreeBranchCoherent({
2339
2528
  db: input.db ?? null,
2340
2529
  repoRoot,
@@ -2375,6 +2564,16 @@ export async function realizeExecutionWorkspace(input) {
2375
2564
  }
2376
2565
  const validation = reusable.validation;
2377
2566
  const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
2567
+ if (requestedExistingBranch) {
2568
+ throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", but the worktree path "${worktreePath}" already exists and is not a reusable checkout of that branch${reason}. Repair or remove that worktree, then retry; exact-branch realization never reconciles it onto another branch.`, {
2569
+ workspaceValidation: {
2570
+ reason: "existing_branch_worktree_not_reusable",
2571
+ reasonCode: validation && !validation.valid ? validation.reasonCode : null,
2572
+ requestedExistingBranch,
2573
+ worktreePath,
2574
+ },
2575
+ });
2576
+ }
2378
2577
  throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`);
2379
2578
  }
2380
2579
  const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
@@ -2385,8 +2584,86 @@ export async function realizeExecutionWorkspace(input) {
2385
2584
  }
2386
2585
  const validation = reusable.validation;
2387
2586
  const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
2587
+ if (requestedExistingBranch) {
2588
+ throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", which is already checked out at "${registeredBranchWorktree}", but that worktree is not reusable${reason}. Repair or remove that worktree, then retry.`, {
2589
+ workspaceValidation: {
2590
+ reason: "existing_branch_worktree_not_reusable",
2591
+ reasonCode: validation && !validation.valid ? validation.reasonCode : null,
2592
+ requestedExistingBranch,
2593
+ worktreePath: registeredBranchWorktree,
2594
+ },
2595
+ });
2596
+ }
2388
2597
  throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`);
2389
2598
  }
2599
+ if (requestedExistingBranch) {
2600
+ try {
2601
+ await recordGitOperation(input.recorder, {
2602
+ phase: "worktree_prepare",
2603
+ args: ["worktree", "add", worktreePath, branchName],
2604
+ cwd: repoRoot,
2605
+ metadata: {
2606
+ repoRoot,
2607
+ worktreePath,
2608
+ branchName,
2609
+ baseRef,
2610
+ baseRefSha: currentBaseRefSha,
2611
+ created: false,
2612
+ attachedExistingBranch: true,
2613
+ },
2614
+ successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
2615
+ failureLabel: `git worktree add ${worktreePath}`,
2616
+ });
2617
+ }
2618
+ catch (attachError) {
2619
+ const message = attachError instanceof Error ? attachError.message : String(attachError);
2620
+ throw new WorkspaceRuntimeValidationFailure(`Could not attach existing branch "${requestedExistingBranch}" as a git worktree at "${worktreePath}": ${message}`, {
2621
+ workspaceValidation: {
2622
+ reason: "existing_branch_attach_failed",
2623
+ requestedExistingBranch,
2624
+ worktreePath,
2625
+ },
2626
+ });
2627
+ }
2628
+ await provisionExecutionWorktree({
2629
+ strategy: rawStrategy,
2630
+ base: input.base,
2631
+ repoRoot,
2632
+ worktreePath,
2633
+ branchName,
2634
+ issue: input.issue,
2635
+ agent: input.agent,
2636
+ created: true,
2637
+ recorder: input.recorder ?? null,
2638
+ });
2639
+ return {
2640
+ ...input.base,
2641
+ repoRef: baseRef,
2642
+ strategy: "git_worktree",
2643
+ cwd: worktreePath,
2644
+ branchName,
2645
+ worktreePath,
2646
+ warnings: baseRefreshWarnings,
2647
+ // The worktree is new, but the pinned branch pre-existed: it stays
2648
+ // operator-owned so terminal cleanup never deletes it.
2649
+ created: true,
2650
+ branchCreatedByRuntime: false,
2651
+ baseRefSha: currentBaseRefSha,
2652
+ };
2653
+ }
2654
+ // No reusable worktree exists, so a fresh `git worktree add -b <branch> <baseRef>`
2655
+ // must run next. An unresolved base ref would make git fail with
2656
+ // `fatal: invalid reference`. Stop here instead and raise a pre-dispatch
2657
+ // configuration failure that the setup catch routes to a human owner.
2658
+ if (!baseRefResolution.resolved) {
2659
+ throw new UnresolvedWorkspaceBaseRefError({
2660
+ requestedRef: baseRefResolution.requestedRef,
2661
+ recoveryIdentityRef: baseRefResolution.recoveryIdentityRef,
2662
+ attemptedRefs: baseRefResolution.attemptedRefs,
2663
+ fetchError: baseRefResolution.fetchError,
2664
+ });
2665
+ }
2666
+ let branchCreatedByRuntime = true;
2390
2667
  try {
2391
2668
  await recordGitOperation(input.recorder, {
2392
2669
  phase: "worktree_prepare",
@@ -2425,6 +2702,9 @@ export async function realizeExecutionWorkspace(input) {
2425
2702
  successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
2426
2703
  failureLabel: `git worktree add ${worktreePath}`,
2427
2704
  });
2705
+ // The template rendered to a branch that already existed, so this
2706
+ // attach did not create the branch and cleanup must not delete it.
2707
+ branchCreatedByRuntime = false;
2428
2708
  }
2429
2709
  catch (attachError) {
2430
2710
  if (!gitErrorIncludes(attachError, "already checked out")) {
@@ -2457,6 +2737,7 @@ export async function realizeExecutionWorkspace(input) {
2457
2737
  worktreePath,
2458
2738
  warnings: baseRefreshWarnings,
2459
2739
  created: true,
2740
+ branchCreatedByRuntime,
2460
2741
  baseRefSha: currentBaseRefSha,
2461
2742
  };
2462
2743
  }
@@ -2479,6 +2760,11 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2479
2760
  worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
2480
2761
  warnings: [],
2481
2762
  created: false,
2763
+ // Only the versioned ownership record introduced with branch-level
2764
+ // ownership semantics can authorize branch recreation or deletion. Older
2765
+ // createdByRuntime=true rows described worktree ownership, so trusting
2766
+ // them here could recreate or later delete an operator-owned branch.
2767
+ branchCreatedByRuntime: isRuntimeOwnedGitBranch(input.workspace.metadata),
2482
2768
  baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
2483
2769
  };
2484
2770
  const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
@@ -2507,7 +2793,12 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2507
2793
  const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
2508
2794
  const reuseWorktreePath = realized.worktreePath ?? cwd;
2509
2795
  const repairWarnings = [];
2510
- if (await isGitCheckout(reuseWorktreePath)) {
2796
+ if (await isGitCheckout(reuseWorktreePath) && realized.branchCreatedByRuntime) {
2797
+ // Branch-coherence repair may check out another branch, adopt a forward
2798
+ // branch, or move the recorded ref from a detached HEAD. Those repairs
2799
+ // are valid only for a branch that this runtime created. An attached
2800
+ // operator-owned branch must retain its exact identity and tip; the
2801
+ // validation below rejects any mismatch without mutating Git state.
2511
2802
  const coherence = await ensureGitWorktreeBranchCoherent({
2512
2803
  db: input.db ?? null,
2513
2804
  repoRoot,
@@ -2549,7 +2840,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2549
2840
  ? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef, input.resolveGitAuth)
2550
2841
  : [];
2551
2842
  const currentBaseRefSha = reuseBaseRef ? await resolveBaseRefSha(repoRoot, reuseBaseRef) : null;
2552
- const refresh = reuseBaseRef && currentBaseRefSha
2843
+ // An unstarted-worktree refresh can fast-forward the checked-out branch.
2844
+ // Never run it for an attached operator-owned ref.
2845
+ const refresh = realized.branchCreatedByRuntime && reuseBaseRef && currentBaseRefSha
2553
2846
  ? await refreshUnstartedWorktreeToBase({
2554
2847
  repoRoot,
2555
2848
  worktreePath: reuseWorktreePath,
@@ -2569,22 +2862,20 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2569
2862
  });
2570
2863
  realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings];
2571
2864
  realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
2572
- if (provisionCommand) {
2573
- await provisionExecutionWorktree({
2574
- strategy: {
2575
- type: "git_worktree",
2576
- provisionCommand,
2577
- },
2578
- base: input.base,
2579
- repoRoot,
2580
- worktreePath: realized.worktreePath ?? cwd,
2581
- branchName: realized.branchName ?? "",
2582
- issue: input.issue,
2583
- agent: input.agent,
2584
- created: false,
2585
- recorder: input.recorder ?? null,
2586
- });
2587
- }
2865
+ await provisionExecutionWorktree({
2866
+ strategy: {
2867
+ type: "git_worktree",
2868
+ ...(provisionCommand ? { provisionCommand } : {}),
2869
+ },
2870
+ base: input.base,
2871
+ repoRoot,
2872
+ worktreePath: realized.worktreePath ?? cwd,
2873
+ branchName: realized.branchName ?? "",
2874
+ issue: input.issue,
2875
+ agent: input.agent,
2876
+ created: false,
2877
+ recorder: input.recorder ?? null,
2878
+ });
2588
2879
  return realized;
2589
2880
  }
2590
2881
  const worktreePath = realized.worktreePath ?? cwd;
@@ -2624,6 +2915,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2624
2915
  && !gitErrorIncludes(error, "unknown revision")) {
2625
2916
  throw error;
2626
2917
  }
2918
+ if (!realized.branchCreatedByRuntime) {
2919
+ throw new Error(`Execution workspace "${worktreePath}" cannot be restored because its operator-owned branch "${branchName}" no longer exists.`);
2920
+ }
2627
2921
  const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
2628
2922
  const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
2629
2923
  await recordGitOperation(input.recorder, {
@@ -2672,6 +2966,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
2672
2966
  worktreePath,
2673
2967
  warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
2674
2968
  created,
2969
+ branchCreatedByRuntime: realized.branchCreatedByRuntime || created,
2675
2970
  baseRefSha: recordedBaseRefSha
2676
2971
  ?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
2677
2972
  ?? baseDrift.currentBaseRefSha,
@@ -2789,7 +3084,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input) {
2789
3084
  warnings.push(`Could not read worktree instance pointer: ${err instanceof Error ? err.message : String(err)}`);
2790
3085
  }
2791
3086
  }
3087
+ // Local-directory ownership keeps the historical createdByRuntime signal.
3088
+ // Git branch deletion additionally requires the version marker introduced
3089
+ // with branch-level ownership semantics. Unmarked legacy rows fail closed:
3090
+ // their worktrees are removable, but their branch refs are operator-owned.
2792
3091
  const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
3092
+ const branchCreatedByRuntime = isRuntimeOwnedGitBranch(input.workspace.metadata);
2793
3093
  const cleanupCommands = input.runCleanupCommands === false
2794
3094
  ? []
2795
3095
  : [
@@ -2877,7 +3177,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input) {
2877
3177
  }
2878
3178
  }
2879
3179
  }
2880
- if (createdByRuntime && input.workspace.branchName) {
3180
+ if (branchCreatedByRuntime && input.workspace.branchName) {
2881
3181
  if (!repoRoot) {
2882
3182
  warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
2883
3183
  }
@@ -3269,6 +3569,83 @@ async function canBindRuntimePort(port) {
3269
3569
  });
3270
3570
  });
3271
3571
  }
3572
+ /**
3573
+ * True when a loopback listener is present on the port, read WITHOUT binding it.
3574
+ *
3575
+ * The readiness wait must never bind the exact port the guest is about to bind.
3576
+ * A probe `listen()` holds the port for the length of one bind/close, and if the
3577
+ * guest's own `listen()` lands in that window the guest fails with EADDRINUSE on
3578
+ * its assigned port. That self-inflicted race is the runtime exposure port flake
3579
+ * (a slow guest under load loses the race to the parent probe). A `/proc` read
3580
+ * carries the same "a listener appeared" signal with no bind. Where `/proc` is
3581
+ * absent (non-Linux dev hosts), fall back to the bind probe; those hosts do not
3582
+ * run the concurrent managed lanes that expose the race.
3583
+ */
3584
+ async function hasLoopbackPortListener(port) {
3585
+ const facts = await readListenerBindFacts(port).catch(() => null);
3586
+ if (facts)
3587
+ return facts.present;
3588
+ return !(await canBindRuntimePort(port));
3589
+ }
3590
+ /**
3591
+ * True when one line of the failure text reports an EADDRINUSE bind conflict on
3592
+ * the given port.
3593
+ *
3594
+ * Node prints the failing bind on a single line, for example
3595
+ * `Error: listen EADDRINUSE: address already in use 127.0.0.1:42000`. The match
3596
+ * requires the EADDRINUSE marker and the port on the SAME line. So an
3597
+ * auxiliary-port conflict on one line cannot combine with an unrelated
3598
+ * assigned-port mention on a different, benign line and trigger a wrong
3599
+ * quarantine.
3600
+ *
3601
+ * Node formats a bind address as `host:port`, so the failing port always
3602
+ * follows a colon (for example `127.0.0.1:42000` or `:::42000`). The match
3603
+ * requires that colon and a full-number boundary. So a different port in the
3604
+ * same line cannot look like the assigned app or HMR port, and port 4200 never
3605
+ * matches `:42000`.
3606
+ */
3607
+ function eaddrinuseTextNamesPort(text, port) {
3608
+ const eaddrinusePattern = /EADDRINUSE|address already in use/i;
3609
+ const portPattern = new RegExp(`:${port}(?![0-9])`);
3610
+ return text
3611
+ .split(/\r?\n/)
3612
+ .some((line) => eaddrinusePattern.test(line) && portPattern.test(line));
3613
+ }
3614
+ /**
3615
+ * Decide which assigned exposure ports a real host listener owns after a guest
3616
+ * start failure.
3617
+ *
3618
+ * The guest owns its own output, so an assigned-port EADDRINUSE line is a claim,
3619
+ * not proof. A managed guest can print a synthetic EADDRINUSE line for its
3620
+ * assigned port with no host listener behind it. A quarantine on that text alone
3621
+ * would drain the shared exposure-port pool across repeated starts. So a port
3622
+ * counts as a host collision only when all of the following hold:
3623
+ * - the failure text names the port with EADDRINUSE, and
3624
+ * - a real listener is present on the port now, and
3625
+ * - the listener owner is not the guest process the runtime just launched, nor a
3626
+ * descendant of it.
3627
+ *
3628
+ * The runtime launches the guest as a shell process group leader, so the real dev
3629
+ * server usually binds the port from a descendant, not the shell pid itself. The
3630
+ * ownership test therefore matches the shell pid against both the owner pid and
3631
+ * the owner process group id. This is the same process-group attribution that
3632
+ * `isLocalServiceProcessOwnedBy` applies on the host platform. A raw pid equality
3633
+ * would treat a guest descendant as an external owner and quarantine a valid pair.
3634
+ *
3635
+ * An unknown owner with a present listener counts as a host owner: the /proc read
3636
+ * proves a listener, and a guest that lost its assigned port does not hold it.
3637
+ */
3638
+ export function classifyExposureHostCollisions(input) {
3639
+ const hostCollisionPorts = [];
3640
+ for (const state of input.ports) {
3641
+ const guestOwnsPort = input.childPid != null &&
3642
+ (state.ownerPid === input.childPid || state.ownerProcessGroupId === input.childPid);
3643
+ if (state.named && state.listenerPresent && !guestOwnsPort) {
3644
+ hostCollisionPorts.push(state.port);
3645
+ }
3646
+ }
3647
+ return { hostCollisionPorts, hostCollision: hostCollisionPorts.length > 0 };
3648
+ }
3272
3649
  async function readReservedRuntimePorts(input) {
3273
3650
  if (!input.db || input.ports.length === 0)
3274
3651
  return new Set();
@@ -3373,6 +3750,14 @@ export async function allocateRuntimeServicePort(overrides) {
3373
3750
  for (let attempt = 0; attempt < PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
3374
3751
  const candidate = await probe();
3375
3752
  lastCandidate = candidate;
3753
+ // Never hand back a port inside the runtime exposure app-port range. The
3754
+ // reconciler classifies a persisted row by its port. A port in that range
3755
+ // marks the row as an exposure reservation, not a managed auto port. An auto
3756
+ // port from that range makes the reconciler read a stopped managed row as an
3757
+ // exposure reservation and report drift instead of the adoption. The kernel
3758
+ // can hand out an ephemeral port in that band, so skip the candidate here.
3759
+ if (isRuntimeExposureAppPort(candidate))
3760
+ continue;
3376
3761
  if (!reservePortIfFree(candidate))
3377
3762
  continue;
3378
3763
  const ownerPid = await portOwnerLookup(candidate);
@@ -3614,9 +3999,10 @@ async function waitForAllocatedPortBind(input) {
3614
3999
  }
3615
4000
  return;
3616
4001
  }
3617
- // A failed bind probe proves only that some listener appeared. If listener ownership cannot
4002
+ // A present listener proves only that some listener appeared. If listener ownership cannot
3618
4003
  // be attributed to this child after a stability delay, retry instead of accepting a sibling.
3619
- if (!(await canBindRuntimePort(input.port))) {
4004
+ // The presence read never binds the port, so it cannot steal the port from the child.
4005
+ if (await hasLoopbackPortListener(input.port)) {
3620
4006
  await delay(250);
3621
4007
  if (input.child.exitCode !== null || input.child.signalCode !== null) {
3622
4008
  throw new Error("service process exited after losing its allocated port");
@@ -3655,15 +4041,73 @@ function resolveRuntimeServiceHealthUrl(url, input) {
3655
4041
  }
3656
4042
  return url;
3657
4043
  }
4044
+ /**
4045
+ * Whether a managed workspace runtime satisfies the *user* readiness contract.
4046
+ *
4047
+ * Returns null when this service is not an identity-resolvable managed workspace
4048
+ * runtime, so non-workspace services keep their existing behavior.
4049
+ *
4050
+ * For a workspace runtime this *replaces* the semantic transport check rather
4051
+ * than adding to it. The probe reads the same `/api/health` response the legacy
4052
+ * check read, so every legacy verdict is already implied: reaching
4053
+ * `readiness_missing` means the response was `200` with `status: ok` (exactly
4054
+ * what the legacy check asserted), and every other rejection means it was not.
4055
+ * Stacking a second request on top would double the latency of every reuse
4056
+ * decision for no extra information.
4057
+ */
4058
+ async function probeManagedWorkspaceRuntimeReadiness(healthUrl, input) {
4059
+ if (!isPaperclipDevRuntimeService(input))
4060
+ return null;
4061
+ const identity = resolveManagedWorkspaceIdentity({
4062
+ workspaceCwd: input.cwd ?? null,
4063
+ executionWorkspaceId: input.executionWorkspaceId ?? null,
4064
+ companyId: input.companyId ?? null,
4065
+ });
4066
+ if (!identity)
4067
+ return null;
4068
+ const result = await probeManagedWorkspaceReadiness({ healthUrl, identity });
4069
+ const verified = !result.ok
4070
+ ? result
4071
+ : input.db
4072
+ ? await probeManagedWorkspaceHandoffSubjects({ db: input.db, healthUrl, identity })
4073
+ : {
4074
+ ok: false,
4075
+ reason: "not_ready",
4076
+ readiness: result.readiness,
4077
+ detail: "control-plane database is unavailable for board identity verification",
4078
+ };
4079
+ if (verified.ok)
4080
+ return true;
4081
+ logManagedWorkspaceReadinessRejection({
4082
+ executionWorkspaceId: identity.executionWorkspaceId,
4083
+ healthUrl,
4084
+ result: verified,
4085
+ });
4086
+ // A guest that does not implement the readiness contract yet is not evidence of
4087
+ // an unhealthy clone; it is only evidence that the contract cannot be checked.
4088
+ return !shouldBlockPublicationOnReadiness(verified);
4089
+ }
3658
4090
  async function isRuntimeServiceUrlHealthy(url, input) {
3659
- if (!url)
4091
+ const localProbeUrl = input?.provider === "local_process" && input.port && isPaperclipDevRuntimeService(input)
4092
+ ? `http://127.0.0.1:${input.port}`
4093
+ : null;
4094
+ const probeUrl = localProbeUrl ?? url;
4095
+ if (!probeUrl)
3660
4096
  return true;
3661
- const healthUrl = resolveRuntimeServiceHealthUrl(url, input);
4097
+ const healthUrl = resolveRuntimeServiceHealthUrl(probeUrl, input);
3662
4098
  if (!healthUrl)
3663
4099
  return false;
4100
+ const readiness = await probeManagedWorkspaceRuntimeReadiness(healthUrl, input ?? {});
4101
+ if (readiness !== null)
4102
+ return readiness;
3664
4103
  try {
3665
4104
  const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
3666
- return response.ok;
4105
+ if (!response.ok)
4106
+ return false;
4107
+ if (!isPaperclipDevRuntimeService(input ?? {}))
4108
+ return true;
4109
+ const payload = await response.json().catch(() => null);
4110
+ return payload?.status === "ok";
3667
4111
  }
3668
4112
  catch {
3669
4113
  return false;
@@ -3845,6 +4289,42 @@ function readRuntimeProvisionCommand(config) {
3845
4289
  const workspaceStrategy = parseObject(config.workspaceStrategy);
3846
4290
  return asString(config.runtimeProvisionCommand, asString(workspaceStrategy.runtimeProvisionCommand, "")).trim();
3847
4291
  }
4292
+ const BUILTIN_WORKSPACE_SEED_COMMAND = "bash ./scripts/provision-worktree-runtime.sh";
4293
+ function readWorkspaceSeedOperationEvidence(worktreePath) {
4294
+ const manifestPath = path.join(worktreePath, ".paperclip", "seed-manifest.json");
4295
+ try {
4296
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
4297
+ const state = typeof manifest.state === "string" ? manifest.state : "unknown";
4298
+ const phase = typeof manifest.phase === "string" ? manifest.phase : null;
4299
+ const verified = isVerifiedWorktreeSeedManifest(manifest);
4300
+ return {
4301
+ verified,
4302
+ error: verified
4303
+ ? null
4304
+ : phase
4305
+ ? `Workspace seed command returned without a verified manifest (state: ${state}, phase: ${phase}).`
4306
+ : `Workspace seed command returned without a verified manifest (state: ${state}).`,
4307
+ metadata: {
4308
+ provisionKind: "workspace_seed",
4309
+ seedState: state,
4310
+ seedPhase: phase,
4311
+ seedFailurePhase: state === "failed" ? phase : null,
4312
+ },
4313
+ };
4314
+ }
4315
+ catch {
4316
+ return {
4317
+ verified: false,
4318
+ error: "Workspace seed command returned without a readable seed manifest.",
4319
+ metadata: {
4320
+ provisionKind: "workspace_seed",
4321
+ seedState: existsSync(manifestPath) ? "unreadable" : "absent",
4322
+ seedPhase: null,
4323
+ seedFailurePhase: "seed_manifest_unreadable",
4324
+ },
4325
+ };
4326
+ }
4327
+ }
3848
4328
  export function resolveRuntimeProvisionCommand(input) {
3849
4329
  const configuredCommand = readRuntimeProvisionCommand(input.config);
3850
4330
  if (configuredCommand)
@@ -3852,15 +4332,24 @@ export function resolveRuntimeProvisionCommand(input) {
3852
4332
  if (input.workspace.strategy !== "git_worktree")
3853
4333
  return "";
3854
4334
  const stateDir = path.join(input.workspace.cwd, ".paperclip");
3855
- const pendingMarker = path.join(stateDir, "seed-pending");
3856
- const completeMarker = path.join(stateDir, "seed-complete");
4335
+ const manifestPath = path.join(stateDir, "seed-manifest.json");
3857
4336
  const provisionScript = path.join(input.workspace.baseCwd, "scripts", "provision-worktree-runtime.sh");
3858
- if (!existsSync(pendingMarker)
3859
- || existsSync(completeMarker)
3860
- || !existsSync(provisionScript)) {
4337
+ const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath);
4338
+ if (!needsSeed || !existsSync(provisionScript)) {
3861
4339
  return "";
3862
4340
  }
3863
- return "bash ./scripts/provision-worktree-runtime.sh";
4341
+ return BUILTIN_WORKSPACE_SEED_COMMAND;
4342
+ }
4343
+ function resolveRuntimeProvision(input) {
4344
+ const command = resolveRuntimeProvisionCommand(input);
4345
+ if (!command)
4346
+ return { command, kind: null };
4347
+ return {
4348
+ command,
4349
+ kind: readRuntimeProvisionCommand(input.config)
4350
+ ? "runtime_dependencies"
4351
+ : "workspace_seed",
4352
+ };
3864
4353
  }
3865
4354
  function runtimeProvisionWorkspaceKey(input) {
3866
4355
  return input.executionWorkspaceId
@@ -3888,8 +4377,9 @@ async function runRuntimeProvisionWithWorkspaceMutex(input) {
3888
4377
  })
3889
4378
  : null);
3890
4379
  const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd);
4380
+ const workspaceSeed = input.runtimeProvisionKind === "workspace_seed";
3891
4381
  const promise = recordWorkspaceCommandOperation(recorder, {
3892
- phase: "workspace_runtime_provision",
4382
+ phase: workspaceSeed ? "workspace_seed" : "workspace_runtime_provision",
3893
4383
  command,
3894
4384
  resolvedCommand,
3895
4385
  cwd: input.workspace.cwd,
@@ -3902,14 +4392,19 @@ async function runRuntimeProvisionWithWorkspaceMutex(input) {
3902
4392
  agent: input.agent,
3903
4393
  created: input.workspace.created,
3904
4394
  }),
3905
- label: `Runtime provision command "${command}"`,
4395
+ label: workspaceSeed
4396
+ ? `Workspace seed command "${command}"`
4397
+ : `Runtime provision command "${command}"`,
3906
4398
  metadata: {
3907
4399
  executionWorkspaceId: input.executionWorkspaceId ?? null,
3908
4400
  projectWorkspaceId: input.workspace.workspaceId,
3909
4401
  serviceName: asString(input.service.name, "service"),
4402
+ provisionKind: workspaceSeed ? "workspace_seed" : "runtime_dependencies",
3910
4403
  resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
3911
4404
  },
3912
- successMessage: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
4405
+ successMessage: workspaceSeed
4406
+ ? `Verified the workspace database seed for ${input.workspace.cwd}\n`
4407
+ : `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
3913
4408
  onLog: input.onLog,
3914
4409
  }).then(() => undefined);
3915
4410
  runtimeProvisionByWorkspace.set(workspaceKey, promise);
@@ -4192,12 +4687,26 @@ async function spawnLocalRuntimeService(input) {
4192
4687
  const portEnvKey = asString(portConfig.envKey, "PORT");
4193
4688
  env[portEnvKey] = String(port);
4194
4689
  }
4690
+ // Per-workspace handoff key, readiness token, and workspace id. Injected for
4691
+ // the Paperclip dev runtime whether or not it is HTTPS-exposed, because the
4692
+ // password-independent login handoff and the protected readiness probe are
4693
+ // both needed for a plain-HTTP loopback workspace too (PAP-17572).
4694
+ const managedWorkspaceIdentity = isPaperclipDevRuntimeService({ serviceName, command })
4695
+ ? resolveManagedWorkspaceIdentity({
4696
+ workspaceCwd: input.workspace.cwd,
4697
+ executionWorkspaceId: input.executionWorkspaceId ?? null,
4698
+ companyId: input.agent.companyId,
4699
+ })
4700
+ : null;
4701
+ if (managedWorkspaceIdentity) {
4702
+ Object.assign(env, buildManagedWorkspaceGuestEnv(managedWorkspaceIdentity));
4703
+ }
4195
4704
  if (exposureConfig) {
4196
4705
  // Paperclip dev-runtime-specific hardening. Other managed processes are
4197
4706
  // still rejected by the broker unless /proc proves loopback-only listeners.
4198
4707
  //
4199
4708
  // Three independent layers force the loopback bind, because a guest checkout
4200
- // can be arbitrarily old (PAP-17256): the `--bind custom --bind-host` argv
4709
+ // can be arbitrarily old (PAP-17256): the `--bind loopback` argv
4201
4710
  // added above, these env vars for a runner that reads them, and HOST for one
4202
4711
  // old enough to ignore both and infer its bind mode from HOST alone.
4203
4712
  env.PAPERCLIP_BIND = RUNTIME_EXPOSURE_BIND_MODE;
@@ -4245,7 +4754,14 @@ async function spawnLocalRuntimeService(input) {
4245
4754
  });
4246
4755
  if (adoptedRecord) {
4247
4756
  const adoptedUrl = adoptedRecord.url ?? backendUrl;
4248
- if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName, command }))) {
4757
+ if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, {
4758
+ db: input.db,
4759
+ serviceName,
4760
+ command,
4761
+ cwd: input.workspace.cwd,
4762
+ executionWorkspaceId: input.executionWorkspaceId ?? null,
4763
+ companyId: input.agent.companyId,
4764
+ }))) {
4249
4765
  await terminateLocalService(adoptedRecord);
4250
4766
  await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
4251
4767
  }
@@ -4396,12 +4912,22 @@ async function spawnLocalRuntimeService(input) {
4396
4912
  throw error;
4397
4913
  }
4398
4914
  const shell = resolveShell();
4399
- const child = spawn(shell, ["-lc", command], {
4400
- cwd: serviceCwd,
4401
- env,
4402
- detached: process.platform !== "win32",
4403
- stdio: ["ignore", "pipe", "pipe"],
4404
- });
4915
+ const serviceLog = await openLocalServiceLogFile(serviceKey);
4916
+ let child;
4917
+ try {
4918
+ child = spawn(shell, ["-lc", command], {
4919
+ cwd: serviceCwd,
4920
+ env,
4921
+ detached: process.platform !== "win32",
4922
+ // The service receives duplicate append-only file descriptors. Closing
4923
+ // Paperclip (or this parent handle below) cannot strand a request logger
4924
+ // on an orphaned socketpair during startup reconciliation.
4925
+ stdio: ["ignore", serviceLog.handle.fd, serviceLog.handle.fd],
4926
+ });
4927
+ }
4928
+ finally {
4929
+ await serviceLog.handle.close();
4930
+ }
4405
4931
  record.child = child;
4406
4932
  record.providerRef = child.pid ? String(child.pid) : null;
4407
4933
  record.processGroupId = child.pid ?? null;
@@ -4411,24 +4937,22 @@ async function spawnLocalRuntimeService(input) {
4411
4937
  });
4412
4938
  });
4413
4939
  const earlyExitPromise = new Promise((_, reject) => {
4414
- child.once("exit", (code, signal) => {
4940
+ // `close` follows `exit` after the child's inherited stdout/stderr file
4941
+ // descriptors are closed. Waiting for it makes the startup log excerpt
4942
+ // deterministic instead of racing the final validation line.
4943
+ child.once("close", (code, signal) => {
4415
4944
  reject(new Error(`service process exited before readiness (code ${code ?? "unknown"}, signal ${signal ?? "none"})`));
4416
4945
  });
4417
4946
  });
4418
- let stderrExcerpt = "";
4419
- let stdoutExcerpt = "";
4420
- child.stdout?.on("data", async (chunk) => {
4421
- const text = String(chunk);
4422
- stdoutExcerpt = (stdoutExcerpt + text).slice(-4096);
4423
- if (input.onLog)
4424
- await input.onLog("stdout", `[service:${serviceName}] ${text}`);
4425
- });
4426
- child.stderr?.on("data", async (chunk) => {
4427
- const text = String(chunk);
4428
- stderrExcerpt = (stderrExcerpt + text).slice(-4096);
4429
- if (input.onLog)
4430
- await input.onLog("stderr", `[service:${serviceName}] ${text}`);
4431
- });
4947
+ const readServiceOutputExcerpt = async () => {
4948
+ try {
4949
+ const contents = await fs.readFile(serviceLog.logPath);
4950
+ return contents.subarray(Math.max(serviceLog.startOffset, contents.length - 4096)).toString("utf8");
4951
+ }
4952
+ catch {
4953
+ return "";
4954
+ }
4955
+ };
4432
4956
  if (child.pid) {
4433
4957
  await writeLocalServiceRegistryRecord({
4434
4958
  version: 1,
@@ -4506,10 +5030,48 @@ async function spawnLocalRuntimeService(input) {
4506
5030
  throw new Error(`HTTPS exposure failed: ${code}${provisioned.errorDetail ? ` — ${provisioned.errorDetail}` : ""}`);
4507
5031
  }
4508
5032
  }
5033
+ // Transport readiness only proves a listener answered. A managed workspace
5034
+ // must additionally satisfy the protected readiness contract — own database,
5035
+ // cloned rows, login handoff, and matching instance/workspace identity —
5036
+ // before it may be published as running/healthy (PAP-17572).
5037
+ if (managedWorkspaceIdentity) {
5038
+ const publishHealthUrl = resolveRuntimeServiceHealthUrl(record.port ? `http://127.0.0.1:${record.port}` : rewriteUrlHostToLoopback(record.url ?? backendUrl), { serviceName, command });
5039
+ if (!publishHealthUrl) {
5040
+ throw new Error("Managed workspace readiness gate could not resolve a health URL");
5041
+ }
5042
+ let gate = await waitForManagedWorkspaceReadiness({
5043
+ healthUrl: publishHealthUrl,
5044
+ identity: managedWorkspaceIdentity,
5045
+ });
5046
+ if (gate.ok) {
5047
+ if (!record.db) {
5048
+ throw new Error("Managed workspace readiness gate could not resolve the control-plane database");
5049
+ }
5050
+ gate = await probeManagedWorkspaceHandoffSubjects({
5051
+ db: record.db,
5052
+ healthUrl: publishHealthUrl,
5053
+ identity: managedWorkspaceIdentity,
5054
+ });
5055
+ }
5056
+ if (!gate.ok) {
5057
+ logManagedWorkspaceReadinessRejection({
5058
+ executionWorkspaceId: managedWorkspaceIdentity.executionWorkspaceId,
5059
+ healthUrl: publishHealthUrl,
5060
+ result: gate,
5061
+ });
5062
+ if (shouldBlockPublicationOnReadiness(gate)) {
5063
+ throw new Error(`Workspace is not ready to publish (${gate.reason}${gate.detail ? `: ${gate.detail}` : ""})`);
5064
+ }
5065
+ }
5066
+ }
4509
5067
  record.status = "running";
4510
5068
  record.healthStatus = "healthy";
4511
5069
  record.lastUsedAt = new Date().toISOString();
4512
5070
  record.stoppedAt = null;
5071
+ const serviceOutputExcerpt = await readServiceOutputExcerpt();
5072
+ if (serviceOutputExcerpt && input.onLog) {
5073
+ await input.onLog("stdout", `[service:${serviceName}] ${serviceOutputExcerpt}`);
5074
+ }
4513
5075
  await touchLocalServiceRegistryRecord(record.serviceKey, {
4514
5076
  runtimeServiceId: record.id,
4515
5077
  lastSeenAt: record.lastUsedAt,
@@ -4518,13 +5080,70 @@ async function spawnLocalRuntimeService(input) {
4518
5080
  releasePortReservation(reservedPort);
4519
5081
  releasePortReservation(claimedIdentityPort);
4520
5082
  const failureMessage = err instanceof Error ? err.message : String(err);
5083
+ const serviceOutputExcerpt = await readServiceOutputExcerpt();
4521
5084
  const bindCollision = !exposureConfig && (err instanceof RuntimeServicePortBindCollision || Boolean(port
4522
5085
  && (input.allowFixedPortFallback || portType === "auto")
4523
- && /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${stderrExcerpt}`)));
5086
+ && /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${serviceOutputExcerpt}`)));
5087
+ // An exposed guest that exits with EADDRINUSE on its ASSIGNED port lost the
5088
+ // port after allocation. Only the assigned app or HMR port is re-allocatable
5089
+ // from the dedicated broker range, so quarantine and re-allocation apply only
5090
+ // when the failure names one of those ports. An unrelated auxiliary-port
5091
+ // conflict (a different port the guest also bound) leaves the valid pair
5092
+ // intact and surfaces as a terminal error, not a quarantine that burns the
5093
+ // bounded retries.
5094
+ const exposureAssignedPorts = exposureConfig && port
5095
+ ? [port, ...(exposureConfig.includePaperclipViteHmr ? [deriveViteHmrPort(port)] : [])]
5096
+ : [];
5097
+ const collisionText = `${failureMessage}\n${serviceOutputExcerpt}`;
5098
+ const exposureNamedPorts = exposureAssignedPorts.filter((candidate) => eaddrinuseTextNamesPort(collisionText, candidate));
5099
+ // The guest owns its own output, so an assigned-port EADDRINUSE line is a
5100
+ // claim, not proof. A managed guest can print a synthetic EADDRINUSE line for
5101
+ // its assigned port with no host listener behind it. A quarantine on that text
5102
+ // alone would burn the shared exposure-port pool across repeated starts. So the
5103
+ // runtime reads live host listener state and quarantines only after it confirms
5104
+ // that a real host listener owns the port.
5105
+ const exposureTextNamesAssignedPort = Boolean(exposureConfig && port && exposureNamedPorts.length > 0);
5106
+ let exposureCollisionDiagnosis = null;
5107
+ let exposureHostCollision = false;
5108
+ if (exposureTextNamesAssignedPort) {
5109
+ const facts = [];
5110
+ const hostStates = [];
5111
+ for (const collisionPort of exposureAssignedPorts) {
5112
+ // `readLocalServicePortOwner` reads lsof and returns the listener pid, so a
5113
+ // non-null pid also proves a present listener. `readListenerBindFacts` reads
5114
+ // /proc for the same presence signal and the bound addresses.
5115
+ const ownerPid = await readLocalServicePortOwner(collisionPort).catch(() => null);
5116
+ const bind = await readListenerBindFacts(collisionPort).catch(() => null);
5117
+ // Read the owner process group id too. The guest runs as a shell process
5118
+ // group leader, so the real dev server usually binds the port from a
5119
+ // descendant. The classify step matches the shell pid against the owner pgid
5120
+ // to keep a guest descendant from looking like an external owner.
5121
+ const ownerProcessGroupId = ownerPid != null ? await readLocalServiceProcessGroupId(ownerPid).catch(() => null) : null;
5122
+ hostStates.push({
5123
+ port: collisionPort,
5124
+ named: exposureNamedPorts.includes(collisionPort),
5125
+ listenerPresent: Boolean(bind?.present) || ownerPid != null,
5126
+ ownerPid,
5127
+ ownerProcessGroupId,
5128
+ });
5129
+ const boundTo = bind?.present ? bind.addresses.join(", ") : "no listener";
5130
+ facts.push(`port ${collisionPort} bound to ${boundTo}, owner pid ${ownerPid ?? "none"}`);
5131
+ }
5132
+ exposureHostCollision = classifyExposureHostCollisions({
5133
+ childPid: child.pid ?? null,
5134
+ ports: hostStates,
5135
+ }).hostCollision;
5136
+ exposureCollisionDiagnosis = facts.join("; ");
5137
+ }
5138
+ // Quarantine the whole assigned pair, not only the named port. The broker
5139
+ // allocates the app and HMR ports as one unit, so a re-allocation must skip
5140
+ // both to land on the next free pair.
5141
+ const exposureCollisionPorts = exposureHostCollision ? exposureAssignedPorts : [];
4524
5142
  if (child.pid) {
4525
5143
  await terminateLocalService({
4526
5144
  pid: child.pid,
4527
5145
  processGroupId: child.pid,
5146
+ port,
4528
5147
  });
4529
5148
  }
4530
5149
  await cleanupRecordExposure(record, { preserveFailure: true });
@@ -4538,7 +5157,33 @@ async function spawnLocalRuntimeService(input) {
4538
5157
  }
4539
5158
  if (bindCollision && port)
4540
5159
  throw new RuntimeServicePortBindCollision(port);
4541
- throw new Error(`Failed to start runtime service "${serviceName}": ${failureMessage}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`);
5160
+ if (exposureHostCollision && port) {
5161
+ // A verified host listener holds the assigned exposure port. Quarantine the
5162
+ // pair so the bounded re-allocation never re-offers it, then throw a retryable
5163
+ // collision. `startLocalRuntimeService` re-runs allocation, which skips the
5164
+ // quarantined pair and takes the next free pair inside the dedicated range.
5165
+ // This hardens a real host that races an external process for a range port.
5166
+ for (const collisionPort of exposureCollisionPorts) {
5167
+ quarantinedRuntimeExposurePorts.add(collisionPort);
5168
+ }
5169
+ if (input.onLog) {
5170
+ await input.onLog("stderr", `[service:${serviceName}] exposure port ${port} collided during startup (EADDRINUSE); `
5171
+ + `${exposureCollisionDiagnosis ?? "owner unavailable"}. `
5172
+ + `Quarantined pair ${exposureCollisionPorts.join("/")} and reallocating.\n`).catch(() => undefined);
5173
+ }
5174
+ throw new RuntimeServicePortBindCollision(port, exposureCollisionDiagnosis, true);
5175
+ }
5176
+ const deploymentBindConflict = /local_trusted requires server\.bind=loopback/i.test(`${failureMessage}\n${serviceOutputExcerpt}`);
5177
+ // The guest reported an assigned-port EADDRINUSE, but no host listener owned
5178
+ // the port. Explain that the runtime did not quarantine the pair, so a future
5179
+ // occurrence needs no diagnostic cycle and the pool stays intact.
5180
+ const unverifiedExposureCollision = exposureTextNamesAssignedPort && !exposureHostCollision;
5181
+ const actionableFailure = deploymentBindConflict
5182
+ ? `${failureMessage} | deployment/bind conflict: local_trusted requires server.bind=loopback; the managed runtime requested an incompatible bind mode`
5183
+ : unverifiedExposureCollision
5184
+ ? `${failureMessage} | exposure port collision not verified: the guest reported EADDRINUSE on assigned port ${exposureNamedPorts.join("/")}, but no host listener owns it (${exposureCollisionDiagnosis ?? "owner unavailable"}); the runtime did not quarantine the pair`
5185
+ : failureMessage;
5186
+ throw new Error(`Failed to start runtime service "${serviceName}": ${actionableFailure}${serviceOutputExcerpt ? ` | output: ${serviceOutputExcerpt.trim()}` : ""}`);
4542
5187
  });
4543
5188
  return { record, readiness: readinessPromise };
4544
5189
  }
@@ -4623,8 +5268,10 @@ async function startLocalRuntimeService(input, options) {
4623
5268
  return started;
4624
5269
  }
4625
5270
  catch (error) {
4626
- if (!(error instanceof RuntimeServicePortBindCollision) || !retryBindCollisions)
5271
+ if (!(error instanceof RuntimeServicePortBindCollision)
5272
+ || !(retryBindCollisions || error.exposureReallocatable)) {
4627
5273
  throw error;
5274
+ }
4628
5275
  excludedPorts.add(error.port);
4629
5276
  started = null;
4630
5277
  }
@@ -4708,19 +5355,14 @@ async function stopRuntimeService(serviceId) {
4708
5355
  if (!record)
4709
5356
  return;
4710
5357
  clearIdleTimer(record);
4711
- record.status = "stopped";
4712
- record.healthStatus = "unknown";
4713
- record.lastUsedAt = new Date().toISOString();
4714
- record.stoppedAt = new Date().toISOString();
4715
- runtimeServicesById.delete(serviceId);
4716
- if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
4717
- runtimeServicesByReuseKey.delete(record.reuseKey);
4718
- }
5358
+ // Remove any public exposure first, but keep the process registered and the
5359
+ // row non-stopped until verified termination succeeds.
4719
5360
  await cleanupRecordExposure(record);
4720
5361
  if (record.child && record.child.pid) {
4721
5362
  await terminateLocalService({
4722
5363
  pid: record.child.pid,
4723
5364
  processGroupId: record.processGroupId ?? record.child.pid,
5365
+ port: record.port,
4724
5366
  });
4725
5367
  }
4726
5368
  else if (record.providerRef) {
@@ -4729,12 +5371,57 @@ async function stopRuntimeService(serviceId) {
4729
5371
  await terminateLocalService({
4730
5372
  pid,
4731
5373
  processGroupId: record.processGroupId,
5374
+ port: record.port,
4732
5375
  });
4733
5376
  }
4734
5377
  }
5378
+ record.status = "stopped";
5379
+ record.healthStatus = "unknown";
5380
+ record.lastUsedAt = new Date().toISOString();
5381
+ record.stoppedAt = new Date().toISOString();
5382
+ runtimeServicesById.delete(serviceId);
5383
+ if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
5384
+ runtimeServicesByReuseKey.delete(record.reuseKey);
5385
+ }
4735
5386
  await removeLocalServiceRegistryRecord(record.serviceKey);
4736
5387
  await persistRuntimeServiceRecord(record.db, record);
4737
5388
  }
5389
+ async function findHealthyRunningRuntimeService(reuseKey) {
5390
+ const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null;
5391
+ const existing = existingId ? runtimeServicesById.get(existingId) : null;
5392
+ if (!existing || existing.status !== "running")
5393
+ return null;
5394
+ const healthInput = {
5395
+ db: existing.db,
5396
+ serviceName: existing.serviceName,
5397
+ command: existing.command,
5398
+ provider: existing.provider,
5399
+ port: existing.port,
5400
+ cwd: existing.cwd,
5401
+ executionWorkspaceId: existing.executionWorkspaceId,
5402
+ companyId: existing.companyId,
5403
+ };
5404
+ let healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
5405
+ if (!healthy) {
5406
+ // A single timeout or connection reset is not enough evidence to destroy a
5407
+ // shared runtime that active runs may still use. Confirm the failure after
5408
+ // a short bounded delay before entering the destructive replacement path.
5409
+ await delay(250);
5410
+ healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
5411
+ }
5412
+ if (healthy)
5413
+ return existing;
5414
+ if (existing.leaseRunIds.size > 0) {
5415
+ existing.healthStatus = "unhealthy";
5416
+ if (reuseKey && runtimeServicesByReuseKey.get(reuseKey) === existing.id) {
5417
+ runtimeServicesByReuseKey.delete(reuseKey);
5418
+ }
5419
+ await persistRuntimeServiceRecord(existing.db, existing);
5420
+ return null;
5421
+ }
5422
+ await stopRuntimeService(existing.id);
5423
+ return null;
5424
+ }
4738
5425
  async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input) {
4739
5426
  const now = new Date();
4740
5427
  const exposureRows = await input.db
@@ -4893,9 +5580,21 @@ function registerRuntimeService(db, record) {
4893
5580
  runtimeServicesByReuseKey.delete(current.reuseKey);
4894
5581
  }
4895
5582
  void (async () => {
4896
- await cleanupRecordExposure(current);
4897
- await removeLocalServiceRegistryRecord(current.serviceKey);
4898
- await persistRuntimeServiceRecord(db, current);
5583
+ // The child exited on its own. Record the terminal status as best effort.
5584
+ // The persist can fail when a parent row is already gone: a caller can
5585
+ // delete the project or the company while this service still runs, and the
5586
+ // `project_id` foreign key then rejects the write. Catch every error here,
5587
+ // or the detached persist becomes an unhandled rejection and crashes the
5588
+ // host. This path runs off the child `exit` event, so no caller awaits it.
5589
+ try {
5590
+ await cleanupRecordExposure(current);
5591
+ await removeLocalServiceRegistryRecord(current.serviceKey);
5592
+ await persistRuntimeServiceRecord(db, current);
5593
+ }
5594
+ catch (err) {
5595
+ const detail = err instanceof Error ? err.message : String(err);
5596
+ console.warn(`[workspace-runtime] runtime service exit cleanup failed for ${current.id}: ${detail}`);
5597
+ }
4899
5598
  })();
4900
5599
  });
4901
5600
  }
@@ -4977,7 +5676,7 @@ async function isPersistedIsolatedExecutionWorkspace(input) {
4977
5676
  .then((rows) => rows[0] ?? null);
4978
5677
  return row?.mode === "isolated_workspace";
4979
5678
  }
4980
- export async function ensureRuntimeServicesForRun(input) {
5679
+ async function ensureRuntimeServicesForRunInvocation(input) {
4981
5680
  const rawServices = selectRuntimeServiceEntries({
4982
5681
  config: input.config,
4983
5682
  respectDesiredStates: true,
@@ -4986,7 +5685,8 @@ export async function ensureRuntimeServicesForRun(input) {
4986
5685
  });
4987
5686
  const acquiredServiceIds = [];
4988
5687
  const refs = [];
4989
- const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
5688
+ const runtimeProvision = resolveRuntimeProvision(input);
5689
+ const runtimeProvisionCommand = runtimeProvision.command;
4990
5690
  const provisionCoordinator = createRuntimeProvisionCoordinator();
4991
5691
  const allowFixedPortFallback = await isPersistedIsolatedExecutionWorkspace({
4992
5692
  db: input.db,
@@ -5014,9 +5714,8 @@ export async function ensureRuntimeServicesForRun(input) {
5014
5714
  scopeId,
5015
5715
  }).reuseKey;
5016
5716
  if (reuseKey) {
5017
- const existingId = runtimeServicesByReuseKey.get(reuseKey);
5018
- const existing = existingId ? runtimeServicesById.get(existingId) : null;
5019
- if (existing && existing.status === "running") {
5717
+ const existing = await findHealthyRunningRuntimeService(reuseKey);
5718
+ if (existing) {
5020
5719
  existing.leaseRunIds.add(input.runId);
5021
5720
  existing.lastUsedAt = new Date().toISOString();
5022
5721
  existing.stoppedAt = null;
@@ -5042,6 +5741,7 @@ export async function ensureRuntimeServicesForRun(input) {
5042
5741
  service,
5043
5742
  onLog: input.onLog,
5044
5743
  runtimeProvisionCommand,
5744
+ runtimeProvisionKind: runtimeProvision.kind,
5045
5745
  recorder: input.recorder,
5046
5746
  provisionCoordinator,
5047
5747
  allowFixedPortFallback,
@@ -5062,6 +5762,104 @@ export async function ensureRuntimeServicesForRun(input) {
5062
5762
  }
5063
5763
  return refs;
5064
5764
  }
5765
+ async function withRuntimeStartMutex(ownerKey, start) {
5766
+ const previous = runtimeControlStartByOwner.get(ownerKey) ?? Promise.resolve();
5767
+ let release;
5768
+ const current = new Promise((resolve) => {
5769
+ release = resolve;
5770
+ });
5771
+ const queued = previous.then(() => current);
5772
+ runtimeControlStartByOwner.set(ownerKey, queued);
5773
+ await previous;
5774
+ try {
5775
+ return await start();
5776
+ }
5777
+ finally {
5778
+ release();
5779
+ if (runtimeControlStartByOwner.get(ownerKey) === queued)
5780
+ runtimeControlStartByOwner.delete(ownerKey);
5781
+ }
5782
+ }
5783
+ function resolveRuntimeStartMutexPlan(input) {
5784
+ const fallbackOwnerId = input.executionWorkspaceId
5785
+ ?? input.workspace.workspaceId
5786
+ ?? path.resolve(input.workspace.cwd);
5787
+ const replacementReuseKeys = [];
5788
+ const keys = input.services.map((service) => {
5789
+ const { scopeType, scopeId } = resolveServiceScopeId({
5790
+ service,
5791
+ workspace: input.workspace,
5792
+ executionWorkspaceId: input.executionWorkspaceId,
5793
+ issue: input.issue,
5794
+ runId: input.runId,
5795
+ agent: input.agent,
5796
+ });
5797
+ const reuseKey = resolveRuntimeServiceReuseIdentity({
5798
+ service,
5799
+ workspace: input.workspace,
5800
+ agent: input.agent,
5801
+ issue: input.issue,
5802
+ adapterEnv: input.adapterEnv,
5803
+ scopeType,
5804
+ scopeId,
5805
+ }).reuseKey;
5806
+ // Converge all callers that can replace an existing shared runtime on its
5807
+ // reuse identity. For an initial start, retain owner-level concurrency so
5808
+ // the exposure allocator's in-flight pair claims remain authoritative.
5809
+ if (reuseKey
5810
+ && (runtimeServicesByReuseKey.has(reuseKey) || runtimeReplacementClaimsByReuseKey.has(reuseKey))) {
5811
+ runtimeReplacementClaimsByReuseKey.set(reuseKey, (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 0) + 1);
5812
+ replacementReuseKeys.push(reuseKey);
5813
+ return `reuse:${reuseKey}`;
5814
+ }
5815
+ return `${input.agent.companyId}:owner:${fallbackOwnerId}`;
5816
+ });
5817
+ return {
5818
+ ownerKeys: [...new Set(keys)].sort(),
5819
+ replacementReuseKeys,
5820
+ };
5821
+ }
5822
+ function releaseRuntimeReplacementClaims(reuseKeys) {
5823
+ for (const reuseKey of reuseKeys) {
5824
+ const next = (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 1) - 1;
5825
+ if (next <= 0)
5826
+ runtimeReplacementClaimsByReuseKey.delete(reuseKey);
5827
+ else
5828
+ runtimeReplacementClaimsByReuseKey.set(reuseKey, next);
5829
+ }
5830
+ }
5831
+ async function withRuntimeStartMutexes(ownerKeys, start) {
5832
+ const acquire = async (index) => {
5833
+ const ownerKey = ownerKeys[index];
5834
+ if (!ownerKey)
5835
+ return await start();
5836
+ return await withRuntimeStartMutex(ownerKey, () => acquire(index + 1));
5837
+ };
5838
+ return await acquire(0);
5839
+ }
5840
+ export async function ensureRuntimeServicesForRun(input) {
5841
+ const services = selectRuntimeServiceEntries({
5842
+ config: input.config,
5843
+ respectDesiredStates: true,
5844
+ defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
5845
+ serviceStates: readConfiguredServiceStates(input.config),
5846
+ });
5847
+ const mutexPlan = resolveRuntimeStartMutexPlan({
5848
+ services,
5849
+ workspace: input.workspace,
5850
+ executionWorkspaceId: input.executionWorkspaceId,
5851
+ issue: input.issue,
5852
+ runId: input.runId,
5853
+ agent: input.agent,
5854
+ adapterEnv: input.adapterEnv,
5855
+ });
5856
+ try {
5857
+ return await withRuntimeStartMutexes(mutexPlan.ownerKeys, () => ensureRuntimeServicesForRunInvocation(input));
5858
+ }
5859
+ finally {
5860
+ releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
5861
+ }
5862
+ }
5065
5863
  async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, persistenceDb = input.db, registryDb = input.db, options) {
5066
5864
  const refs = [];
5067
5865
  const pendingReadiness = [];
@@ -5085,9 +5883,8 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawService
5085
5883
  scopeId,
5086
5884
  }).reuseKey;
5087
5885
  if (reuseKey) {
5088
- const existingId = runtimeServicesByReuseKey.get(reuseKey);
5089
- const existing = existingId ? runtimeServicesById.get(existingId) : null;
5090
- if (existing && existing.status === "running") {
5886
+ const existing = await findHealthyRunningRuntimeService(reuseKey);
5887
+ if (existing) {
5091
5888
  const prepared = options?.preparedProvisioning;
5092
5889
  if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) {
5093
5890
  await persistenceDb
@@ -5119,6 +5916,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawService
5119
5916
  service,
5120
5917
  onLog: input.onLog,
5121
5918
  runtimeProvisionCommand: options?.runtimeProvisionCommand,
5919
+ runtimeProvisionKind: options?.runtimeProvisionKind,
5122
5920
  recorder: input.recorder,
5123
5921
  provisionCoordinator: options?.provisionCoordinator,
5124
5922
  preparedProvisioningRecord: options?.preparedProvisioning?.service === service
@@ -5187,7 +5985,7 @@ async function discardFailedDeferredRuntimeStart(db, record) {
5187
5985
  await removeLocalServiceRegistryRecord(record.serviceKey).catch(() => undefined);
5188
5986
  await persistRuntimeServiceRecord(db, record);
5189
5987
  }
5190
- export async function startRuntimeServicesForWorkspaceControl(input) {
5988
+ async function startRuntimeServicesForWorkspaceControlInvocation(input) {
5191
5989
  const rawServices = selectRuntimeServiceEntries({
5192
5990
  config: input.config,
5193
5991
  serviceIndex: input.serviceIndex,
@@ -5196,7 +5994,8 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5196
5994
  serviceStates: readConfiguredServiceStates(input.config),
5197
5995
  });
5198
5996
  const invocationId = input.invocationId ?? randomUUID();
5199
- const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
5997
+ const runtimeProvision = resolveRuntimeProvision(input);
5998
+ const runtimeProvisionCommand = runtimeProvision.command;
5200
5999
  const provisionCoordinator = createRuntimeProvisionCoordinator();
5201
6000
  const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices);
5202
6001
  if (rawServices.length === 0
@@ -5206,7 +6005,11 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5206
6005
  // path outside the parent-row transaction avoids a crash window where the
5207
6006
  // broker lease exists but the DB transaction has not committed its handle.
5208
6007
  || hasHttpsExposure) {
5209
- const batch = await startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, input.db, input.db, { runtimeProvisionCommand, provisionCoordinator });
6008
+ const batch = await startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, input.db, input.db, {
6009
+ runtimeProvisionCommand,
6010
+ runtimeProvisionKind: runtimeProvision.kind,
6011
+ provisionCoordinator,
6012
+ });
5210
6013
  return batch.refs;
5211
6014
  }
5212
6015
  let startBatch = {
@@ -5236,9 +6039,8 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5236
6039
  scopeType,
5237
6040
  scopeId,
5238
6041
  }).reuseKey;
5239
- const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null;
5240
- const existing = existingId ? runtimeServicesById.get(existingId) : null;
5241
- if (existing?.status === "running")
6042
+ const existing = await findHealthyRunningRuntimeService(reuseKey);
6043
+ if (existing)
5242
6044
  continue;
5243
6045
  const record = await prepareRuntimeProvisioning({
5244
6046
  db: input.db,
@@ -5253,6 +6055,7 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5253
6055
  service,
5254
6056
  onLog: input.onLog,
5255
6057
  runtimeProvisionCommand,
6058
+ runtimeProvisionKind: runtimeProvision.kind,
5256
6059
  recorder: input.recorder,
5257
6060
  provisionCoordinator,
5258
6061
  reuseKey,
@@ -5274,6 +6077,7 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5274
6077
  deferReadiness: true,
5275
6078
  allowFixedPortFallback,
5276
6079
  runtimeProvisionCommand,
6080
+ runtimeProvisionKind: runtimeProvision.kind,
5277
6081
  provisionCoordinator,
5278
6082
  preparedProvisioning,
5279
6083
  });
@@ -5370,6 +6174,31 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
5370
6174
  throw error;
5371
6175
  }
5372
6176
  }
6177
+ export async function startRuntimeServicesForWorkspaceControl(input) {
6178
+ const services = selectRuntimeServiceEntries({
6179
+ config: input.config,
6180
+ serviceIndex: input.serviceIndex,
6181
+ respectDesiredStates: input.respectDesiredStates,
6182
+ defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
6183
+ serviceStates: readConfiguredServiceStates(input.config),
6184
+ });
6185
+ const invocationId = input.invocationId ?? "workspace_control";
6186
+ const mutexPlan = resolveRuntimeStartMutexPlan({
6187
+ services,
6188
+ workspace: input.workspace,
6189
+ executionWorkspaceId: input.executionWorkspaceId,
6190
+ issue: input.issue,
6191
+ runId: invocationId,
6192
+ agent: input.actor,
6193
+ adapterEnv: input.adapterEnv,
6194
+ });
6195
+ try {
6196
+ return await withRuntimeStartMutexes(mutexPlan.ownerKeys, () => startRuntimeServicesForWorkspaceControlInvocation(input));
6197
+ }
6198
+ finally {
6199
+ releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
6200
+ }
6201
+ }
5373
6202
  export async function releaseRuntimeServicesForRun(runId) {
5374
6203
  const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
5375
6204
  runtimeServiceLeasesByRun.delete(runId);
@@ -5382,7 +6211,12 @@ export async function releaseRuntimeServicesForRun(runId) {
5382
6211
  const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual");
5383
6212
  await persistRuntimeServiceRecord(record.db, record);
5384
6213
  if (record.leaseRunIds.size === 0) {
5385
- if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") {
6214
+ const detachedUnhealthySharedRuntime = record.healthStatus === "unhealthy"
6215
+ && Boolean(record.reuseKey)
6216
+ && runtimeServicesByReuseKey.get(record.reuseKey) !== record.id;
6217
+ if (record.lifecycle === "ephemeral"
6218
+ || stopType === "on_run_finish"
6219
+ || detachedUnhealthySharedRuntime) {
5386
6220
  await stopRuntimeService(serviceId);
5387
6221
  continue;
5388
6222
  }
@@ -5571,6 +6405,45 @@ async function buildPersistedRuntimeExposureIntentLookup(db) {
5571
6405
  return readRuntimeExposureIntent(parseObject(entry.expose));
5572
6406
  };
5573
6407
  }
6408
+ export async function refreshPersistedRuntimeServiceHealth(input) {
6409
+ const ownershipCondition = input.projectWorkspaceId
6410
+ ? or(eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), and(eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), eq(workspaceRuntimeServices.scopeType, "project_workspace")))
6411
+ : eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId);
6412
+ const rows = await input.db
6413
+ .select({
6414
+ id: workspaceRuntimeServices.id,
6415
+ serviceName: workspaceRuntimeServices.serviceName,
6416
+ command: workspaceRuntimeServices.command,
6417
+ provider: workspaceRuntimeServices.provider,
6418
+ port: workspaceRuntimeServices.port,
6419
+ url: workspaceRuntimeServices.url,
6420
+ healthStatus: workspaceRuntimeServices.healthStatus,
6421
+ cwd: workspaceRuntimeServices.cwd,
6422
+ executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
6423
+ companyId: workspaceRuntimeServices.companyId,
6424
+ })
6425
+ .from(workspaceRuntimeServices)
6426
+ .where(and(eq(workspaceRuntimeServices.companyId, input.companyId), eq(workspaceRuntimeServices.provider, "local_process"), eq(workspaceRuntimeServices.status, "running"), ownershipCondition));
6427
+ const results = await Promise.all(rows.map(async (row) => ({
6428
+ row,
6429
+ healthStatus: await isRuntimeServiceUrlHealthy(row.url, { ...row, db: input.db })
6430
+ ? "healthy"
6431
+ : "unhealthy",
6432
+ })));
6433
+ await Promise.all(results.map(async ({ row, healthStatus }) => {
6434
+ const liveRecord = runtimeServicesById.get(row.id);
6435
+ if (liveRecord)
6436
+ liveRecord.healthStatus = healthStatus;
6437
+ if (row.healthStatus === healthStatus)
6438
+ return;
6439
+ await input.db.update(workspaceRuntimeServices).set({ healthStatus, updatedAt: new Date() }).where(and(eq(workspaceRuntimeServices.id, row.id), eq(workspaceRuntimeServices.companyId, input.companyId), eq(workspaceRuntimeServices.status, "running")));
6440
+ }));
6441
+ return {
6442
+ checked: results.length,
6443
+ healthy: results.filter((result) => result.healthStatus === "healthy").length,
6444
+ unhealthy: results.filter((result) => result.healthStatus === "unhealthy").length,
6445
+ };
6446
+ }
5574
6447
  export async function reconcilePersistedRuntimeServicesOnStartup(db) {
5575
6448
  const rows = await db
5576
6449
  .select()
@@ -5724,9 +6597,28 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db) {
5724
6597
  }
5725
6598
  if (adoptedRecord) {
5726
6599
  const adoptedUrl = adoptedRecord.url ?? row.backendUrl ?? row.url ?? null;
6600
+ const adoptedHealthInput = {
6601
+ db,
6602
+ serviceName: row.serviceName,
6603
+ command: row.command,
6604
+ provider: "local_process",
6605
+ port: adoptedRecord.port ?? row.port,
6606
+ cwd: row.cwd,
6607
+ executionWorkspaceId: row.executionWorkspaceId ?? null,
6608
+ companyId: row.companyId,
6609
+ };
6610
+ // A surviving service can be slow to answer one probe when the host is
6611
+ // busy at startup. One timeout is not enough evidence to terminate it.
6612
+ // Confirm an unhealthy verdict with a second probe after a short bounded
6613
+ // delay, the same way the reuse path protects a shared runtime.
6614
+ let adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
6615
+ if (!adoptedHealthy) {
6616
+ await delay(250);
6617
+ adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
6618
+ }
5727
6619
  if (backfillDecision.action === "reprovision"
5728
6620
  || !exposureHealthMatches
5729
- || !(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName: row.serviceName, command: row.command }))) {
6621
+ || !adoptedHealthy) {
5730
6622
  if (backfillDecision.action === "reprovision")
5731
6623
  backfilled += 1;
5732
6624
  await terminateLocalService(adoptedRecord);
@@ -5885,6 +6777,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db) {
5885
6777
  worktreePath: null,
5886
6778
  warnings: [],
5887
6779
  created: false,
6780
+ branchCreatedByRuntime: false,
5888
6781
  },
5889
6782
  config: {
5890
6783
  workspaceRuntime: runtimeConfig.workspaceRuntime,
@@ -5937,6 +6830,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db) {
5937
6830
  worktreePath: row.strategyType === "git_worktree" ? row.cwd : null,
5938
6831
  warnings: [],
5939
6832
  created: false,
6833
+ branchCreatedByRuntime: isRuntimeOwnedGitBranch(row.metadata),
5940
6834
  },
5941
6835
  executionWorkspaceId: row.id,
5942
6836
  config: {