@hunter-harness/workflow-harness 0.2.41 → 0.2.43

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 (262) 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 +56 -1
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -0
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +57 -6
  10. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  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-run/reference.md +6 -1
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +16 -10
  18. package/harness/bundles/general/claude-code/harness-sync/reference.md +34 -14
  19. package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
  20. package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
  21. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
  22. package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_archive.py +4 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +290 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_environment.py +719 -35
  26. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_profile.py +34 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1165 -3
  29. package/harness/bundles/general/claude-code/scripts/harness_service.py +76 -0
  30. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +127 -39
  31. package/harness/bundles/general/claude-code/scripts/harness_verification.py +408 -8
  32. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  33. package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +56 -1
  34. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -0
  36. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  37. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  38. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
  40. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +57 -6
  41. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  42. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -1
  47. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  48. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +16 -10
  49. package/harness/bundles/general/codebuddy/harness-sync/reference.md +34 -14
  50. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
  51. package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
  52. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
  53. package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +4 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +290 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +719 -35
  57. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
  58. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +34 -0
  59. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1165 -3
  60. package/harness/bundles/general/codebuddy/scripts/harness_service.py +76 -0
  61. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +127 -39
  62. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +408 -8
  63. package/harness/bundles/general/codex/.harness-build.json +1 -1
  64. package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +56 -1
  65. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-archive/reference.md +1 -0
  67. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
  68. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
  69. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +41 -15
  71. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +57 -6
  72. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  73. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  75. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  76. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  77. package/harness/bundles/general/codex/harness-run/reference.md +6 -1
  78. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  79. package/harness/bundles/general/codex/harness-sync/SKILL.md +16 -10
  80. package/harness/bundles/general/codex/harness-sync/reference.md +34 -14
  81. package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
  82. package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
  83. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
  84. package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
  85. package/harness/bundles/general/codex/scripts/harness_archive.py +4 -0
  86. package/harness/bundles/general/codex/scripts/harness_efficiency.py +290 -0
  87. package/harness/bundles/general/codex/scripts/harness_environment.py +719 -35
  88. package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
  89. package/harness/bundles/general/codex/scripts/harness_profile.py +34 -0
  90. package/harness/bundles/general/codex/scripts/harness_runtime.py +1165 -3
  91. package/harness/bundles/general/codex/scripts/harness_service.py +76 -0
  92. package/harness/bundles/general/codex/scripts/harness_test_runner.py +127 -39
  93. package/harness/bundles/general/codex/scripts/harness_verification.py +408 -8
  94. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  95. package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +56 -1
  96. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  97. package/harness/bundles/general/cursor/harness-archive/reference.md +1 -0
  98. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
  99. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
  100. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +41 -15
  102. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +57 -6
  103. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  104. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  105. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  106. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  107. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-run/reference.md +6 -1
  109. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  110. package/harness/bundles/general/cursor/harness-sync/SKILL.md +16 -10
  111. package/harness/bundles/general/cursor/harness-sync/reference.md +34 -14
  112. package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
  113. package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
  114. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
  115. package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
  116. package/harness/bundles/general/cursor/scripts/harness_archive.py +4 -0
  117. package/harness/bundles/general/cursor/scripts/harness_efficiency.py +290 -0
  118. package/harness/bundles/general/cursor/scripts/harness_environment.py +719 -35
  119. package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
  120. package/harness/bundles/general/cursor/scripts/harness_profile.py +34 -0
  121. package/harness/bundles/general/cursor/scripts/harness_runtime.py +1165 -3
  122. package/harness/bundles/general/cursor/scripts/harness_service.py +76 -0
  123. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +127 -39
  124. package/harness/bundles/general/cursor/scripts/harness_verification.py +408 -8
  125. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  126. package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +56 -1
  127. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -0
  130. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  131. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  132. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  133. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
  134. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +57 -6
  135. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  136. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  137. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  138. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  139. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  140. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  141. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  142. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +16 -10
  143. package/harness/bundles/java/claude-code/harness-sync/reference.md +34 -14
  144. package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
  145. package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
  146. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
  147. package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
  148. package/harness/bundles/java/claude-code/scripts/harness_archive.py +4 -0
  149. package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +290 -0
  150. package/harness/bundles/java/claude-code/scripts/harness_environment.py +719 -35
  151. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
  152. package/harness/bundles/java/claude-code/scripts/harness_profile.py +34 -0
  153. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1165 -3
  154. package/harness/bundles/java/claude-code/scripts/harness_service.py +76 -0
  155. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +127 -39
  156. package/harness/bundles/java/claude-code/scripts/harness_verification.py +408 -8
  157. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  158. package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +56 -1
  159. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  161. package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -0
  162. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  163. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  164. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  165. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
  166. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +57 -6
  167. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  168. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  169. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  170. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  171. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  172. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  173. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  174. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +16 -10
  175. package/harness/bundles/java/codebuddy/harness-sync/reference.md +34 -14
  176. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
  177. package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
  178. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
  179. package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
  180. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +4 -0
  181. package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +290 -0
  182. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +719 -35
  183. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
  184. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +34 -0
  185. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1165 -3
  186. package/harness/bundles/java/codebuddy/scripts/harness_service.py +76 -0
  187. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +127 -39
  188. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +408 -8
  189. package/harness/bundles/java/codex/.harness-build.json +1 -1
  190. package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +56 -1
  191. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  192. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  193. package/harness/bundles/java/codex/harness-archive/reference.md +1 -0
  194. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
  195. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
  196. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  197. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +41 -15
  198. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +57 -6
  199. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  200. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  201. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  202. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  203. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  204. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  205. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  206. package/harness/bundles/java/codex/harness-sync/SKILL.md +16 -10
  207. package/harness/bundles/java/codex/harness-sync/reference.md +34 -14
  208. package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
  209. package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
  210. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
  211. package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
  212. package/harness/bundles/java/codex/scripts/harness_archive.py +4 -0
  213. package/harness/bundles/java/codex/scripts/harness_efficiency.py +290 -0
  214. package/harness/bundles/java/codex/scripts/harness_environment.py +719 -35
  215. package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
  216. package/harness/bundles/java/codex/scripts/harness_profile.py +34 -0
  217. package/harness/bundles/java/codex/scripts/harness_runtime.py +1165 -3
  218. package/harness/bundles/java/codex/scripts/harness_service.py +76 -0
  219. package/harness/bundles/java/codex/scripts/harness_test_runner.py +127 -39
  220. package/harness/bundles/java/codex/scripts/harness_verification.py +408 -8
  221. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  222. package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +56 -1
  223. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  224. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  225. package/harness/bundles/java/cursor/harness-archive/reference.md +1 -0
  226. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
  227. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
  228. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  229. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +41 -15
  230. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +57 -6
  231. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  232. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  233. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  234. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  235. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  236. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  237. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  238. package/harness/bundles/java/cursor/harness-sync/SKILL.md +16 -10
  239. package/harness/bundles/java/cursor/harness-sync/reference.md +34 -14
  240. package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
  241. package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
  242. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
  243. package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
  244. package/harness/bundles/java/cursor/scripts/harness_archive.py +4 -0
  245. package/harness/bundles/java/cursor/scripts/harness_efficiency.py +290 -0
  246. package/harness/bundles/java/cursor/scripts/harness_environment.py +719 -35
  247. package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
  248. package/harness/bundles/java/cursor/scripts/harness_profile.py +34 -0
  249. package/harness/bundles/java/cursor/scripts/harness_runtime.py +1165 -3
  250. package/harness/bundles/java/cursor/scripts/harness_service.py +76 -0
  251. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +127 -39
  252. package/harness/bundles/java/cursor/scripts/harness_verification.py +408 -8
  253. package/harness/manifests/general/claude-code.json +46 -33
  254. package/harness/manifests/general/codebuddy.json +46 -33
  255. package/harness/manifests/general/codex.json +46 -33
  256. package/harness/manifests/general/cursor.json +46 -33
  257. package/harness/manifests/java/claude-code.json +47 -34
  258. package/harness/manifests/java/codebuddy.json +47 -34
  259. package/harness/manifests/java/codex.json +47 -34
  260. package/harness/manifests/java/cursor.json +47 -34
  261. package/hunter-workflow-family.json +12 -10
  262. package/package.json +1 -1
