@hunter-harness/workflow-harness 0.2.43 → 0.2.45

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 (274) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/build-profile-v3.schema.json +100 -0
  3. package/harness/bundles/general/claude-code/contracts/fixtures/managed-execution.json +341 -0
  4. package/harness/bundles/general/claude-code/contracts/process-identity.schema.json +132 -0
  5. package/harness/bundles/general/claude-code/contracts/run-session.schema.json +142 -0
  6. package/harness/bundles/general/claude-code/contracts/service-retirement-receipt.schema.json +85 -0
  7. package/harness/bundles/general/claude-code/contracts/service-session.schema.json +147 -0
  8. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-submit/checklist.md +12 -0
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +324 -28
  20. package/harness/bundles/general/claude-code/scripts/harness_change.py +137 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +153 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_environment.py +55 -25
  23. package/harness/bundles/general/claude-code/scripts/harness_execution_contracts.py +596 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_integration.py +289 -52
  25. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +5 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_orchestration.py +344 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_process.py +1065 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_profile.py +244 -4
  29. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +646 -132
  30. package/harness/bundles/general/claude-code/scripts/harness_service.py +1469 -194
  31. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +107 -117
  32. package/harness/bundles/general/claude-code/scripts/harness_verification.py +79 -0
  33. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  34. package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +100 -0
  35. package/harness/bundles/general/codebuddy/contracts/fixtures/managed-execution.json +341 -0
  36. package/harness/bundles/general/codebuddy/contracts/process-identity.schema.json +132 -0
  37. package/harness/bundles/general/codebuddy/contracts/run-session.schema.json +142 -0
  38. package/harness/bundles/general/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
  39. package/harness/bundles/general/codebuddy/contracts/service-session.schema.json +147 -0
  40. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  48. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +12 -0
  49. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  50. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  51. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +324 -28
  52. package/harness/bundles/general/codebuddy/scripts/harness_change.py +137 -2
  53. package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +153 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +55 -25
  55. package/harness/bundles/general/codebuddy/scripts/harness_execution_contracts.py +596 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +289 -52
  57. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +5 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_orchestration.py +344 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_process.py +1065 -0
  60. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +244 -4
  61. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +646 -132
  62. package/harness/bundles/general/codebuddy/scripts/harness_service.py +1469 -194
  63. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +107 -117
  64. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +79 -0
  65. package/harness/bundles/general/codex/.harness-build.json +1 -1
  66. package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +100 -0
  67. package/harness/bundles/general/codex/contracts/fixtures/managed-execution.json +341 -0
  68. package/harness/bundles/general/codex/contracts/process-identity.schema.json +132 -0
  69. package/harness/bundles/general/codex/contracts/run-session.schema.json +142 -0
  70. package/harness/bundles/general/codex/contracts/service-retirement-receipt.schema.json +85 -0
  71. package/harness/bundles/general/codex/contracts/service-session.schema.json +147 -0
  72. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  75. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  76. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  77. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  78. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  80. package/harness/bundles/general/codex/harness-submit/checklist.md +12 -0
  81. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  82. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  83. package/harness/bundles/general/codex/scripts/harness_archive.py +324 -28
  84. package/harness/bundles/general/codex/scripts/harness_change.py +137 -2
  85. package/harness/bundles/general/codex/scripts/harness_efficiency.py +153 -0
  86. package/harness/bundles/general/codex/scripts/harness_environment.py +55 -25
  87. package/harness/bundles/general/codex/scripts/harness_execution_contracts.py +596 -0
  88. package/harness/bundles/general/codex/scripts/harness_integration.py +289 -52
  89. package/harness/bundles/general/codex/scripts/harness_ledger.py +5 -0
  90. package/harness/bundles/general/codex/scripts/harness_orchestration.py +344 -0
  91. package/harness/bundles/general/codex/scripts/harness_process.py +1065 -0
  92. package/harness/bundles/general/codex/scripts/harness_profile.py +244 -4
  93. package/harness/bundles/general/codex/scripts/harness_runtime.py +646 -132
  94. package/harness/bundles/general/codex/scripts/harness_service.py +1469 -194
  95. package/harness/bundles/general/codex/scripts/harness_test_runner.py +107 -117
  96. package/harness/bundles/general/codex/scripts/harness_verification.py +79 -0
  97. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  98. package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +100 -0
  99. package/harness/bundles/general/cursor/contracts/fixtures/managed-execution.json +341 -0
  100. package/harness/bundles/general/cursor/contracts/process-identity.schema.json +132 -0
  101. package/harness/bundles/general/cursor/contracts/run-session.schema.json +142 -0
  102. package/harness/bundles/general/cursor/contracts/service-retirement-receipt.schema.json +85 -0
  103. package/harness/bundles/general/cursor/contracts/service-session.schema.json +147 -0
  104. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  105. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  106. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  107. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  109. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  110. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  111. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  112. package/harness/bundles/general/cursor/harness-submit/checklist.md +12 -0
  113. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  114. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  115. package/harness/bundles/general/cursor/scripts/harness_archive.py +324 -28
  116. package/harness/bundles/general/cursor/scripts/harness_change.py +137 -2
  117. package/harness/bundles/general/cursor/scripts/harness_efficiency.py +153 -0
  118. package/harness/bundles/general/cursor/scripts/harness_environment.py +55 -25
  119. package/harness/bundles/general/cursor/scripts/harness_execution_contracts.py +596 -0
  120. package/harness/bundles/general/cursor/scripts/harness_integration.py +289 -52
  121. package/harness/bundles/general/cursor/scripts/harness_ledger.py +5 -0
  122. package/harness/bundles/general/cursor/scripts/harness_orchestration.py +344 -0
  123. package/harness/bundles/general/cursor/scripts/harness_process.py +1065 -0
  124. package/harness/bundles/general/cursor/scripts/harness_profile.py +244 -4
  125. package/harness/bundles/general/cursor/scripts/harness_runtime.py +646 -132
  126. package/harness/bundles/general/cursor/scripts/harness_service.py +1469 -194
  127. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +107 -117
  128. package/harness/bundles/general/cursor/scripts/harness_verification.py +79 -0
  129. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  130. package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +100 -0
  131. package/harness/bundles/java/claude-code/contracts/fixtures/managed-execution.json +341 -0
  132. package/harness/bundles/java/claude-code/contracts/process-identity.schema.json +132 -0
  133. package/harness/bundles/java/claude-code/contracts/run-session.schema.json +142 -0
  134. package/harness/bundles/java/claude-code/contracts/service-retirement-receipt.schema.json +85 -0
  135. package/harness/bundles/java/claude-code/contracts/service-session.schema.json +147 -0
  136. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  137. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  138. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  139. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  140. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  141. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  142. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  143. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  144. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  145. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  146. package/harness/bundles/java/claude-code/harness-submit/checklist.md +12 -0
  147. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  148. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  149. package/harness/bundles/java/claude-code/scripts/harness_archive.py +324 -28
  150. package/harness/bundles/java/claude-code/scripts/harness_change.py +137 -2
  151. package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +153 -0
  152. package/harness/bundles/java/claude-code/scripts/harness_environment.py +55 -25
  153. package/harness/bundles/java/claude-code/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/java/claude-code/scripts/harness_integration.py +289 -52
  155. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +5 -0
  156. package/harness/bundles/java/claude-code/scripts/harness_orchestration.py +344 -0
  157. package/harness/bundles/java/claude-code/scripts/harness_process.py +1065 -0
  158. package/harness/bundles/java/claude-code/scripts/harness_profile.py +244 -4
  159. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +646 -132
  160. package/harness/bundles/java/claude-code/scripts/harness_service.py +1469 -194
  161. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +107 -117
  162. package/harness/bundles/java/claude-code/scripts/harness_verification.py +79 -0
  163. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  164. package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +100 -0
  165. package/harness/bundles/java/codebuddy/contracts/fixtures/managed-execution.json +341 -0
  166. package/harness/bundles/java/codebuddy/contracts/process-identity.schema.json +132 -0
  167. package/harness/bundles/java/codebuddy/contracts/run-session.schema.json +142 -0
  168. package/harness/bundles/java/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
  169. package/harness/bundles/java/codebuddy/contracts/service-session.schema.json +147 -0
  170. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  171. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  172. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  173. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  174. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  175. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  176. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  177. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  178. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  179. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  180. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +12 -0
  181. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  182. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  183. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +324 -28
  184. package/harness/bundles/java/codebuddy/scripts/harness_change.py +137 -2
  185. package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +153 -0
  186. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +55 -25
  187. package/harness/bundles/java/codebuddy/scripts/harness_execution_contracts.py +596 -0
  188. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +289 -52
  189. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +5 -0
  190. package/harness/bundles/java/codebuddy/scripts/harness_orchestration.py +344 -0
  191. package/harness/bundles/java/codebuddy/scripts/harness_process.py +1065 -0
  192. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +244 -4
  193. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +646 -132
  194. package/harness/bundles/java/codebuddy/scripts/harness_service.py +1469 -194
  195. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +107 -117
  196. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +79 -0
  197. package/harness/bundles/java/codex/.harness-build.json +1 -1
  198. package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +100 -0
  199. package/harness/bundles/java/codex/contracts/fixtures/managed-execution.json +341 -0
  200. package/harness/bundles/java/codex/contracts/process-identity.schema.json +132 -0
  201. package/harness/bundles/java/codex/contracts/run-session.schema.json +142 -0
  202. package/harness/bundles/java/codex/contracts/service-retirement-receipt.schema.json +85 -0
  203. package/harness/bundles/java/codex/contracts/service-session.schema.json +147 -0
  204. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  205. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  206. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  207. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  208. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  209. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  210. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  211. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codex/harness-submit/checklist.md +12 -0
  215. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  216. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  217. package/harness/bundles/java/codex/scripts/harness_archive.py +324 -28
  218. package/harness/bundles/java/codex/scripts/harness_change.py +137 -2
  219. package/harness/bundles/java/codex/scripts/harness_efficiency.py +153 -0
  220. package/harness/bundles/java/codex/scripts/harness_environment.py +55 -25
  221. package/harness/bundles/java/codex/scripts/harness_execution_contracts.py +596 -0
  222. package/harness/bundles/java/codex/scripts/harness_integration.py +289 -52
  223. package/harness/bundles/java/codex/scripts/harness_ledger.py +5 -0
  224. package/harness/bundles/java/codex/scripts/harness_orchestration.py +344 -0
  225. package/harness/bundles/java/codex/scripts/harness_process.py +1065 -0
  226. package/harness/bundles/java/codex/scripts/harness_profile.py +244 -4
  227. package/harness/bundles/java/codex/scripts/harness_runtime.py +646 -132
  228. package/harness/bundles/java/codex/scripts/harness_service.py +1469 -194
  229. package/harness/bundles/java/codex/scripts/harness_test_runner.py +107 -117
  230. package/harness/bundles/java/codex/scripts/harness_verification.py +79 -0
  231. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  232. package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +100 -0
  233. package/harness/bundles/java/cursor/contracts/fixtures/managed-execution.json +341 -0
  234. package/harness/bundles/java/cursor/contracts/process-identity.schema.json +132 -0
  235. package/harness/bundles/java/cursor/contracts/run-session.schema.json +142 -0
  236. package/harness/bundles/java/cursor/contracts/service-retirement-receipt.schema.json +85 -0
  237. package/harness/bundles/java/cursor/contracts/service-session.schema.json +147 -0
  238. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  239. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-submit/checklist.md +12 -0
  249. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/scripts/harness_archive.py +324 -28
  252. package/harness/bundles/java/cursor/scripts/harness_change.py +137 -2
  253. package/harness/bundles/java/cursor/scripts/harness_efficiency.py +153 -0
  254. package/harness/bundles/java/cursor/scripts/harness_environment.py +55 -25
  255. package/harness/bundles/java/cursor/scripts/harness_execution_contracts.py +596 -0
  256. package/harness/bundles/java/cursor/scripts/harness_integration.py +289 -52
  257. package/harness/bundles/java/cursor/scripts/harness_ledger.py +5 -0
  258. package/harness/bundles/java/cursor/scripts/harness_orchestration.py +344 -0
  259. package/harness/bundles/java/cursor/scripts/harness_process.py +1065 -0
  260. package/harness/bundles/java/cursor/scripts/harness_profile.py +244 -4
  261. package/harness/bundles/java/cursor/scripts/harness_runtime.py +646 -132
  262. package/harness/bundles/java/cursor/scripts/harness_service.py +1469 -194
  263. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +107 -117
  264. package/harness/bundles/java/cursor/scripts/harness_verification.py +79 -0
  265. package/harness/manifests/general/claude-code.json +58 -26
  266. package/harness/manifests/general/codebuddy.json +58 -26
  267. package/harness/manifests/general/codex.json +58 -26
  268. package/harness/manifests/general/cursor.json +58 -26
  269. package/harness/manifests/java/claude-code.json +60 -28
  270. package/harness/manifests/java/codebuddy.json +60 -28
  271. package/harness/manifests/java/codex.json +60 -28
  272. package/harness/manifests/java/cursor.json +60 -28
  273. package/hunter-workflow-family.json +4 -4
  274. package/package.json +1 -1
