@hunter-harness/workflow-harness 0.4.0 → 0.4.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 (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,3355 @@
1
+ #!/usr/bin/env python3
2
+ """Harness service-session manager (D7).
3
+
4
+ Subcommands:
5
+ ensure — reuse / restart / start / needs-user-decision
6
+ status — session + liveness + fingerprint match
7
+ stop — stop AI-managed service and clear session
8
+
9
+ Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
10
+
11
+ Safety: never kill a process that cannot be verified as AI-started.
12
+ Missing or corrupt session → treat as user process → needs-user-decision.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import signal
24
+ import socket
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any
33
+ from urllib.parse import urlparse
34
+
35
+
36
+ SCRIPTS_DIR = Path(__file__).resolve().parent
37
+ if str(SCRIPTS_DIR) not in sys.path:
38
+ sys.path.insert(0, str(SCRIPTS_DIR))
39
+
40
+ from harness_ledger import compute_inputs_hash # noqa: E402
41
+ import harness_process as _process_provider # noqa: E402
42
+ from harness_profile import ( # noqa: E402
43
+ normalize_service_start,
44
+ resolve_service_argv,
45
+ )
46
+
47
+
48
+ if hasattr(sys.stdout, "reconfigure"):
49
+ sys.stdout.reconfigure(encoding="utf-8")
50
+ if hasattr(sys.stderr, "reconfigure"):
51
+ sys.stderr.reconfigure(encoding="utf-8")
52
+
53
+
54
+ PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
55
+ SESSION_REL = Path("runtime") / "service-session.json"
56
+ LOG_REL = Path("logs") / "service-start.log"
57
+ MUTATION_LOCK_REL = Path("runtime") / "service-mutation.lock"
58
+ MUTATION_STATE_REL = Path("runtime") / "service-mutation.json"
59
+ HEARTBEAT_TTL_SECONDS = 30
60
+
61
+ FATAL_KEYWORDS = (
62
+ "BindException",
63
+ "Could not resolve placeholder",
64
+ "BeanCreationException",
65
+ "BUILD FAILURE",
66
+ )
67
+
68
+ # Process create-time vs session.startedAt tolerance (seconds).
69
+ IDENTITY_TOLERANCE_SEC = 5.0
70
+ STOP_CONFIRM_TIMEOUT_SEC = 5.0
71
+
72
+ # Windows process flags
73
+ _DETACHED_PROCESS = 0x00000008
74
+ _CREATE_NEW_PROCESS_GROUP = 0x00000200
75
+ _CREATE_NO_WINDOW = 0x08000000
76
+ _CREATE_BREAKAWAY_FROM_JOB = 0x01000000
77
+ _STILL_ACTIVE = 259
78
+
79
+
80
+ def now_iso() -> str:
81
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
82
+
83
+
84
+ def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
85
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
86
+ if as_json:
87
+ sys.stdout.write(text)
88
+ else:
89
+ action = payload.get("action") or payload.get("status")
90
+ ok = payload.get("ok", True)
91
+ sys.stdout.write(f"ok={ok} action={action}\n")
92
+
93
+
94
+ def emit_error(message: str, *, as_json: bool, code: int = 1, **extra: Any) -> int:
95
+ payload: dict[str, Any] = {"ok": False, "error": message}
96
+ payload.update(extra)
97
+ if as_json:
98
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
99
+ else:
100
+ sys.stderr.write(f"error: {message}\n")
101
+ return code
102
+
103
+
104
+ def resolve_path(raw: str | Path) -> Path:
105
+ return Path(raw).expanduser().resolve()
106
+
107
+
108
+ def parse_files_arg(raw: str | None) -> list[str]:
109
+ if raw is None or not str(raw).strip():
110
+ return []
111
+ parts = [p.strip() for p in str(raw).split(",")]
112
+ return [p for p in parts if p]
113
+
114
+
115
+ def sha256_text(text: str) -> str:
116
+ return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def session_path(change_dir: Path) -> Path:
120
+ return change_dir / SESSION_REL
121
+
122
+
123
+ def log_path(change_dir: Path) -> Path:
124
+ return change_dir / LOG_REL
125
+
126
+
127
+ def mutation_lock_path(change_dir: Path) -> Path:
128
+ return change_dir / MUTATION_LOCK_REL
129
+
130
+
131
+ def mutation_state_path(change_dir: Path) -> Path:
132
+ return change_dir / MUTATION_STATE_REL
133
+
134
+
135
+ def _allocate_service_generation(change_dir: Path) -> int:
136
+ """Allocate a monotonic incarnation number under the per-change lock."""
137
+
138
+ previous: list[int] = []
139
+ try:
140
+ current = load_session(change_dir)
141
+ except SessionCorrupt:
142
+ current = None
143
+ if isinstance(current, dict):
144
+ value = current.get("serviceGeneration")
145
+ if value is not None:
146
+ previous.append(_normalize_service_generation(value))
147
+ state_path = mutation_state_path(change_dir)
148
+ if state_path.is_file():
149
+ try:
150
+ state = read_json(state_path)
151
+ except (OSError, ValueError, json.JSONDecodeError):
152
+ state = None
153
+ if isinstance(state, dict) and state.get("serviceGeneration") is not None:
154
+ previous.append(_normalize_service_generation(state.get("serviceGeneration")))
155
+ retired_root = change_dir / "runtime" / "retired-service-sessions"
156
+ if retired_root.is_dir():
157
+ for receipt_path in retired_root.glob("*.receipt.json"):
158
+ try:
159
+ receipt = read_json(receipt_path)
160
+ except (OSError, ValueError, json.JSONDecodeError):
161
+ continue
162
+ if isinstance(receipt, dict) and receipt.get("oldGeneration") is not None:
163
+ previous.append(_normalize_service_generation(receipt.get("oldGeneration")))
164
+ now_generation = max(1, int(time.time_ns() // 1_000_000))
165
+ return max([now_generation, *(value + 1 for value in previous)])
166
+
167
+
168
+ class ServiceMutationLock:
169
+ """Small CAS-style lock for ensure/stop/retire writers.
170
+
171
+ The lock record is diagnostic only; ownership is the exclusive file
172
+ descriptor plus a random token. A dead owner may be reclaimed, while a
173
+ live owner always produces a typed conflict and no state mutation.
174
+ """
175
+
176
+ def __init__(self, change_dir: Path, *, operation: str) -> None:
177
+ self.change_dir = change_dir.resolve()
178
+ self.operation = operation
179
+ self.path = mutation_lock_path(self.change_dir)
180
+ self.token = uuid.uuid4().hex
181
+ self._owned = False
182
+
183
+ def acquire(self) -> dict[str, Any] | None:
184
+ self.path.parent.mkdir(parents=True, exist_ok=True)
185
+ payload = {
186
+ "schemaVersion": 1,
187
+ "operation": self.operation,
188
+ "operationId": self.token,
189
+ "pid": os.getpid(),
190
+ "startedAt": now_iso(),
191
+ }
192
+ encoded = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
193
+ for _attempt in range(2):
194
+ try:
195
+ fd = os.open(
196
+ self.path,
197
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
198
+ 0o600,
199
+ )
200
+ except FileExistsError:
201
+ try:
202
+ current = read_json(self.path)
203
+ except (OSError, ValueError, json.JSONDecodeError):
204
+ current = None
205
+ owner_pid = current.get("pid") if isinstance(current, dict) else None
206
+ if (
207
+ isinstance(owner_pid, int)
208
+ and owner_pid > 0
209
+ and not _process_provider.is_pid_alive(owner_pid)
210
+ ):
211
+ try:
212
+ self.path.unlink()
213
+ except FileNotFoundError:
214
+ pass
215
+ continue
216
+ return {
217
+ "ok": False,
218
+ "code": "SERVICE_MUTATION_CONFLICT",
219
+ "path": str(self.path),
220
+ "owner": current,
221
+ }
222
+ else:
223
+ try:
224
+ with os.fdopen(fd, "wb") as handle:
225
+ handle.write(encoded)
226
+ handle.flush()
227
+ os.fsync(handle.fileno())
228
+ except BaseException:
229
+ self.path.unlink(missing_ok=True)
230
+ raise
231
+ self._owned = True
232
+ return None
233
+ return {
234
+ "ok": False,
235
+ "code": "SERVICE_MUTATION_CONFLICT",
236
+ "path": str(self.path),
237
+ }
238
+
239
+ def release(self) -> None:
240
+ if not self._owned:
241
+ return
242
+ try:
243
+ current = read_json(self.path)
244
+ except (OSError, ValueError, json.JSONDecodeError):
245
+ current = None
246
+ if isinstance(current, dict) and current.get("operationId") == self.token:
247
+ self.path.unlink(missing_ok=True)
248
+ self._owned = False
249
+
250
+
251
+ def profile_path(project: Path) -> Path:
252
+ return project / PROFILE_REL
253
+
254
+
255
+ def read_json(path: Path) -> Any:
256
+ return json.loads(path.read_text(encoding="utf-8-sig"))
257
+
258
+
259
+ def write_json(path: Path, data: Any) -> None:
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
262
+ # 原子写 temp+os.replace:崩溃后不留半写文件(与 runtime-helpers.mjs writeJsonUtf8NoBom 一致)。
263
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
264
+ try:
265
+ tmp.write_text(text, encoding="utf-8", newline="\n")
266
+ os.replace(tmp, path)
267
+ except BaseException:
268
+ tmp.unlink(missing_ok=True)
269
+ raise
270
+
271
+
272
+ def load_build_profile(project: Path) -> dict[str, Any]:
273
+ path = profile_path(project)
274
+ if not path.is_file():
275
+ raise FileNotFoundError(
276
+ f"build-profile.json missing: {path}; run harness_preflight.py detect first"
277
+ )
278
+ data = read_json(path)
279
+ if not isinstance(data, dict):
280
+ raise ValueError(f"build-profile.json must be an object: {path}")
281
+ return data
282
+
283
+
284
+ def get_service_start(profile: dict[str, Any]) -> dict[str, Any]:
285
+ svc = profile.get("serviceStart")
286
+ if not isinstance(svc, dict):
287
+ raise ValueError("build-profile.json missing serviceStart object")
288
+ command = svc.get("command")
289
+ argv_template = svc.get("argvTemplate")
290
+ if not (
291
+ isinstance(command, str)
292
+ and command.strip()
293
+ ) and not (
294
+ isinstance(argv_template, list)
295
+ and argv_template
296
+ and all(isinstance(item, str) and "\0" not in item for item in argv_template)
297
+ ):
298
+ raise ValueError(
299
+ "build-profile.json serviceStart.command/argvTemplate is empty; "
300
+ "configure the service start argv explicitly (will not guess)"
301
+ )
302
+ return svc
303
+
304
+
305
+ # Worktree/change 路径标记:持久 profile 的 serviceStart 不得含这些具体路径。
306
+ # runtime resolve 把具体 overlay/profile 注入到 session,不写回持久 profile
307
+ # (spec §3.1 持久 profile 只保存模板;§3.4 修复输入端陈旧 profile)。
308
+ STALE_WORKTREE_MARKERS: tuple[str, ...] = (
309
+ ".worktrees/",
310
+ ".claude/worktrees/",
311
+ ".cursor/worktrees/",
312
+ ".codeium/worktrees/",
313
+ )
314
+
315
+
316
+ def _detect_stale_persistent_values(service_start: dict[str, Any]) -> list[str]:
317
+ """检测 serviceStart 是否含具体旧 worktree/change 持久路径。
318
+
319
+ 持久 profile 只保存模板;command/overlayPath/profile 嵌入 worktree 路径
320
+ 说明是 v1 残留或被错误写回的已解析 overlay,必须拒绝(spec §3.4)。
321
+ """
322
+ stale: list[str] = []
323
+ for field in ("command", "argvTemplate", "overlayPath", "profile"):
324
+ val = service_start.get(field)
325
+ if isinstance(val, list):
326
+ values = [item for item in val if isinstance(item, str)]
327
+ else:
328
+ values = [val] if isinstance(val, str) else []
329
+ if not values:
330
+ continue
331
+ for value in values:
332
+ for marker in STALE_WORKTREE_MARKERS:
333
+ if marker in value:
334
+ stale.append(f"{field} contains stale worktree path: {marker}")
335
+ break
336
+ return stale
337
+
338
+
339
+ def resolve_service_start(
340
+ profile: dict[str, Any],
341
+ *,
342
+ change_name: str | None = None,
343
+ worktree_root: Path | None = None,
344
+ overlay_path: str | None = None,
345
+ leased_port: int | None = None,
346
+ lease_owner: str | None = None,
347
+ ) -> dict[str, Any]:
348
+ """从模板 serviceStart + runtime context 生成 resolved serviceStart。
349
+
350
+ spec §3.1:持久 profile 的 serviceStart 是模板(profile/overlayPath 留空);
351
+ runtime 注入具体 overlay/profile 到返回值,写入 session,**不写回持久 profile**。
352
+ spec §3.4:含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
353
+
354
+ 向后兼容:持久 profile.profile 非空(如 v1 残留 "local-dev")→ 保留;
355
+ overlay_path 未提供 → 保留持久 overlayPath(可能为空)。
356
+ """
357
+ service_start = get_service_start(profile) # 校验结构化 argv/legacy command 非空
358
+
359
+ stale = _detect_stale_persistent_values(service_start)
360
+ if stale:
361
+ raise ValueError(
362
+ "serviceStart contains stale persistent worktree/change values; "
363
+ "run harness_profile.py migrate to clear: " + "; ".join(stale)
364
+ )
365
+
366
+ resolved = dict(service_start)
367
+ # runtime overlay 注入:显式 overlay_path 覆盖;否则保留持久模板值
368
+ if overlay_path is not None:
369
+ resolved["overlayPath"] = overlay_path
370
+ # runtime profile 注入:持久 profile 留空时用 change_name;非空则保留
371
+ if not str(resolved.get("profile") or "").strip():
372
+ resolved["profile"] = change_name or "local-dev"
373
+ if leased_port is not None:
374
+ if not lease_owner:
375
+ raise ValueError("lease owner is required when leased port is provided")
376
+ resolved["leasedPort"] = leased_port
377
+ resolved["leaseOwner"] = lease_owner
378
+ resolved["port"] = leased_port
379
+ for field in ("command", "healthUrl", "healthFile", "overlayPath"):
380
+ value = resolved.get(field)
381
+ if isinstance(value, str):
382
+ resolved[field] = value.replace("{leasedPort}", str(leased_port))
383
+ # Resolve legacy commands through the tested platform parser and resolve
384
+ # placeholders element-by-element. The consumer below only receives
385
+ # ``argv`` and never invokes a shell.
386
+ resolved["argv"] = resolve_service_argv(
387
+ resolved,
388
+ {"{leasedPort}": str(leased_port)} if leased_port is not None else {},
389
+ )
390
+ # worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
391
+ # 不写入返回值,避免污染 session。
392
+ _ = worktree_root
393
+ return resolved
394
+
395
+
396
+ def resolve_service_input_files(
397
+ project: Path,
398
+ service_start: dict[str, Any],
399
+ cli_files: list[str],
400
+ ) -> list[str]:
401
+ """Union of CLI ``--files`` and ``serviceStart.inputFiles`` globs.
402
+
403
+ Globs expand relative to project; only files inside project are kept
404
+ (deduped, path-sorted). Empty result raises ValueError -- never produce a
405
+ reusable empty fingerprint (§5.1/§5.2). Never globs outside project.
406
+ """
407
+ base = project.resolve()
408
+ seen: set[str] = set()
409
+
410
+ for raw in cli_files:
411
+ p = Path(raw).expanduser()
412
+ if not p.is_absolute():
413
+ p = base / p
414
+ try:
415
+ p = p.resolve()
416
+ except OSError:
417
+ continue
418
+ if not p.is_file():
419
+ continue
420
+ try:
421
+ p.relative_to(base)
422
+ except ValueError:
423
+ continue # reject project-external path
424
+ seen.add(p.as_posix())
425
+
426
+ input_files = service_start.get("inputFiles")
427
+ if isinstance(input_files, list):
428
+ for pat in input_files:
429
+ if not isinstance(pat, str) or not pat.strip():
430
+ continue
431
+ for match in base.glob(pat):
432
+ if not match.is_file():
433
+ continue
434
+ resolved = match.resolve()
435
+ try:
436
+ resolved.relative_to(base)
437
+ except ValueError:
438
+ continue # reject project-external glob escape
439
+ seen.add(resolved.as_posix())
440
+
441
+ result = sorted(seen)
442
+ if not result:
443
+ raise ValueError(
444
+ "service inputs are empty; configure serviceStart.inputFiles "
445
+ "(or pass --files) so the service fingerprint covers real source"
446
+ )
447
+ return result
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Process liveness / identity (stdlib-first; ctypes best-effort on Windows)
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ def is_pid_alive(pid: int) -> bool:
456
+ """Compatibility re-export; process facts come from the shared provider."""
457
+ return _process_provider.is_pid_alive(pid)
458
+
459
+
460
+ def _windows_pid_alive(pid: int) -> bool:
461
+ try:
462
+ import ctypes
463
+ from ctypes import wintypes
464
+
465
+ kernel32 = ctypes.windll.kernel32
466
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
467
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
468
+ if not handle:
469
+ handle = kernel32.OpenProcess(0x0400, False, pid) # PROCESS_QUERY_INFORMATION
470
+ if not handle:
471
+ return False
472
+ try:
473
+ exit_code = wintypes.DWORD()
474
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
475
+ return False
476
+ return int(exit_code.value) == _STILL_ACTIVE
477
+ finally:
478
+ kernel32.CloseHandle(handle)
479
+ except Exception:
480
+ return False
481
+
482
+
483
+ def get_process_create_time(pid: int) -> dt.datetime | None:
484
+ """Best-effort process create time (timezone-aware). None if unavailable."""
485
+ return _process_provider.get_process_create_time(pid)
486
+
487
+
488
+ def get_process_executable(pid: int) -> str | None:
489
+ """Best-effort executable identity for PID-reuse protection."""
490
+ return _process_provider.get_process_executable(pid)
491
+
492
+
493
+ def _windows_process_create_time(pid: int) -> dt.datetime | None:
494
+ try:
495
+ import ctypes
496
+ from ctypes import wintypes
497
+
498
+ kernel32 = ctypes.windll.kernel32
499
+ handle = kernel32.OpenProcess(0x1000, False, pid)
500
+ if not handle:
501
+ handle = kernel32.OpenProcess(0x0400, False, pid)
502
+ if not handle:
503
+ return None
504
+ try:
505
+ creation = wintypes.FILETIME()
506
+ exit_time = wintypes.FILETIME()
507
+ kernel_time = wintypes.FILETIME()
508
+ user_time = wintypes.FILETIME()
509
+ ok = kernel32.GetProcessTimes(
510
+ handle,
511
+ ctypes.byref(creation),
512
+ ctypes.byref(exit_time),
513
+ ctypes.byref(kernel_time),
514
+ ctypes.byref(user_time),
515
+ )
516
+ if not ok:
517
+ return None
518
+ val = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
519
+ # FILETIME: 100-ns since 1601-01-01 UTC
520
+ unix_sec = (val - 116444736000000000) / 10_000_000
521
+ return dt.datetime.fromtimestamp(unix_sec, tz=dt.timezone.utc)
522
+ finally:
523
+ kernel32.CloseHandle(handle)
524
+ except Exception:
525
+ return None
526
+
527
+
528
+ def _posix_process_create_time(pid: int) -> dt.datetime | None:
529
+ try:
530
+ stat_path = Path(f"/proc/{pid}/stat")
531
+ if not stat_path.is_file():
532
+ # macOS / other: no reliable stdlib create-time
533
+ return None
534
+ text = stat_path.read_text(encoding="utf-8", errors="replace")
535
+ # comm may contain spaces/parens — split after last ')'
536
+ rparen = text.rfind(")")
537
+ if rparen < 0:
538
+ return None
539
+ fields = text[rparen + 2 :].split()
540
+ # field index 20 in remaining = starttime (clock ticks since boot)
541
+ start_ticks = int(fields[19])
542
+ ticks = os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))
543
+ if not ticks:
544
+ ticks = 100
545
+ boot = _linux_boot_time()
546
+ if boot is None:
547
+ return None
548
+ return dt.datetime.fromtimestamp(boot + start_ticks / ticks, tz=dt.timezone.utc)
549
+ except Exception:
550
+ return None
551
+
552
+
553
+ def _linux_boot_time() -> float | None:
554
+ try:
555
+ for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines():
556
+ if line.startswith("btime "):
557
+ return float(line.split()[1])
558
+ except Exception:
559
+ return None
560
+ return None
561
+
562
+
563
+ def parse_iso(value: Any) -> dt.datetime | None:
564
+ if not isinstance(value, str) or not value.strip():
565
+ return None
566
+ text = value.strip()
567
+ try:
568
+ # Support "...Z" and space separator
569
+ if text.endswith("Z"):
570
+ text = text[:-1] + "+00:00"
571
+ parsed = dt.datetime.fromisoformat(text)
572
+ if parsed.tzinfo is None:
573
+ parsed = parsed.astimezone()
574
+ return parsed
575
+ except ValueError:
576
+ return None
577
+
578
+
579
+ def verify_process_identity(session: dict[str, Any]) -> bool | None:
580
+ """True=same process, False=definitely different/dead, None=cannot verify.
581
+
582
+ Requires pid alive AND create-time within tolerance of session.startedAt.
583
+ If create-time unavailable → None (conservative → needs-user-decision).
584
+ """
585
+ pid = session.get("pid")
586
+ if not isinstance(pid, int) or pid <= 0:
587
+ return False
588
+ process_identity = session.get("processIdentity")
589
+ process_identity = process_identity if isinstance(process_identity, dict) else {}
590
+ process_attestation = session.get("processAttestation")
591
+ process_attestation = (
592
+ process_attestation if isinstance(process_attestation, dict) else {}
593
+ )
594
+ attested_identity = process_attestation or process_identity
595
+ expected: dict[str, Any] = {
596
+ "schemaVersion": 1,
597
+ "pid": pid,
598
+ "createdAt": attested_identity.get("createdAt")
599
+ or process_identity.get("startedAt")
600
+ or session.get("startedAt"),
601
+ "executable": attested_identity.get("executable")
602
+ or process_identity.get("executable"),
603
+ "argvHash": attested_identity.get("argvHash")
604
+ or process_identity.get("argvHash")
605
+ or session.get("argvHash"),
606
+ "workingDirectory": attested_identity.get("workingDirectory")
607
+ or process_identity.get("workingDirectory")
608
+ or session.get("workingDirectory"),
609
+ "parentIdentity": attested_identity.get("parentIdentity")
610
+ or process_identity.get("parentIdentity"),
611
+ "ownerTokenHash": attested_identity.get("ownerTokenHash")
612
+ or process_identity.get("ownerTokenHash")
613
+ or session.get("ownerTokenHash"),
614
+ "fieldProvenance": attested_identity.get("fieldProvenance")
615
+ or process_identity.get("fieldProvenance")
616
+ or {},
617
+ }
618
+ if expected["createdAt"] is None:
619
+ return None
620
+ observed = _process_provider.observe_process_identity(pid)
621
+ # Keep the historical service re-export seam observable for callers and
622
+ # tests that temporarily disable a platform capability. In normal use
623
+ # these wrappers delegate to the provider, so this does not create a
624
+ # second identity implementation.
625
+ compatibility_created = get_process_create_time(pid)
626
+ if compatibility_created is None:
627
+ observed["createdAt"] = None
628
+ observed["fieldProvenance"]["createdAt"] = "UNAVAILABLE"
629
+ observed["capabilities"]["canObserveCreateTime"] = False
630
+ else:
631
+ observed["createdAt"] = compatibility_created.astimezone(
632
+ dt.timezone.utc
633
+ ).isoformat(timespec="microseconds")
634
+ observed["fieldProvenance"]["createdAt"] = "OBSERVED"
635
+ compatibility_executable = get_process_executable(pid)
636
+ if compatibility_executable is None:
637
+ observed["executable"] = None
638
+ observed["fieldProvenance"]["executable"] = "UNAVAILABLE"
639
+ observed["capabilities"]["canObserveExecutable"] = False
640
+ else:
641
+ observed["executable"] = compatibility_executable
642
+ observed["fieldProvenance"]["executable"] = "OBSERVED"
643
+ observed["capabilities"]["canObserveExecutable"] = True
644
+ decision = _process_provider.verify_process_identity(expected, observed)
645
+ if decision.get("ok") is True:
646
+ return True
647
+ if decision.get("reasonCode") in {
648
+ "PROCESS_IDENTITY_MISMATCH",
649
+ "PROCESS_CREATE_TIME_MISMATCH",
650
+ "PROCESS_EXECUTABLE_MISMATCH",
651
+ "PROCESS_ARGV_MISMATCH",
652
+ "PROCESS_CWD_MISMATCH",
653
+ "PROCESS_PARENT_MISMATCH",
654
+ "PROCESS_OWNER_MISMATCH",
655
+ }:
656
+ return False
657
+ return None
658
+
659
+
660
+ def terminate_process_tree(
661
+ pid: int,
662
+ *,
663
+ expected_attestation: dict[str, Any] | None = None,
664
+ ownership_proof: dict[str, Any] | None = None,
665
+ timeout_policy: dict[str, Any] | None = None,
666
+ ) -> dict[str, Any]:
667
+ """Compatibility facade requiring a complete attestation and proof.
668
+
669
+ The old PID-only API is intentionally diagnostic-only. Callers that
670
+ cannot supply a platform ownership proof receive a fail-closed result and
671
+ no signal is sent.
672
+ """
673
+ if not isinstance(expected_attestation, dict):
674
+ expected_attestation = {"pid": pid}
675
+ return _process_provider.terminate_owned_tree(
676
+ expected_attestation,
677
+ ownership_proof,
678
+ timeout_policy,
679
+ )
680
+
681
+
682
+ # ---------------------------------------------------------------------------
683
+ # Port / health probing
684
+ # ---------------------------------------------------------------------------
685
+
686
+
687
+ def extract_port(service_start: dict[str, Any]) -> int | None:
688
+ for key in ("port", "listenPort"):
689
+ val = service_start.get(key)
690
+ if isinstance(val, int) and 0 < val < 65536:
691
+ return val
692
+ if isinstance(val, str) and val.strip().isdigit():
693
+ p = int(val.strip())
694
+ if 0 < p < 65536:
695
+ return p
696
+
697
+ health = service_start.get("healthUrl") or service_start.get("healthFile") or ""
698
+ if not isinstance(health, str) or not health.strip():
699
+ return None
700
+ return port_from_health_spec(health.strip())
701
+
702
+
703
+ def port_from_health_spec(spec: str) -> int | None:
704
+ lower = spec.lower()
705
+ if lower.startswith("file:") or lower.startswith("path:"):
706
+ return None
707
+ # bare path (Windows drive or relative) — not a URL
708
+ if re.match(r"^[A-Za-z]:[\\/]", spec) or (os.sep in spec and "://" not in spec):
709
+ return None
710
+ if lower.startswith("tcp://") or lower.startswith("socket://"):
711
+ rest = spec.split("://", 1)[1]
712
+ host_port = rest.split("/", 1)[0]
713
+ if ":" in host_port:
714
+ try:
715
+ return int(host_port.rsplit(":", 1)[1])
716
+ except ValueError:
717
+ return None
718
+ return None
719
+ try:
720
+ parsed = urlparse(spec if "://" in spec else f"http://{spec}")
721
+ if parsed.port:
722
+ return int(parsed.port)
723
+ except ValueError:
724
+ return None
725
+ return None
726
+
727
+
728
+ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
729
+ """True if something is accepting connections on host:port."""
730
+ try:
731
+ with socket.create_connection((host, port), timeout=0.5):
732
+ return True
733
+ except OSError:
734
+ return False
735
+
736
+
737
+ def resolve_health_file(spec: str) -> Path | None:
738
+ lower = spec.lower()
739
+ if lower.startswith("file:"):
740
+ raw = spec[5:]
741
+ # file:///C:/path or file:C:/path or file:/path
742
+ if raw.startswith("///"):
743
+ raw = raw[3:]
744
+ elif raw.startswith("//"):
745
+ raw = raw[2:]
746
+ return Path(raw)
747
+ if lower.startswith("path:"):
748
+ return Path(spec[5:])
749
+ if re.match(r"^[A-Za-z]:[\\/]", spec) or (spec.startswith(".") and "://" not in spec):
750
+ return Path(spec)
751
+ return None
752
+
753
+
754
+ def probe_health(
755
+ service_start: dict[str, Any],
756
+ *,
757
+ expected_instance_token: str | None = None,
758
+ ) -> bool:
759
+ """Return True if service appears healthy per serviceStart health config."""
760
+ # Prefer explicit healthFile
761
+ health_file = service_start.get("healthFile")
762
+ if isinstance(health_file, str) and health_file.strip():
763
+ path = Path(health_file.strip())
764
+ if not path.is_file():
765
+ return False
766
+ if expected_instance_token:
767
+ try:
768
+ return expected_instance_token in path.read_text(
769
+ encoding="utf-8", errors="replace"
770
+ )
771
+ except OSError:
772
+ return False
773
+ return True
774
+
775
+ spec = service_start.get("healthUrl")
776
+ if not isinstance(spec, str) or not spec.strip():
777
+ # No health probe configured → treat as healthy once process is up
778
+ # (caller should still have started the process). For wait loop, require
779
+ # at least that we have a running pid — handled by caller.
780
+ return True
781
+
782
+ spec = spec.strip()
783
+ file_path = resolve_health_file(spec)
784
+ if file_path is not None:
785
+ if not file_path.is_file():
786
+ return False
787
+ if expected_instance_token:
788
+ try:
789
+ return expected_instance_token in file_path.read_text(
790
+ encoding="utf-8", errors="replace"
791
+ )
792
+ except OSError:
793
+ return False
794
+ return True
795
+
796
+ lower = spec.lower()
797
+ if lower.startswith("tcp://") or lower.startswith("socket://"):
798
+ rest = spec.split("://", 1)[1]
799
+ host_port = rest.split("/", 1)[0]
800
+ if ":" not in host_port:
801
+ return False
802
+ host, port_s = host_port.rsplit(":", 1)
803
+ try:
804
+ port = int(port_s)
805
+ except ValueError:
806
+ return False
807
+ return is_port_in_use(port, host or "127.0.0.1")
808
+
809
+ # HTTP(S)
810
+ url = spec if "://" in spec else f"http://{spec}"
811
+ try:
812
+ headers = (
813
+ {"X-Harness-Instance-Token": expected_instance_token}
814
+ if expected_instance_token
815
+ else {}
816
+ )
817
+ req = urllib.request.Request(url, headers=headers, method="GET")
818
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
819
+ if not 200 <= int(getattr(resp, "status", 200)) < 300:
820
+ return False
821
+ if not expected_instance_token:
822
+ return True
823
+ response_token = resp.headers.get("X-Harness-Instance-Token")
824
+ if response_token == expected_instance_token:
825
+ return True
826
+ body = resp.read().decode("utf-8", errors="replace")
827
+ return expected_instance_token in body
828
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
829
+ return False
830
+
831
+
832
+ def _listener_identity_is_owned(
833
+ session: dict[str, Any],
834
+ service_start: dict[str, Any],
835
+ ) -> bool:
836
+ """Require an owned process-tree proof before reusing tokenless listeners."""
837
+ if extract_port(service_start) is None:
838
+ return True
839
+ attestation = session.get("processAttestation")
840
+ proof = session.get("ownershipProof")
841
+ if not isinstance(attestation, dict) or not isinstance(proof, dict):
842
+ return False
843
+ try:
844
+ observed = _process_provider._observe_proof_members(proof)
845
+ decision = _process_provider.validate_ownership_proof(
846
+ attestation,
847
+ proof,
848
+ observed,
849
+ )
850
+ return decision.get("ok") is True
851
+ except (OSError, TypeError, ValueError):
852
+ return False
853
+
854
+
855
+ def log_has_fatal(path: Path) -> str | None:
856
+ if not path.is_file():
857
+ return None
858
+ try:
859
+ text = path.read_text(encoding="utf-8", errors="replace")
860
+ except OSError:
861
+ return None
862
+ # Check last ~200 lines for fatal keywords
863
+ lines = text.splitlines()
864
+ tail = "\n".join(lines[-200:])
865
+ for kw in FATAL_KEYWORDS:
866
+ if kw in tail:
867
+ return kw
868
+ return None
869
+
870
+
871
+ class ServiceStartError(Exception):
872
+ def __init__(self, message: str, *, fatal_keyword: str | None = None) -> None:
873
+ super().__init__(message)
874
+ self.fatal_keyword = fatal_keyword
875
+
876
+
877
+ def wait_for_healthy(
878
+ service_start: dict[str, Any],
879
+ log_file: Path,
880
+ *,
881
+ pid: int | None = None,
882
+ expected_instance_token: str | None = None,
883
+ ) -> None:
884
+ """Startup wait state machine: 0–30s /2s, 30–120s /5s; fatal keywords abort."""
885
+ timeout_sec = service_start.get("startTimeoutSec", 120)
886
+ try:
887
+ timeout_sec = float(timeout_sec)
888
+ except (TypeError, ValueError):
889
+ timeout_sec = 120.0
890
+ if timeout_sec <= 0:
891
+ timeout_sec = 120.0
892
+
893
+ start = time.monotonic()
894
+ while True:
895
+ elapsed = time.monotonic() - start
896
+ fatal = log_has_fatal(log_file)
897
+ if fatal:
898
+ raise ServiceStartError(
899
+ f"service start aborted: fatal keyword in log: {fatal}",
900
+ fatal_keyword=fatal,
901
+ )
902
+ if pid is not None and not is_pid_alive(pid):
903
+ raise ServiceStartError(f"service process exited early (pid={pid})")
904
+ if probe_health(
905
+ service_start,
906
+ expected_instance_token=expected_instance_token,
907
+ ):
908
+ return
909
+ if elapsed >= timeout_sec:
910
+ raise ServiceStartError(
911
+ f"service start timed out after {timeout_sec:.0f}s "
912
+ f"(health probe not ready; see {log_file})"
913
+ )
914
+ # 0–30s every 2s; 30–120s every 5s
915
+ if elapsed < 30.0:
916
+ time.sleep(2.0)
917
+ else:
918
+ time.sleep(5.0)
919
+
920
+
921
+ # ---------------------------------------------------------------------------
922
+ # Session I/O
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ class SessionCorrupt(Exception):
927
+ pass
928
+
929
+
930
+ class ServiceTransitionConflict(Exception):
931
+ """Raised only when a caller explicitly requests an illegal service edge."""
932
+
933
+
934
+ _SERVICE_REASON_ALIASES = {
935
+ "SERVICE_STARTING": "SERVICE_START_REQUESTED",
936
+ "SERVICE_HEALTH_FAILED": "SERVICE_START_FAILED",
937
+ "SERVICE_STOPPING": "SERVICE_STOP_REQUESTED",
938
+ "SERVICE_STOP_CONFIRMED": "SERVICE_STOPPED",
939
+ "STALE_IDENTITY_MISMATCH": "SERVICE_IDENTITY_STALE",
940
+ "STALE_IDENTITY_RETIRED": "SERVICE_RETIRED",
941
+ "PROCESS_TREE_EXIT_UNCONFIRMED": "SERVICE_STOP_FAILED",
942
+ "OWNED_PORT_RELEASE_UNCONFIRMED": "SERVICE_CLEANUP_INCOMPLETE",
943
+ "LISTENER_IDENTITY_UNVERIFIED": "LISTENER_IDENTITY_UNVERIFIABLE",
944
+ }
945
+
946
+ _SERVICE_TRANSITIONS: dict[str | None, set[str]] = {
947
+ # ``READY``/``FAILED`` are retained for legacy sessions that predate the
948
+ # explicit STARTING record; new writers always persist STARTING first.
949
+ None: {"STARTING", "READY", "FAILED", "STALE_IDENTITY_MISMATCH"},
950
+ "STARTING": {"READY", "FAILED", "STALE_IDENTITY_MISMATCH"},
951
+ "READY": {"READY", "STOPPING", "STALE_IDENTITY_MISMATCH"},
952
+ "STOPPING": {"STOPPED", "FAILED", "STALE_IDENTITY_MISMATCH"},
953
+ "FAILED": {"RETIRED"},
954
+ "STALE_IDENTITY_MISMATCH": {"RETIRED"},
955
+ "STOPPED": {"RETIRED"},
956
+ "RETIRED": set(),
957
+ }
958
+
959
+
960
+ def _canonical_service_reason(reason_code: str) -> str:
961
+ return _SERVICE_REASON_ALIASES.get(reason_code, reason_code)
962
+
963
+
964
+ def _normalize_service_generation(value: Any) -> int:
965
+ if isinstance(value, bool):
966
+ return 1
967
+ if isinstance(value, int) and value > 0:
968
+ return value
969
+ if isinstance(value, str):
970
+ try:
971
+ parsed = int(value)
972
+ if parsed > 0:
973
+ return parsed
974
+ except ValueError:
975
+ if value.strip():
976
+ # Legacy writers used opaque ``generation-*`` strings. Keep
977
+ # their identity stable while moving the durable contract to
978
+ # a positive integer; this is migration-only, not a new
979
+ # generation allocator.
980
+ return max(1, int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:15], 16))
981
+ return 1
982
+
983
+
984
+ def load_session(change_dir: Path) -> dict[str, Any] | None:
985
+ """Return session dict, None if missing, raise SessionCorrupt if damaged."""
986
+ path = session_path(change_dir)
987
+ if not path.is_file():
988
+ return None
989
+ try:
990
+ text = path.read_text(encoding="utf-8-sig")
991
+ except OSError as exc:
992
+ raise SessionCorrupt(f"cannot read session: {exc}") from exc
993
+ if not text.strip():
994
+ raise SessionCorrupt("session file is empty")
995
+ try:
996
+ data = json.loads(text)
997
+ except json.JSONDecodeError as exc:
998
+ raise SessionCorrupt(f"session JSON corrupt: {exc}") from exc
999
+ if not isinstance(data, dict):
1000
+ raise SessionCorrupt("session must be a JSON object")
1001
+ # Minimal required fields for a usable AI session
1002
+ pid = data.get("pid")
1003
+ if not isinstance(pid, int):
1004
+ raise SessionCorrupt("session.pid missing or not an int")
1005
+ if "startedBy" not in data:
1006
+ raise SessionCorrupt("session.startedBy missing")
1007
+ if "startedAt" not in data:
1008
+ raise SessionCorrupt("session.startedAt missing")
1009
+ if "identityCompleteness" not in data:
1010
+ data["identityCompleteness"] = (
1011
+ "COMPLETE"
1012
+ if isinstance(data.get("processAttestation"), dict)
1013
+ and isinstance(data.get("ownershipProof"), dict)
1014
+ else "PARTIAL"
1015
+ )
1016
+ if "serviceGeneration" in data:
1017
+ data["serviceGeneration"] = _normalize_service_generation(
1018
+ data.get("serviceGeneration")
1019
+ )
1020
+ # Legacy sessions may have persisted a raw health token. Keep only its
1021
+ # digest in the durable session; the configured profile remains the sole
1022
+ # source from which a probe may obtain the token value.
1023
+ legacy_token = data.pop("instanceToken", None)
1024
+ if isinstance(legacy_token, str) and legacy_token:
1025
+ data.setdefault("instanceTokenHash", sha256_text(legacy_token))
1026
+ return data
1027
+
1028
+
1029
+ def clear_session(change_dir: Path) -> None:
1030
+ path = session_path(change_dir)
1031
+ if path.is_file():
1032
+ try:
1033
+ path.unlink()
1034
+ except OSError:
1035
+ pass
1036
+
1037
+
1038
+ def write_session(change_dir: Path, session: dict[str, Any]) -> Path:
1039
+ path = session_path(change_dir)
1040
+ write_json(path, session)
1041
+ return path
1042
+
1043
+
1044
+ def _service_operation_id(session: dict[str, Any] | None = None) -> str:
1045
+ value = session.get("operationId") if isinstance(session, dict) else None
1046
+ return str(value or uuid.uuid4().hex)
1047
+
1048
+
1049
+ def _transition(
1050
+ session: dict[str, Any],
1051
+ status: str,
1052
+ *,
1053
+ reason_code: str,
1054
+ operation_id: str,
1055
+ ) -> dict[str, Any]:
1056
+ previous = session.get("status")
1057
+ allowed = _SERVICE_TRANSITIONS.get(previous)
1058
+ if allowed is None or status not in allowed:
1059
+ return {
1060
+ "ok": False,
1061
+ "code": "SERVICE_TRANSITION_CONFLICT",
1062
+ "from": previous,
1063
+ "to": status,
1064
+ "operationId": operation_id,
1065
+ }
1066
+ revision = session.get("stateRevision")
1067
+ try:
1068
+ revision_number = int(revision)
1069
+ except (TypeError, ValueError):
1070
+ revision_number = 0
1071
+ session["stateRevision"] = revision_number + 1
1072
+ session["status"] = status
1073
+ session["reasonCode"] = _canonical_service_reason(reason_code)
1074
+ session["operationId"] = operation_id
1075
+ history = session.setdefault("transitionHistory", [])
1076
+ if not isinstance(history, list):
1077
+ history = []
1078
+ session["transitionHistory"] = history
1079
+ history.append(
1080
+ {
1081
+ "from": previous,
1082
+ "to": status,
1083
+ "reasonCode": _canonical_service_reason(reason_code),
1084
+ "operationId": operation_id,
1085
+ "at": now_iso(),
1086
+ "revision": session["stateRevision"],
1087
+ }
1088
+ )
1089
+ return {
1090
+ "ok": True,
1091
+ "from": previous,
1092
+ "to": status,
1093
+ "reasonCode": session["reasonCode"],
1094
+ "revision": session["stateRevision"],
1095
+ }
1096
+
1097
+
1098
+ def _ensure_session_state(
1099
+ session: dict[str, Any],
1100
+ *,
1101
+ operation_id: str,
1102
+ generation: int | None = None,
1103
+ ) -> None:
1104
+ # Legacy durable service sessions did not persist an explicit status. A
1105
+ # session carrying a process identity is an existing READY incarnation,
1106
+ # while synthetic transition fixtures intentionally remain status-less so
1107
+ # they can exercise the ``None -> STARTING`` edge.
1108
+ if "pid" in session and not isinstance(session.get("status"), str):
1109
+ session["status"] = "READY"
1110
+ session.setdefault("reasonCode", "SERVICE_READY")
1111
+ if not isinstance(session.get("serviceGeneration"), int) or session.get(
1112
+ "serviceGeneration"
1113
+ ) <= 0:
1114
+ session["serviceGeneration"] = _normalize_service_generation(generation)
1115
+ if not isinstance(session.get("stateRevision"), int):
1116
+ session["stateRevision"] = 0
1117
+ session.setdefault("transitionHistory", [])
1118
+ session["heartbeat"] = {
1119
+ "kind": "SUPERVISOR",
1120
+ "writerIdentity": _process_provider.canonical_argv_hash(
1121
+ [str(Path(sys.executable).resolve()), str(os.getpid())]
1122
+ ),
1123
+ "generation": session["serviceGeneration"],
1124
+ "lastSeenAt": now_iso(),
1125
+ "ttlSeconds": HEARTBEAT_TTL_SECONDS,
1126
+ "staleReason": None,
1127
+ }
1128
+ legacy_token = session.pop("instanceToken", None)
1129
+ if isinstance(legacy_token, str) and legacy_token:
1130
+ session.setdefault("instanceTokenHash", sha256_text(legacy_token))
1131
+ session.setdefault("instanceTokenHash", None)
1132
+ session.setdefault("identityCompleteness", "COMPLETE")
1133
+ session["operationId"] = operation_id
1134
+
1135
+
1136
+ def _heartbeat_status(session: dict[str, Any]) -> dict[str, Any]:
1137
+ heartbeat = session.get("heartbeat")
1138
+ if not isinstance(heartbeat, dict):
1139
+ return {"status": "UNKNOWN", "stale": False, "reasonCode": None}
1140
+ raw = heartbeat.get("lastSeenAt")
1141
+ ttl = heartbeat.get("ttlSeconds")
1142
+ try:
1143
+ seen = dt.datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
1144
+ if seen.tzinfo is None:
1145
+ seen = seen.replace(tzinfo=dt.timezone.utc)
1146
+ age = (dt.datetime.now(dt.timezone.utc) - seen.astimezone(dt.timezone.utc)).total_seconds()
1147
+ stale = age > float(ttl)
1148
+ except (TypeError, ValueError):
1149
+ age = float("inf")
1150
+ stale = True
1151
+ return {
1152
+ "status": "STALE" if stale else "FRESH",
1153
+ "stale": stale,
1154
+ "ageSeconds": age,
1155
+ "reasonCode": "SERVICE_HEARTBEAT_STALE" if stale else None,
1156
+ }
1157
+
1158
+
1159
+ def _same_path(left: Any, right: Path | None) -> bool:
1160
+ if right is None or not isinstance(left, str) or not left.strip():
1161
+ return False
1162
+ try:
1163
+ return Path(left).resolve() == right.resolve()
1164
+ except OSError:
1165
+ return False
1166
+
1167
+
1168
+ def session_is_owned(
1169
+ session: dict[str, Any],
1170
+ *,
1171
+ change_id: str | None = None,
1172
+ execution_root: Path | None = None,
1173
+ worktree_root: Path | None = None,
1174
+ ) -> bool:
1175
+ """Return true for an AI session owned by the supplied change/root filters.
1176
+
1177
+ ``change_id`` is required when provided. Root filters match if the session's
1178
+ ``executionRoot`` or ``worktreeRoot`` equals any supplied root (OR).
1179
+ """
1180
+ if session.get("startedBy") != "AI":
1181
+ return False
1182
+ if change_id is not None and session.get("changeId") != change_id:
1183
+ return False
1184
+ root_matches: list[bool] = []
1185
+ if execution_root is not None:
1186
+ root_matches.append(_same_path(session.get("executionRoot"), execution_root))
1187
+ root_matches.append(_same_path(session.get("worktreeRoot"), execution_root))
1188
+ if worktree_root is not None:
1189
+ root_matches.append(_same_path(session.get("worktreeRoot"), worktree_root))
1190
+ root_matches.append(_same_path(session.get("executionRoot"), worktree_root))
1191
+ if root_matches and not any(root_matches):
1192
+ return False
1193
+ return True
1194
+
1195
+
1196
+ def find_owned_sessions(
1197
+ project_root: Path,
1198
+ *,
1199
+ change_id: str | None = None,
1200
+ execution_root: Path | None = None,
1201
+ worktree_root: Path | None = None,
1202
+ ) -> dict[str, list[dict[str, Any]]]:
1203
+ """Discover persisted sessions and classify them without stopping anything."""
1204
+ project = resolve_path(project_root)
1205
+ candidate_dirs: list[Path] = []
1206
+ if change_id:
1207
+ candidate_dirs.extend(
1208
+ [
1209
+ project / ".harness" / "changes" / change_id,
1210
+ project / ".harness" / "state" / "changes" / change_id,
1211
+ ]
1212
+ )
1213
+ else:
1214
+ for root in (
1215
+ project / ".harness" / "changes",
1216
+ project / ".harness" / "state" / "changes",
1217
+ ):
1218
+ if root.is_dir():
1219
+ candidate_dirs.extend(path for path in root.iterdir() if path.is_dir())
1220
+
1221
+ owned: list[dict[str, Any]] = []
1222
+ reported: list[dict[str, Any]] = []
1223
+ seen: set[Path] = set()
1224
+ for change_dir in candidate_dirs:
1225
+ path = session_path(change_dir)
1226
+ try:
1227
+ path = path.resolve()
1228
+ except OSError:
1229
+ continue
1230
+ if path in seen or not path.is_file():
1231
+ continue
1232
+ seen.add(path)
1233
+ try:
1234
+ session = load_session(change_dir)
1235
+ except SessionCorrupt as exc:
1236
+ reported.append(
1237
+ {
1238
+ "sessionPath": str(path),
1239
+ "reason": "session-corrupt",
1240
+ "detail": str(exc),
1241
+ }
1242
+ )
1243
+ continue
1244
+ if session is None:
1245
+ continue
1246
+ item = {"changeDir": str(change_dir.resolve()), "session": session}
1247
+ if session_is_owned(
1248
+ session,
1249
+ change_id=change_id,
1250
+ execution_root=execution_root,
1251
+ worktree_root=worktree_root,
1252
+ ):
1253
+ owned.append(item)
1254
+ else:
1255
+ reported.append(
1256
+ {
1257
+ "sessionPath": str(path),
1258
+ "reason": "not-owned",
1259
+ "startedBy": session.get("startedBy"),
1260
+ "pid": session.get("pid"),
1261
+ "leasedPort": session.get("leasedPort"),
1262
+ }
1263
+ )
1264
+ return {"owned": owned, "reported": reported}
1265
+
1266
+
1267
+ def stop_owned_sessions(
1268
+ project_root: Path,
1269
+ *,
1270
+ change_id: str | None = None,
1271
+ execution_root: Path | None = None,
1272
+ worktree_root: Path | None = None,
1273
+ ) -> dict[str, Any]:
1274
+ """Stop only discovered AI sessions whose persisted ownership still matches."""
1275
+ discovered = find_owned_sessions(
1276
+ project_root,
1277
+ change_id=change_id,
1278
+ execution_root=execution_root,
1279
+ worktree_root=worktree_root,
1280
+ )
1281
+ stopped: list[dict[str, Any]] = []
1282
+ blocked: list[dict[str, Any]] = []
1283
+ ports_still_in_use: list[int] = []
1284
+ for item in discovered["owned"]:
1285
+ change_dir = Path(item["changeDir"])
1286
+ session = item["session"]
1287
+ if not session_is_owned(
1288
+ session,
1289
+ change_id=change_id,
1290
+ execution_root=execution_root,
1291
+ worktree_root=worktree_root,
1292
+ ):
1293
+ blocked.append(
1294
+ {"sessionPath": str(session_path(change_dir)), "reason": "ownership-changed"}
1295
+ )
1296
+ continue
1297
+ result = stop_ai_session(change_dir, session, require_identity=True)
1298
+ result["sessionPath"] = str(session_path(change_dir))
1299
+ if result.get("action") == "needs-user-decision":
1300
+ blocked.append(result)
1301
+ continue
1302
+ pid = session.get("pid")
1303
+ if isinstance(pid, int) and is_pid_alive(pid):
1304
+ blocked.append(
1305
+ {
1306
+ "sessionPath": str(session_path(change_dir)),
1307
+ "reason": "owned-process-still-alive",
1308
+ "pid": pid,
1309
+ }
1310
+ )
1311
+ continue
1312
+ stopped.append(result)
1313
+ port = session.get("leasedPort")
1314
+ if isinstance(port, int) and is_port_in_use(port):
1315
+ ports_still_in_use.append(port)
1316
+ return {
1317
+ "ok": not blocked,
1318
+ "ownedSessions": len(discovered["owned"]),
1319
+ "stopped": stopped,
1320
+ "blocked": blocked,
1321
+ "reported": discovered["reported"],
1322
+ "portsStillInUse": ports_still_in_use,
1323
+ }
1324
+
1325
+
1326
+ # ---------------------------------------------------------------------------
1327
+ # Start / stop helpers
1328
+ # ---------------------------------------------------------------------------
1329
+
1330
+ _WIN_LAUNCHER_SOURCE = """\
1331
+ #!/usr/bin/env python3
1332
+ \"\"\"Harness Windows service launcher (internal).\"\"\"
1333
+ from __future__ import annotations
1334
+
1335
+ import ctypes
1336
+ import json
1337
+ import subprocess
1338
+ import sys
1339
+ from pathlib import Path
1340
+
1341
+ _CREATE_NEW_PROCESS_GROUP = 0x00000200
1342
+ _CREATE_NO_WINDOW = 0x08000000
1343
+ _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
1344
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
1345
+
1346
+
1347
+ class _IoCounters(ctypes.Structure):
1348
+ _fields_ = [
1349
+ ("ReadOperationCount", ctypes.c_uint64),
1350
+ ("WriteOperationCount", ctypes.c_uint64),
1351
+ ("OtherOperationCount", ctypes.c_uint64),
1352
+ ("ReadTransferCount", ctypes.c_uint64),
1353
+ ("WriteTransferCount", ctypes.c_uint64),
1354
+ ("OtherTransferCount", ctypes.c_uint64),
1355
+ ]
1356
+
1357
+
1358
+ class _BasicLimitInformation(ctypes.Structure):
1359
+ _fields_ = [
1360
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
1361
+ ("PerJobUserTimeLimit", ctypes.c_int64),
1362
+ ("LimitFlags", ctypes.c_uint32),
1363
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
1364
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
1365
+ ("ActiveProcessLimit", ctypes.c_uint32),
1366
+ ("Affinity", ctypes.c_size_t),
1367
+ ("PriorityClass", ctypes.c_uint32),
1368
+ ("SchedulingClass", ctypes.c_uint32),
1369
+ ]
1370
+
1371
+
1372
+ class _ExtendedLimitInformation(ctypes.Structure):
1373
+ _fields_ = [
1374
+ ("BasicLimitInformation", _BasicLimitInformation),
1375
+ ("IoInfo", _IoCounters),
1376
+ ("ProcessMemoryLimit", ctypes.c_size_t),
1377
+ ("JobMemoryLimit", ctypes.c_size_t),
1378
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
1379
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
1380
+ ]
1381
+
1382
+
1383
+ def _create_job(name: str) -> int:
1384
+ kernel32 = ctypes.windll.kernel32
1385
+ kernel32.CreateJobObjectW.restype = ctypes.c_void_p
1386
+ handle = kernel32.CreateJobObjectW(None, name)
1387
+ if not handle:
1388
+ raise OSError("CreateJobObjectW failed")
1389
+ info = _ExtendedLimitInformation()
1390
+ info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
1391
+ configured = kernel32.SetInformationJobObject(
1392
+ ctypes.c_void_p(handle),
1393
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
1394
+ ctypes.byref(info),
1395
+ ctypes.sizeof(info),
1396
+ )
1397
+ if not configured:
1398
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
1399
+ raise OSError("SetInformationJobObject failed")
1400
+ return int(handle)
1401
+
1402
+
1403
+ def main() -> int:
1404
+ log_path = Path(sys.argv[1])
1405
+ argv_path = Path(sys.argv[2])
1406
+ pid_path = Path(sys.argv[3])
1407
+ job_name = sys.argv[4]
1408
+ argv = json.loads(argv_path.read_text(encoding="utf-8"))
1409
+ if not isinstance(argv, list) or not argv or any(
1410
+ not isinstance(item, str) or "\\0" in item for item in argv
1411
+ ):
1412
+ raise SystemExit("empty or invalid service argv")
1413
+
1414
+ job_handle = _create_job(job_name)
1415
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1416
+ log_handle = log_path.open("w", encoding="utf-8", errors="replace")
1417
+ try:
1418
+ proc = subprocess.Popen(
1419
+ argv,
1420
+ shell=False,
1421
+ stdout=log_handle,
1422
+ stderr=subprocess.STDOUT,
1423
+ stdin=subprocess.DEVNULL,
1424
+ creationflags=(_CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW),
1425
+ close_fds=False,
1426
+ )
1427
+ finally:
1428
+ log_handle.close()
1429
+
1430
+ kernel32 = ctypes.windll.kernel32
1431
+ assigned = kernel32.AssignProcessToJobObject(
1432
+ ctypes.c_void_p(job_handle),
1433
+ ctypes.c_void_p(int(proc._handle)),
1434
+ )
1435
+ if not assigned:
1436
+ proc.terminate()
1437
+ kernel32.CloseHandle(ctypes.c_void_p(job_handle))
1438
+ raise OSError("AssignProcessToJobObject failed")
1439
+ pid_path.write_text(str(proc.pid), encoding="utf-8")
1440
+ try:
1441
+ return int(proc.wait())
1442
+ finally:
1443
+ kernel32.CloseHandle(ctypes.c_void_p(job_handle))
1444
+
1445
+
1446
+ if __name__ == "__main__":
1447
+ raise SystemExit(main())
1448
+ """
1449
+
1450
+
1451
+ def _runtime_dir(change_dir: Path) -> Path:
1452
+ path = change_dir / "runtime"
1453
+ path.mkdir(parents=True, exist_ok=True)
1454
+ return path
1455
+
1456
+
1457
+ def _launcher_pid_path(change_dir: Path) -> Path:
1458
+ return _runtime_dir(change_dir) / "_harness_service.launcher.pid"
1459
+
1460
+
1461
+ def _job_id_path(change_dir: Path) -> Path:
1462
+ return _runtime_dir(change_dir) / "_harness_service.job.id"
1463
+
1464
+
1465
+ def _breakaway_note_path(change_dir: Path) -> Path:
1466
+ return _runtime_dir(change_dir) / "_harness_service.breakaway.json"
1467
+
1468
+
1469
+ def _child_pid_path(change_dir: Path) -> Path:
1470
+ return _runtime_dir(change_dir) / "_harness_service.child.pid"
1471
+
1472
+
1473
+ def _cleanup_windows_launcher(change_dir: Path) -> None:
1474
+ if os.name != "nt":
1475
+ return
1476
+ path = _launcher_pid_path(change_dir)
1477
+ if not path.is_file():
1478
+ return
1479
+ text = path.read_text(encoding="utf-8").strip()
1480
+ try:
1481
+ path.unlink()
1482
+ except OSError:
1483
+ pass
1484
+ if text.isdigit():
1485
+ pid = int(text)
1486
+ if is_pid_alive(pid):
1487
+ terminate_process_tree(pid)
1488
+
1489
+
1490
+ def _wait_for_windows_launcher_exit(
1491
+ change_dir: Path,
1492
+ *,
1493
+ timeout_sec: float = STOP_CONFIRM_TIMEOUT_SEC,
1494
+ ) -> bool:
1495
+ """Wait for the detached Job owner to release log/file handles.
1496
+
1497
+ The launcher is deliberately not killed by PID. Its named Job has
1498
+ already been terminated through the ownership proof; this wait merely
1499
+ observes the launcher completing its ``proc.wait()`` and avoids a race
1500
+ with archive/temp-directory cleanup.
1501
+ """
1502
+ pid = _read_positive_int(_launcher_pid_path(change_dir))
1503
+ if pid is None:
1504
+ return True
1505
+ deadline = time.monotonic() + max(0.0, timeout_sec)
1506
+ while is_pid_alive(pid) and time.monotonic() < deadline:
1507
+ time.sleep(0.05)
1508
+ return not is_pid_alive(pid)
1509
+
1510
+
1511
+ def _write_windows_launcher(change_dir: Path) -> Path:
1512
+ launcher = _runtime_dir(change_dir) / "_harness_service_launcher.py"
1513
+ launcher.write_text(_WIN_LAUNCHER_SOURCE, encoding="utf-8", newline="\n")
1514
+ return launcher
1515
+
1516
+
1517
+ def _wait_for_child_pid(pid_path: Path, *, timeout_sec: float = 15.0) -> int:
1518
+ deadline = time.monotonic() + timeout_sec
1519
+ while time.monotonic() < deadline:
1520
+ if pid_path.is_file():
1521
+ text = pid_path.read_text(encoding="utf-8").strip()
1522
+ if text.isdigit():
1523
+ return int(text)
1524
+ time.sleep(0.05)
1525
+ raise TimeoutError(f"service child pid not recorded within {timeout_sec:.0f}s ({pid_path})")
1526
+
1527
+
1528
+ def _read_positive_int(path: Path) -> int | None:
1529
+ try:
1530
+ text = path.read_text(encoding="utf-8").strip()
1531
+ except OSError:
1532
+ return None
1533
+ return int(text) if text.isdigit() and int(text) > 0 else None
1534
+
1535
+
1536
+ def _read_optional_text(path: Path) -> str | None:
1537
+ try:
1538
+ text = path.read_text(encoding="utf-8").strip()
1539
+ except OSError:
1540
+ return None
1541
+ return text or None
1542
+
1543
+
1544
+ def _start_detached_service_windows(
1545
+ argv: list[str],
1546
+ *,
1547
+ change_dir: Path,
1548
+ cwd: Path,
1549
+ log_file: Path,
1550
+ owner_token: str,
1551
+ ) -> _process_provider.SpawnedProcess:
1552
+ runtime = _runtime_dir(change_dir)
1553
+ launcher = _write_windows_launcher(change_dir)
1554
+ command_path = runtime / "_harness_service.command.txt"
1555
+ pid_path = _child_pid_path(change_dir)
1556
+ owner_hash = _process_provider._token_hash(owner_token)
1557
+ proof_id = "proof-" + uuid.uuid4().hex
1558
+ job_id = (
1559
+ "Local\\HunterHarness-"
1560
+ + owner_hash.split(":", 1)[1][:16]
1561
+ + "-"
1562
+ + proof_id
1563
+ )
1564
+ command_path.write_text(
1565
+ json.dumps(argv, ensure_ascii=False, separators=(",", ":")),
1566
+ encoding="utf-8",
1567
+ newline="\n",
1568
+ )
1569
+ _job_id_path(change_dir).write_text(job_id, encoding="utf-8", newline="\n")
1570
+ if pid_path.exists():
1571
+ pid_path.unlink()
1572
+
1573
+ launcher_args = [
1574
+ sys.executable,
1575
+ str(launcher),
1576
+ str(log_file),
1577
+ str(command_path),
1578
+ str(pid_path),
1579
+ job_id,
1580
+ ]
1581
+ base_flags = _DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
1582
+ # CREATE_BREAKAWAY_FROM_JOB fails with ERROR_ACCESS_DENIED (WinError 5)
1583
+ # whenever the *caller* already runs inside a Job Object that was created
1584
+ # without JOB_OBJECT_LIMIT_BREAKAWAY_OK — which is exactly how agent CLIs
1585
+ # (Claude Code / CodeBuddy / Codex) contain their child processes. Breakaway
1586
+ # is an optimisation (the service outlives the agent session), not a
1587
+ # requirement, so fall back to spawning inside the caller's job rather than
1588
+ # failing the whole start.
1589
+ launcher_proc = None
1590
+ breakaway = True
1591
+ try:
1592
+ launcher_proc = subprocess.Popen(
1593
+ launcher_args,
1594
+ cwd=str(cwd),
1595
+ stdin=subprocess.DEVNULL,
1596
+ stdout=subprocess.DEVNULL,
1597
+ stderr=subprocess.DEVNULL,
1598
+ creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB,
1599
+ close_fds=False,
1600
+ )
1601
+ except OSError as exc:
1602
+ if getattr(exc, "winerror", None) != 5:
1603
+ raise
1604
+ breakaway = False
1605
+ launcher_proc = subprocess.Popen(
1606
+ launcher_args,
1607
+ cwd=str(cwd),
1608
+ stdin=subprocess.DEVNULL,
1609
+ stdout=subprocess.DEVNULL,
1610
+ stderr=subprocess.DEVNULL,
1611
+ creationflags=base_flags,
1612
+ close_fds=False,
1613
+ )
1614
+ _breakaway_note_path(change_dir).write_text(
1615
+ json.dumps(
1616
+ {
1617
+ "breakawayFromJob": breakaway,
1618
+ "note": (
1619
+ None
1620
+ if breakaway
1621
+ else "spawned inside the caller's Job Object "
1622
+ "(CREATE_BREAKAWAY_FROM_JOB denied); the service is "
1623
+ "terminated when the agent session ends"
1624
+ ),
1625
+ },
1626
+ ensure_ascii=False,
1627
+ )
1628
+ + "\n",
1629
+ encoding="utf-8",
1630
+ newline="\n",
1631
+ )
1632
+ _launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
1633
+ _wait_for_child_pid(pid_path)
1634
+ # The launcher intentionally remains alive as the Job Object owner. Close
1635
+ # only this controller's process handle so Popen does not warn at teardown.
1636
+ launcher_handle = getattr(launcher_proc, "_handle", None)
1637
+ if launcher_handle is not None:
1638
+ launcher_handle.Close()
1639
+ launcher_proc.returncode = 0
1640
+ child_pid = _read_positive_int(pid_path)
1641
+ if child_pid is None:
1642
+ raise TimeoutError(f"service child pid is invalid ({pid_path})")
1643
+ observed = _process_provider.observe_process_identity(child_pid)
1644
+ attestation = json.loads(json.dumps(observed))
1645
+ attestation["argvHash"] = _process_provider.canonical_argv_hash(argv)
1646
+ attestation["workingDirectory"] = str(cwd.resolve())
1647
+ attestation["ownerTokenHash"] = owner_hash
1648
+ attestation["fieldProvenance"]["argvHash"] = (
1649
+ "OBSERVED" if observed.get("argvHash") is not None else "ATTESTED"
1650
+ )
1651
+ attestation["fieldProvenance"]["workingDirectory"] = (
1652
+ "OBSERVED"
1653
+ if observed.get("workingDirectory") is not None
1654
+ else "ATTESTED"
1655
+ )
1656
+ attestation["fieldProvenance"]["ownerTokenHash"] = "ATTESTED"
1657
+ attestation["capabilities"]["canVerifyOwnership"] = True
1658
+ proof = {
1659
+ "schemaVersion": 1,
1660
+ "proofId": proof_id,
1661
+ "kind": "WINDOWS_NAMED_JOB",
1662
+ "jobName": job_id,
1663
+ "ownershipTokenHash": owner_hash,
1664
+ "leaderPid": child_pid,
1665
+ "leaderCreatedAt": attestation.get("createdAt"),
1666
+ "members": [attestation],
1667
+ "membersComplete": True,
1668
+ "leaderExited": False,
1669
+ }
1670
+ return _process_provider.SpawnedProcess(
1671
+ process=launcher_proc,
1672
+ attestation=attestation,
1673
+ observedIdentity=observed,
1674
+ ownershipProof=proof,
1675
+ )
1676
+
1677
+
1678
+ def start_detached_service(
1679
+ argv: list[str] | tuple[str, ...] | str,
1680
+ *,
1681
+ change_dir: Path,
1682
+ cwd: Path,
1683
+ ) -> _process_provider.SpawnedProcess:
1684
+ """Start service with structured argv; log to logs/service-start.log.
1685
+
1686
+ ``str`` is retained only for callers from the legacy facade and is parsed
1687
+ by the same profile parser; no caller is allowed to reach ``shell=True``.
1688
+ """
1689
+ if isinstance(argv, str):
1690
+ argv = resolve_service_argv({"command": argv})
1691
+ argv = list(argv)
1692
+ if not argv:
1693
+ raise ValueError("ARGUMENT_INVALID: service argv is empty")
1694
+ log_file = log_path(change_dir)
1695
+ log_file.parent.mkdir(parents=True, exist_ok=True)
1696
+ if log_file.is_file():
1697
+ log_file.unlink()
1698
+
1699
+ owner_token = f"service:{change_dir.resolve()}:{uuid.uuid4().hex}"
1700
+ if os.name == "nt":
1701
+ return _start_detached_service_windows(
1702
+ argv,
1703
+ change_dir=change_dir,
1704
+ cwd=cwd,
1705
+ log_file=log_file,
1706
+ owner_token=owner_token,
1707
+ )
1708
+
1709
+ log_handle = log_file.open("w", encoding="utf-8", errors="replace")
1710
+ try:
1711
+ return _process_provider.spawn_structured_argv(
1712
+ argv,
1713
+ cwd=cwd,
1714
+ environment={
1715
+ "PYTHONUTF8": "1",
1716
+ "PYTHONIOENCODING": "utf-8",
1717
+ },
1718
+ owner_token=owner_token,
1719
+ stdout=log_handle,
1720
+ stderr=subprocess.STDOUT,
1721
+ )
1722
+ finally:
1723
+ try:
1724
+ log_handle.close()
1725
+ except OSError:
1726
+ pass
1727
+
1728
+
1729
+ def build_session(
1730
+ *,
1731
+ pid: int,
1732
+ module_inputs_hash: str,
1733
+ module_inputs_files: list[str],
1734
+ command: str,
1735
+ service_start: dict[str, Any],
1736
+ argv: list[str] | None = None,
1737
+ process_attestation: dict[str, Any] | None = None,
1738
+ ownership_proof: dict[str, Any] | None = None,
1739
+ started_at: str | None = None,
1740
+ worktree_root: Path | None = None,
1741
+ execution_root: Path | None = None,
1742
+ change_id: str | None = None,
1743
+ attempt_id: str | None = None,
1744
+ service_pid: int | None = None,
1745
+ job_id: str | None = None,
1746
+ ) -> dict[str, Any]:
1747
+ profile_name = service_start.get("profile") or "local-dev"
1748
+ overlay = service_start.get("overlayPath") or ""
1749
+ effective_started_at = started_at or now_iso()
1750
+ executable = get_process_executable(pid) or (
1751
+ sys.executable if os.name == "nt" else os.environ.get("SHELL", "/bin/sh")
1752
+ )
1753
+ owned_ports: set[int] = set()
1754
+ port = extract_port(service_start)
1755
+ if port is not None:
1756
+ owned_ports.add(port)
1757
+ configured_ports = service_start.get("ownedPorts")
1758
+ if isinstance(configured_ports, list):
1759
+ for item in configured_ports:
1760
+ try:
1761
+ candidate = int(item)
1762
+ except (TypeError, ValueError):
1763
+ continue
1764
+ if 0 < candidate < 65536:
1765
+ owned_ports.add(candidate)
1766
+ resolved_argv = list(argv or [])
1767
+ argv_hash = (
1768
+ _process_provider.canonical_argv_hash(resolved_argv)
1769
+ if resolved_argv
1770
+ else sha256_text(command)
1771
+ )
1772
+ attestation = (
1773
+ json.loads(json.dumps(process_attestation))
1774
+ if isinstance(process_attestation, dict)
1775
+ else None
1776
+ )
1777
+ process_identity = {
1778
+ "executable": str(executable),
1779
+ "commandHash": argv_hash,
1780
+ "argvHash": argv_hash,
1781
+ "startedAt": effective_started_at,
1782
+ "parentChain": [
1783
+ {
1784
+ "pid": os.getpid(),
1785
+ "executable": str(Path(sys.executable).resolve()),
1786
+ }
1787
+ ],
1788
+ }
1789
+ if attestation is not None:
1790
+ process_identity.update(attestation)
1791
+ # Preserve the service's public legacy field names while keeping the
1792
+ # provider attestation as the source of truth for destructive actions.
1793
+ process_identity["commandHash"] = argv_hash
1794
+ process_identity["argvHash"] = attestation.get("argvHash", argv_hash)
1795
+ session = {
1796
+ "schemaVersion": 1,
1797
+ "serviceId": str(
1798
+ service_start.get("serviceId")
1799
+ or change_id
1800
+ or profile_name
1801
+ ),
1802
+ "sessionId": "service-" + uuid.uuid4().hex,
1803
+ "status": "STARTING",
1804
+ "reasonCode": "SERVICE_START_REQUESTED",
1805
+ "pid": pid,
1806
+ "startedBy": "AI",
1807
+ "moduleInputsHash": module_inputs_hash,
1808
+ "moduleInputsFiles": module_inputs_files,
1809
+ "profile": profile_name,
1810
+ "startCommandHash": argv_hash,
1811
+ "overlayPath": overlay,
1812
+ "startedAt": effective_started_at,
1813
+ "command": command,
1814
+ "argv": resolved_argv,
1815
+ "ownedPorts": sorted(owned_ports),
1816
+ "processIdentity": process_identity,
1817
+ "fingerprint": module_inputs_hash,
1818
+ "cleanupComplete": False,
1819
+ "supersedesSessionId": None,
1820
+ "leaseIdentity": None,
1821
+ }
1822
+ if attestation is not None:
1823
+ session["processAttestation"] = attestation
1824
+ if isinstance(ownership_proof, dict):
1825
+ session["ownershipProof"] = json.loads(json.dumps(ownership_proof))
1826
+ if isinstance(service_pid, int) and service_pid > 0:
1827
+ session["servicePid"] = service_pid
1828
+ if job_id:
1829
+ session["jobId"] = str(job_id)
1830
+ if isinstance(service_start.get("leasedPort"), int):
1831
+ session["leasedPort"] = service_start["leasedPort"]
1832
+ session["leaseOwner"] = service_start.get("leaseOwner")
1833
+ lease_id = service_start.get("leaseId")
1834
+ lease_change_id = service_start.get("leaseChangeId") or change_id
1835
+ lease_run_id = service_start.get("runId") or attempt_id
1836
+ lease_expires_at = service_start.get("leaseExpiresAt")
1837
+ lease_generation = service_start.get("leaseGeneration")
1838
+ if (
1839
+ isinstance(lease_id, str)
1840
+ and lease_id
1841
+ and isinstance(lease_change_id, str)
1842
+ and lease_change_id
1843
+ and isinstance(lease_run_id, str)
1844
+ and lease_run_id
1845
+ and isinstance(lease_expires_at, str)
1846
+ and lease_expires_at
1847
+ ):
1848
+ session["leaseIdentity"] = {
1849
+ "leaseId": lease_id,
1850
+ "changeId": lease_change_id,
1851
+ "runId": lease_run_id,
1852
+ "expiresAt": lease_expires_at,
1853
+ "generation": _normalize_service_generation(lease_generation),
1854
+ }
1855
+ if isinstance(service_start.get("listenerIdentity"), dict):
1856
+ session["leaseIdentity"]["listenerIdentity"] = json.loads(
1857
+ json.dumps(service_start["listenerIdentity"], ensure_ascii=False)
1858
+ )
1859
+ if worktree_root is not None:
1860
+ session["worktreeRoot"] = str(worktree_root.resolve())
1861
+ if execution_root is not None:
1862
+ session["executionRoot"] = str(execution_root.resolve())
1863
+ if change_id:
1864
+ session["changeId"] = change_id
1865
+ if attempt_id:
1866
+ session["attemptId"] = attempt_id
1867
+ return session
1868
+
1869
+
1870
+ def compute_module_hash(
1871
+ files: list[str],
1872
+ session: dict[str, Any] | None = None,
1873
+ ) -> tuple[str, list[str]]:
1874
+ """Compute inputsHash from --files, else session.moduleInputsFiles, else empty."""
1875
+ use_files = list(files)
1876
+ if not use_files and session is not None:
1877
+ stored = session.get("moduleInputsFiles")
1878
+ if isinstance(stored, list):
1879
+ use_files = [str(x) for x in stored if str(x).strip()]
1880
+ if not use_files:
1881
+ # Empty set → stable empty hash (order-independent)
1882
+ return compute_inputs_hash([])
1883
+ return compute_inputs_hash(use_files)
1884
+
1885
+
1886
+ def needs_user_decision(
1887
+ *,
1888
+ reason: str,
1889
+ as_json: bool,
1890
+ **extra: Any,
1891
+ ) -> int:
1892
+ payload: dict[str, Any] = {
1893
+ "ok": True,
1894
+ "action": "needs-user-decision",
1895
+ "reason": reason,
1896
+ }
1897
+ payload.update(extra)
1898
+ emit_json(payload, as_json=as_json)
1899
+ return 0
1900
+
1901
+
1902
+ def _confirm_service_shutdown(session: dict[str, Any]) -> dict[str, Any]:
1903
+ tracked_pids = sorted(
1904
+ {
1905
+ value
1906
+ for value in (session.get("pid"), session.get("servicePid"))
1907
+ if isinstance(value, int) and value > 0
1908
+ }
1909
+ )
1910
+ ports = sorted(
1911
+ {
1912
+ int(value)
1913
+ for value in (session.get("ownedPorts") or [])
1914
+ if isinstance(value, int) and 0 < value < 65536
1915
+ }
1916
+ )
1917
+ deadline = time.monotonic() + STOP_CONFIRM_TIMEOUT_SEC
1918
+ while True:
1919
+ alive_pids = [pid for pid in tracked_pids if is_pid_alive(pid)]
1920
+ occupied_ports = [port for port in ports if is_port_in_use(port)]
1921
+ if not alive_pids and not occupied_ports:
1922
+ return {
1923
+ "ok": True,
1924
+ "alivePids": [],
1925
+ "occupiedPorts": [],
1926
+ }
1927
+ if time.monotonic() >= deadline:
1928
+ return {
1929
+ "ok": False,
1930
+ "alivePids": alive_pids,
1931
+ "occupiedPorts": occupied_ports,
1932
+ }
1933
+ time.sleep(0.05)
1934
+
1935
+
1936
+ def _remove_windows_service_metadata(change_dir: Path) -> None:
1937
+ if os.name != "nt":
1938
+ return
1939
+ runtime = change_dir / "runtime"
1940
+ for name in (
1941
+ "_harness_service.launcher.pid",
1942
+ "_harness_service.child.pid",
1943
+ "_harness_service.job.id",
1944
+ "_harness_service.command.txt",
1945
+ "_harness_service_launcher.py",
1946
+ ):
1947
+ try:
1948
+ (runtime / name).unlink(missing_ok=True)
1949
+ except OSError:
1950
+ pass
1951
+
1952
+
1953
+ def stop_ai_session(
1954
+ change_dir: Path,
1955
+ session: dict[str, Any],
1956
+ *,
1957
+ require_identity: bool = True,
1958
+ ) -> dict[str, Any]:
1959
+ """Stop verified AI session process and clear session file."""
1960
+ operation_id = _service_operation_id(session)
1961
+ _ensure_session_state(session, operation_id=operation_id)
1962
+ pid = session.get("pid")
1963
+ if require_identity:
1964
+ identity = verify_process_identity(session)
1965
+ if identity is not True:
1966
+ _transition(
1967
+ session,
1968
+ "STALE_IDENTITY_MISMATCH" if identity is False else "FAILED",
1969
+ reason_code=(
1970
+ "STALE_IDENTITY_MISMATCH"
1971
+ if identity is False
1972
+ else "IDENTITY_UNVERIFIABLE"
1973
+ ),
1974
+ operation_id=operation_id,
1975
+ )
1976
+ write_session(change_dir, session)
1977
+ return {
1978
+ "ok": True,
1979
+ "action": "needs-user-decision",
1980
+ "reasonCode": (
1981
+ "PROCESS_IDENTITY_MISMATCH"
1982
+ if identity is False
1983
+ else "IDENTITY_UNVERIFIABLE"
1984
+ ),
1985
+ "reason": (
1986
+ "cannot-verify-process-identity"
1987
+ if identity is None
1988
+ else "process-identity-mismatch"
1989
+ ),
1990
+ "pid": pid,
1991
+ "killed": False,
1992
+ }
1993
+ stopping_transition = _transition(
1994
+ session,
1995
+ "STOPPING",
1996
+ reason_code="SERVICE_STOPPING",
1997
+ operation_id=operation_id,
1998
+ )
1999
+ if not stopping_transition.get("ok"):
2000
+ return {
2001
+ "ok": False,
2002
+ "code": "SERVICE_TRANSITION_CONFLICT",
2003
+ "reasonCode": "SERVICE_TRANSITION_CONFLICT",
2004
+ "action": "stop-refused",
2005
+ "from": stopping_transition.get("from"),
2006
+ "to": stopping_transition.get("to"),
2007
+ "operationId": operation_id,
2008
+ "killed": False,
2009
+ "sessionCleared": False,
2010
+ }
2011
+ write_session(change_dir, session)
2012
+ termination: dict[str, Any] | None = None
2013
+ if isinstance(pid, int) and is_pid_alive(pid):
2014
+ termination = terminate_process_tree(
2015
+ pid,
2016
+ expected_attestation=(
2017
+ session.get("processAttestation")
2018
+ if isinstance(session.get("processAttestation"), dict)
2019
+ else None
2020
+ ),
2021
+ ownership_proof=(
2022
+ session.get("ownershipProof")
2023
+ if isinstance(session.get("ownershipProof"), dict)
2024
+ else None
2025
+ ),
2026
+ timeout_policy={"graceSeconds": STOP_CONFIRM_TIMEOUT_SEC},
2027
+ )
2028
+ if (
2029
+ isinstance(termination, dict)
2030
+ and termination.get("reasonCode") == "IDENTITY_UNVERIFIABLE"
2031
+ ):
2032
+ _transition(
2033
+ session,
2034
+ "STALE_IDENTITY_MISMATCH",
2035
+ reason_code="STALE_IDENTITY_MISMATCH",
2036
+ operation_id=operation_id,
2037
+ )
2038
+ write_session(change_dir, session)
2039
+ return {
2040
+ "ok": True,
2041
+ "action": "needs-user-decision",
2042
+ "reasonCode": "IDENTITY_UNVERIFIABLE",
2043
+ "reason": "ownership-proof-unavailable",
2044
+ "pid": pid,
2045
+ "killed": False,
2046
+ }
2047
+ confirmation = _confirm_service_shutdown(session)
2048
+ if confirmation["alivePids"]:
2049
+ _transition(
2050
+ session,
2051
+ "FAILED",
2052
+ reason_code="PROCESS_TREE_EXIT_UNCONFIRMED",
2053
+ operation_id=operation_id,
2054
+ )
2055
+ write_session(change_dir, session)
2056
+ return {
2057
+ "ok": False,
2058
+ "code": "PROCESS_TREE_EXIT_UNCONFIRMED",
2059
+ "reasonCode": "SERVICE_STOP_FAILED",
2060
+ "action": "cleanup-unconfirmed",
2061
+ "pid": pid,
2062
+ "alivePids": confirmation["alivePids"],
2063
+ "occupiedPorts": confirmation["occupiedPorts"],
2064
+ "killed": True,
2065
+ "sessionCleared": False,
2066
+ }
2067
+ if confirmation["occupiedPorts"]:
2068
+ _transition(
2069
+ session,
2070
+ "FAILED",
2071
+ reason_code="OWNED_PORT_RELEASE_UNCONFIRMED",
2072
+ operation_id=operation_id,
2073
+ )
2074
+ write_session(change_dir, session)
2075
+ return {
2076
+ "ok": False,
2077
+ "code": "OWNED_PORT_RELEASE_UNCONFIRMED",
2078
+ "reasonCode": "SERVICE_CLEANUP_INCOMPLETE",
2079
+ "action": "cleanup-unconfirmed",
2080
+ "pid": pid,
2081
+ "alivePids": [],
2082
+ "occupiedPorts": confirmation["occupiedPorts"],
2083
+ "killed": True,
2084
+ "sessionCleared": False,
2085
+ }
2086
+ if os.name == "nt" and not _wait_for_windows_launcher_exit(change_dir):
2087
+ _transition(
2088
+ session,
2089
+ "FAILED",
2090
+ reason_code="SERVICE_STOP_TIMEOUT",
2091
+ operation_id=operation_id,
2092
+ )
2093
+ write_session(change_dir, session)
2094
+ return {
2095
+ "ok": False,
2096
+ "code": "SERVICE_STOP_TIMEOUT",
2097
+ "reasonCode": "SERVICE_STOP_TIMEOUT",
2098
+ "action": "cleanup-unconfirmed",
2099
+ "pid": pid,
2100
+ "alivePids": [],
2101
+ "occupiedPorts": [],
2102
+ "launcherStillAlive": True,
2103
+ "killed": True,
2104
+ "sessionCleared": False,
2105
+ }
2106
+ stopped_transition = _transition(
2107
+ session,
2108
+ "STOPPED",
2109
+ reason_code="SERVICE_STOP_CONFIRMED",
2110
+ operation_id=operation_id,
2111
+ )
2112
+ if not stopped_transition.get("ok"):
2113
+ write_session(change_dir, session)
2114
+ return {
2115
+ "ok": False,
2116
+ "code": "SERVICE_TRANSITION_CONFLICT",
2117
+ "reasonCode": "SERVICE_TRANSITION_CONFLICT",
2118
+ "action": "cleanup-unconfirmed",
2119
+ "pid": pid,
2120
+ "killed": True,
2121
+ "sessionCleared": False,
2122
+ "alivePids": [],
2123
+ "occupiedPorts": [],
2124
+ }
2125
+ _remove_windows_service_metadata(change_dir)
2126
+ clear_session(change_dir)
2127
+ return {
2128
+ "ok": True,
2129
+ "code": "SERVICE_STOP_CONFIRMED",
2130
+ "reasonCode": "SERVICE_STOPPED",
2131
+ "action": "stopped",
2132
+ "pid": pid,
2133
+ "killed": True,
2134
+ "sessionCleared": True,
2135
+ "alivePids": [],
2136
+ "occupiedPorts": [],
2137
+ }
2138
+
2139
+
2140
+ # ---------------------------------------------------------------------------
2141
+ # Subcommands
2142
+ # ---------------------------------------------------------------------------
2143
+
2144
+
2145
+ def cmd_ensure(args: argparse.Namespace) -> int:
2146
+ """Serialize ensure mutations and expose a stable conflict receipt."""
2147
+
2148
+ change_dir = resolve_path(args.change_dir)
2149
+ lock = ServiceMutationLock(change_dir, operation="ensure")
2150
+ conflict = lock.acquire()
2151
+ if conflict is not None:
2152
+ emit_json(conflict, as_json=bool(args.json))
2153
+ return 4
2154
+ setattr(args, "_service_operation_id", lock.token)
2155
+ try:
2156
+ return _cmd_ensure_impl(args)
2157
+ finally:
2158
+ lock.release()
2159
+
2160
+
2161
+ def _cmd_ensure_impl(args: argparse.Namespace) -> int:
2162
+ as_json = bool(args.json)
2163
+ change_dir = resolve_path(args.change_dir)
2164
+ project = resolve_path(args.project)
2165
+ files = parse_files_arg(getattr(args, "files", None))
2166
+
2167
+ try:
2168
+ profile = load_build_profile(project)
2169
+ except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
2170
+ return emit_error(str(exc), as_json=as_json)
2171
+
2172
+ # cluster 3 (spec §3.1/§3.4): resolve 模板 serviceStart + runtime context。
2173
+ # 持久 profile 只保存模板;runtime overlay/profile 注入到 session,不写回持久 profile。
2174
+ # 含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
2175
+ change_name = getattr(args, "change_name", None) or change_dir.name
2176
+ worktree_root_raw = getattr(args, "worktree_root", None)
2177
+ execution_root_raw = getattr(args, "execution_root", None)
2178
+ attempt_id = str(getattr(args, "attempt_id", None) or "").strip() or None
2179
+ worktree_root = (
2180
+ resolve_path(worktree_root_raw) if worktree_root_raw else project
2181
+ )
2182
+ execution_root = (
2183
+ resolve_path(execution_root_raw) if execution_root_raw else None
2184
+ )
2185
+ overlay = getattr(args, "overlay", None)
2186
+ leased_port = getattr(args, "leased_port", None)
2187
+ lease_owner = str(getattr(args, "lease_owner", None) or "").strip() or None
2188
+ try:
2189
+ service_start = resolve_service_start(
2190
+ profile,
2191
+ change_name=change_name,
2192
+ worktree_root=project,
2193
+ overlay_path=overlay,
2194
+ leased_port=leased_port,
2195
+ lease_owner=lease_owner,
2196
+ )
2197
+ except ValueError as exc:
2198
+ return emit_error(str(exc), as_json=as_json)
2199
+
2200
+ argv = list(service_start.get("argv") or [])
2201
+ if not argv:
2202
+ return emit_error(
2203
+ "serviceStart resolved to an empty argv; run profile migration and review",
2204
+ as_json=as_json,
2205
+ )
2206
+ command = str(service_start.get("command") or " ".join(argv)).strip()
2207
+ port = extract_port(service_start)
2208
+
2209
+ # Resolve service input file set (CLI --files ∪ serviceStart.inputFiles).
2210
+ # Empty result is deferred: _start_and_record rejects it before generating a
2211
+ # reusable empty fingerprint; the port-occupied / reuse paths still proceed.
2212
+ try:
2213
+ files = resolve_service_input_files(project, service_start, files)
2214
+ except ValueError:
2215
+ files = []
2216
+
2217
+ try:
2218
+ session = load_session(change_dir)
2219
+ except SessionCorrupt as exc:
2220
+ return needs_user_decision(
2221
+ reason=f"session-corrupt: {exc}",
2222
+ as_json=as_json,
2223
+ detail="missing or corrupt session is treated as a user process; will not kill",
2224
+ )
2225
+
2226
+ # --- Branch 1: existing AI session with live verified process ---
2227
+ if session is not None:
2228
+ pid = session["pid"]
2229
+ alive = is_pid_alive(pid)
2230
+ if alive:
2231
+ identity = verify_process_identity(session)
2232
+ if identity is not True:
2233
+ return needs_user_decision(
2234
+ reason=(
2235
+ "cannot-verify-process-identity"
2236
+ if identity is None
2237
+ else "process-identity-mismatch"
2238
+ ),
2239
+ as_json=as_json,
2240
+ pid=pid,
2241
+ reasonCode=(
2242
+ "IDENTITY_UNVERIFIABLE"
2243
+ if identity is None
2244
+ else "PROCESS_IDENTITY_MISMATCH"
2245
+ ),
2246
+ sessionPath=str(session_path(change_dir)),
2247
+ detail=(
2248
+ "pid is alive but create-time/cmdline identity could not be "
2249
+ "confirmed against session; refusing to reuse or kill"
2250
+ ),
2251
+ )
2252
+
2253
+ try:
2254
+ current_hash, current_files = compute_module_hash(files, session)
2255
+ except (OSError, FileNotFoundError) as exc:
2256
+ return emit_error(f"inputsHash failed: {exc}", as_json=as_json)
2257
+
2258
+ stored_hash = session.get("moduleInputsHash")
2259
+ # §5.3: reuse must compare inputsHash + startCommandHash + profile
2260
+ # + overlayPath (process identity already verified above). Any
2261
+ # change -> restart; never reuse on a partial match.
2262
+ current_cmd_hash = _process_provider.canonical_argv_hash(argv)
2263
+ current_profile = service_start.get("profile") or "local-dev"
2264
+ current_overlay = service_start.get("overlayPath") or ""
2265
+ health_match = probe_health(
2266
+ service_start,
2267
+ expected_instance_token=(
2268
+ str(service_start.get("instanceToken"))
2269
+ if service_start.get("requireInstanceToken") is True
2270
+ and service_start.get("instanceToken")
2271
+ else None
2272
+ ),
2273
+ )
2274
+ configured_token = service_start.get("instanceToken")
2275
+ if (
2276
+ service_start.get("requireInstanceToken") is True
2277
+ and isinstance(configured_token, str)
2278
+ and session.get("instanceTokenHash") is not None
2279
+ and session.get("instanceTokenHash") != sha256_text(configured_token)
2280
+ ):
2281
+ health_match = False
2282
+ if (
2283
+ health_match
2284
+ and service_start.get("requireInstanceToken") is not True
2285
+ and extract_port(service_start) is not None
2286
+ ):
2287
+ health_match = _listener_identity_is_owned(session, service_start)
2288
+ fingerprint_match = (
2289
+ stored_hash == current_hash
2290
+ and session.get("startCommandHash") == current_cmd_hash
2291
+ and session.get("profile") == current_profile
2292
+ and session.get("overlayPath") == current_overlay
2293
+ and session.get("leasedPort") == service_start.get("leasedPort")
2294
+ and session.get("leaseOwner") == service_start.get("leaseOwner")
2295
+ and health_match
2296
+ )
2297
+ if fingerprint_match:
2298
+ operation_id = str(
2299
+ getattr(args, "_service_operation_id", "") or uuid.uuid4().hex
2300
+ )
2301
+ _ensure_session_state(
2302
+ session,
2303
+ operation_id=operation_id,
2304
+ )
2305
+ _transition(
2306
+ session,
2307
+ "READY",
2308
+ reason_code="SERVICE_REUSED",
2309
+ operation_id=operation_id,
2310
+ )
2311
+ write_session(change_dir, session)
2312
+ payload = {
2313
+ "ok": True,
2314
+ "action": "reused",
2315
+ "pid": pid,
2316
+ "moduleInputsHash": current_hash,
2317
+ "moduleInputsFiles": current_files,
2318
+ "sessionPath": str(session_path(change_dir)),
2319
+ }
2320
+ emit_json(payload, as_json=as_json)
2321
+ return 0
2322
+
2323
+ # Fingerprint mismatch → stop old, start new
2324
+ stop_result = stop_ai_session(change_dir, session, require_identity=True)
2325
+ if stop_result.get("action") == "needs-user-decision":
2326
+ emit_json(stop_result, as_json=as_json)
2327
+ return 0
2328
+ if not stop_result.get("ok"):
2329
+ emit_json(stop_result, as_json=as_json)
2330
+ return 1
2331
+
2332
+ return _start_and_record(
2333
+ change_dir=change_dir,
2334
+ project=project,
2335
+ service_start=service_start,
2336
+ command=command,
2337
+ argv=argv,
2338
+ files=files if files else current_files,
2339
+ as_json=as_json,
2340
+ action="restarted",
2341
+ previousPid=pid,
2342
+ worktree_root=worktree_root,
2343
+ execution_root=execution_root,
2344
+ change_id=change_name,
2345
+ attempt_id=attempt_id,
2346
+ operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
2347
+ )
2348
+
2349
+ # pid dead → stale session; clear and fall through
2350
+ clear_session(change_dir)
2351
+
2352
+ # --- Branch 2: no usable session; port occupied → user decision ---
2353
+ if port is not None and is_port_in_use(port):
2354
+ return needs_user_decision(
2355
+ reason="port-occupied-without-ai-session",
2356
+ as_json=as_json,
2357
+ port=port,
2358
+ detail=(
2359
+ "port is in use but no verified AI service-session exists; "
2360
+ "treated as user process — will not kill"
2361
+ ),
2362
+ )
2363
+
2364
+ # --- Branch 3: start fresh ---
2365
+ return _start_and_record(
2366
+ change_dir=change_dir,
2367
+ project=project,
2368
+ service_start=service_start,
2369
+ command=command,
2370
+ argv=argv,
2371
+ files=files,
2372
+ as_json=as_json,
2373
+ action="started",
2374
+ worktree_root=worktree_root,
2375
+ execution_root=execution_root,
2376
+ change_id=change_name,
2377
+ attempt_id=attempt_id,
2378
+ operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
2379
+ )
2380
+
2381
+
2382
+ def _start_and_record(
2383
+ *,
2384
+ change_dir: Path,
2385
+ project: Path,
2386
+ service_start: dict[str, Any],
2387
+ command: str,
2388
+ argv: list[str],
2389
+ files: list[str],
2390
+ as_json: bool,
2391
+ action: str,
2392
+ worktree_root: Path | None = None,
2393
+ execution_root: Path | None = None,
2394
+ change_id: str | None = None,
2395
+ attempt_id: str | None = None,
2396
+ operation_id: str | None = None,
2397
+ **extra: Any,
2398
+ ) -> int:
2399
+ if not files:
2400
+ # §5.1/§5.2: never generate a reusable empty service fingerprint.
2401
+ return emit_error(
2402
+ "service inputs are empty; configure serviceStart.inputFiles "
2403
+ "(or pass --files) so the service fingerprint covers real source",
2404
+ as_json=as_json,
2405
+ )
2406
+ try:
2407
+ module_hash, module_files = compute_module_hash(files, None)
2408
+ except (OSError, FileNotFoundError) as exc:
2409
+ return emit_error(f"inputsHash failed: {exc}", as_json=as_json)
2410
+
2411
+ # Clear stale file-based health markers so wait_for_healthy cannot
2412
+ # succeed on a leftover marker from a previous process.
2413
+ _clear_file_health_markers(service_start)
2414
+
2415
+ started_at = now_iso()
2416
+ operation_id = operation_id or uuid.uuid4().hex
2417
+ generation = _allocate_service_generation(change_dir)
2418
+ mutation_state = {
2419
+ "schemaVersion": 1,
2420
+ "status": "STARTING",
2421
+ "reasonCode": "SERVICE_START_REQUESTED",
2422
+ "serviceGeneration": generation,
2423
+ "stateRevision": 1,
2424
+ "operationId": operation_id,
2425
+ "startedAt": started_at,
2426
+ "argvHash": _process_provider.canonical_argv_hash(argv),
2427
+ }
2428
+ write_json(mutation_state_path(change_dir), mutation_state)
2429
+ spawned: _process_provider.SpawnedProcess
2430
+ try:
2431
+ spawned = start_detached_service(argv, change_dir=change_dir, cwd=project)
2432
+ pid = int(spawned.attestation.get("pid") or spawned.process.pid)
2433
+ except (OSError, TimeoutError, ValueError, RuntimeError) as exc:
2434
+ _cleanup_windows_launcher(change_dir)
2435
+ mutation_state.update(
2436
+ {
2437
+ "status": "FAILED",
2438
+ "reasonCode": "SERVICE_START_FAILED",
2439
+ "endedAt": now_iso(),
2440
+ }
2441
+ )
2442
+ write_json(mutation_state_path(change_dir), mutation_state)
2443
+ if getattr(exc, "winerror", None) == 5:
2444
+ return emit_error(
2445
+ "failed to start service: Windows denied process creation "
2446
+ f"({exc}). The agent shell blocks detached spawns; start the "
2447
+ "service from a terminal the agent does not own (or use the "
2448
+ "documented nohup fallback) and re-run `ensure` so the harness "
2449
+ "adopts the running instance.",
2450
+ as_json=as_json,
2451
+ reasonCode="SERVICE_SPAWN_DENIED",
2452
+ )
2453
+ return emit_error(
2454
+ f"failed to start service: {exc}",
2455
+ as_json=as_json,
2456
+ reasonCode="SERVICE_START_FAILED",
2457
+ )
2458
+
2459
+ # Give the OS a moment to register the process before identity/create-time reads
2460
+ time.sleep(0.15)
2461
+
2462
+ try:
2463
+ wait_for_healthy(
2464
+ service_start,
2465
+ log_path(change_dir),
2466
+ pid=pid,
2467
+ expected_instance_token=(
2468
+ str(service_start.get("instanceToken"))
2469
+ if service_start.get("requireInstanceToken") is True
2470
+ and service_start.get("instanceToken")
2471
+ else None
2472
+ ),
2473
+ )
2474
+ except ServiceStartError as exc:
2475
+ # Best-effort cleanup of the failed start
2476
+ if is_pid_alive(pid):
2477
+ _capture_owned_service_members(spawned)
2478
+ terminate_process_tree(
2479
+ pid,
2480
+ expected_attestation=spawned.attestation,
2481
+ ownership_proof=spawned.ownershipProof,
2482
+ )
2483
+ _cleanup_windows_launcher(change_dir)
2484
+ clear_session(change_dir)
2485
+ mutation_state.update(
2486
+ {
2487
+ "status": "FAILED",
2488
+ "reasonCode": "SERVICE_START_FAILED",
2489
+ "endedAt": now_iso(),
2490
+ }
2491
+ )
2492
+ write_json(mutation_state_path(change_dir), mutation_state)
2493
+ return emit_error(
2494
+ str(exc),
2495
+ as_json=as_json,
2496
+ action="start-failed",
2497
+ pid=pid,
2498
+ fatalKeyword=exc.fatal_keyword,
2499
+ )
2500
+
2501
+ _capture_owned_service_members(spawned)
2502
+ session = build_session(
2503
+ pid=pid,
2504
+ module_inputs_hash=module_hash,
2505
+ module_inputs_files=module_files,
2506
+ command=command,
2507
+ service_start=service_start,
2508
+ started_at=started_at,
2509
+ worktree_root=worktree_root,
2510
+ execution_root=execution_root,
2511
+ change_id=change_id,
2512
+ attempt_id=attempt_id,
2513
+ service_pid=(
2514
+ _read_positive_int(_child_pid_path(change_dir))
2515
+ if os.name == "nt"
2516
+ else pid
2517
+ ),
2518
+ job_id=(
2519
+ _read_optional_text(_job_id_path(change_dir))
2520
+ if os.name == "nt"
2521
+ else None
2522
+ ),
2523
+ process_attestation=spawned.attestation,
2524
+ ownership_proof=spawned.ownershipProof,
2525
+ argv=argv,
2526
+ )
2527
+ _ensure_session_state(
2528
+ session,
2529
+ operation_id=operation_id,
2530
+ generation=generation,
2531
+ )
2532
+ if isinstance(service_start.get("instanceToken"), str):
2533
+ session["instanceTokenHash"] = sha256_text(
2534
+ service_start["instanceToken"]
2535
+ )
2536
+ _transition(
2537
+ session,
2538
+ "READY",
2539
+ reason_code="SERVICE_READY",
2540
+ operation_id=operation_id,
2541
+ )
2542
+ write_session(change_dir, session)
2543
+ mutation_state.update(
2544
+ {
2545
+ "status": "READY",
2546
+ "reasonCode": "SERVICE_READY",
2547
+ "stateRevision": session.get("stateRevision"),
2548
+ "endedAt": now_iso(),
2549
+ }
2550
+ )
2551
+ write_json(mutation_state_path(change_dir), mutation_state)
2552
+
2553
+ payload: dict[str, Any] = {
2554
+ "ok": True,
2555
+ "action": action,
2556
+ "pid": pid,
2557
+ "moduleInputsHash": module_hash,
2558
+ "moduleInputsFiles": module_files,
2559
+ "sessionPath": str(session_path(change_dir)),
2560
+ "logPath": str(log_path(change_dir)),
2561
+ "startedAt": started_at,
2562
+ }
2563
+ payload.update(extra)
2564
+ emit_json(payload, as_json=as_json)
2565
+ return 0
2566
+
2567
+
2568
+ def _capture_owned_service_members(
2569
+ spawned: _process_provider.SpawnedProcess,
2570
+ ) -> None:
2571
+ """Materialize the provider's current owned members before persistence."""
2572
+ proof = spawned.ownershipProof
2573
+ if not isinstance(proof, dict):
2574
+ return
2575
+ _process_provider.capture_owned_members(spawned)
2576
+
2577
+
2578
+ def _clear_file_health_markers(service_start: dict[str, Any]) -> None:
2579
+ candidates: list[Path] = []
2580
+ hf = service_start.get("healthFile")
2581
+ if isinstance(hf, str) and hf.strip():
2582
+ candidates.append(Path(hf.strip()))
2583
+ spec = service_start.get("healthUrl")
2584
+ if isinstance(spec, str) and spec.strip():
2585
+ resolved = resolve_health_file(spec.strip())
2586
+ if resolved is not None:
2587
+ candidates.append(resolved)
2588
+ for path in candidates:
2589
+ try:
2590
+ if path.is_file():
2591
+ path.unlink()
2592
+ except OSError:
2593
+ pass
2594
+
2595
+
2596
+ def cmd_status(args: argparse.Namespace) -> int:
2597
+ as_json = bool(args.json)
2598
+ change_dir = resolve_path(args.change_dir)
2599
+ files = parse_files_arg(getattr(args, "files", None))
2600
+
2601
+ try:
2602
+ session = load_session(change_dir)
2603
+ except SessionCorrupt as exc:
2604
+ payload = {
2605
+ "ok": True,
2606
+ "action": "status",
2607
+ "sessionPresent": True,
2608
+ "sessionCorrupt": True,
2609
+ "reason": str(exc),
2610
+ "alive": False,
2611
+ "identityVerified": False,
2612
+ "fingerprintMatch": None,
2613
+ "treatAsUserProcess": True,
2614
+ }
2615
+ emit_json(payload, as_json=as_json)
2616
+ return 0
2617
+
2618
+ if session is None:
2619
+ payload = {
2620
+ "ok": True,
2621
+ "action": "status",
2622
+ "sessionPresent": False,
2623
+ "alive": False,
2624
+ "identityVerified": False,
2625
+ "fingerprintMatch": None,
2626
+ }
2627
+ emit_json(payload, as_json=as_json)
2628
+ return 0
2629
+
2630
+ pid = session.get("pid")
2631
+ alive = isinstance(pid, int) and is_pid_alive(pid)
2632
+ identity = verify_process_identity(session) if alive else False
2633
+ heartbeat = _heartbeat_status(session)
2634
+
2635
+ fingerprint_match: bool | None = None
2636
+ current_hash: str | None = None
2637
+ try:
2638
+ current_hash, _ = compute_module_hash(files, session)
2639
+ stored = session.get("moduleInputsHash")
2640
+ if isinstance(stored, str) and stored:
2641
+ fingerprint_match = stored == current_hash
2642
+ except (OSError, FileNotFoundError):
2643
+ fingerprint_match = None
2644
+
2645
+ payload = {
2646
+ "ok": True,
2647
+ "action": "status",
2648
+ "sessionPresent": True,
2649
+ "sessionCorrupt": False,
2650
+ "session": session,
2651
+ "pid": pid,
2652
+ "alive": alive,
2653
+ "identityVerified": identity is True,
2654
+ "identityStatus": (
2655
+ "verified" if identity is True else ("unknown" if identity is None else "mismatch")
2656
+ ),
2657
+ "reasonCode": (
2658
+ "SERVICE_READY"
2659
+ if identity is True
2660
+ else ("IDENTITY_UNVERIFIABLE" if identity is None else "PROCESS_IDENTITY_MISMATCH")
2661
+ ),
2662
+ "fingerprintMatch": fingerprint_match,
2663
+ "currentModuleInputsHash": current_hash,
2664
+ "startedBy": session.get("startedBy"),
2665
+ "serviceGeneration": session.get("serviceGeneration"),
2666
+ "stateRevision": session.get("stateRevision"),
2667
+ "serviceStatus": session.get("status"),
2668
+ "heartbeat": heartbeat,
2669
+ }
2670
+ emit_json(payload, as_json=as_json)
2671
+ return 0
2672
+
2673
+
2674
+ def cmd_stop(args: argparse.Namespace) -> int:
2675
+ change_dir = resolve_path(args.change_dir)
2676
+ lock = ServiceMutationLock(change_dir, operation="stop")
2677
+ conflict = lock.acquire()
2678
+ if conflict is not None:
2679
+ emit_json(conflict, as_json=bool(args.json))
2680
+ return 4
2681
+ setattr(args, "_service_operation_id", lock.token)
2682
+ try:
2683
+ return _cmd_stop_impl(args)
2684
+ finally:
2685
+ lock.release()
2686
+
2687
+
2688
+ def _cmd_stop_impl(args: argparse.Namespace) -> int:
2689
+ as_json = bool(args.json)
2690
+ change_dir = resolve_path(args.change_dir)
2691
+ if_started_by_ai = bool(getattr(args, "if_started_by_ai", False))
2692
+
2693
+ try:
2694
+ session = load_session(change_dir)
2695
+ except SessionCorrupt as exc:
2696
+ return needs_user_decision(
2697
+ reason=f"session-corrupt: {exc}",
2698
+ as_json=as_json,
2699
+ detail="corrupt session treated as user process; will not kill",
2700
+ killed=False,
2701
+ )
2702
+
2703
+ if session is None:
2704
+ payload = {
2705
+ "ok": True,
2706
+ "action": "already-stopped",
2707
+ "killed": False,
2708
+ "sessionCleared": False,
2709
+ "detail": "no service-session.json",
2710
+ }
2711
+ emit_json(payload, as_json=as_json)
2712
+ return 0
2713
+
2714
+ started_by = session.get("startedBy")
2715
+ if if_started_by_ai and started_by != "AI":
2716
+ payload = {
2717
+ "ok": True,
2718
+ "action": "skipped",
2719
+ "reason": "not-started-by-ai",
2720
+ "startedBy": started_by,
2721
+ "killed": False,
2722
+ "sessionCleared": False,
2723
+ }
2724
+ emit_json(payload, as_json=as_json)
2725
+ return 0
2726
+
2727
+ # Default stop also refuses to kill non-AI / unverified processes
2728
+ if started_by != "AI":
2729
+ return needs_user_decision(
2730
+ reason="not-started-by-ai",
2731
+ as_json=as_json,
2732
+ startedBy=started_by,
2733
+ killed=False,
2734
+ detail="session not marked startedBy=AI; will not kill",
2735
+ )
2736
+
2737
+ result = stop_ai_session(change_dir, session, require_identity=True)
2738
+ emit_json(result, as_json=as_json)
2739
+ return 0 if result.get("ok") else 1
2740
+
2741
+
2742
+ def retire_stale_session(change_dir: Path) -> dict[str, Any]:
2743
+ change_dir = resolve_path(change_dir)
2744
+ lock = ServiceMutationLock(change_dir, operation="retire-stale")
2745
+ conflict = lock.acquire()
2746
+ if conflict is not None:
2747
+ return conflict
2748
+ try:
2749
+ return _retire_stale_session_impl(change_dir, operation_id=lock.token)
2750
+ finally:
2751
+ lock.release()
2752
+
2753
+
2754
+ def _project_root_for_change(change_dir: Path) -> Path | None:
2755
+ resolved = resolve_path(change_dir)
2756
+ for candidate in (resolved, *resolved.parents):
2757
+ if candidate.name == "changes" and candidate.parent.name == ".harness":
2758
+ return candidate.parent.parent
2759
+ if (candidate / ".harness").is_dir():
2760
+ return candidate
2761
+ return None
2762
+
2763
+
2764
+ def _retirement_receipt_digest(receipt: dict[str, Any]) -> str:
2765
+ unsigned = {
2766
+ key: value for key, value in receipt.items() if key != "receiptDigest"
2767
+ }
2768
+ return sha256_text(
2769
+ json.dumps(unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2770
+ )
2771
+
2772
+
2773
+ def _retirement_lease_cleanup(
2774
+ change_dir: Path,
2775
+ session: dict[str, Any],
2776
+ ) -> dict[str, Any]:
2777
+ """Attempt lease cleanup only with a complete lease/listener identity."""
2778
+
2779
+ lease = session.get("leaseIdentity")
2780
+ if not isinstance(lease, dict):
2781
+ return {
2782
+ "status": "RETAINED",
2783
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
2784
+ "leaseId": None,
2785
+ }
2786
+ lease_id = lease.get("leaseId")
2787
+ change_id = lease.get("changeId") or session.get("changeId")
2788
+ run_id = lease.get("runId") or session.get("attemptId")
2789
+ port = session.get("leasedPort")
2790
+ if not (
2791
+ isinstance(lease_id, str)
2792
+ and lease_id
2793
+ and isinstance(change_id, str)
2794
+ and change_id
2795
+ and isinstance(run_id, str)
2796
+ and run_id
2797
+ and isinstance(port, int)
2798
+ and 0 < port < 65536
2799
+ ):
2800
+ return {
2801
+ "status": "UNVERIFIED",
2802
+ "reasonCode": "LEASE_CAS_MISMATCH",
2803
+ "leaseId": lease_id if isinstance(lease_id, str) else None,
2804
+ }
2805
+ # A lease without a separately persisted listener identity cannot be
2806
+ # released during stale retirement. The old process is precisely the
2807
+ # identity that is no longer trusted, so do not reconstruct it from PID.
2808
+ listener_identity = lease.get("listenerIdentity")
2809
+ if not isinstance(listener_identity, dict):
2810
+ return {
2811
+ "status": "UNVERIFIED",
2812
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
2813
+ "leaseId": lease_id,
2814
+ }
2815
+ project_root = _project_root_for_change(change_dir)
2816
+ if project_root is None:
2817
+ return {
2818
+ "status": "UNVERIFIED",
2819
+ "reasonCode": "LEASE_CAS_MISMATCH",
2820
+ "leaseId": lease_id,
2821
+ }
2822
+ try:
2823
+ import harness_change
2824
+
2825
+ release = harness_change.release_port(
2826
+ project_root,
2827
+ change_id=change_id,
2828
+ run_id=run_id,
2829
+ port=port,
2830
+ lease_id=lease_id,
2831
+ generation=_normalize_service_generation(lease.get("generation")),
2832
+ listener_identity=listener_identity,
2833
+ )
2834
+ except (ImportError, OSError, TypeError, ValueError) as exc:
2835
+ return {
2836
+ "status": "UNVERIFIED",
2837
+ "reasonCode": "LEASE_CAS_MISMATCH",
2838
+ "leaseId": lease_id,
2839
+ }
2840
+ if release.get("ok"):
2841
+ return {
2842
+ "status": "RELEASED",
2843
+ "reasonCode": "SERVICE_RETIRED",
2844
+ "leaseId": lease_id,
2845
+ }
2846
+ code = str(release.get("code") or "")
2847
+ reason = (
2848
+ "LISTENER_IDENTITY_UNVERIFIABLE"
2849
+ if "LISTENER" in code or code == "IDENTITY_UNVERIFIABLE"
2850
+ else "LEASE_CAS_MISMATCH"
2851
+ )
2852
+ return {
2853
+ "status": "RETAINED" if reason.startswith("LISTENER") else "UNVERIFIED",
2854
+ "reasonCode": reason,
2855
+ "leaseId": lease_id,
2856
+ }
2857
+
2858
+
2859
+ def _retire_stale_session_impl(
2860
+ change_dir: Path,
2861
+ *,
2862
+ operation_id: str,
2863
+ ) -> dict[str, Any]:
2864
+ """Finalize one stale generation without touching an unknown process."""
2865
+ change_dir = resolve_path(change_dir)
2866
+ try:
2867
+ session = load_session(change_dir)
2868
+ except SessionCorrupt as exc:
2869
+ return {
2870
+ "ok": False,
2871
+ "code": "SERVICE_SESSION_CORRUPT",
2872
+ "action": "retire-refused",
2873
+ "error": str(exc),
2874
+ "unknownProcessUntouched": True,
2875
+ }
2876
+ if session is None:
2877
+ retired_root = change_dir / "runtime" / "retired-service-sessions"
2878
+ for receipt_path in sorted(retired_root.glob("*.receipt.json")):
2879
+ try:
2880
+ existing_receipt = read_json(receipt_path)
2881
+ except (OSError, ValueError, json.JSONDecodeError):
2882
+ continue
2883
+ if isinstance(existing_receipt, dict) and (
2884
+ existing_receipt.get("state") == "FINALIZED"
2885
+ or existing_receipt.get("action") == "retired-stale"
2886
+ ):
2887
+ evidence = retired_root / f"{receipt_path.name[:-len('.receipt.json')]}.json"
2888
+ return {
2889
+ "ok": True,
2890
+ "code": "SERVICE_RETIRED",
2891
+ "action": "already-retired",
2892
+ "receipt": existing_receipt,
2893
+ "retiredEvidence": str(evidence) if evidence.is_file() else None,
2894
+ "unknownProcessUntouched": True,
2895
+ }
2896
+ return {
2897
+ "ok": True,
2898
+ "code": "SERVICE_SESSION_ABSENT",
2899
+ "action": "already-retired",
2900
+ "unknownProcessUntouched": True,
2901
+ }
2902
+ identity = verify_process_identity(session)
2903
+ if identity is True:
2904
+ return {
2905
+ "ok": False,
2906
+ "code": "SERVICE_PROCESS_STILL_OWNED",
2907
+ "action": "retire-refused",
2908
+ "unknownProcessUntouched": True,
2909
+ }
2910
+ if identity is None:
2911
+ return {
2912
+ "ok": False,
2913
+ "code": "SERVICE_PROCESS_IDENTITY_UNVERIFIED",
2914
+ "action": "retire-refused",
2915
+ "unknownProcessUntouched": True,
2916
+ }
2917
+ service_id = str(session.get("serviceId") or change_dir.name)
2918
+ old_session_id = str(
2919
+ session.get("sessionId")
2920
+ or "legacy-" + sha256_text(json.dumps(session, sort_keys=True))[7:23]
2921
+ )
2922
+ old_generation = _normalize_service_generation(session.get("serviceGeneration"))
2923
+ retired_root = change_dir / "runtime" / "retired-service-sessions"
2924
+ retired_root.mkdir(parents=True, exist_ok=True)
2925
+ safe_service = re.sub(r"[^A-Za-z0-9._-]+", "-", service_id).strip("-") or "service"
2926
+ receipt_path = retired_root / f"{safe_service}-{old_generation}.receipt.json"
2927
+ if receipt_path.is_file():
2928
+ try:
2929
+ existing_receipt = read_json(receipt_path)
2930
+ except (OSError, ValueError, json.JSONDecodeError):
2931
+ existing_receipt = None
2932
+ if (
2933
+ isinstance(existing_receipt, dict)
2934
+ and existing_receipt.get("serviceId") == service_id
2935
+ and _normalize_service_generation(existing_receipt.get("oldGeneration"))
2936
+ == old_generation
2937
+ ):
2938
+ evidence = existing_receipt.get("retiredEvidence")
2939
+ return {
2940
+ "ok": True,
2941
+ "code": "SERVICE_RETIRED",
2942
+ "action": "already-retired",
2943
+ "receipt": existing_receipt,
2944
+ "retiredEvidence": evidence,
2945
+ "unknownProcessUntouched": True,
2946
+ }
2947
+ pending_path = change_dir / "runtime" / "retirement.pending.json"
2948
+ write_json(
2949
+ pending_path,
2950
+ {
2951
+ "schemaVersion": 1,
2952
+ "serviceId": service_id,
2953
+ "oldSessionId": old_session_id,
2954
+ "oldGeneration": old_generation,
2955
+ "operationId": operation_id,
2956
+ "state": "PENDING",
2957
+ },
2958
+ )
2959
+ lease_cleanup = _retirement_lease_cleanup(change_dir, session)
2960
+ retired_session = json.loads(json.dumps(session, ensure_ascii=False))
2961
+ _ensure_session_state(retired_session, operation_id=operation_id, generation=old_generation)
2962
+ if retired_session.get("status") not in {"STALE_IDENTITY_MISMATCH", "RETIRED"}:
2963
+ _transition(
2964
+ retired_session,
2965
+ "STALE_IDENTITY_MISMATCH",
2966
+ reason_code="SERVICE_IDENTITY_STALE",
2967
+ operation_id=operation_id,
2968
+ )
2969
+ if retired_session.get("status") != "RETIRED":
2970
+ _transition(
2971
+ retired_session,
2972
+ "RETIRED",
2973
+ reason_code="SERVICE_RETIRED",
2974
+ operation_id=operation_id,
2975
+ )
2976
+ retired_session["cleanupComplete"] = lease_cleanup["status"] == "RELEASED"
2977
+ retired_session["supersedesSessionId"] = None
2978
+ source = session_path(change_dir)
2979
+ destination = retired_root / f"{safe_service}-{old_generation}.json"
2980
+ quarantined = False
2981
+ if source.is_file():
2982
+ try:
2983
+ import harness_runtime as _runtime
2984
+
2985
+ sensitive = [
2986
+ item
2987
+ for item in _runtime.sensitive_evidence_candidates(source.parent)
2988
+ if item.get("path") == source.name
2989
+ ]
2990
+ if sensitive:
2991
+ quarantine = _runtime.quarantine_sensitive_evidence(
2992
+ source,
2993
+ change_root=change_dir,
2994
+ reason="service retirement legacy evidence",
2995
+ )
2996
+ if not quarantine.get("ok"):
2997
+ return {
2998
+ "ok": False,
2999
+ "code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
3000
+ "action": "retire-refused",
3001
+ "quarantine": quarantine,
3002
+ "unknownProcessUntouched": True,
3003
+ }
3004
+ quarantined = True
3005
+ except (ImportError, OSError, ValueError, TypeError) as exc:
3006
+ return {
3007
+ "ok": False,
3008
+ "code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
3009
+ "action": "retire-refused",
3010
+ "error": str(exc),
3011
+ "unknownProcessUntouched": True,
3012
+ }
3013
+ if source.is_file():
3014
+ os.replace(source, destination)
3015
+ # Replace the moved legacy payload with the sanitized, typed retired
3016
+ # state only after the atomic move has succeeded.
3017
+ write_json(destination, retired_session)
3018
+ elif not destination.is_file() and not quarantined:
3019
+ return {
3020
+ "ok": False,
3021
+ "code": "SERVICE_SESSION_ABSENT",
3022
+ "action": "retire-refused",
3023
+ "unknownProcessUntouched": True,
3024
+ }
3025
+ receipt = {
3026
+ "schemaVersion": 1,
3027
+ "receiptId": f"retirement-{safe_service}-{old_generation}",
3028
+ "operationId": operation_id,
3029
+ "serviceId": service_id,
3030
+ "oldSessionId": old_session_id,
3031
+ "oldGeneration": old_generation,
3032
+ "state": "FINALIZED",
3033
+ "retirementStateCommit": {
3034
+ "status": "COMMITTED",
3035
+ "reasonCode": "SERVICE_RETIRED",
3036
+ },
3037
+ "leaseCleanup": lease_cleanup,
3038
+ "cleanupComplete": lease_cleanup["status"] == "RELEASED",
3039
+ "awaitingSuperseder": True,
3040
+ "supersededBySessionId": None,
3041
+ }
3042
+ receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
3043
+ # Keep the evidence path in the response/pending journal, not in the
3044
+ # strict receipt contract; the destination is derived from the receipt.
3045
+ write_json(receipt_path, receipt)
3046
+ pending_path.unlink(missing_ok=True)
3047
+ return {
3048
+ "ok": True,
3049
+ "code": "SERVICE_RETIRED",
3050
+ "action": "retired-stale",
3051
+ "receipt": receipt,
3052
+ "retiredEvidence": str(destination),
3053
+ "unknownProcessUntouched": True,
3054
+ }
3055
+
3056
+
3057
+ def cmd_retire_stale(args: argparse.Namespace) -> int:
3058
+ result = retire_stale_session(resolve_path(args.change_dir))
3059
+ emit_json(result, as_json=bool(args.json))
3060
+ return 0 if result.get("ok") else 1
3061
+
3062
+
3063
+ def _link_superseder_unlocked(
3064
+ change_dir: Path,
3065
+ *,
3066
+ retirement_receipt: Path,
3067
+ new_session: dict[str, Any],
3068
+ operation_id: str | None = None,
3069
+ ) -> dict[str, Any]:
3070
+ """CAS-link one future service generation to a finalized retirement."""
3071
+
3072
+ change_dir = resolve_path(change_dir)
3073
+ receipt_path = resolve_path(retirement_receipt)
3074
+ try:
3075
+ receipt = read_json(receipt_path)
3076
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
3077
+ return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID", "error": str(exc)}
3078
+ if not isinstance(receipt, dict):
3079
+ return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID"}
3080
+ raw_old_generation = receipt.get("oldGeneration")
3081
+ raw_new_generation = new_session.get("serviceGeneration")
3082
+ if raw_old_generation is None or raw_new_generation is None:
3083
+ return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
3084
+ old_generation = _normalize_service_generation(raw_old_generation)
3085
+ new_generation = _normalize_service_generation(raw_new_generation)
3086
+ if old_generation == new_generation:
3087
+ return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
3088
+ receipt["schemaVersion"] = 1
3089
+ receipt["oldGeneration"] = old_generation
3090
+ receipt.setdefault("receiptId", f"retirement-{change_dir.name}-{old_generation}")
3091
+ receipt.setdefault("operationId", operation_id or uuid.uuid4().hex)
3092
+ receipt.setdefault("serviceId", str(receipt.get("serviceId") or change_dir.name))
3093
+ receipt.setdefault("oldSessionId", str(receipt.get("sessionId") or "legacy-" + change_dir.name))
3094
+ receipt.setdefault("state", "FINALIZED")
3095
+ receipt.setdefault(
3096
+ "retirementStateCommit",
3097
+ {"status": "COMMITTED", "reasonCode": "SERVICE_RETIRED"},
3098
+ )
3099
+ receipt.setdefault(
3100
+ "leaseCleanup",
3101
+ {
3102
+ "status": "RETAINED",
3103
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
3104
+ "leaseId": None,
3105
+ },
3106
+ )
3107
+ receipt["cleanupComplete"] = receipt["leaseCleanup"].get("status") == "RELEASED"
3108
+ receipt.setdefault("supersededBySessionId", None)
3109
+ legacy_superseder = receipt.pop("superseder", None)
3110
+ if receipt.get("supersededBySessionId") is None and isinstance(legacy_superseder, dict):
3111
+ legacy_id = legacy_superseder.get("sessionId")
3112
+ if isinstance(legacy_id, str) and legacy_id:
3113
+ receipt["supersededBySessionId"] = legacy_id
3114
+ existing_id = receipt.get("supersededBySessionId")
3115
+ if isinstance(existing_id, str) and existing_id:
3116
+ if existing_id == new_session.get("sessionId"):
3117
+ return {
3118
+ "ok": True,
3119
+ "code": "SUPERSEDER_ALREADY_LINKED",
3120
+ "receipt": receipt,
3121
+ }
3122
+ return {
3123
+ "ok": False,
3124
+ "code": "SUPERSEDER_LINK_CONFLICT",
3125
+ "receipt": receipt,
3126
+ }
3127
+ if receipt.get("awaitingSuperseder") is False:
3128
+ return {
3129
+ "ok": False,
3130
+ "code": "SUPERSEDER_LINK_CONFLICT",
3131
+ "receipt": receipt,
3132
+ }
3133
+ operation_id = operation_id or uuid.uuid4().hex
3134
+ new_session["schemaVersion"] = 1
3135
+ new_session.setdefault("serviceId", receipt["serviceId"])
3136
+ new_session.setdefault("sessionId", "service-" + uuid.uuid4().hex)
3137
+ new_session.setdefault("status", "STARTING")
3138
+ new_session.setdefault("reasonCode", "SERVICE_START_REQUESTED")
3139
+ new_session.setdefault("stateRevision", 1)
3140
+ new_session.setdefault("operationId", operation_id)
3141
+ new_session.setdefault("fingerprint", sha256_text(""))
3142
+ new_session.setdefault("processIdentity", None)
3143
+ new_session.setdefault("heartbeat", None)
3144
+ new_session.setdefault("leaseIdentity", None)
3145
+ new_session.setdefault("transitionHistory", [])
3146
+ new_session.setdefault("cleanupComplete", False)
3147
+ new_session["serviceGeneration"] = new_generation
3148
+ new_session["supersedesSessionId"] = receipt["oldSessionId"]
3149
+ retirement_commit = receipt.get("retirementStateCommit")
3150
+ if not isinstance(retirement_commit, dict):
3151
+ retirement_commit = {}
3152
+ lease_cleanup = receipt.get("leaseCleanup")
3153
+ if not isinstance(lease_cleanup, dict):
3154
+ lease_cleanup = {}
3155
+ commit_status = str(retirement_commit.get("status") or "COMMITTED")
3156
+ if commit_status not in {"PENDING", "COMMITTED", "CONFLICT"}:
3157
+ commit_status = "COMMITTED"
3158
+ lease_status = str(lease_cleanup.get("status") or "RETAINED")
3159
+ if lease_status not in {"PENDING", "RELEASED", "RETAINED", "UNVERIFIED"}:
3160
+ lease_status = "UNVERIFIED"
3161
+ canonical_receipt = {
3162
+ "schemaVersion": 1,
3163
+ "receiptId": str(receipt["receiptId"]),
3164
+ "operationId": operation_id,
3165
+ "serviceId": str(receipt["serviceId"]),
3166
+ "oldSessionId": str(receipt["oldSessionId"]),
3167
+ "oldGeneration": old_generation,
3168
+ "state": "FINALIZED",
3169
+ "retirementStateCommit": {
3170
+ "status": commit_status,
3171
+ "reasonCode": _canonical_service_reason(
3172
+ str(retirement_commit.get("reasonCode") or "SERVICE_RETIRED")
3173
+ ),
3174
+ },
3175
+ "leaseCleanup": {
3176
+ "status": lease_status,
3177
+ "reasonCode": _canonical_service_reason(
3178
+ str(lease_cleanup.get("reasonCode") or "LISTENER_IDENTITY_UNVERIFIABLE")
3179
+ ),
3180
+ "leaseId": lease_cleanup.get("leaseId")
3181
+ if isinstance(lease_cleanup.get("leaseId"), str)
3182
+ else None,
3183
+ },
3184
+ "cleanupComplete": lease_status == "RELEASED",
3185
+ "awaitingSuperseder": False,
3186
+ "supersededBySessionId": new_session.get("sessionId"),
3187
+ }
3188
+ receipt = canonical_receipt
3189
+ receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
3190
+ write_json(receipt_path, receipt)
3191
+ write_session(change_dir, new_session)
3192
+ return {
3193
+ "ok": True,
3194
+ "code": "SUPERSEDER_LINKED",
3195
+ "receipt": receipt,
3196
+ "session": new_session,
3197
+ }
3198
+
3199
+
3200
+ def link_superseder(
3201
+ change_dir: Path,
3202
+ *,
3203
+ retirement_receipt: Path,
3204
+ new_session: dict[str, Any],
3205
+ operation_id: str | None = None,
3206
+ ) -> dict[str, Any]:
3207
+ """CAS-link one superseder while serializing writers for this service."""
3208
+
3209
+ change_dir = resolve_path(change_dir)
3210
+ lock = ServiceMutationLock(change_dir, operation="link-superseder")
3211
+ conflict = lock.acquire()
3212
+ if conflict is not None:
3213
+ return conflict
3214
+ try:
3215
+ return _link_superseder_unlocked(
3216
+ change_dir,
3217
+ retirement_receipt=retirement_receipt,
3218
+ new_session=new_session,
3219
+ operation_id=operation_id or lock.token,
3220
+ )
3221
+ finally:
3222
+ lock.release()
3223
+
3224
+
3225
+ def cmd_link_superseder(args: argparse.Namespace) -> int:
3226
+ change_dir = resolve_path(args.change_dir)
3227
+ try:
3228
+ new_session = load_session(change_dir)
3229
+ except SessionCorrupt as exc:
3230
+ return emit_error(str(exc), as_json=bool(args.json))
3231
+ if new_session is None:
3232
+ return emit_error(
3233
+ "new service session is missing",
3234
+ as_json=bool(args.json),
3235
+ code=4,
3236
+ )
3237
+ result = link_superseder(
3238
+ change_dir,
3239
+ retirement_receipt=resolve_path(args.retirement_receipt),
3240
+ new_session=new_session,
3241
+ )
3242
+ emit_json(result, as_json=bool(args.json))
3243
+ return 0 if result.get("ok") else 4
3244
+
3245
+
3246
+ def build_parser() -> argparse.ArgumentParser:
3247
+ parser = argparse.ArgumentParser(
3248
+ prog="harness_service.py",
3249
+ description="Manage AI service-session lifecycle (ensure/status/stop)",
3250
+ )
3251
+ parser.add_argument(
3252
+ "--json",
3253
+ action="store_true",
3254
+ help="emit machine-readable JSON on stdout",
3255
+ )
3256
+ sub = parser.add_subparsers(dest="command_name", required=True)
3257
+
3258
+ p_ensure = sub.add_parser("ensure", help="reuse / restart / start service")
3259
+ p_ensure.add_argument("--change-dir", required=True)
3260
+ p_ensure.add_argument("--project", required=True)
3261
+ p_ensure.add_argument(
3262
+ "--files",
3263
+ default=None,
3264
+ help="comma-separated service module source files for inputsHash",
3265
+ )
3266
+ p_ensure.add_argument(
3267
+ "--change-name",
3268
+ default=None,
3269
+ help="change-name for runtime profile resolve (default: change-dir name)",
3270
+ )
3271
+ p_ensure.add_argument(
3272
+ "--overlay",
3273
+ default=None,
3274
+ help="runtime overlay path injected into resolved serviceStart",
3275
+ )
3276
+ p_ensure.add_argument(
3277
+ "--leased-port",
3278
+ type=int,
3279
+ default=None,
3280
+ help="port allocated by harness_change.py lease-port",
3281
+ )
3282
+ p_ensure.add_argument(
3283
+ "--lease-owner",
3284
+ default=None,
3285
+ help="run id that owns --leased-port",
3286
+ )
3287
+ p_ensure.add_argument(
3288
+ "--worktree-root",
3289
+ default=None,
3290
+ help="worktree that owns the service (default: --project)",
3291
+ )
3292
+ p_ensure.add_argument(
3293
+ "--execution-root",
3294
+ default=None,
3295
+ help="ephemeral execution worktree that owns the service",
3296
+ )
3297
+ p_ensure.add_argument(
3298
+ "--attempt-id",
3299
+ default=None,
3300
+ help="phase or transaction attempt that started the service",
3301
+ )
3302
+ p_ensure.add_argument("--json", action="store_true")
3303
+ p_ensure.set_defaults(func=cmd_ensure)
3304
+
3305
+ p_status = sub.add_parser("status", help="show session + liveness + fingerprint")
3306
+ p_status.add_argument("--change-dir", required=True)
3307
+ p_status.add_argument(
3308
+ "--files",
3309
+ default=None,
3310
+ help="optional files for current fingerprint comparison",
3311
+ )
3312
+ p_status.add_argument("--json", action="store_true")
3313
+ p_status.set_defaults(func=cmd_status)
3314
+
3315
+ p_stop = sub.add_parser("stop", help="stop service and clear session")
3316
+ p_stop.add_argument("--change-dir", required=True)
3317
+ p_stop.add_argument(
3318
+ "--if-started-by-ai",
3319
+ action="store_true",
3320
+ help="only stop when session.startedBy == AI (archive cleanup)",
3321
+ )
3322
+ p_stop.add_argument("--json", action="store_true")
3323
+ p_stop.set_defaults(func=cmd_stop)
3324
+
3325
+ p_retire = sub.add_parser(
3326
+ "retire-stale",
3327
+ help="retire stale Harness state without terminating unknown processes",
3328
+ )
3329
+ p_retire.add_argument("--change-dir", required=True)
3330
+ p_retire.add_argument("--json", action="store_true")
3331
+ p_retire.set_defaults(func=cmd_retire_stale)
3332
+
3333
+ p_link = sub.add_parser(
3334
+ "link-superseder",
3335
+ help="CAS-link a new service generation to a retirement receipt",
3336
+ )
3337
+ p_link.add_argument("--change-dir", required=True)
3338
+ p_link.add_argument("--retirement-receipt", required=True)
3339
+ p_link.add_argument("--json", action="store_true")
3340
+ p_link.set_defaults(func=cmd_link_superseder)
3341
+
3342
+ return parser
3343
+
3344
+
3345
+ def main(argv: list[str] | None = None) -> int:
3346
+ parser = build_parser()
3347
+ args = parser.parse_args(argv)
3348
+ # Allow top-level --json as well as subcommand --json
3349
+ if getattr(args, "json", False) is False and "--json" in (argv or sys.argv[1:]):
3350
+ args.json = True
3351
+ return int(args.func(args))
3352
+
3353
+
3354
+ if __name__ == "__main__":
3355
+ raise SystemExit(main())