@@ -4,7 +4,9 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import contextlib
7
8
  import datetime as dt
9
+ import hashlib
8
10
  import json
9
11
  import os
10
12
  import re
@@ -12,15 +14,24 @@ import shutil
12
14
  import subprocess
13
15
  import sys
14
16
  import tempfile
17
+ import threading
18
+ import time
19
+ import uuid
15
20
  from pathlib import Path
16
21
  from typing import Any, Callable
17
22
 
23
+ SCRIPTS_DIR = Path(__file__).resolve().parent
24
+ if str(SCRIPTS_DIR) not in sys.path:
25
+ sys.path.insert(0, str(SCRIPTS_DIR))
26
+
18
27
  if hasattr(sys.stdout, "reconfigure"):
19
28
  sys.stdout.reconfigure(encoding="utf-8")
20
29
  if hasattr(sys.stderr, "reconfigure"):
21
30
  sys.stderr.reconfigure(encoding="utf-8")
22
31
 
23
32
  SCHEMA_VERSION = 1
33
+ RUN_SESSION_SCHEMA_VERSION = 1
34
+ RUN_TERMINAL_STATUSES = {"OK", "FAIL", "INCOMPLETE", "CANCELLED"}
24
35
  _CHANGE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
25
36
  _ADAPTERS = {
26
37
  "claude-code": (".worktrees", "harness/"),
@@ -34,6 +45,40 @@ def now_iso() -> str:
34
45
  return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
35
46
 
36
47
 
48
+ def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
49
+ ended_at = now_iso()
50
+ started_at = str(receipt.get("stageStartedAt") or ended_at)
51
+ try:
52
+ started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
53
+ ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
54
+ wall_ms = max(0, int((ended - started).total_seconds() * 1000))
55
+ except ValueError:
56
+ wall_ms = 0
57
+ stage = str(receipt.get("stage") or "unknown")
58
+ timings = receipt.setdefault("stageTimings", [])
59
+ if isinstance(timings, list):
60
+ timings.append(
61
+ {
62
+ "stage": stage,
63
+ "createdAt": started_at,
64
+ "endedAt": ended_at,
65
+ "wallClockMs": wall_ms,
66
+ "activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
67
+ "resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
68
+ }
69
+ )
70
+ receipt["stage"] = next_stage
71
+ receipt["stageStartedAt"] = ended_at
72
+
73
+
74
+ def _close_current_stage(receipt: dict[str, Any]) -> None:
75
+ current = str(receipt.get("stage") or "record")
76
+ _stage_transition(receipt, current)
77
+ timings = receipt.get("stageTimings")
78
+ if isinstance(timings, list) and timings:
79
+ receipt["stageStartedAt"] = timings[-1].get("endedAt")
80
+
81
+
37
82
  def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
38
83
  path.parent.mkdir(parents=True, exist_ok=True)
39
84
  text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -46,11 +91,1067 @@ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
46
91
  handle.write(text)
47
92
  handle.flush()
48
93
  os.fsync(handle.fileno())
49
- os.replace(tmp, path)
94
+ # Windows can transiently deny replacement while another process has
95
+ # just finished reading the receipt. Keep the atomic replace contract,
96
+ # but retry the sharing violation within a small bounded window.
97
+ for attempt in range(20):
98
+ try:
99
+ os.replace(tmp, path)
100
+ break
101
+ except PermissionError:
102
+ if attempt == 19:
103
+ raise
104
+ time.sleep(0.01 * (attempt + 1))
50
105
  finally:
51
106
  tmp.unlink(missing_ok=True)
52
107
 
53
108
 
109
+ def _sha256_json(value: Any) -> str:
110
+ encoded = json.dumps(
111
+ value,
112
+ ensure_ascii=False,
113
+ sort_keys=True,
114
+ separators=(",", ":"),
115
+ ).encode("utf-8")
116
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()
117
+
118
+
119
+ def _run_sessions_root(state_root: Path) -> Path:
120
+ return state_root.expanduser().resolve() / "runtime" / "run-sessions"
121
+
122
+
123
+ def _run_session_root(state_root: Path, session_id: str) -> Path:
124
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
125
+ raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
126
+ return _run_sessions_root(state_root) / session_id
127
+
128
+
129
+ def _run_receipt_path(state_root: Path, session_id: str) -> Path:
130
+ return _run_session_root(state_root, session_id) / "session.json"
131
+
132
+
133
+ def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
134
+ path = _run_receipt_path(state_root, session_id)
135
+ try:
136
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
137
+ except FileNotFoundError as exc:
138
+ raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
139
+ if not isinstance(value, dict):
140
+ raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
141
+ return value
142
+
143
+
144
+ def _resource_lock_root() -> Path:
145
+ return Path(
146
+ os.environ.get(
147
+ "HARNESS_RESOURCE_LOCK_ROOT",
148
+ str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
149
+ )
150
+ ).expanduser().resolve()
151
+
152
+
153
+ def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
154
+ digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
155
+ return _resource_lock_root() / f"{digest}.json"
156
+
157
+
158
+ def _lock_owner_identity(owner: Any) -> bool | None:
159
+ if not isinstance(owner, dict):
160
+ return None
161
+ pid = owner.get("pid")
162
+ started_at = owner.get("startedAt")
163
+ executable = owner.get("executable")
164
+ if (
165
+ not isinstance(pid, int)
166
+ or pid <= 0
167
+ or not isinstance(started_at, str)
168
+ or not started_at
169
+ or not isinstance(executable, str)
170
+ or not executable
171
+ ):
172
+ return None
173
+ from harness_service import verify_process_identity
174
+
175
+ return verify_process_identity(
176
+ {
177
+ "pid": pid,
178
+ "startedAt": started_at,
179
+ "processIdentity": {"executable": executable},
180
+ }
181
+ )
182
+
183
+
184
+ def _valid_resource_lock_record(
185
+ record: Any,
186
+ *,
187
+ lock_name: str,
188
+ ) -> bool:
189
+ return bool(
190
+ isinstance(record, dict)
191
+ and record.get("schemaVersion") == 2
192
+ and record.get("lockName") == lock_name
193
+ and isinstance(record.get("sessionId"), str)
194
+ and record.get("sessionId")
195
+ and isinstance(record.get("token"), str)
196
+ and record.get("token")
197
+ and isinstance(record.get("heartbeatAtUnix"), (int, float))
198
+ and isinstance(record.get("expiresAtUnix"), (int, float))
199
+ and _lock_owner_identity(record.get("owner")) is not None
200
+ )
201
+
202
+
203
+ def _write_resource_lock(
204
+ path: Path,
205
+ *,
206
+ lock_name: str,
207
+ session_id: str,
208
+ token: str,
209
+ owner: dict[str, Any],
210
+ acquired_at: str,
211
+ ) -> None:
212
+ now = time.time()
213
+ atomic_write_json(
214
+ path,
215
+ {
216
+ "schemaVersion": 2,
217
+ "lockName": lock_name,
218
+ "sessionId": session_id,
219
+ "token": token,
220
+ "acquiredAt": acquired_at,
221
+ "heartbeatAtUnix": now,
222
+ "expiresAtUnix": now + 300.0,
223
+ "owner": owner,
224
+ },
225
+ )
226
+
227
+
228
+ def _refresh_resource_locks(
229
+ state_root: Path,
230
+ session_id: str,
231
+ tokens: dict[str, str],
232
+ owner: dict[str, Any],
233
+ ) -> bool:
234
+ refreshed = True
235
+ for lock_name, token in sorted(tokens.items()):
236
+ path = _resource_lock_path(state_root, lock_name)
237
+ try:
238
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
239
+ except (OSError, json.JSONDecodeError):
240
+ refreshed = False
241
+ continue
242
+ if (
243
+ not _valid_resource_lock_record(record, lock_name=lock_name)
244
+ or record.get("sessionId") != session_id
245
+ or record.get("token") != token
246
+ ):
247
+ refreshed = False
248
+ continue
249
+ _write_resource_lock(
250
+ path,
251
+ lock_name=lock_name,
252
+ session_id=session_id,
253
+ token=token,
254
+ owner=owner,
255
+ acquired_at=str(record["acquiredAt"]),
256
+ )
257
+ return refreshed
258
+
259
+
260
+ def _release_resource_locks(
261
+ state_root: Path,
262
+ session_id: str,
263
+ tokens: dict[str, str],
264
+ ) -> bool:
265
+ released = True
266
+ for lock_name, token in sorted(tokens.items()):
267
+ path = _resource_lock_path(state_root, lock_name)
268
+ try:
269
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
270
+ except FileNotFoundError:
271
+ continue
272
+ except (OSError, json.JSONDecodeError):
273
+ released = False
274
+ continue
275
+ if (
276
+ not isinstance(record, dict)
277
+ or record.get("sessionId") != session_id
278
+ or record.get("token") != token
279
+ ):
280
+ released = False
281
+ continue
282
+ try:
283
+ path.unlink()
284
+ except OSError:
285
+ released = False
286
+ return released
287
+
288
+
289
+ def _acquire_resource_locks(
290
+ state_root: Path,
291
+ session_id: str,
292
+ lock_names: list[str],
293
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
294
+ tokens: dict[str, str] = {}
295
+ for lock_name in lock_names:
296
+ path = _resource_lock_path(state_root, lock_name)
297
+ path.parent.mkdir(parents=True, exist_ok=True)
298
+ token = uuid.uuid4().hex
299
+ owner_identity = _process_identity(os.getpid(), sys.executable)
300
+ now = time.time()
301
+ record = {
302
+ "schemaVersion": 2,
303
+ "lockName": lock_name,
304
+ "sessionId": session_id,
305
+ "token": token,
306
+ "acquiredAt": now_iso(),
307
+ "heartbeatAtUnix": now,
308
+ "expiresAtUnix": now + 300.0,
309
+ "owner": owner_identity,
310
+ }
311
+ encoded = (
312
+ json.dumps(record, ensure_ascii=False, indent=2) + "\n"
313
+ ).encode("utf-8")
314
+ for attempt in range(2):
315
+ try:
316
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
317
+ except FileExistsError:
318
+ owner: dict[str, Any] | None = None
319
+ try:
320
+ loaded = json.loads(path.read_text(encoding="utf-8-sig"))
321
+ owner = loaded if isinstance(loaded, dict) else None
322
+ except (OSError, json.JSONDecodeError):
323
+ owner = None
324
+ reclaimable = bool(
325
+ _valid_resource_lock_record(owner, lock_name=lock_name)
326
+ and _lock_owner_identity(owner.get("owner")) is False
327
+ )
328
+ if reclaimable and attempt == 0:
329
+ try:
330
+ current = json.loads(path.read_text(encoding="utf-8-sig"))
331
+ if current.get("token") == owner.get("token"):
332
+ path.unlink()
333
+ continue
334
+ except (OSError, json.JSONDecodeError):
335
+ pass
336
+ _release_resource_locks(state_root, session_id, tokens)
337
+ return {}, {
338
+ "lockName": lock_name,
339
+ "ownerSessionId": (
340
+ owner.get("sessionId") if owner is not None else None
341
+ ),
342
+ "lockRecordValid": bool(
343
+ _valid_resource_lock_record(owner, lock_name=lock_name)
344
+ ),
345
+ "ownerIdentity": (
346
+ _lock_owner_identity(owner.get("owner"))
347
+ if owner is not None
348
+ else None
349
+ ),
350
+ }
351
+ else:
352
+ break
353
+ else:
354
+ raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
355
+ try:
356
+ with os.fdopen(fd, "wb") as handle:
357
+ handle.write(encoded)
358
+ handle.flush()
359
+ os.fsync(handle.fileno())
360
+ except BaseException:
361
+ path.unlink(missing_ok=True)
362
+ _release_resource_locks(state_root, session_id, tokens)
363
+ raise
364
+ tokens[lock_name] = token
365
+ return tokens, None
366
+
367
+
368
+ def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
369
+ atomic_write_json(
370
+ _run_receipt_path(state_root, str(receipt["sessionId"])),
371
+ receipt,
372
+ )
373
+
374
+
375
+ def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
376
+ from harness_service import get_process_create_time, get_process_executable
377
+
378
+ created = get_process_create_time(pid)
379
+ executable = get_process_executable(pid)
380
+ return {
381
+ "pid": pid,
382
+ "startedAt": (
383
+ created.astimezone().isoformat(timespec="milliseconds")
384
+ if created is not None
385
+ else now_iso()
386
+ ),
387
+ "executable": executable or _absolute_executable(executable_hint),
388
+ }
389
+
390
+
391
+ def _spawn_detached_worker(
392
+ argv: list[str],
393
+ *,
394
+ cwd: Path,
395
+ env: dict[str, str],
396
+ output: Any = subprocess.DEVNULL,
397
+ ) -> subprocess.Popen[bytes]:
398
+ kwargs: dict[str, Any] = {
399
+ "cwd": str(cwd),
400
+ "env": env,
401
+ "stdin": subprocess.DEVNULL,
402
+ "stdout": output,
403
+ "stderr": output,
404
+ "close_fds": True,
405
+ }
406
+ if os.name == "nt":
407
+ detached = 0x00000008
408
+ new_group = 0x00000200
409
+ no_window = 0x08000000
410
+ breakaway = 0x01000000
411
+ try:
412
+ return subprocess.Popen(
413
+ argv,
414
+ creationflags=detached | new_group | no_window | breakaway,
415
+ **kwargs,
416
+ )
417
+ except OSError:
418
+ return subprocess.Popen(
419
+ argv,
420
+ creationflags=detached | new_group | no_window,
421
+ **kwargs,
422
+ )
423
+ return subprocess.Popen(argv, start_new_session=True, **kwargs)
424
+
425
+
426
+ def start_run_session(
427
+ *,
428
+ state_root: Path,
429
+ verification: str,
430
+ argv: list[str],
431
+ working_directory: Path,
432
+ environment: dict[str, str] | None = None,
433
+ timeout_seconds: float | None = None,
434
+ heartbeat_seconds: float = 30.0,
435
+ expected_duration_seconds: float | None = None,
436
+ product_identity: str | None = None,
437
+ resource_locks: list[str] | None = None,
438
+ run_id: str | None = None,
439
+ session_id: str | None = None,
440
+ ) -> dict[str, Any]:
441
+ """Start a re-attachable managed command without shell re-parsing.
442
+
443
+ The short-lived launch specification is removed by the worker immediately
444
+ after spawn. Environment values are inherited by the worker and hashed in
445
+ the durable receipt; they are never serialized.
446
+ """
447
+ if not verification.strip():
448
+ raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
449
+ if not argv or not all(isinstance(item, str) and item for item in argv):
450
+ raise ValueError("RUN_SESSION_ARGV_REQUIRED")
451
+ if heartbeat_seconds <= 0:
452
+ raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
453
+ if timeout_seconds is not None and timeout_seconds <= 0:
454
+ raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
455
+ normalized_resource_locks: list[str] = []
456
+ for raw_lock in resource_locks or []:
457
+ if (
458
+ not isinstance(raw_lock, str)
459
+ or not raw_lock.strip()
460
+ or len(raw_lock.strip()) > 256
461
+ ):
462
+ raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
463
+ normalized_resource_locks.append(raw_lock.strip())
464
+ normalized_resource_locks = sorted(set(normalized_resource_locks))
465
+
466
+ state_root = state_root.expanduser().resolve()
467
+ working_directory = working_directory.expanduser().resolve()
468
+ if not working_directory.is_dir():
469
+ raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
470
+ session_id = session_id or f"run-{uuid.uuid4().hex}"
471
+ run_id = run_id or session_id
472
+ session_root = _run_session_root(state_root, session_id)
473
+ session_root.mkdir(parents=True, exist_ok=False)
474
+ created_at = now_iso()
475
+ stdout_path = session_root / "stdout.log"
476
+ stderr_path = session_root / "stderr.log"
477
+ worker_log_path = session_root / "worker.log"
478
+ spec_path = session_root / "launch-spec.json"
479
+ overrides = dict(environment or {})
480
+ command_hash = _sha256_json(argv)
481
+ environment_hash = _sha256_json(overrides)
482
+ receipt: dict[str, Any] = {
483
+ "schemaVersion": RUN_SESSION_SCHEMA_VERSION,
484
+ "sessionId": session_id,
485
+ "runId": run_id,
486
+ "verification": verification,
487
+ "commandHash": command_hash,
488
+ "workingDirectory": str(working_directory),
489
+ "environmentHash": environment_hash,
490
+ "launcherPid": os.getpid(),
491
+ "workerPid": None,
492
+ "servicePid": None,
493
+ "processIdentity": None,
494
+ "createdAt": created_at,
495
+ "lastHeartbeatAt": created_at,
496
+ "startedAt": None,
497
+ "endedAt": None,
498
+ "exitCode": None,
499
+ "status": "STARTING",
500
+ "reasonCode": "WORKER_STARTING",
501
+ "stage": "prepare",
502
+ "stageStartedAt": created_at,
503
+ "stageTimings": [],
504
+ "stdoutPath": str(stdout_path),
505
+ "stderrPath": str(stderr_path),
506
+ "workerLogPath": str(worker_log_path),
507
+ "resultDigest": None,
508
+ "cleanupStatus": "PENDING",
509
+ "testProcessStarted": False,
510
+ "timeoutSeconds": timeout_seconds,
511
+ "heartbeatSeconds": heartbeat_seconds,
512
+ "expectedDurationSeconds": expected_duration_seconds,
513
+ "productIdentity": product_identity,
514
+ "resourceLocks": normalized_resource_locks,
515
+ "resourceLockTokens": {},
516
+ "resourceWaitMs": 0,
517
+ "activeTimeMs": 0,
518
+ "wallClockMs": 0,
519
+ "diagnosticPath": None,
520
+ }
521
+ _write_run_receipt(state_root, receipt)
522
+ lock_tokens, lock_conflict = _acquire_resource_locks(
523
+ state_root,
524
+ session_id,
525
+ normalized_resource_locks,
526
+ )
527
+ if lock_conflict is not None:
528
+ receipt.update(
529
+ {
530
+ "status": "INCOMPLETE",
531
+ "reasonCode": "RESOURCE_LOCK_BUSY",
532
+ "endedAt": now_iso(),
533
+ "cleanupStatus": "NO_CHILD",
534
+ "resourceLockConflict": lock_conflict,
535
+ }
536
+ )
537
+ _write_run_receipt(state_root, receipt)
538
+ return receipt
539
+ receipt["resourceLockTokens"] = lock_tokens
540
+ _write_run_receipt(state_root, receipt)
541
+ atomic_write_json(
542
+ spec_path,
543
+ {
544
+ "schemaVersion": 1,
545
+ "stateRoot": str(state_root),
546
+ "sessionId": session_id,
547
+ "argv": argv,
548
+ "heartbeatSeconds": heartbeat_seconds,
549
+ },
550
+ )
551
+ try:
552
+ spec_path.chmod(0o600)
553
+ except OSError:
554
+ pass
555
+
556
+ worker_env = {**os.environ, **overrides}
557
+ worker_env["PYTHONUTF8"] = "1"
558
+ worker_env["PYTHONIOENCODING"] = "utf-8"
559
+ worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
560
+ worker_argv = [
561
+ _absolute_executable(sys.executable),
562
+ str(Path(__file__).resolve()),
563
+ "_worker",
564
+ "--state-root",
565
+ str(state_root),
566
+ "--session-id",
567
+ session_id,
568
+ ]
569
+ try:
570
+ worker_log_path.touch()
571
+ worker = _spawn_detached_worker(
572
+ worker_argv,
573
+ cwd=working_directory,
574
+ env=worker_env,
575
+ )
576
+ except OSError as exc:
577
+ spec_path.unlink(missing_ok=True)
578
+ locks_released = _release_resource_locks(
579
+ state_root,
580
+ session_id,
581
+ lock_tokens,
582
+ )
583
+ receipt.update(
584
+ {
585
+ "status": "INCOMPLETE",
586
+ "reasonCode": "WORKER_LAUNCH_FAILED",
587
+ "endedAt": now_iso(),
588
+ "cleanupStatus": (
589
+ "NO_CHILD"
590
+ if locks_released
591
+ else "RESOURCE_LOCK_RELEASE_FAILED"
592
+ ),
593
+ "error": str(exc),
594
+ }
595
+ )
596
+ _write_run_receipt(state_root, receipt)
597
+ return receipt
598
+ receipt["workerPid"] = worker.pid
599
+ receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
600
+ # Best-effort early handoff. The worker repeats and verifies this transfer
601
+ # before spawning the managed command. Do not rewrite the session receipt
602
+ # here: the detached worker may already have advanced it to RUNNING.
603
+ _refresh_resource_locks(
604
+ state_root,
605
+ session_id,
606
+ lock_tokens,
607
+ dict(receipt["workerIdentity"]),
608
+ )
609
+ threading.Thread(target=worker.wait, daemon=True).start()
610
+ return receipt
611
+
612
+
613
+ def _terminate_managed_child(
614
+ process: subprocess.Popen[bytes],
615
+ job: Any,
616
+ ) -> bool:
617
+ from harness_service import is_pid_alive, terminate_process_tree
618
+
619
+ if job is not None and getattr(job, "handle", None) is not None:
620
+ job.terminate_and_wait()
621
+ try:
622
+ process.wait(timeout=5)
623
+ except subprocess.TimeoutExpired:
624
+ return False
625
+ return not is_pid_alive(process.pid)
626
+ terminate_process_tree(process.pid)
627
+ try:
628
+ process.wait(timeout=5)
629
+ except subprocess.TimeoutExpired:
630
+ return False
631
+ return not is_pid_alive(process.pid)
632
+
633
+
634
+ def _run_session_worker(state_root: Path, session_id: str) -> int:
635
+ from harness_service import verify_process_identity
636
+
637
+ session_root = _run_session_root(state_root, session_id)
638
+ spec_path = session_root / "launch-spec.json"
639
+ receipt = _load_run_receipt(state_root, session_id)
640
+ try:
641
+ spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
642
+ except (OSError, json.JSONDecodeError) as exc:
643
+ _release_resource_locks(
644
+ state_root,
645
+ session_id,
646
+ dict(receipt.get("resourceLockTokens") or {}),
647
+ )
648
+ receipt.update(
649
+ {
650
+ "status": "INCOMPLETE",
651
+ "reasonCode": "LAUNCH_SPEC_INVALID",
652
+ "endedAt": now_iso(),
653
+ "cleanupStatus": "NO_CHILD",
654
+ "error": str(exc),
655
+ }
656
+ )
657
+ _write_run_receipt(state_root, receipt)
658
+ return 1
659
+ argv = [str(item) for item in spec.get("argv", [])]
660
+ heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
661
+ spec_path.unlink(missing_ok=True)
662
+ started_monotonic = time.monotonic()
663
+ _stage_transition(receipt, "spawn")
664
+ receipt.update(
665
+ {
666
+ "workerPid": os.getpid(),
667
+ "workerIdentity": _process_identity(os.getpid(), sys.executable),
668
+ "lastHeartbeatAt": now_iso(),
669
+ }
670
+ )
671
+ if not _refresh_resource_locks(
672
+ state_root,
673
+ session_id,
674
+ dict(receipt.get("resourceLockTokens") or {}),
675
+ dict(receipt["workerIdentity"]),
676
+ ):
677
+ receipt.update(
678
+ {
679
+ "status": "INCOMPLETE",
680
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
681
+ "endedAt": now_iso(),
682
+ "cleanupStatus": "NO_CHILD",
683
+ }
684
+ )
685
+ _write_run_receipt(state_root, receipt)
686
+ return 1
687
+ _write_run_receipt(state_root, receipt)
688
+
689
+ stdout_path = Path(str(receipt["stdoutPath"]))
690
+ stderr_path = Path(str(receipt["stderrPath"]))
691
+ stdout_path.parent.mkdir(parents=True, exist_ok=True)
692
+ job = None
693
+ process: subprocess.Popen[bytes] | None = None
694
+ try:
695
+ with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
696
+ "wb", buffering=0
697
+ ) as stderr:
698
+ popen_kwargs: dict[str, Any] = {
699
+ "cwd": str(receipt["workingDirectory"]),
700
+ "env": os.environ.copy(),
701
+ "stdin": subprocess.DEVNULL,
702
+ "stdout": stdout,
703
+ "stderr": stderr,
704
+ "shell": False,
705
+ "close_fds": True,
706
+ }
707
+ if os.name == "nt":
708
+ popen_kwargs["creationflags"] = 0x00000200
709
+ else:
710
+ popen_kwargs["start_new_session"] = True
711
+ try:
712
+ process = subprocess.Popen(argv, **popen_kwargs)
713
+ except OSError as exc:
714
+ receipt.update(
715
+ {
716
+ "status": "INCOMPLETE",
717
+ "reasonCode": "LAUNCHER_FAILED",
718
+ "endedAt": now_iso(),
719
+ "exitCode": None,
720
+ "testProcessStarted": False,
721
+ "cleanupStatus": "NO_CHILD",
722
+ "error": str(exc),
723
+ }
724
+ )
725
+ return 1
726
+ identity = _process_identity(process.pid, argv[0])
727
+ if os.name == "nt":
728
+ from harness_test_runner import _WindowsKillOnCloseJob
729
+
730
+ candidate = _WindowsKillOnCloseJob()
731
+ if candidate.assign(process):
732
+ job = candidate
733
+ else:
734
+ identity_session = {
735
+ "pid": process.pid,
736
+ "startedAt": identity["startedAt"],
737
+ "processIdentity": {
738
+ "executable": identity["executable"],
739
+ },
740
+ }
741
+ verified = verify_process_identity(identity_session) is True
742
+ terminated = (
743
+ _terminate_managed_child(process, None)
744
+ if verified
745
+ else False
746
+ )
747
+ receipt.update(
748
+ {
749
+ "servicePid": process.pid,
750
+ "processIdentity": identity,
751
+ "startedAt": identity["startedAt"],
752
+ "status": "INCOMPLETE",
753
+ "reasonCode": "PROCESS_TREE_ISOLATION_UNAVAILABLE",
754
+ "stage": "spawn",
755
+ "testProcessStarted": True,
756
+ "processTreeIsolated": False,
757
+ "exitCode": process.poll(),
758
+ "cleanupStatus": (
759
+ "PROCESS_TREE_TERMINATED"
760
+ if terminated
761
+ else "PROCESS_TERMINATION_UNCONFIRMED"
762
+ ),
763
+ }
764
+ )
765
+ return 1
766
+ _stage_transition(receipt, "execute")
767
+ receipt.update(
768
+ {
769
+ "servicePid": process.pid,
770
+ "processIdentity": identity,
771
+ "startedAt": identity["startedAt"],
772
+ "status": "RUNNING",
773
+ "reasonCode": "CHILD_RUNNING",
774
+ "testProcessStarted": True,
775
+ "processTreeIsolated": job is not None or os.name != "nt",
776
+ }
777
+ )
778
+ _write_run_receipt(state_root, receipt)
779
+
780
+ timeout_seconds = receipt.get("timeoutSeconds")
781
+ next_heartbeat = time.monotonic()
782
+ termination_reason: str | None = None
783
+ while process.poll() is None:
784
+ now = time.monotonic()
785
+ if timeout_seconds is not None and (
786
+ now - started_monotonic >= float(timeout_seconds)
787
+ ):
788
+ termination_reason = "TIMEOUT"
789
+ cancel_path = session_root / "cancel-request.json"
790
+ if cancel_path.is_file():
791
+ termination_reason = "CANCEL_REQUESTED"
792
+ if termination_reason is not None:
793
+ diagnostic_path = session_root / "diagnostic.json"
794
+ identity_session = {
795
+ "pid": process.pid,
796
+ "startedAt": identity["startedAt"],
797
+ "processIdentity": {
798
+ "executable": identity["executable"],
799
+ },
800
+ }
801
+ verified = verify_process_identity(identity_session) is True
802
+ job_owned = job is not None
803
+ diagnostic = {
804
+ "schemaVersion": 1,
805
+ "sessionId": session_id,
806
+ "reasonCode": termination_reason,
807
+ "observedAt": now_iso(),
808
+ "processIdentityVerified": verified,
809
+ "terminationAuthority": (
810
+ "JOB_OBJECT_OWNED"
811
+ if job_owned
812
+ else (
813
+ "PROCESS_IDENTITY_VERIFIED"
814
+ if verified
815
+ else "NONE"
816
+ )
817
+ ),
818
+ "pid": process.pid,
819
+ "stdoutBytes": (
820
+ stdout_path.stat().st_size if stdout_path.exists() else 0
821
+ ),
822
+ "stderrBytes": (
823
+ stderr_path.stat().st_size if stderr_path.exists() else 0
824
+ ),
825
+ }
826
+ atomic_write_json(diagnostic_path, diagnostic)
827
+ receipt["diagnosticPath"] = str(diagnostic_path)
828
+ if job_owned or verified:
829
+ terminated = _terminate_managed_child(process, job)
830
+ job = None
831
+ if not terminated:
832
+ receipt.update(
833
+ {
834
+ "status": "INCOMPLETE",
835
+ "reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
836
+ "cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
837
+ }
838
+ )
839
+ else:
840
+ receipt.update(
841
+ {
842
+ "status": "INCOMPLETE",
843
+ "reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
844
+ "cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
845
+ }
846
+ )
847
+ break
848
+ if now >= next_heartbeat:
849
+ receipt["lastHeartbeatAt"] = now_iso()
850
+ receipt["activeTimeMs"] = int(
851
+ (now - started_monotonic) * 1000
852
+ )
853
+ receipt["wallClockMs"] = receipt["activeTimeMs"]
854
+ if not _refresh_resource_locks(
855
+ state_root,
856
+ session_id,
857
+ dict(receipt.get("resourceLockTokens") or {}),
858
+ dict(receipt["workerIdentity"]),
859
+ ):
860
+ receipt.update(
861
+ {
862
+ "status": "INCOMPLETE",
863
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
864
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
865
+ }
866
+ )
867
+ _terminate_managed_child(process, job)
868
+ job = None
869
+ break
870
+ _write_run_receipt(state_root, receipt)
871
+ next_heartbeat = now + heartbeat_seconds
872
+ time.sleep(min(heartbeat_seconds, 0.05))
873
+
874
+ exit_code = process.poll()
875
+ _stage_transition(receipt, "summarize")
876
+ if termination_reason is not None and receipt["reasonCode"] not in {
877
+ "PROCESS_IDENTITY_UNVERIFIED",
878
+ "PROCESS_TERMINATION_UNCONFIRMED",
879
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
880
+ }:
881
+ receipt.update(
882
+ {
883
+ "status": "CANCELLED",
884
+ "reasonCode": termination_reason,
885
+ "exitCode": exit_code,
886
+ "cleanupStatus": "PROCESS_TREE_TERMINATED",
887
+ }
888
+ )
889
+ elif receipt["reasonCode"] not in {
890
+ "PROCESS_IDENTITY_UNVERIFIED",
891
+ "PROCESS_TERMINATION_UNCONFIRMED",
892
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
893
+ }:
894
+ receipt.update(
895
+ {
896
+ "status": "OK" if exit_code == 0 else "FAIL",
897
+ "reasonCode": (
898
+ "CHILD_EXIT_ZERO"
899
+ if exit_code == 0
900
+ else "CHILD_EXIT_NONZERO"
901
+ ),
902
+ "exitCode": exit_code,
903
+ "cleanupStatus": "PROCESS_EXITED",
904
+ }
905
+ )
906
+ except BaseException as exc:
907
+ receipt.update(
908
+ {
909
+ "status": "INCOMPLETE",
910
+ "reasonCode": "WORKER_FAILED",
911
+ "exitCode": None if process is None else process.poll(),
912
+ "cleanupStatus": "WORKER_EXCEPTION",
913
+ "error": str(exc),
914
+ }
915
+ )
916
+ finally:
917
+ if job is not None:
918
+ tree_drained = job.terminate_and_wait()
919
+ if not tree_drained:
920
+ receipt.update(
921
+ {
922
+ "status": "INCOMPLETE",
923
+ "reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
924
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
925
+ }
926
+ )
927
+ elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
928
+ receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
929
+ ended = time.monotonic()
930
+ _stage_transition(receipt, "record")
931
+ receipt["endedAt"] = now_iso()
932
+ receipt["lastHeartbeatAt"] = receipt["endedAt"]
933
+ receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
934
+ receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
935
+ receipt.get("resourceWaitMs") or 0
936
+ )
937
+ digest = hashlib.sha256()
938
+ for path in (stdout_path, stderr_path):
939
+ if path.is_file():
940
+ digest.update(path.read_bytes())
941
+ receipt["resultDigest"] = "sha256:" + digest.hexdigest()
942
+ locks_released = _release_resource_locks(
943
+ state_root,
944
+ session_id,
945
+ dict(receipt.get("resourceLockTokens") or {}),
946
+ )
947
+ if not locks_released:
948
+ receipt.update(
949
+ {
950
+ "status": "INCOMPLETE",
951
+ "reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
952
+ "cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
953
+ }
954
+ )
955
+ _close_current_stage(receipt)
956
+ _write_run_receipt(state_root, receipt)
957
+ return 0 if receipt.get("status") == "OK" else 1
958
+
959
+
960
+ def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
961
+ worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
962
+ try:
963
+ worker_log = worker_log_path.open(
964
+ "a",
965
+ encoding="utf-8",
966
+ errors="replace",
967
+ buffering=1,
968
+ )
969
+ except OSError:
970
+ return _run_session_worker(state_root, session_id)
971
+ with worker_log:
972
+ with (
973
+ contextlib.redirect_stdout(worker_log),
974
+ contextlib.redirect_stderr(worker_log),
975
+ ):
976
+ return _run_session_worker(state_root, session_id)
977
+
978
+
979
+ def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
980
+ from harness_service import is_pid_alive, verify_process_identity
981
+
982
+ receipt = _load_run_receipt(state_root, session_id)
983
+ worker_pid = receipt.get("workerPid")
984
+ worker_identity = receipt.get("workerIdentity")
985
+ worker_session = {
986
+ "pid": worker_pid,
987
+ "startedAt": (
988
+ worker_identity.get("startedAt")
989
+ if isinstance(worker_identity, dict)
990
+ else None
991
+ ),
992
+ "processIdentity": {
993
+ "executable": (
994
+ worker_identity.get("executable")
995
+ if isinstance(worker_identity, dict)
996
+ else None
997
+ )
998
+ },
999
+ }
1000
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1001
+ if (
1002
+ isinstance(worker_pid, int)
1003
+ and worker_pid > 0
1004
+ and is_pid_alive(worker_pid)
1005
+ and verify_process_identity(worker_session) is True
1006
+ ):
1007
+ finalizing = dict(receipt)
1008
+ finalizing.update(
1009
+ {
1010
+ "status": "FINALIZING",
1011
+ "reasonCode": "WORKER_FINALIZING",
1012
+ }
1013
+ )
1014
+ return finalizing
1015
+ return receipt
1016
+ heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
1017
+ try:
1018
+ heartbeat_at = dt.datetime.fromisoformat(
1019
+ heartbeat_text.replace("Z", "+00:00")
1020
+ )
1021
+ if heartbeat_at.tzinfo is None:
1022
+ heartbeat_at = heartbeat_at.astimezone()
1023
+ heartbeat_age = (
1024
+ dt.datetime.now().astimezone() - heartbeat_at
1025
+ ).total_seconds()
1026
+ except ValueError:
1027
+ heartbeat_age = float("inf")
1028
+ grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
1029
+ startup_grace = max(grace, 10.0)
1030
+ # The detached worker owns the durable identity record. The launcher can
1031
+ # return before the worker has published it, so STARTING is intentionally
1032
+ # treated as a bounded handshake window rather than an identity failure.
1033
+ if (
1034
+ receipt.get("status") == "STARTING"
1035
+ and receipt.get("workerIdentity") is None
1036
+ and heartbeat_age < startup_grace
1037
+ ):
1038
+ return receipt
1039
+ identity_state = verify_process_identity(worker_session)
1040
+ worker_gone = (
1041
+ isinstance(worker_pid, int)
1042
+ and worker_pid > 0
1043
+ and not is_pid_alive(worker_pid)
1044
+ )
1045
+ if worker_gone or identity_state is False or heartbeat_age >= grace:
1046
+ latest = _load_run_receipt(state_root, session_id)
1047
+ if latest.get("status") in RUN_TERMINAL_STATUSES:
1048
+ return latest
1049
+ if heartbeat_age < grace and worker_gone and identity_state is not False:
1050
+ return latest
1051
+ receipt.update(
1052
+ {
1053
+ "status": "INCOMPLETE",
1054
+ "reasonCode": (
1055
+ "WORKER_IDENTITY_MISMATCH"
1056
+ if identity_state is False and not worker_gone
1057
+ else "HEARTBEAT_LOST"
1058
+ ),
1059
+ "endedAt": now_iso(),
1060
+ "cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
1061
+ }
1062
+ )
1063
+ _write_run_receipt(state_root, receipt)
1064
+ return receipt
1065
+
1066
+
1067
+ def read_run_session_log(
1068
+ state_root: Path,
1069
+ session_id: str,
1070
+ *,
1071
+ stream: str = "stdout",
1072
+ cursor: int = 0,
1073
+ max_bytes: int = 64 * 1024,
1074
+ ) -> dict[str, Any]:
1075
+ if stream not in {"stdout", "stderr"}:
1076
+ raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
1077
+ if cursor < 0 or max_bytes <= 0:
1078
+ raise ValueError("RUN_SESSION_CURSOR_INVALID")
1079
+ receipt = _load_run_receipt(state_root, session_id)
1080
+ path = Path(str(receipt[f"{stream}Path"]))
1081
+ raw = b""
1082
+ size = path.stat().st_size if path.is_file() else 0
1083
+ if path.is_file() and cursor < size:
1084
+ with path.open("rb") as handle:
1085
+ handle.seek(cursor)
1086
+ raw = handle.read(max_bytes)
1087
+ while raw:
1088
+ try:
1089
+ text = raw.decode("utf-8")
1090
+ break
1091
+ except UnicodeDecodeError as exc:
1092
+ if exc.end != len(raw):
1093
+ text = raw.decode("utf-8", errors="replace")
1094
+ break
1095
+ raw = raw[: exc.start]
1096
+ else:
1097
+ text = ""
1098
+ else:
1099
+ text = ""
1100
+ next_cursor = cursor + len(raw)
1101
+ terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
1102
+ return {
1103
+ "ok": True,
1104
+ "sessionId": session_id,
1105
+ "stream": stream,
1106
+ "cursor": cursor,
1107
+ "nextCursor": next_cursor,
1108
+ "text": text,
1109
+ "eof": terminal and next_cursor >= size,
1110
+ "status": receipt.get("status"),
1111
+ }
1112
+
1113
+
1114
+ def cancel_run_session(
1115
+ state_root: Path,
1116
+ session_id: str,
1117
+ *,
1118
+ reason: str = "CANCEL_REQUESTED",
1119
+ ) -> dict[str, Any]:
1120
+ from harness_service import verify_process_identity
1121
+
1122
+ receipt = _load_run_receipt(state_root, session_id)
1123
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1124
+ return receipt
1125
+ worker_identity = receipt.get("workerIdentity")
1126
+ worker_session = {
1127
+ "pid": receipt.get("workerPid"),
1128
+ "startedAt": (
1129
+ worker_identity.get("startedAt")
1130
+ if isinstance(worker_identity, dict)
1131
+ else None
1132
+ ),
1133
+ "processIdentity": {
1134
+ "executable": (
1135
+ worker_identity.get("executable")
1136
+ if isinstance(worker_identity, dict)
1137
+ else None
1138
+ )
1139
+ },
1140
+ }
1141
+ if verify_process_identity(worker_session) is not True:
1142
+ return {
1143
+ **receipt,
1144
+ "ok": False,
1145
+ "code": "WORKER_IDENTITY_UNVERIFIED",
1146
+ "unknownProcessUntouched": True,
1147
+ }
1148
+ atomic_write_json(
1149
+ _run_session_root(state_root, session_id) / "cancel-request.json",
1150
+ {"reasonCode": reason, "requestedAt": now_iso()},
1151
+ )
1152
+ return {**receipt, "ok": True, "action": "cancel-requested"}
1153
+
1154
+
54
1155
  def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