@@ -38,6 +38,11 @@ if str(SCRIPTS_DIR) not in sys.path:
38
38
  sys.path.insert(0, str(SCRIPTS_DIR))
39
39
 
40
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
+ )
41
46
 
42
47
 
43
48
  if hasattr(sys.stdout, "reconfigure"):
@@ -49,6 +54,9 @@ if hasattr(sys.stderr, "reconfigure"):
49
54
  PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
50
55
  SESSION_REL = Path("runtime") / "service-session.json"
51
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
52
60
 
53
61
  FATAL_KEYWORDS = (
54
62
  "BindException",
@@ -116,6 +124,130 @@ def log_path(change_dir: Path) -> Path:
116
124
  return change_dir / LOG_REL
117
125
 
118
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
+
119
251
  def profile_path(project: Path) -> Path:
120
252
  return project / PROFILE_REL
121
253
 
@@ -154,10 +286,18 @@ def get_service_start(profile: dict[str, Any]) -> dict[str, Any]:
154
286
  if not isinstance(svc, dict):
155
287
  raise ValueError("build-profile.json missing serviceStart object")
156
288
  command = svc.get("command")
157
- if not isinstance(command, str) or not command.strip():
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
+ ):
158
298
  raise ValueError(
159
- "build-profile.json serviceStart.command is empty; "
160
- "configure the service start command explicitly (will not guess)"
299
+ "build-profile.json serviceStart.command/argvTemplate is empty; "
300
+ "configure the service start argv explicitly (will not guess)"
161
301
  )
162
302
  return svc
163
303
 
@@ -180,14 +320,19 @@ def _detect_stale_persistent_values(service_start: dict[str, Any]) -> list[str]:
180
320
  说明是 v1 残留或被错误写回的已解析 overlay,必须拒绝(spec §3.4)。
181
321
  """
182
322
  stale: list[str] = []
183
- for field in ("command", "overlayPath", "profile"):
323
+ for field in ("command", "argvTemplate", "overlayPath", "profile"):
184
324
  val = service_start.get(field)
185
- if not isinstance(val, str) or not val:
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:
186
330
  continue
187
- for marker in STALE_WORKTREE_MARKERS:
188
- if marker in val:
189
- stale.append(f"{field} contains stale worktree path: {marker}")
190
- break
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
191
336
  return stale
192
337
 
193
338
 
@@ -209,7 +354,7 @@ def resolve_service_start(
209
354
  向后兼容:持久 profile.profile 非空(如 v1 残留 "local-dev")→ 保留;
210
355
  overlay_path 未提供 → 保留持久 overlayPath(可能为空)。
211
356
  """
212
- service_start = get_service_start(profile) # 校验 command 非空
357
+ service_start = get_service_start(profile) # 校验结构化 argv/legacy command 非空
213
358
 
214
359
  stale = _detect_stale_persistent_values(service_start)
215
360
  if stale:
@@ -235,6 +380,13 @@ def resolve_service_start(
235
380
  value = resolved.get(field)
236
381
  if isinstance(value, str):
237
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
+ )
238
390
  # worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
239
391
  # 不写入返回值,避免污染 session。
240
392
  _ = worktree_root
@@ -301,19 +453,8 @@ def resolve_service_input_files(
301
453
 
302
454
 
303
455
  def is_pid_alive(pid: int) -> bool:
304
- if not isinstance(pid, int) or pid <= 0:
305
- return False
306
- if os.name == "nt":
307
- return _windows_pid_alive(pid)
308
- try:
309
- os.kill(pid, 0)
310
- return True
311
- except ProcessLookupError:
312
- return False
313
- except PermissionError:
314
- return True
315
- except OSError:
316
- return False
456
+ """Compatibility re-export; process facts come from the shared provider."""
457
+ return _process_provider.is_pid_alive(pid)
317
458
 
318
459
 
319
460
  def _windows_pid_alive(pid: int) -> bool:
@@ -341,38 +482,12 @@ def _windows_pid_alive(pid: int) -> bool:
341
482
 
342
483
  def get_process_create_time(pid: int) -> dt.datetime | None:
343
484
  """Best-effort process create time (timezone-aware). None if unavailable."""
344
- if os.name == "nt":
345
- return _windows_process_create_time(pid)
346
- return _posix_process_create_time(pid)
485
+ return _process_provider.get_process_create_time(pid)
347
486
 
348
487
 
349
488
  def get_process_executable(pid: int) -> str | None:
350
489
  """Best-effort executable identity for PID-reuse protection."""
351
- if os.name == "nt":
352
- try:
353
- import ctypes
354
- from ctypes import wintypes
355
-
356
- kernel32 = ctypes.windll.kernel32
357
- handle = kernel32.OpenProcess(0x1000, False, pid)
358
- if not handle:
359
- return None
360
- try:
361
- capacity = wintypes.DWORD(32768)
362
- buffer = ctypes.create_unicode_buffer(capacity.value)
363
- if not kernel32.QueryFullProcessImageNameW(
364
- handle, 0, buffer, ctypes.byref(capacity)
365
- ):
366
- return None
367
- return str(Path(buffer.value).resolve())
368
- finally:
369
- kernel32.CloseHandle(handle)
370
- except Exception:
371
- return None
372
- try:
373
- return str(Path(f"/proc/{pid}/exe").resolve(strict=True))
374
- except OSError:
375
- return None
490
+ return _process_provider.get_process_executable(pid)
376
491
 
377
492
 
378
493
  def _windows_process_create_time(pid: int) -> dt.datetime | None:
@@ -470,82 +585,98 @@ def verify_process_identity(session: dict[str, Any]) -> bool | None:
470
585
  pid = session.get("pid")
471
586
  if not isinstance(pid, int) or pid <= 0:
472
587
  return False
473
- if not is_pid_alive(pid):
474
- return False
475
-
476
- started_at = parse_iso(session.get("startedAt"))
477
- if started_at is None:
478
- return None
479
-
480
- create_time = get_process_create_time(pid)
481
- if create_time is None:
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:
482
619
  return None
483
-
484
- # Compare in UTC
485
- started_utc = started_at.astimezone(dt.timezone.utc)
486
- create_utc = create_time.astimezone(dt.timezone.utc)
487
- delta = abs((create_utc - started_utc).total_seconds())
488
- if delta > IDENTITY_TOLERANCE_SEC:
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
+ }:
489
656
  return False
490
- process_identity = session.get("processIdentity")
491
- if isinstance(process_identity, dict):
492
- expected_executable = str(
493
- process_identity.get("executable") or ""
494
- ).strip()
495
- if not expected_executable:
496
- return None
497
- actual_executable = get_process_executable(pid)
498
- if actual_executable is None:
499
- return None
500
- try:
501
- if Path(actual_executable).resolve() != Path(
502
- expected_executable
503
- ).resolve():
504
- return False
505
- except OSError:
506
- return None
507
- return True
657
+ return None
508
658
 
509
659
 
510
- def terminate_process_tree(pid: int) -> None:
511
- """Stop process (and children on Windows via taskkill /T). Never raises for missing pid."""
512
- if not isinstance(pid, int) or pid <= 0:
513
- return
514
- if not is_pid_alive(pid):
515
- return
516
- if os.name == "nt":
517
- try:
518
- subprocess.run(
519
- ["taskkill", "/PID", str(pid), "/T", "/F"],
520
- capture_output=True,
521
- text=True,
522
- encoding="utf-8",
523
- errors="replace",
524
- timeout=30,
525
- check=False,
526
- )
527
- except (OSError, subprocess.TimeoutExpired):
528
- pass
529
- return
530
- try:
531
- os.killpg(pid, signal.SIGTERM)
532
- except OSError:
533
- try:
534
- os.kill(pid, signal.SIGTERM)
535
- except OSError:
536
- pass
537
- # Brief wait then SIGKILL
538
- deadline = time.monotonic() + 3.0
539
- while time.monotonic() < deadline and is_pid_alive(pid):
540
- time.sleep(0.1)
541
- if is_pid_alive(pid):
542
- try:
543
- os.killpg(pid, signal.SIGKILL)
544
- except OSError:
545
- try:
546
- os.kill(pid, signal.SIGKILL)
547
- except OSError:
548
- pass
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
+ )
549
680
 
550
681
 
551
682
  # ---------------------------------------------------------------------------
@@ -620,12 +751,26 @@ def resolve_health_file(spec: str) -> Path | None:
620
751
  return None
621
752
 
622
753
 
623
- def probe_health(service_start: dict[str, Any]) -> bool:
754
+ def probe_health(
755
+ service_start: dict[str, Any],
756
+ *,
757
+ expected_instance_token: str | None = None,
758
+ ) -> bool:
624
759
  """Return True if service appears healthy per serviceStart health config."""