55
1156
  if agent not in _ADAPTERS:
56
1157
  raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
@@ -221,6 +1322,35 @@ def build_parser() -> argparse.ArgumentParser:
221
1322
  p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
222
1323
  p_adapter.add_argument("--change", required=True)
223
1324
  p_adapter.add_argument("--json", action="store_true")
1325
+ p_start = sub.add_parser("run-start", help="start a managed background run")
1326
+ p_start.add_argument("--state-root", required=True)
1327
+ p_start.add_argument("--verification", required=True)
1328
+ p_start.add_argument("--working-directory", required=True)
1329
+ p_start.add_argument("--timeout-seconds", type=float)
1330
+ p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
1331
+ p_start.add_argument("--expected-duration-seconds", type=float)
1332
+ p_start.add_argument("--product-identity")
1333
+ p_start.add_argument("--resource-lock", action="append", default=[])
1334
+ p_start.add_argument("--json", action="store_true")
1335
+ p_start.add_argument("argv", nargs=argparse.REMAINDER)
1336
+ p_status = sub.add_parser("run-status", help="read a managed run receipt")
1337
+ p_status.add_argument("--state-root", required=True)
1338
+ p_status.add_argument("--session-id", required=True)
1339
+ p_status.add_argument("--json", action="store_true")
1340
+ p_log = sub.add_parser("run-log", help="read an incremental log page")
1341
+ p_log.add_argument("--state-root", required=True)
1342
+ p_log.add_argument("--session-id", required=True)
1343
+ p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
1344
+ p_log.add_argument("--cursor", type=int, default=0)
1345
+ p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
1346
+ p_log.add_argument("--json", action="store_true")
1347
+ p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
1348
+ p_cancel.add_argument("--state-root", required=True)
1349
+ p_cancel.add_argument("--session-id", required=True)
1350
+ p_cancel.add_argument("--json", action="store_true")
1351
+ p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
1352
+ p_worker.add_argument("--state-root", required=True)
1353
+ p_worker.add_argument("--session-id", required=True)
224
1354
  return parser