625
760
  # Prefer explicit healthFile
626
761
  health_file = service_start.get("healthFile")
627
762
  if isinstance(health_file, str) and health_file.strip():
628
- return Path(health_file.strip()).is_file()
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
629
774
 
630
775
  spec = service_start.get("healthUrl")
631
776
  if not isinstance(spec, str) or not spec.strip():
@@ -637,7 +782,16 @@ def probe_health(service_start: dict[str, Any]) -> bool:
637
782
  spec = spec.strip()
638
783
  file_path = resolve_health_file(spec)
639
784
  if file_path is not None:
640
- return file_path.is_file()
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
641
795
 
642
796
  lower = spec.lower()
643
797
  if lower.startswith("tcp://") or lower.startswith("socket://"):
@@ -655,13 +809,49 @@ def probe_health(service_start: dict[str, Any]) -> bool:
655
809
  # HTTP(S)
656
810
  url = spec if "://" in spec else f"http://{spec}"
657
811
  try:
658
- req = urllib.request.Request(url, method="GET")
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")
659
818
  with urllib.request.urlopen(req, timeout=2.0) as resp:
660
- return 200 <= int(getattr(resp, "status", 200)) < 300
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
661
828
  except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
662
829
  return False
663
830
 
664
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
+
665
855
  def log_has_fatal(path: Path) -> str | None:
666
856
  if not path.is_file():
667
857
  return None
@@ -689,6 +879,7 @@ def wait_for_healthy(
689
879
  log_file: Path,
690
880
  *,
691
881
  pid: int | None = None,
882
+ expected_instance_token: str | None = None,
692
883
  ) -> None:
693
884
  """Startup wait state machine: 0–30s /2s, 30–120s /5s; fatal keywords abort."""
694
885
  timeout_sec = service_start.get("startTimeoutSec", 120)
@@ -710,7 +901,10 @@ def wait_for_healthy(
710
901
  )
711
902
  if pid is not None and not is_pid_alive(pid):
712
903
  raise ServiceStartError(f"service process exited early (pid={pid})")
713
- if probe_health(service_start):
904
+ if probe_health(
905
+ service_start,
906
+ expected_instance_token=expected_instance_token,
907
+ ):
714
908
  return
715
909
  if elapsed >= timeout_sec:
716
910
  raise ServiceStartError(
@@ -733,6 +927,60 @@ class SessionCorrupt(Exception):
733
927
  pass
734
928
 
735
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
+
736
984
  def load_session(change_dir: Path) -> dict[str, Any] | None:
737
985
  """Return session dict, None if missing, raise SessionCorrupt if damaged."""
738
986
  path = session_path(change_dir)
@@ -758,6 +1006,23 @@ def load_session(change_dir: Path) -> dict[str, Any] | None:
758
1006
  raise SessionCorrupt("session.startedBy missing")
759
1007
  if "startedAt" not in data:
760
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))
761
1026
  return data
762
1027
 
763
1028
 
@@ -776,6 +1041,121 @@ def write_session(change_dir: Path, session: dict[str, Any]) -> Path:
776
1041
  return path
777
1042
 
778
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
+
779
1159
  def _same_path(left: Any, right: Path | None) -> bool:
780
1160
  if right is None or not isinstance(left, str) or not left.strip():
781
1161
  return False