225
1355
 
226
1356
 
@@ -229,15 +1359,47 @@ def main(argv: list[str] | None = None) -> int:
229
1359
  try:
230
1360
  if args.command == "doctor":
231
1361
  result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
232
- else:
1362
+ elif args.command == "adapter":
233
1363
  result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
1364
+ elif args.command == "_worker":
1365
+ return _run_session_worker_with_log(
1366
+ Path(args.state_root),
1367
+ args.session_id,
1368
+ )
1369
+ elif args.command == "run-start":
1370
+ command_argv = list(args.argv)
1371
+ if command_argv and command_argv[0] == "--":
1372
+ command_argv = command_argv[1:]
1373
+ result = start_run_session(
1374
+ state_root=Path(args.state_root),
1375
+ verification=args.verification,
1376
+ argv=command_argv,
1377
+ working_directory=Path(args.working_directory),
1378
+ timeout_seconds=args.timeout_seconds,
1379
+ heartbeat_seconds=args.heartbeat_seconds,
1380
+ expected_duration_seconds=args.expected_duration_seconds,
1381
+ product_identity=args.product_identity,
1382
+ resource_locks=list(args.resource_lock),
1383
+ )
1384
+ elif args.command == "run-status":
1385
+ result = run_session_status(Path(args.state_root), args.session_id)
1386
+ elif args.command == "run-log":
1387
+ result = read_run_session_log(
1388
+ Path(args.state_root),
1389
+ args.session_id,
1390
+ stream=args.stream,
1391
+ cursor=args.cursor,
1392
+ max_bytes=args.max_bytes,
1393
+ )
1394
+ else:
1395
+ result = cancel_run_session(Path(args.state_root), args.session_id)
234
1396
  except (OSError, ValueError) as exc:
235
1397
  code = str(exc).split(":", 1)[0]
236
1398
  result = {"ok": False, "code": code, "error": str(exc)}
237
1399
  stream = sys.stderr
238
1400
  stream.write(json.dumps(result, ensure_ascii=False) + "\n")
239
1401
  return 1
240
- if args.json:
1402
+ if getattr(args, "json", False):
241
1403
  sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
242
1404
  else:
243
1405
  sys.stdout.write(str(result.get("action")) + "\n")