@@ -953,6 +1333,7 @@ _WIN_LAUNCHER_SOURCE = """\
953
1333
  from __future__ import annotations
954
1334
 
955
1335
  import ctypes
1336
+ import json
956
1337
  import subprocess
957
1338
  import sys
958
1339
  from pathlib import Path
@@ -1021,20 +1402,22 @@ def _create_job(name: str) -> int:
1021
1402
 
1022
1403
  def main() -> int:
1023
1404
  log_path = Path(sys.argv[1])
1024
- command_path = Path(sys.argv[2])
1405
+ argv_path = Path(sys.argv[2])
1025
1406
  pid_path = Path(sys.argv[3])
1026
1407
  job_name = sys.argv[4]
1027
- command = command_path.read_text(encoding="utf-8").strip()
1028
- if not command:
1029
- raise SystemExit("empty service command")
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")
1030
1413
 
1031
1414
  job_handle = _create_job(job_name)
1032
1415
  log_path.parent.mkdir(parents=True, exist_ok=True)
1033
1416
  log_handle = log_path.open("w", encoding="utf-8", errors="replace")
1034
1417
  try:
1035
1418
  proc = subprocess.Popen(
1036
- command,
1037
- shell=True,
1419
+ argv,
1420
+ shell=False,
1038
1421
  stdout=log_handle,
1039
1422
  stderr=subprocess.STDOUT,
1040
1423
  stdin=subprocess.DEVNULL,
@@ -1100,6 +1483,27 @@ def _cleanup_windows_launcher(change_dir: Path) -> None:
1100
1483
  terminate_process_tree(pid)
1101
1484
 
1102
1485
 
1486
+ def _wait_for_windows_launcher_exit(
1487
+ change_dir: Path,
1488
+ *,
1489
+ timeout_sec: float = STOP_CONFIRM_TIMEOUT_SEC,
1490
+ ) -> bool:
1491
+ """Wait for the detached Job owner to release log/file handles.
1492
+
1493
+ The launcher is deliberately not killed by PID. Its named Job has
1494
+ already been terminated through the ownership proof; this wait merely
1495
+ observes the launcher completing its ``proc.wait()`` and avoids a race
1496
+ with archive/temp-directory cleanup.
1497
+ """
1498
+ pid = _read_positive_int(_launcher_pid_path(change_dir))
1499
+ if pid is None:
1500
+ return True
1501
+ deadline = time.monotonic() + max(0.0, timeout_sec)
1502
+ while is_pid_alive(pid) and time.monotonic() < deadline:
1503
+ time.sleep(0.05)
1504
+ return not is_pid_alive(pid)
1505
+
1506
+
1103
1507
  def _write_windows_launcher(change_dir: Path) -> Path:
1104
1508
  launcher = _runtime_dir(change_dir) / "_harness_service_launcher.py"
1105
1509
  launcher.write_text(_WIN_LAUNCHER_SOURCE, encoding="utf-8", newline="\n")
@@ -1134,18 +1538,30 @@ def _read_optional_text(path: Path) -> str | None:
1134
1538
 
1135
1539
 
1136
1540
  def _start_detached_service_windows(
1137
- command: str,
1541
+ argv: list[str],
1138
1542
  *,
1139
1543
  change_dir: Path,
1140
1544
  cwd: Path,
1141
1545
  log_file: Path,
1142
- ) -> int:
1546
+ owner_token: str,
1547
+ ) -> _process_provider.SpawnedProcess:
1143
1548
  runtime = _runtime_dir(change_dir)
1144
1549
  launcher = _write_windows_launcher(change_dir)
1145
1550
  command_path = runtime / "_harness_service.command.txt"
1146
1551
  pid_path = _child_pid_path(change_dir)
1147
- job_id = f"HunterHarnessService-{uuid.uuid4()}"
1148
- command_path.write_text(command, encoding="utf-8", newline="\n")
1552
+ owner_hash = _process_provider._token_hash(owner_token)
1553
+ proof_id = "proof-" + uuid.uuid4().hex
1554
+ job_id = (
1555
+ "Local\\HunterHarness-"
1556
+ + owner_hash.split(":", 1)[1][:16]
1557
+ + "-"
1558
+ + proof_id
1559
+ )
1560
+ command_path.write_text(
1561
+ json.dumps(argv, ensure_ascii=False, separators=(",", ":")),
1562
+ encoding="utf-8",
1563
+ newline="\n",
1564
+ )
1149
1565
  _job_id_path(change_dir).write_text(job_id, encoding="utf-8", newline="\n")
1150
1566
  if pid_path.exists():
1151
1567
  pid_path.unlink()
@@ -1175,57 +1591,100 @@ def _start_detached_service_windows(
1175
1591
  )
1176
1592
  _launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
1177
1593
  _wait_for_child_pid(pid_path)
1178
- root_pid = int(launcher_proc.pid)
1179
1594
  # The launcher intentionally remains alive as the Job Object owner. Close
1180
1595
  # only this controller's process handle so Popen does not warn at teardown.
1181
1596
  launcher_handle = getattr(launcher_proc, "_handle", None)
1182
1597
  if launcher_handle is not None:
1183
1598
  launcher_handle.Close()
1184
1599
  launcher_proc.returncode = 0
1185
- return root_pid
1600
+ child_pid = _read_positive_int(pid_path)
1601
+ if child_pid is None:
1602
+ raise TimeoutError(f"service child pid is invalid ({pid_path})")
1603
+ observed = _process_provider.observe_process_identity(child_pid)
1604
+ attestation = json.loads(json.dumps(observed))
1605
+ attestation["argvHash"] = _process_provider.canonical_argv_hash(argv)
1606
+ attestation["workingDirectory"] = str(cwd.resolve())
1607
+ attestation["ownerTokenHash"] = owner_hash
1608
+ attestation["fieldProvenance"]["argvHash"] = (
1609
+ "OBSERVED" if observed.get("argvHash") is not None else "ATTESTED"
1610
+ )
1611
+ attestation["fieldProvenance"]["workingDirectory"] = (
1612
+ "OBSERVED"
1613
+ if observed.get("workingDirectory") is not None
1614
+ else "ATTESTED"
1615
+ )
1616
+ attestation["fieldProvenance"]["ownerTokenHash"] = "ATTESTED"
1617
+ attestation["capabilities"]["canVerifyOwnership"] = True
1618
+ proof = {
1619
+ "schemaVersion": 1,
1620
+ "proofId": proof_id,
1621
+ "kind": "WINDOWS_NAMED_JOB",
1622
+ "jobName": job_id,
1623
+ "ownershipTokenHash": owner_hash,
1624
+ "leaderPid": child_pid,
1625
+ "leaderCreatedAt": attestation.get("createdAt"),
1626
+ "members": [attestation],
1627
+ "membersComplete": True,
1628
+ "leaderExited": False,
1629
+ }
1630
+ return _process_provider.SpawnedProcess(
1631
+ process=launcher_proc,
1632
+ attestation=attestation,
1633
+ observedIdentity=observed,
1634
+ ownershipProof=proof,
1635
+ )
1186
1636
 
1187
1637
 
1188
1638
  def start_detached_service(
1189
- command: str,
1639
+ argv: list[str] | tuple[str, ...] | str,
1190
1640
  *,
1191
1641
  change_dir: Path,
1192
1642
  cwd: Path,
1193
- ) -> int:
1194
- """Start service detached from this script; log to logs/service-start.log."""
1643
+ ) -> _process_provider.SpawnedProcess:
1644
+ """Start service with structured argv; log to logs/service-start.log.
1645
+
1646
+ ``str`` is retained only for callers from the legacy facade and is parsed
1647
+ by the same profile parser; no caller is allowed to reach ``shell=True``.
1648
+ """
1649
+ if isinstance(argv, str):
1650
+ argv = resolve_service_argv({"command": argv})
1651
+ argv = list(argv)
1652
+ if not argv:
1653
+ raise ValueError("ARGUMENT_INVALID: service argv is empty")
1195
1654
  log_file = log_path(change_dir)
1196
1655
  log_file.parent.mkdir(parents=True, exist_ok=True)
1197
1656
  if log_file.is_file():
1198
1657
  log_file.unlink()
1199
1658
 
1659
+ owner_token = f"service:{change_dir.resolve()}:{uuid.uuid4().hex}"
1200
1660
  if os.name == "nt":
1201
1661
  return _start_detached_service_windows(
1202
- command,
1662
+ argv,
1203
1663
  change_dir=change_dir,
1204
1664
  cwd=cwd,
1205
1665
  log_file=log_file,
1666
+ owner_token=owner_token,
1206
1667
  )
1207
1668
 
1208
1669
  log_handle = log_file.open("w", encoding="utf-8", errors="replace")
1209
- popen_kwargs: dict[str, Any] = {
1210
- "args": command,
1211
- "shell": True,
1212
- "stdout": log_handle,
1213
- "stderr": subprocess.STDOUT,
1214
- "cwd": str(cwd),
1215
- "stdin": subprocess.DEVNULL,
1216
- "start_new_session": True,
1217
- "close_fds": True,
1218
- }
1219
1670
  try:
1220
- proc = subprocess.Popen(**popen_kwargs)
1671
+ return _process_provider.spawn_structured_argv(
1672
+ argv,
1673
+ cwd=cwd,
1674
+ environment={
1675
+ "PYTHONUTF8": "1",
1676
+ "PYTHONIOENCODING": "utf-8",
1677
+ },
1678
+ owner_token=owner_token,
1679
+ stdout=log_handle,
1680
+ stderr=subprocess.STDOUT,
1681
+ )
1221
1682
  finally:
1222
1683
  try:
1223
1684
  log_handle.close()
1224
1685
  except OSError:
1225
1686
  pass
1226
1687
 
1227
- return int(proc.pid)
1228
-
1229
1688
 
1230
1689
  def build_session(
1231
1690
  *,
@@ -1234,6 +1693,9 @@ def build_session(
1234
1693
  module_inputs_files: list[str],
1235
1694
  command: str,
1236
1695
  service_start: dict[str, Any],
1696
+ argv: list[str] | None = None,
1697
+ process_attestation: dict[str, Any] | None = None,
1698
+ ownership_proof: dict[str, Any] | None = None,
1237
1699
  started_at: str | None = None,
1238
1700
  worktree_root: Path | None = None,
1239
1701
  execution_root: Path | None = None,
@@ -1261,29 +1723,66 @@ def build_session(
1261
1723
  continue
1262
1724
  if 0 < candidate < 65536:
1263
1725
  owned_ports.add(candidate)
1726
+ resolved_argv = list(argv or [])
1727
+ argv_hash = (
1728
+ _process_provider.canonical_argv_hash(resolved_argv)
1729
+ if resolved_argv
1730
+ else sha256_text(command)
1731
+ )
1732
+ attestation = (
1733
+ json.loads(json.dumps(process_attestation))
1734
+ if isinstance(process_attestation, dict)
1735
+ else None
1736
+ )
1737
+ process_identity = {
1738
+ "executable": str(executable),
1739
+ "commandHash": argv_hash,
1740
+ "argvHash": argv_hash,
1741
+ "startedAt": effective_started_at,
1742
+ "parentChain": [
1743
+ {
1744
+ "pid": os.getpid(),
1745
+ "executable": str(Path(sys.executable).resolve()),
1746
+ }
1747
+ ],
1748
+ }
1749
+ if attestation is not None:
1750
+ process_identity.update(attestation)
1751
+ # Preserve the service's public legacy field names while keeping the
1752
+ # provider attestation as the source of truth for destructive actions.
1753
+ process_identity["commandHash"] = argv_hash
1754
+ process_identity["argvHash"] = attestation.get("argvHash", argv_hash)
1264
1755
  session = {
1756
+ "schemaVersion": 1,
1757
+ "serviceId": str(
1758
+ service_start.get("serviceId")
1759
+ or change_id
1760
+ or profile_name
1761
+ ),
1762
+ "sessionId": "service-" + uuid.uuid4().hex,
1763
+ "status": "STARTING",
1764
+ "reasonCode": "SERVICE_START_REQUESTED",
1265
1765
  "pid": pid,
1266
1766
  "startedBy": "AI",
1267
1767
  "moduleInputsHash": module_inputs_hash,
1268
1768
  "moduleInputsFiles": module_inputs_files,
1269
1769
  "profile": profile_name,
1270
- "startCommandHash": sha256_text(command),
1770
+ "startCommandHash": argv_hash,
1271
1771
  "overlayPath": overlay,
1272
1772
  "startedAt": effective_started_at,
1273
1773
  "command": command,
1774
+ "argv": resolved_argv,
1274
1775
  "ownedPorts": sorted(owned_ports),
1275
- "processIdentity": {
1276
- "executable": str(executable),
1277
- "commandHash": sha256_text(command),
1278
- "startedAt": effective_started_at,
1279
- "parentChain": [
1280
- {
1281
- "pid": os.getpid(),
1282
- "executable": str(Path(sys.executable).resolve()),
1283
- }
1284
- ],
1285
- },
1776
+ "processIdentity": process_identity,
1777
+ "fingerprint": module_inputs_hash,
1778
+ "cleanupComplete": False,
1779
+ "supersedesSessionId": None,
1780
+ "leaseIdentity": None,
1286
1781
  }
1782
+ if attestation is not None:
1783
+ session["processAttestation"] = attestation
1784
+ if isinstance(ownership_proof, dict):
1785
+ session["ownershipProof"] = json.loads(json.dumps(ownership_proof))
1287
1786
  if isinstance(service_pid, int) and service_pid > 0:
1288
1787
  session["servicePid"] = service_pid
1289
1788
  if job_id:
@@ -1291,6 +1790,32 @@ def build_session(
1291
1790
  if isinstance(service_start.get("leasedPort"), int):
1292
1791
  session["leasedPort"] = service_start["leasedPort"]
1293
1792
  session["leaseOwner"] = service_start.get("leaseOwner")
1793
+ lease_id = service_start.get("leaseId")
1794
+ lease_change_id = service_start.get("leaseChangeId") or change_id
1795
+ lease_run_id = service_start.get("runId") or attempt_id
1796
+ lease_expires_at = service_start.get("leaseExpiresAt")
1797
+ lease_generation = service_start.get("leaseGeneration")
1798
+ if (
1799
+ isinstance(lease_id, str)
1800
+ and lease_id
1801
+ and isinstance(lease_change_id, str)
1802
+ and lease_change_id
1803
+ and isinstance(lease_run_id, str)
1804
+ and lease_run_id
1805
+ and isinstance(lease_expires_at, str)
1806
+ and lease_expires_at
1807
+ ):
1808
+ session["leaseIdentity"] = {
1809
+ "leaseId": lease_id,
1810
+ "changeId": lease_change_id,
1811
+ "runId": lease_run_id,
1812
+ "expiresAt": lease_expires_at,
1813
+ "generation": _normalize_service_generation(lease_generation),
1814
+ }
1815
+ if isinstance(service_start.get("listenerIdentity"), dict):
1816
+ session["leaseIdentity"]["listenerIdentity"] = json.loads(
1817
+ json.dumps(service_start["listenerIdentity"], ensure_ascii=False)
1818
+ )
1294
1819
  if worktree_root is not None:
1295
1820
  session["worktreeRoot"] = str(worktree_root.resolve())
1296
1821
  if execution_root is not None:
@@ -1392,13 +1917,31 @@ def stop_ai_session(
1392
1917
  require_identity: bool = True,
1393
1918
  ) -> dict[str, Any]:
1394
1919
  """Stop verified AI session process and clear session file."""
1920
+ operation_id = _service_operation_id(session)
1921
+ _ensure_session_state(session, operation_id=operation_id)
1395
1922
  pid = session.get("pid")
1396
1923
  if require_identity:
1397
1924
  identity = verify_process_identity(session)
1398
1925
  if identity is not True:
1926
+ _transition(
1927
+ session,
1928
+ "STALE_IDENTITY_MISMATCH" if identity is False else "FAILED",
1929
+ reason_code=(
1930
+ "STALE_IDENTITY_MISMATCH"
1931
+ if identity is False
1932
+ else "IDENTITY_UNVERIFIABLE"
1933
+ ),
1934
+ operation_id=operation_id,
1935
+ )
1936
+ write_session(change_dir, session)
1399
1937
  return {
1400
1938
  "ok": True,
1401
1939
  "action": "needs-user-decision",
1940
+ "reasonCode": (
1941
+ "PROCESS_IDENTITY_MISMATCH"
1942
+ if identity is False
1943
+ else "IDENTITY_UNVERIFIABLE"
1944
+ ),
1402
1945
  "reason": (
1403
1946
  "cannot-verify-process-identity"
1404
1947
  if identity is None
@@ -1407,13 +1950,73 @@ def stop_ai_session(
1407
1950
  "pid": pid,
1408
1951
  "killed": False,
1409
1952
  }
1953
+ stopping_transition = _transition(
1954
+ session,
1955
+ "STOPPING",
1956
+ reason_code="SERVICE_STOPPING",
1957
+ operation_id=operation_id,
1958
+ )
1959
+ if not stopping_transition.get("ok"):
1960
+ return {
1961
+ "ok": False,
1962
+ "code": "SERVICE_TRANSITION_CONFLICT",
1963
+ "reasonCode": "SERVICE_TRANSITION_CONFLICT",
1964
+ "action": "stop-refused",
1965
+ "from": stopping_transition.get("from"),
1966
+ "to": stopping_transition.get("to"),
1967
+ "operationId": operation_id,
1968
+ "killed": False,
1969
+ "sessionCleared": False,
1970
+ }
1971
+ write_session(change_dir, session)
1972
+ termination: dict[str, Any] | None = None
1410
1973
  if isinstance(pid, int) and is_pid_alive(pid):
1411
- terminate_process_tree(pid)
1974
+ termination = terminate_process_tree(
1975
+ pid,
1976
+ expected_attestation=(
1977
+ session.get("processAttestation")
1978
+ if isinstance(session.get("processAttestation"), dict)
1979
+ else None
1980
+ ),
1981
+ ownership_proof=(
1982
+ session.get("ownershipProof")
1983
+ if isinstance(session.get("ownershipProof"), dict)
1984
+ else None
1985
+ ),
1986
+ timeout_policy={"graceSeconds": STOP_CONFIRM_TIMEOUT_SEC},
1987
+ )
1988
+ if (
1989
+ isinstance(termination, dict)
1990
+ and termination.get("reasonCode") == "IDENTITY_UNVERIFIABLE"
1991
+ ):
1992
+ _transition(
1993
+ session,
1994
+ "STALE_IDENTITY_MISMATCH",
1995
+ reason_code="STALE_IDENTITY_MISMATCH",
1996
+ operation_id=operation_id,
1997
+ )
1998
+ write_session(change_dir, session)
1999
+ return {
2000
+ "ok": True,
2001
+ "action": "needs-user-decision",
2002
+ "reasonCode": "IDENTITY_UNVERIFIABLE",
2003
+ "reason": "ownership-proof-unavailable",
2004
+ "pid": pid,
2005
+ "killed": False,
2006
+ }
1412
2007
  confirmation = _confirm_service_shutdown(session)
1413
2008
  if confirmation["alivePids"]:
2009
+ _transition(
2010
+ session,
2011
+ "FAILED",
2012
+ reason_code="PROCESS_TREE_EXIT_UNCONFIRMED",
2013
+ operation_id=operation_id,
2014
+ )
2015
+ write_session(change_dir, session)
1414
2016
  return {
1415
2017
  "ok": False,
1416
2018
  "code": "PROCESS_TREE_EXIT_UNCONFIRMED",
2019
+ "reasonCode": "SERVICE_STOP_FAILED",
1417
2020
  "action": "cleanup-unconfirmed",
1418
2021
  "pid": pid,
1419
2022
  "alivePids": confirmation["alivePids"],
@@ -1422,9 +2025,17 @@ def stop_ai_session(
1422
2025
  "sessionCleared": False,
1423
2026
  }
1424
2027
  if confirmation["occupiedPorts"]:
2028
+ _transition(
2029
+ session,
2030
+ "FAILED",
2031
+ reason_code="OWNED_PORT_RELEASE_UNCONFIRMED",
2032
+ operation_id=operation_id,
2033
+ )
2034
+ write_session(change_dir, session)
1425
2035
  return {
1426
2036
  "ok": False,
1427
2037
  "code": "OWNED_PORT_RELEASE_UNCONFIRMED",
2038
+ "reasonCode": "SERVICE_CLEANUP_INCOMPLETE",
1428
2039
  "action": "cleanup-unconfirmed",
1429
2040
  "pid": pid,
1430
2041
  "alivePids": [],
@@ -1432,11 +2043,51 @@ def stop_ai_session(
1432
2043
  "killed": True,
1433
2044
  "sessionCleared": False,
1434
2045
  }
2046
+ if os.name == "nt" and not _wait_for_windows_launcher_exit(change_dir):
2047
+ _transition(
2048
+ session,
2049
+ "FAILED",
2050
+ reason_code="SERVICE_STOP_TIMEOUT",
2051
+ operation_id=operation_id,
2052
+ )
2053
+ write_session(change_dir, session)
2054
+ return {
2055
+ "ok": False,
2056
+ "code": "SERVICE_STOP_TIMEOUT",
2057
+ "reasonCode": "SERVICE_STOP_TIMEOUT",
2058
+ "action": "cleanup-unconfirmed",
2059
+ "pid": pid,
2060
+ "alivePids": [],
2061
+ "occupiedPorts": [],
2062
+ "launcherStillAlive": True,
2063
+ "killed": True,
2064
+ "sessionCleared": False,
2065
+ }
2066
+ stopped_transition = _transition(
2067
+ session,
2068
+ "STOPPED",
2069
+ reason_code="SERVICE_STOP_CONFIRMED",
2070
+ operation_id=operation_id,
2071
+ )
2072
+ if not stopped_transition.get("ok"):
2073
+ write_session(change_dir, session)
2074
+ return {
2075
+ "ok": False,
2076
+ "code": "SERVICE_TRANSITION_CONFLICT",
2077
+ "reasonCode": "SERVICE_TRANSITION_CONFLICT",
2078
+ "action": "cleanup-unconfirmed",
2079
+ "pid": pid,
2080
+ "killed": True,
2081
+ "sessionCleared": False,
2082
+ "alivePids": [],
2083
+ "occupiedPorts": [],
2084
+ }
1435
2085
  _remove_windows_service_metadata(change_dir)
1436
2086
  clear_session(change_dir)
1437
2087
  return {
1438
2088
  "ok": True,
1439
2089
  "code": "SERVICE_STOP_CONFIRMED",
2090
+ "reasonCode": "SERVICE_STOPPED",
1440
2091
  "action": "stopped",
1441
2092
  "pid": pid,
1442
2093
  "killed": True,
@@ -1452,6 +2103,22 @@ def stop_ai_session(
1452
2103
 
1453
2104
 
1454
2105
  def cmd_ensure(args: argparse.Namespace) -> int:
2106
+ """Serialize ensure mutations and expose a stable conflict receipt."""
2107
+
2108
+ change_dir = resolve_path(args.change_dir)
2109
+ lock = ServiceMutationLock(change_dir, operation="ensure")
2110
+ conflict = lock.acquire()
2111
+ if conflict is not None:
2112
+ emit_json(conflict, as_json=bool(args.json))
2113
+ return 4
2114
+ setattr(args, "_service_operation_id", lock.token)
2115
+ try:
2116
+ return _cmd_ensure_impl(args)
2117
+ finally:
2118
+ lock.release()
2119
+
2120
+
2121
+ def _cmd_ensure_impl(args: argparse.Namespace) -> int:
1455
2122
  as_json = bool(args.json)
1456
2123
  change_dir = resolve_path(args.change_dir)
1457
2124
  project = resolve_path(args.project)
@@ -1490,7 +2157,13 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1490
2157
  except ValueError as exc:
1491
2158
  return emit_error(str(exc), as_json=as_json)
1492
2159
 
1493
- command = str(service_start["command"]).strip()
2160
+ argv = list(service_start.get("argv") or [])
2161
+ if not argv:
2162
+ return emit_error(
2163
+ "serviceStart resolved to an empty argv; run profile migration and review",
2164
+ as_json=as_json,
2165
+ )
2166
+ command = str(service_start.get("command") or " ".join(argv)).strip()
1494
2167
  port = extract_port(service_start)
1495
2168
 
1496
2169
  # Resolve service input file set (CLI --files ∪ serviceStart.inputFiles).
@@ -1525,6 +2198,11 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1525
2198
  ),
1526
2199
  as_json=as_json,
1527
2200
  pid=pid,
2201
+ reasonCode=(
2202
+ "IDENTITY_UNVERIFIABLE"
2203
+ if identity is None
2204
+ else "PROCESS_IDENTITY_MISMATCH"
2205
+ ),
1528
2206
  sessionPath=str(session_path(change_dir)),
1529
2207
  detail=(
1530
2208
  "pid is alive but create-time/cmdline identity could not be "
@@ -1541,9 +2219,32 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1541
2219
  # §5.3: reuse must compare inputsHash + startCommandHash + profile
1542
2220
  # + overlayPath (process identity already verified above). Any
1543
2221
  # change -> restart; never reuse on a partial match.
1544
- current_cmd_hash = sha256_text(command)
2222
+ current_cmd_hash = _process_provider.canonical_argv_hash(argv)
1545
2223
  current_profile = service_start.get("profile") or "local-dev"
1546
2224
  current_overlay = service_start.get("overlayPath") or ""
2225
+ health_match = probe_health(
2226
+ service_start,
2227
+ expected_instance_token=(
2228
+ str(service_start.get("instanceToken"))
2229
+ if service_start.get("requireInstanceToken") is True
2230
+ and service_start.get("instanceToken")
2231
+ else None
2232
+ ),
2233
+ )
2234
+ configured_token = service_start.get("instanceToken")
2235
+ if (
2236
+ service_start.get("requireInstanceToken") is True
2237
+ and isinstance(configured_token, str)
2238
+ and session.get("instanceTokenHash") is not None
2239
+ and session.get("instanceTokenHash") != sha256_text(configured_token)
2240
+ ):
2241
+ health_match = False
2242
+ if (
2243
+ health_match
2244
+ and service_start.get("requireInstanceToken") is not True
2245
+ and extract_port(service_start) is not None
2246
+ ):
2247
+ health_match = _listener_identity_is_owned(session, service_start)
1547
2248
  fingerprint_match = (
1548
2249
  stored_hash == current_hash
1549
2250
  and session.get("startCommandHash") == current_cmd_hash
@@ -1551,8 +2252,23 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1551
2252
  and session.get("overlayPath") == current_overlay
1552
2253
  and session.get("leasedPort") == service_start.get("leasedPort")
1553
2254
  and session.get("leaseOwner") == service_start.get("leaseOwner")
2255
+ and health_match
1554
2256
  )
1555
2257
  if fingerprint_match:
2258
+ operation_id = str(
2259
+ getattr(args, "_service_operation_id", "") or uuid.uuid4().hex
2260
+ )
2261
+ _ensure_session_state(
2262
+ session,
2263
+ operation_id=operation_id,
2264
+ )
2265
+ _transition(
2266
+ session,
2267
+ "READY",
2268
+ reason_code="SERVICE_REUSED",
2269
+ operation_id=operation_id,
2270
+ )
2271
+ write_session(change_dir, session)
1556
2272
  payload = {
1557
2273
  "ok": True,
1558
2274
  "action": "reused",
@@ -1578,6 +2294,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1578
2294
  project=project,
1579
2295
  service_start=service_start,
1580
2296
  command=command,
2297
+ argv=argv,
1581
2298
  files=files if files else current_files,
1582
2299
  as_json=as_json,
1583
2300
  action="restarted",
@@ -1586,6 +2303,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1586
2303
  execution_root=execution_root,
1587
2304
  change_id=change_name,
1588
2305
  attempt_id=attempt_id,
2306
+ operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
1589
2307
  )
1590
2308
 
1591
2309
  # pid dead → stale session; clear and fall through
@@ -1609,6 +2327,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1609
2327
  project=project,
1610
2328
  service_start=service_start,
1611
2329
  command=command,
2330
+ argv=argv,
1612
2331
  files=files,
1613
2332
  as_json=as_json,
1614
2333
  action="started",
@@ -1616,6 +2335,7 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1616
2335
  execution_root=execution_root,
1617
2336
  change_id=change_name,
1618
2337
  attempt_id=attempt_id,
2338
+ operation_id=str(getattr(args, "_service_operation_id", "") or uuid.uuid4().hex),
1619
2339
  )
1620
2340
 
1621
2341
 
@@ -1625,6 +2345,7 @@ def _start_and_record(
1625
2345
  project: Path,
1626
2346
  service_start: dict[str, Any],
1627
2347
  command: str,
2348
+ argv: list[str],
1628
2349
  files: list[str],
1629
2350
  as_json: bool,
1630
2351
  action: str,
@@ -1632,6 +2353,7 @@ def _start_and_record(
1632
2353
  execution_root: Path | None = None,
1633
2354
  change_id: str | None = None,
1634
2355
  attempt_id: str | None = None,
2356
+ operation_id: str | None = None,
1635
2357
  **extra: Any,
1636
2358
  ) -> int:
1637
2359
  if not files:
@@ -1651,23 +2373,69 @@ def _start_and_record(
1651
2373
  _clear_file_health_markers(service_start)
1652
2374
 
1653
2375
  started_at = now_iso()
2376
+ operation_id = operation_id or uuid.uuid4().hex
2377
+ generation = _allocate_service_generation(change_dir)
2378
+ mutation_state = {
2379
+ "schemaVersion": 1,
2380
+ "status": "STARTING",
2381
+ "reasonCode": "SERVICE_START_REQUESTED",
2382
+ "serviceGeneration": generation,
2383
+ "stateRevision": 1,
2384
+ "operationId": operation_id,
2385
+ "startedAt": started_at,
2386
+ "argvHash": _process_provider.canonical_argv_hash(argv),
2387
+ }
2388
+ write_json(mutation_state_path(change_dir), mutation_state)
2389
+ spawned: _process_provider.SpawnedProcess
1654
2390
  try:
1655
- pid = start_detached_service(command, change_dir=change_dir, cwd=project)
1656
- except (OSError, TimeoutError) as exc:
2391
+ spawned = start_detached_service(argv, change_dir=change_dir, cwd=project)
2392
+ pid = int(spawned.attestation.get("pid") or spawned.process.pid)
2393
+ except (OSError, TimeoutError, ValueError, RuntimeError) as exc:
1657
2394
  _cleanup_windows_launcher(change_dir)
2395
+ mutation_state.update(
2396
+ {
2397
+ "status": "FAILED",
2398
+ "reasonCode": "SERVICE_START_FAILED",
2399
+ "endedAt": now_iso(),
2400
+ }
2401
+ )
2402
+ write_json(mutation_state_path(change_dir), mutation_state)
1658
2403
  return emit_error(f"failed to start service: {exc}", as_json=as_json)
1659
2404
 
1660
2405
  # Give the OS a moment to register the process before identity/create-time reads
1661
2406
  time.sleep(0.15)
1662
2407
 
1663
2408
  try:
1664
- wait_for_healthy(service_start, log_path(change_dir), pid=pid)
2409
+ wait_for_healthy(
2410
+ service_start,
2411
+ log_path(change_dir),
2412
+ pid=pid,
2413
+ expected_instance_token=(
2414
+ str(service_start.get("instanceToken"))
2415
+ if service_start.get("requireInstanceToken") is True
2416
+ and service_start.get("instanceToken")
2417
+ else None
2418
+ ),
2419
+ )
1665
2420
  except ServiceStartError as exc:
1666
2421
  # Best-effort cleanup of the failed start
1667
2422
  if is_pid_alive(pid):
1668
- terminate_process_tree(pid)
2423
+ _capture_owned_service_members(spawned)
2424
+ terminate_process_tree(
2425
+ pid,
2426
+ expected_attestation=spawned.attestation,
2427
+ ownership_proof=spawned.ownershipProof,
2428
+ )
1669
2429
  _cleanup_windows_launcher(change_dir)
1670
2430
  clear_session(change_dir)
2431
+ mutation_state.update(
2432
+ {
2433
+ "status": "FAILED",
2434
+ "reasonCode": "SERVICE_START_FAILED",
2435
+ "endedAt": now_iso(),
2436
+ }
2437
+ )
2438
+ write_json(mutation_state_path(change_dir), mutation_state)
1671
2439
  return emit_error(
1672
2440
  str(exc),
1673
2441
  as_json=as_json,
@@ -1676,6 +2444,7 @@ def _start_and_record(
1676
2444
  fatalKeyword=exc.fatal_keyword,
1677
2445
  )
1678
2446
 
2447
+ _capture_owned_service_members(spawned)
1679
2448
  session = build_session(
1680
2449
  pid=pid,
1681
2450
  module_inputs_hash=module_hash,
@@ -1697,8 +2466,35 @@ def _start_and_record(
1697
2466
  if os.name == "nt"
1698
2467
  else None
1699
2468
  ),
2469
+ process_attestation=spawned.attestation,
2470
+ ownership_proof=spawned.ownershipProof,
2471
+ argv=argv,
2472
+ )
2473
+ _ensure_session_state(
2474
+ session,
2475
+ operation_id=operation_id,
2476
+ generation=generation,
2477
+ )
2478
+ if isinstance(service_start.get("instanceToken"), str):
2479
+ session["instanceTokenHash"] = sha256_text(
2480
+ service_start["instanceToken"]
2481
+ )
2482
+ _transition(
2483
+ session,
2484
+ "READY",
2485
+ reason_code="SERVICE_READY",
2486
+ operation_id=operation_id,
1700
2487
  )
1701
2488
  write_session(change_dir, session)
2489
+ mutation_state.update(
2490
+ {
2491
+ "status": "READY",
2492
+ "reasonCode": "SERVICE_READY",
2493
+ "stateRevision": session.get("stateRevision"),
2494
+ "endedAt": now_iso(),
2495
+ }
2496
+ )
2497
+ write_json(mutation_state_path(change_dir), mutation_state)
1702
2498
 
1703
2499
  payload: dict[str, Any] = {
1704
2500
  "ok": True,
@@ -1715,6 +2511,16 @@ def _start_and_record(
1715
2511
  return 0
1716
2512
 
1717
2513
 
2514
+ def _capture_owned_service_members(
2515
+ spawned: _process_provider.SpawnedProcess,
2516
+ ) -> None:
2517
+ """Materialize the provider's current owned members before persistence."""
2518
+ proof = spawned.ownershipProof
2519
+ if not isinstance(proof, dict):
2520
+ return
2521
+ _process_provider.capture_owned_members(spawned)
2522
+
2523
+
1718
2524
  def _clear_file_health_markers(service_start: dict[str, Any]) -> None:
1719
2525
  candidates: list[Path] = []
1720
2526
  hf = service_start.get("healthFile")
@@ -1770,6 +2576,7 @@ def cmd_status(args: argparse.Namespace) -> int:
1770
2576
  pid = session.get("pid")
1771
2577
  alive = isinstance(pid, int) and is_pid_alive(pid)
1772
2578
  identity = verify_process_identity(session) if alive else False
2579
+ heartbeat = _heartbeat_status(session)
1773
2580
 
1774
2581
  fingerprint_match: bool | None = None
1775
2582
  current_hash: str | None = None
@@ -1793,15 +2600,38 @@ def cmd_status(args: argparse.Namespace) -> int:
1793
2600
  "identityStatus": (
1794
2601
  "verified" if identity is True else ("unknown" if identity is None else "mismatch")
1795
2602
  ),
2603
+ "reasonCode": (
2604
+ "SERVICE_READY"
2605
+ if identity is True
2606
+ else ("IDENTITY_UNVERIFIABLE" if identity is None else "PROCESS_IDENTITY_MISMATCH")
2607
+ ),
1796
2608
  "fingerprintMatch": fingerprint_match,
1797
2609
  "currentModuleInputsHash": current_hash,
1798
2610
  "startedBy": session.get("startedBy"),
2611
+ "serviceGeneration": session.get("serviceGeneration"),
2612
+ "stateRevision": session.get("stateRevision"),
2613
+ "serviceStatus": session.get("status"),
2614
+ "heartbeat": heartbeat,
1799
2615
  }
1800
2616
  emit_json(payload, as_json=as_json)
1801
2617
  return 0
1802
2618
 
1803
2619
 
1804
2620
  def cmd_stop(args: argparse.Namespace) -> int:
2621
+ change_dir = resolve_path(args.change_dir)
2622
+ lock = ServiceMutationLock(change_dir, operation="stop")
2623
+ conflict = lock.acquire()
2624
+ if conflict is not None:
2625
+ emit_json(conflict, as_json=bool(args.json))
2626
+ return 4
2627
+ setattr(args, "_service_operation_id", lock.token)
2628
+ try:
2629
+ return _cmd_stop_impl(args)
2630
+ finally:
2631
+ lock.release()
2632
+
2633
+
2634
+ def _cmd_stop_impl(args: argparse.Namespace) -> int:
1805
2635
  as_json = bool(args.json)
1806
2636
  change_dir = resolve_path(args.change_dir)
1807
2637
  if_started_by_ai = bool(getattr(args, "if_started_by_ai", False))
@@ -1856,12 +2686,128 @@ def cmd_stop(args: argparse.Namespace) -> int:
1856
2686
 
1857
2687
 
1858
2688
  def retire_stale_session(change_dir: Path) -> dict[str, Any]:
1859
- """Retire only Harness state whose recorded process identity is gone.
2689
+ change_dir = resolve_path(change_dir)
2690
+ lock = ServiceMutationLock(change_dir, operation="retire-stale")
2691
+ conflict = lock.acquire()
2692
+ if conflict is not None:
2693
+ return conflict
2694
+ try:
2695
+ return _retire_stale_session_impl(change_dir, operation_id=lock.token)
2696
+ finally:
2697
+ lock.release()
1860
2698
 
1861
- This operation never terminates a process or releases a live listener. The
1862
- original receipt is moved into an immutable evidence directory so a new
1863
- superseding session may be created safely.
1864
- """
2699
+
2700
+ def _project_root_for_change(change_dir: Path) -> Path | None:
2701
+ resolved = resolve_path(change_dir)
2702
+ for candidate in (resolved, *resolved.parents):
2703
+ if candidate.name == "changes" and candidate.parent.name == ".harness":
2704
+ return candidate.parent.parent
2705
+ if (candidate / ".harness").is_dir():
2706
+ return candidate
2707
+ return None
2708
+
2709
+
2710
+ def _retirement_receipt_digest(receipt: dict[str, Any]) -> str:
2711
+ unsigned = {
2712
+ key: value for key, value in receipt.items() if key != "receiptDigest"
2713
+ }
2714
+ return sha256_text(
2715
+ json.dumps(unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2716
+ )
2717
+
2718
+
2719
+ def _retirement_lease_cleanup(
2720
+ change_dir: Path,
2721
+ session: dict[str, Any],
2722
+ ) -> dict[str, Any]:
2723
+ """Attempt lease cleanup only with a complete lease/listener identity."""
2724
+
2725
+ lease = session.get("leaseIdentity")
2726
+ if not isinstance(lease, dict):
2727
+ return {
2728
+ "status": "RETAINED",
2729
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
2730
+ "leaseId": None,
2731
+ }
2732
+ lease_id = lease.get("leaseId")
2733
+ change_id = lease.get("changeId") or session.get("changeId")
2734
+ run_id = lease.get("runId") or session.get("attemptId")
2735
+ port = session.get("leasedPort")
2736
+ if not (
2737
+ isinstance(lease_id, str)
2738
+ and lease_id
2739
+ and isinstance(change_id, str)
2740
+ and change_id
2741
+ and isinstance(run_id, str)
2742
+ and run_id
2743
+ and isinstance(port, int)
2744
+ and 0 < port < 65536
2745
+ ):
2746
+ return {
2747
+ "status": "UNVERIFIED",
2748
+ "reasonCode": "LEASE_CAS_MISMATCH",
2749
+ "leaseId": lease_id if isinstance(lease_id, str) else None,
2750
+ }
2751
+ # A lease without a separately persisted listener identity cannot be
2752
+ # released during stale retirement. The old process is precisely the
2753
+ # identity that is no longer trusted, so do not reconstruct it from PID.
2754
+ listener_identity = lease.get("listenerIdentity")
2755
+ if not isinstance(listener_identity, dict):
2756
+ return {
2757
+ "status": "UNVERIFIED",
2758
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
2759
+ "leaseId": lease_id,
2760
+ }
2761
+ project_root = _project_root_for_change(change_dir)
2762
+ if project_root is None:
2763
+ return {
2764
+ "status": "UNVERIFIED",
2765
+ "reasonCode": "LEASE_CAS_MISMATCH",
2766
+ "leaseId": lease_id,
2767
+ }
2768
+ try:
2769
+ import harness_change
2770
+
2771
+ release = harness_change.release_port(
2772
+ project_root,
2773
+ change_id=change_id,
2774
+ run_id=run_id,
2775
+ port=port,
2776
+ lease_id=lease_id,
2777
+ generation=_normalize_service_generation(lease.get("generation")),
2778
+ listener_identity=listener_identity,
2779
+ )
2780
+ except (ImportError, OSError, TypeError, ValueError) as exc:
2781
+ return {
2782
+ "status": "UNVERIFIED",
2783
+ "reasonCode": "LEASE_CAS_MISMATCH",
2784
+ "leaseId": lease_id,
2785
+ }
2786
+ if release.get("ok"):
2787
+ return {
2788
+ "status": "RELEASED",
2789
+ "reasonCode": "SERVICE_RETIRED",
2790
+ "leaseId": lease_id,
2791
+ }
2792
+ code = str(release.get("code") or "")
2793
+ reason = (
2794
+ "LISTENER_IDENTITY_UNVERIFIABLE"
2795
+ if "LISTENER" in code or code == "IDENTITY_UNVERIFIABLE"
2796
+ else "LEASE_CAS_MISMATCH"
2797
+ )
2798
+ return {
2799
+ "status": "RETAINED" if reason.startswith("LISTENER") else "UNVERIFIED",
2800
+ "reasonCode": reason,
2801
+ "leaseId": lease_id,
2802
+ }
2803
+
2804
+
2805
+ def _retire_stale_session_impl(
2806
+ change_dir: Path,
2807
+ *,
2808
+ operation_id: str,
2809
+ ) -> dict[str, Any]:
2810
+ """Finalize one stale generation without touching an unknown process."""
1865
2811
  change_dir = resolve_path(change_dir)
1866
2812
  try:
1867
2813
  session = load_session(change_dir)
@@ -1874,6 +2820,25 @@ def retire_stale_session(change_dir: Path) -> dict[str, Any]:
1874
2820
  "unknownProcessUntouched": True,
1875
2821
  }
1876
2822
  if session is None:
2823
+ retired_root = change_dir / "runtime" / "retired-service-sessions"
2824
+ for receipt_path in sorted(retired_root.glob("*.receipt.json")):
2825
+ try:
2826
+ existing_receipt = read_json(receipt_path)
2827
+ except (OSError, ValueError, json.JSONDecodeError):
2828
+ continue
2829
+ if isinstance(existing_receipt, dict) and (
2830
+ existing_receipt.get("state") == "FINALIZED"
2831
+ or existing_receipt.get("action") == "retired-stale"
2832
+ ):
2833
+ evidence = retired_root / f"{receipt_path.name[:-len('.receipt.json')]}.json"
2834
+ return {
2835
+ "ok": True,
2836
+ "code": "SERVICE_RETIRED",
2837
+ "action": "already-retired",
2838
+ "receipt": existing_receipt,
2839
+ "retiredEvidence": str(evidence) if evidence.is_file() else None,
2840
+ "unknownProcessUntouched": True,
2841
+ }
1877
2842
  return {
1878
2843
  "ok": True,
1879
2844
  "code": "SERVICE_SESSION_ABSENT",
@@ -1895,26 +2860,144 @@ def retire_stale_session(change_dir: Path) -> dict[str, Any]:
1895
2860
  "action": "retire-refused",
1896
2861
  "unknownProcessUntouched": True,
1897
2862
  }
1898
- source = session_path(change_dir)
1899
- raw_id = str(session.get("sessionId") or session.get("session_id") or "")
1900
- safe_id = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_id).strip("-")
1901
- if not safe_id:
1902
- safe_id = sha256_text(json.dumps(session, sort_keys=True))[7:23]
2863
+ service_id = str(session.get("serviceId") or change_dir.name)
2864
+ old_session_id = str(
2865
+ session.get("sessionId")
2866
+ or "legacy-" + sha256_text(json.dumps(session, sort_keys=True))[7:23]
2867
+ )
2868
+ old_generation = _normalize_service_generation(session.get("serviceGeneration"))
1903
2869
  retired_root = change_dir / "runtime" / "retired-service-sessions"
1904
2870
  retired_root.mkdir(parents=True, exist_ok=True)
1905
- destination = retired_root / f"{safe_id}-{int(time.time() * 1000)}.json"
1906
- os.replace(source, destination)
2871
+ safe_service = re.sub(r"[^A-Za-z0-9._-]+", "-", service_id).strip("-") or "service"
2872
+ receipt_path = retired_root / f"{safe_service}-{old_generation}.receipt.json"
2873
+ if receipt_path.is_file():
2874
+ try:
2875
+ existing_receipt = read_json(receipt_path)
2876
+ except (OSError, ValueError, json.JSONDecodeError):
2877
+ existing_receipt = None
2878
+ if (
2879
+ isinstance(existing_receipt, dict)
2880
+ and existing_receipt.get("serviceId") == service_id
2881
+ and _normalize_service_generation(existing_receipt.get("oldGeneration"))
2882
+ == old_generation
2883
+ ):
2884
+ evidence = existing_receipt.get("retiredEvidence")
2885
+ return {
2886
+ "ok": True,
2887
+ "code": "SERVICE_RETIRED",
2888
+ "action": "already-retired",
2889
+ "receipt": existing_receipt,
2890
+ "retiredEvidence": evidence,
2891
+ "unknownProcessUntouched": True,
2892
+ }
2893
+ pending_path = change_dir / "runtime" / "retirement.pending.json"
2894
+ write_json(
2895
+ pending_path,
2896
+ {
2897
+ "schemaVersion": 1,
2898
+ "serviceId": service_id,
2899
+ "oldSessionId": old_session_id,
2900
+ "oldGeneration": old_generation,
2901
+ "operationId": operation_id,
2902
+ "state": "PENDING",
2903
+ },
2904
+ )
2905
+ lease_cleanup = _retirement_lease_cleanup(change_dir, session)
2906
+ retired_session = json.loads(json.dumps(session, ensure_ascii=False))
2907
+ _ensure_session_state(retired_session, operation_id=operation_id, generation=old_generation)
2908
+ if retired_session.get("status") not in {"STALE_IDENTITY_MISMATCH", "RETIRED"}:
2909
+ _transition(
2910
+ retired_session,
2911
+ "STALE_IDENTITY_MISMATCH",
2912
+ reason_code="SERVICE_IDENTITY_STALE",
2913
+ operation_id=operation_id,
2914
+ )
2915
+ if retired_session.get("status") != "RETIRED":
2916
+ _transition(
2917
+ retired_session,
2918
+ "RETIRED",
2919
+ reason_code="SERVICE_RETIRED",
2920
+ operation_id=operation_id,
2921
+ )
2922
+ retired_session["cleanupComplete"] = lease_cleanup["status"] == "RELEASED"
2923
+ retired_session["supersedesSessionId"] = None
2924
+ source = session_path(change_dir)
2925
+ destination = retired_root / f"{safe_service}-{old_generation}.json"
2926
+ quarantined = False
2927
+ if source.is_file():
2928
+ try:
2929
+ import harness_runtime as _runtime
2930
+
2931
+ sensitive = [
2932
+ item
2933
+ for item in _runtime.sensitive_evidence_candidates(source.parent)
2934
+ if item.get("path") == source.name
2935
+ ]
2936
+ if sensitive:
2937
+ quarantine = _runtime.quarantine_sensitive_evidence(
2938
+ source,
2939
+ change_root=change_dir,
2940
+ reason="service retirement legacy evidence",
2941
+ )
2942
+ if not quarantine.get("ok"):
2943
+ return {
2944
+ "ok": False,
2945
+ "code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
2946
+ "action": "retire-refused",
2947
+ "quarantine": quarantine,
2948
+ "unknownProcessUntouched": True,
2949
+ }
2950
+ quarantined = True
2951
+ except (ImportError, OSError, ValueError, TypeError) as exc:
2952
+ return {
2953
+ "ok": False,
2954
+ "code": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
2955
+ "action": "retire-refused",
2956
+ "error": str(exc),
2957
+ "unknownProcessUntouched": True,
2958
+ }
2959
+ if source.is_file():
2960
+ os.replace(source, destination)
2961
+ # Replace the moved legacy payload with the sanitized, typed retired
2962
+ # state only after the atomic move has succeeded.
2963
+ write_json(destination, retired_session)
2964
+ elif not destination.is_file() and not quarantined:
2965
+ return {
2966
+ "ok": False,
2967
+ "code": "SERVICE_SESSION_ABSENT",
2968
+ "action": "retire-refused",
2969
+ "unknownProcessUntouched": True,
2970
+ }
1907
2971
  receipt = {
1908
2972
  "schemaVersion": 1,
2973
+ "receiptId": f"retirement-{safe_service}-{old_generation}",
2974
+ "operationId": operation_id,
2975
+ "serviceId": service_id,
2976
+ "oldSessionId": old_session_id,
2977
+ "oldGeneration": old_generation,
2978
+ "state": "FINALIZED",
2979
+ "retirementStateCommit": {
2980
+ "status": "COMMITTED",
2981
+ "reasonCode": "SERVICE_RETIRED",
2982
+ },
2983
+ "leaseCleanup": lease_cleanup,
2984
+ "cleanupComplete": lease_cleanup["status"] == "RELEASED",
2985
+ "awaitingSuperseder": True,
2986
+ "supersededBySessionId": None,
2987
+ }
2988
+ receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
2989
+ # Keep the evidence path in the response/pending journal, not in the
2990
+ # strict receipt contract; the destination is derived from the receipt.
2991
+ write_json(receipt_path, receipt)
2992
+ pending_path.unlink(missing_ok=True)
2993
+ return {
2994
+ "ok": True,
2995
+ "code": "SERVICE_RETIRED",
1909
2996
  "action": "retired-stale",
1910
- "code": "STALE_IDENTITY_RETIRED",
1911
- "retiredAt": now_iso(),
2997
+ "receipt": receipt,
1912
2998
  "retiredEvidence": str(destination),
1913
2999
  "unknownProcessUntouched": True,
1914
- "sessionId": raw_id or None,
1915
3000
  }
1916
- write_json(retired_root / f"{destination.stem}.receipt.json", receipt)
1917
- return {"ok": True, **receipt}
1918
3001
 
1919
3002
 
1920
3003
  def cmd_retire_stale(args: argparse.Namespace) -> int:
@@ -1923,6 +3006,189 @@ def cmd_retire_stale(args: argparse.Namespace) -> int:
1923
3006
  return 0 if result.get("ok") else 1
1924
3007
 
1925
3008
 
3009
+ def _link_superseder_unlocked(
3010
+ change_dir: Path,
3011
+ *,
3012
+ retirement_receipt: Path,
3013
+ new_session: dict[str, Any],
3014
+ operation_id: str | None = None,
3015
+ ) -> dict[str, Any]:
3016
+ """CAS-link one future service generation to a finalized retirement."""
3017
+
3018
+ change_dir = resolve_path(change_dir)
3019
+ receipt_path = resolve_path(retirement_receipt)
3020
+ try:
3021
+ receipt = read_json(receipt_path)
3022
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
3023
+ return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID", "error": str(exc)}
3024
+ if not isinstance(receipt, dict):
3025
+ return {"ok": False, "code": "RETIREMENT_RECEIPT_INVALID"}
3026
+ raw_old_generation = receipt.get("oldGeneration")
3027
+ raw_new_generation = new_session.get("serviceGeneration")
3028
+ if raw_old_generation is None or raw_new_generation is None:
3029
+ return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
3030
+ old_generation = _normalize_service_generation(raw_old_generation)
3031
+ new_generation = _normalize_service_generation(raw_new_generation)
3032
+ if old_generation == new_generation:
3033
+ return {"ok": False, "code": "SUPERSEDER_GENERATION_INVALID"}
3034
+ receipt["schemaVersion"] = 1
3035
+ receipt["oldGeneration"] = old_generation
3036
+ receipt.setdefault("receiptId", f"retirement-{change_dir.name}-{old_generation}")
3037
+ receipt.setdefault("operationId", operation_id or uuid.uuid4().hex)
3038
+ receipt.setdefault("serviceId", str(receipt.get("serviceId") or change_dir.name))
3039
+ receipt.setdefault("oldSessionId", str(receipt.get("sessionId") or "legacy-" + change_dir.name))
3040
+ receipt.setdefault("state", "FINALIZED")
3041
+ receipt.setdefault(
3042
+ "retirementStateCommit",
3043
+ {"status": "COMMITTED", "reasonCode": "SERVICE_RETIRED"},
3044
+ )
3045
+ receipt.setdefault(
3046
+ "leaseCleanup",
3047
+ {
3048
+ "status": "RETAINED",
3049
+ "reasonCode": "LISTENER_IDENTITY_UNVERIFIABLE",
3050
+ "leaseId": None,
3051
+ },
3052
+ )
3053
+ receipt["cleanupComplete"] = receipt["leaseCleanup"].get("status") == "RELEASED"
3054
+ receipt.setdefault("supersededBySessionId", None)
3055
+ legacy_superseder = receipt.pop("superseder", None)
3056
+ if receipt.get("supersededBySessionId") is None and isinstance(legacy_superseder, dict):
3057
+ legacy_id = legacy_superseder.get("sessionId")
3058
+ if isinstance(legacy_id, str) and legacy_id:
3059
+ receipt["supersededBySessionId"] = legacy_id
3060
+ existing_id = receipt.get("supersededBySessionId")
3061
+ if isinstance(existing_id, str) and existing_id:
3062
+ if existing_id == new_session.get("sessionId"):
3063
+ return {
3064
+ "ok": True,
3065
+ "code": "SUPERSEDER_ALREADY_LINKED",
3066
+ "receipt": receipt,
3067
+ }
3068
+ return {
3069
+ "ok": False,
3070
+ "code": "SUPERSEDER_LINK_CONFLICT",
3071
+ "receipt": receipt,
3072
+ }
3073
+ if receipt.get("awaitingSuperseder") is False:
3074
+ return {
3075
+ "ok": False,
3076
+ "code": "SUPERSEDER_LINK_CONFLICT",
3077
+ "receipt": receipt,
3078
+ }
3079
+ operation_id = operation_id or uuid.uuid4().hex
3080
+ new_session["schemaVersion"] = 1
3081
+ new_session.setdefault("serviceId", receipt["serviceId"])
3082
+ new_session.setdefault("sessionId", "service-" + uuid.uuid4().hex)
3083
+ new_session.setdefault("status", "STARTING")
3084
+ new_session.setdefault("reasonCode", "SERVICE_START_REQUESTED")
3085
+ new_session.setdefault("stateRevision", 1)
3086
+ new_session.setdefault("operationId", operation_id)
3087
+ new_session.setdefault("fingerprint", sha256_text(""))
3088
+ new_session.setdefault("processIdentity", None)
3089
+ new_session.setdefault("heartbeat", None)
3090
+ new_session.setdefault("leaseIdentity", None)
3091
+ new_session.setdefault("transitionHistory", [])
3092
+ new_session.setdefault("cleanupComplete", False)
3093
+ new_session["serviceGeneration"] = new_generation
3094
+ new_session["supersedesSessionId"] = receipt["oldSessionId"]
3095
+ retirement_commit = receipt.get("retirementStateCommit")
3096
+ if not isinstance(retirement_commit, dict):
3097
+ retirement_commit = {}
3098
+ lease_cleanup = receipt.get("leaseCleanup")
3099
+ if not isinstance(lease_cleanup, dict):
3100
+ lease_cleanup = {}
3101
+ commit_status = str(retirement_commit.get("status") or "COMMITTED")
3102
+ if commit_status not in {"PENDING", "COMMITTED", "CONFLICT"}:
3103
+ commit_status = "COMMITTED"
3104
+ lease_status = str(lease_cleanup.get("status") or "RETAINED")
3105
+ if lease_status not in {"PENDING", "RELEASED", "RETAINED", "UNVERIFIED"}:
3106
+ lease_status = "UNVERIFIED"
3107
+ canonical_receipt = {
3108
+ "schemaVersion": 1,
3109
+ "receiptId": str(receipt["receiptId"]),
3110
+ "operationId": operation_id,
3111
+ "serviceId": str(receipt["serviceId"]),
3112
+ "oldSessionId": str(receipt["oldSessionId"]),
3113
+ "oldGeneration": old_generation,
3114
+ "state": "FINALIZED",
3115
+ "retirementStateCommit": {
3116
+ "status": commit_status,
3117
+ "reasonCode": _canonical_service_reason(
3118
+ str(retirement_commit.get("reasonCode") or "SERVICE_RETIRED")
3119
+ ),
3120
+ },
3121
+ "leaseCleanup": {
3122
+ "status": lease_status,
3123
+ "reasonCode": _canonical_service_reason(
3124
+ str(lease_cleanup.get("reasonCode") or "LISTENER_IDENTITY_UNVERIFIABLE")
3125
+ ),
3126
+ "leaseId": lease_cleanup.get("leaseId")
3127
+ if isinstance(lease_cleanup.get("leaseId"), str)
3128
+ else None,
3129
+ },
3130
+ "cleanupComplete": lease_status == "RELEASED",
3131
+ "awaitingSuperseder": False,
3132
+ "supersededBySessionId": new_session.get("sessionId"),
3133
+ }
3134
+ receipt = canonical_receipt
3135
+ receipt["receiptDigest"] = _retirement_receipt_digest(receipt)
3136
+ write_json(receipt_path, receipt)
3137
+ write_session(change_dir, new_session)
3138
+ return {
3139
+ "ok": True,
3140
+ "code": "SUPERSEDER_LINKED",
3141
+ "receipt": receipt,
3142
+ "session": new_session,
3143
+ }
3144
+
3145
+
3146
+ def link_superseder(
3147
+ change_dir: Path,
3148
+ *,
3149
+ retirement_receipt: Path,
3150
+ new_session: dict[str, Any],
3151
+ operation_id: str | None = None,
3152
+ ) -> dict[str, Any]:
3153
+ """CAS-link one superseder while serializing writers for this service."""
3154
+
3155
+ change_dir = resolve_path(change_dir)
3156
+ lock = ServiceMutationLock(change_dir, operation="link-superseder")
3157
+ conflict = lock.acquire()
3158
+ if conflict is not None:
3159
+ return conflict
3160
+ try:
3161
+ return _link_superseder_unlocked(
3162
+ change_dir,
3163
+ retirement_receipt=retirement_receipt,
3164
+ new_session=new_session,
3165
+ operation_id=operation_id or lock.token,
3166
+ )
3167
+ finally:
3168
+ lock.release()
3169
+
3170
+
3171
+ def cmd_link_superseder(args: argparse.Namespace) -> int:
3172
+ change_dir = resolve_path(args.change_dir)
3173
+ try:
3174
+ new_session = load_session(change_dir)
3175
+ except SessionCorrupt as exc:
3176
+ return emit_error(str(exc), as_json=bool(args.json))
3177
+ if new_session is None:
3178
+ return emit_error(
3179
+ "new service session is missing",
3180
+ as_json=bool(args.json),
3181
+ code=4,
3182
+ )
3183
+ result = link_superseder(
3184
+ change_dir,
3185
+ retirement_receipt=resolve_path(args.retirement_receipt),
3186
+ new_session=new_session,
3187
+ )
3188
+ emit_json(result, as_json=bool(args.json))
3189
+ return 0 if result.get("ok") else 4
3190
+
3191
+
1926
3192
  def build_parser() -> argparse.ArgumentParser:
1927
3193
  parser = argparse.ArgumentParser(
1928
3194
  prog="harness_service.py",
@@ -2010,6 +3276,15 @@ def build_parser() -> argparse.ArgumentParser:
2010
3276
  p_retire.add_argument("--json", action="store_true")
2011
3277
  p_retire.set_defaults(func=cmd_retire_stale)
2012
3278
 
3279
+ p_link = sub.add_parser(
3280
+ "link-superseder",
3281
+ help="CAS-link a new service generation to a retirement receipt",
3282
+ )
3283
+ p_link.add_argument("--change-dir", required=True)
3284
+ p_link.add_argument("--retirement-receipt", required=True)
3285
+ p_link.add_argument("--json", action="store_true")
3286
+ p_link.set_defaults(func=cmd_link_superseder)
3287
+
2013
3288
  return parser
2014
3289
 
2015
3290