@hunter-harness/workflow-harness 0.2.42 → 0.2.44

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 (334) 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 +156 -1
  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-archive/reference.md +1 -0
  10. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  11. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  12. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -1
  19. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  20. package/harness/bundles/general/claude-code/harness-submit/checklist.md +12 -0
  21. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  22. package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
  23. package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
  24. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
  25. package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_archive.py +271 -22
  27. package/harness/bundles/general/claude-code/scripts/harness_change.py +137 -2
  28. package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +443 -0
  29. package/harness/bundles/general/claude-code/scripts/harness_environment.py +760 -46
  30. package/harness/bundles/general/claude-code/scripts/harness_execution_contracts.py +596 -0
  31. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
  32. package/harness/bundles/general/claude-code/scripts/harness_integration.py +289 -52
  33. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +5 -0
  34. package/harness/bundles/general/claude-code/scripts/harness_orchestration.py +344 -0
  35. package/harness/bundles/general/claude-code/scripts/harness_process.py +1065 -0
  36. package/harness/bundles/general/claude-code/scripts/harness_profile.py +278 -4
  37. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1576 -3
  38. package/harness/bundles/general/claude-code/scripts/harness_service.py +1528 -177
  39. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +224 -146
  40. package/harness/bundles/general/claude-code/scripts/harness_verification.py +487 -8
  41. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  42. package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +156 -1
  43. package/harness/bundles/general/codebuddy/contracts/fixtures/managed-execution.json +341 -0
  44. package/harness/bundles/general/codebuddy/contracts/process-identity.schema.json +132 -0
  45. package/harness/bundles/general/codebuddy/contracts/run-session.schema.json +142 -0
  46. package/harness/bundles/general/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
  47. package/harness/bundles/general/codebuddy/contracts/service-session.schema.json +147 -0
  48. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  49. package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -0
  50. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  51. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  52. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  53. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  54. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  55. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  56. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  57. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  58. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -1
  59. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  60. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +12 -0
  61. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  62. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
  63. package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
  64. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
  65. package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
  66. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +271 -22
  67. package/harness/bundles/general/codebuddy/scripts/harness_change.py +137 -2
  68. package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +443 -0
  69. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +760 -46
  70. package/harness/bundles/general/codebuddy/scripts/harness_execution_contracts.py +596 -0
  71. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
  72. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +289 -52
  73. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +5 -0
  74. package/harness/bundles/general/codebuddy/scripts/harness_orchestration.py +344 -0
  75. package/harness/bundles/general/codebuddy/scripts/harness_process.py +1065 -0
  76. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +278 -4
  77. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1576 -3
  78. package/harness/bundles/general/codebuddy/scripts/harness_service.py +1528 -177
  79. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +224 -146
  80. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +487 -8
  81. package/harness/bundles/general/codex/.harness-build.json +1 -1
  82. package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +156 -1
  83. package/harness/bundles/general/codex/contracts/fixtures/managed-execution.json +341 -0
  84. package/harness/bundles/general/codex/contracts/process-identity.schema.json +132 -0
  85. package/harness/bundles/general/codex/contracts/run-session.schema.json +142 -0
  86. package/harness/bundles/general/codex/contracts/service-retirement-receipt.schema.json +85 -0
  87. package/harness/bundles/general/codex/contracts/service-session.schema.json +147 -0
  88. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  89. package/harness/bundles/general/codex/harness-archive/reference.md +1 -0
  90. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
  91. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
  92. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  96. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  97. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  98. package/harness/bundles/general/codex/harness-run/reference.md +6 -1
  99. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  100. package/harness/bundles/general/codex/harness-submit/checklist.md +12 -0
  101. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
  103. package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
  104. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
  105. package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
  106. package/harness/bundles/general/codex/scripts/harness_archive.py +271 -22
  107. package/harness/bundles/general/codex/scripts/harness_change.py +137 -2
  108. package/harness/bundles/general/codex/scripts/harness_efficiency.py +443 -0
  109. package/harness/bundles/general/codex/scripts/harness_environment.py +760 -46
  110. package/harness/bundles/general/codex/scripts/harness_execution_contracts.py +596 -0
  111. package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
  112. package/harness/bundles/general/codex/scripts/harness_integration.py +289 -52
  113. package/harness/bundles/general/codex/scripts/harness_ledger.py +5 -0
  114. package/harness/bundles/general/codex/scripts/harness_orchestration.py +344 -0
  115. package/harness/bundles/general/codex/scripts/harness_process.py +1065 -0
  116. package/harness/bundles/general/codex/scripts/harness_profile.py +278 -4
  117. package/harness/bundles/general/codex/scripts/harness_runtime.py +1576 -3
  118. package/harness/bundles/general/codex/scripts/harness_service.py +1528 -177
  119. package/harness/bundles/general/codex/scripts/harness_test_runner.py +224 -146
  120. package/harness/bundles/general/codex/scripts/harness_verification.py +487 -8
  121. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  122. package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +156 -1
  123. package/harness/bundles/general/cursor/contracts/fixtures/managed-execution.json +341 -0
  124. package/harness/bundles/general/cursor/contracts/process-identity.schema.json +132 -0
  125. package/harness/bundles/general/cursor/contracts/run-session.schema.json +142 -0
  126. package/harness/bundles/general/cursor/contracts/service-retirement-receipt.schema.json +85 -0
  127. package/harness/bundles/general/cursor/contracts/service-session.schema.json +147 -0
  128. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  129. package/harness/bundles/general/cursor/harness-archive/reference.md +1 -0
  130. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
  131. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
  132. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  133. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  134. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  135. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  136. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  137. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  138. package/harness/bundles/general/cursor/harness-run/reference.md +6 -1
  139. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  140. package/harness/bundles/general/cursor/harness-submit/checklist.md +12 -0
  141. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  142. package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
  143. package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
  144. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
  145. package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
  146. package/harness/bundles/general/cursor/scripts/harness_archive.py +271 -22
  147. package/harness/bundles/general/cursor/scripts/harness_change.py +137 -2
  148. package/harness/bundles/general/cursor/scripts/harness_efficiency.py +443 -0
  149. package/harness/bundles/general/cursor/scripts/harness_environment.py +760 -46
  150. package/harness/bundles/general/cursor/scripts/harness_execution_contracts.py +596 -0
  151. package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
  152. package/harness/bundles/general/cursor/scripts/harness_integration.py +289 -52
  153. package/harness/bundles/general/cursor/scripts/harness_ledger.py +5 -0
  154. package/harness/bundles/general/cursor/scripts/harness_orchestration.py +344 -0
  155. package/harness/bundles/general/cursor/scripts/harness_process.py +1065 -0
  156. package/harness/bundles/general/cursor/scripts/harness_profile.py +278 -4
  157. package/harness/bundles/general/cursor/scripts/harness_runtime.py +1576 -3
  158. package/harness/bundles/general/cursor/scripts/harness_service.py +1528 -177
  159. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +224 -146
  160. package/harness/bundles/general/cursor/scripts/harness_verification.py +487 -8
  161. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  162. package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +156 -1
  163. package/harness/bundles/java/claude-code/contracts/fixtures/managed-execution.json +341 -0
  164. package/harness/bundles/java/claude-code/contracts/process-identity.schema.json +132 -0
  165. package/harness/bundles/java/claude-code/contracts/run-session.schema.json +142 -0
  166. package/harness/bundles/java/claude-code/contracts/service-retirement-receipt.schema.json +85 -0
  167. package/harness/bundles/java/claude-code/contracts/service-session.schema.json +147 -0
  168. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  169. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  170. package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -0
  171. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  172. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  173. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  174. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  175. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  176. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  177. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  178. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  179. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  180. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  181. package/harness/bundles/java/claude-code/harness-submit/checklist.md +12 -0
  182. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
  184. package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
  185. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
  186. package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
  187. package/harness/bundles/java/claude-code/scripts/harness_archive.py +271 -22
  188. package/harness/bundles/java/claude-code/scripts/harness_change.py +137 -2
  189. package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +443 -0
  190. package/harness/bundles/java/claude-code/scripts/harness_environment.py +760 -46
  191. package/harness/bundles/java/claude-code/scripts/harness_execution_contracts.py +596 -0
  192. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
  193. package/harness/bundles/java/claude-code/scripts/harness_integration.py +289 -52
  194. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +5 -0
  195. package/harness/bundles/java/claude-code/scripts/harness_orchestration.py +344 -0
  196. package/harness/bundles/java/claude-code/scripts/harness_process.py +1065 -0
  197. package/harness/bundles/java/claude-code/scripts/harness_profile.py +278 -4
  198. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1576 -3
  199. package/harness/bundles/java/claude-code/scripts/harness_service.py +1528 -177
  200. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +224 -146
  201. package/harness/bundles/java/claude-code/scripts/harness_verification.py +487 -8
  202. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  203. package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +156 -1
  204. package/harness/bundles/java/codebuddy/contracts/fixtures/managed-execution.json +341 -0
  205. package/harness/bundles/java/codebuddy/contracts/process-identity.schema.json +132 -0
  206. package/harness/bundles/java/codebuddy/contracts/run-session.schema.json +142 -0
  207. package/harness/bundles/java/codebuddy/contracts/service-retirement-receipt.schema.json +85 -0
  208. package/harness/bundles/java/codebuddy/contracts/service-session.schema.json +147 -0
  209. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -0
  212. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  213. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  214. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  217. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  218. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  219. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  220. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  221. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  222. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +12 -0
  223. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  224. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
  225. package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
  226. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
  227. package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
  228. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +271 -22
  229. package/harness/bundles/java/codebuddy/scripts/harness_change.py +137 -2
  230. package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +443 -0
  231. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +760 -46
  232. package/harness/bundles/java/codebuddy/scripts/harness_execution_contracts.py +596 -0
  233. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
  234. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +289 -52
  235. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +5 -0
  236. package/harness/bundles/java/codebuddy/scripts/harness_orchestration.py +344 -0
  237. package/harness/bundles/java/codebuddy/scripts/harness_process.py +1065 -0
  238. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +278 -4
  239. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1576 -3
  240. package/harness/bundles/java/codebuddy/scripts/harness_service.py +1528 -177
  241. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +224 -146
  242. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +487 -8
  243. package/harness/bundles/java/codex/.harness-build.json +1 -1
  244. package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +156 -1
  245. package/harness/bundles/java/codex/contracts/fixtures/managed-execution.json +341 -0
  246. package/harness/bundles/java/codex/contracts/process-identity.schema.json +132 -0
  247. package/harness/bundles/java/codex/contracts/run-session.schema.json +142 -0
  248. package/harness/bundles/java/codex/contracts/service-retirement-receipt.schema.json +85 -0
  249. package/harness/bundles/java/codex/contracts/service-session.schema.json +147 -0
  250. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  251. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  252. package/harness/bundles/java/codex/harness-archive/reference.md +1 -0
  253. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
  254. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
  255. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  256. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  257. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  258. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  259. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  260. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  261. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  262. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  263. package/harness/bundles/java/codex/harness-submit/checklist.md +12 -0
  264. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  265. package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
  266. package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
  267. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
  268. package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
  269. package/harness/bundles/java/codex/scripts/harness_archive.py +271 -22
  270. package/harness/bundles/java/codex/scripts/harness_change.py +137 -2
  271. package/harness/bundles/java/codex/scripts/harness_efficiency.py +443 -0
  272. package/harness/bundles/java/codex/scripts/harness_environment.py +760 -46
  273. package/harness/bundles/java/codex/scripts/harness_execution_contracts.py +596 -0
  274. package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
  275. package/harness/bundles/java/codex/scripts/harness_integration.py +289 -52
  276. package/harness/bundles/java/codex/scripts/harness_ledger.py +5 -0
  277. package/harness/bundles/java/codex/scripts/harness_orchestration.py +344 -0
  278. package/harness/bundles/java/codex/scripts/harness_process.py +1065 -0
  279. package/harness/bundles/java/codex/scripts/harness_profile.py +278 -4
  280. package/harness/bundles/java/codex/scripts/harness_runtime.py +1576 -3
  281. package/harness/bundles/java/codex/scripts/harness_service.py +1528 -177
  282. package/harness/bundles/java/codex/scripts/harness_test_runner.py +224 -146
  283. package/harness/bundles/java/codex/scripts/harness_verification.py +487 -8
  284. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  285. package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +156 -1
  286. package/harness/bundles/java/cursor/contracts/fixtures/managed-execution.json +341 -0
  287. package/harness/bundles/java/cursor/contracts/process-identity.schema.json +132 -0
  288. package/harness/bundles/java/cursor/contracts/run-session.schema.json +142 -0
  289. package/harness/bundles/java/cursor/contracts/service-retirement-receipt.schema.json +85 -0
  290. package/harness/bundles/java/cursor/contracts/service-session.schema.json +147 -0
  291. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  292. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  293. package/harness/bundles/java/cursor/harness-archive/reference.md +1 -0
  294. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
  295. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
  296. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  297. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  298. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  299. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  300. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  301. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  302. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  303. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  304. package/harness/bundles/java/cursor/harness-submit/checklist.md +12 -0
  305. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  306. package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
  307. package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
  308. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
  309. package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
  310. package/harness/bundles/java/cursor/scripts/harness_archive.py +271 -22
  311. package/harness/bundles/java/cursor/scripts/harness_change.py +137 -2
  312. package/harness/bundles/java/cursor/scripts/harness_efficiency.py +443 -0
  313. package/harness/bundles/java/cursor/scripts/harness_environment.py +760 -46
  314. package/harness/bundles/java/cursor/scripts/harness_execution_contracts.py +596 -0
  315. package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
  316. package/harness/bundles/java/cursor/scripts/harness_integration.py +289 -52
  317. package/harness/bundles/java/cursor/scripts/harness_ledger.py +5 -0
  318. package/harness/bundles/java/cursor/scripts/harness_orchestration.py +344 -0
  319. package/harness/bundles/java/cursor/scripts/harness_process.py +1065 -0
  320. package/harness/bundles/java/cursor/scripts/harness_profile.py +278 -4
  321. package/harness/bundles/java/cursor/scripts/harness_runtime.py +1576 -3
  322. package/harness/bundles/java/cursor/scripts/harness_service.py +1528 -177
  323. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +224 -146
  324. package/harness/bundles/java/cursor/scripts/harness_verification.py +487 -8
  325. package/harness/manifests/general/claude-code.json +76 -31
  326. package/harness/manifests/general/codebuddy.json +76 -31
  327. package/harness/manifests/general/codex.json +76 -31
  328. package/harness/manifests/general/cursor.json +76 -31
  329. package/harness/manifests/java/claude-code.json +77 -32
  330. package/harness/manifests/java/codebuddy.json +77 -32
  331. package/harness/manifests/java/codex.json +77 -32
  332. package/harness/manifests/java/cursor.json +77 -32
  333. package/hunter-workflow-family.json +6 -4
  334. 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,16 +14,31 @@ 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}$")
36
+ SECRET_SCAN_RULES_VERSION = "secret-scan-v1"
37
+ SECRET_SCAN_RECEIPT_REL = Path("meta") / "secret-scan-receipt.json"
38
+ _SENSITIVE_ASSIGNMENT = re.compile(
39
+ rb"(?i)[\"']?(?:password|passwd|token|secret|cookie|authorization|database[_-]?url)"
40
+ rb"[\"']?\s*[:=]\s*[\"']?[^\s\"']{4,}"
41
+ )
25
42
  _ADAPTERS = {
26
43
  "claude-code": (".worktrees", "harness/"),
27
44
  "codex": (".worktrees", "harness/"),
@@ -34,6 +51,40 @@ def now_iso() -> str:
34
51
  return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
35
52
 
36
53
 
54
+ def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
55
+ ended_at = now_iso()
56
+ started_at = str(receipt.get("stageStartedAt") or ended_at)
57
+ try:
58
+ started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
59
+ ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
60
+ wall_ms = max(0, int((ended - started).total_seconds() * 1000))
61
+ except ValueError:
62
+ wall_ms = 0
63
+ stage = str(receipt.get("stage") or "unknown")
64
+ timings = receipt.setdefault("stageTimings", [])
65
+ if isinstance(timings, list):
66
+ timings.append(
67
+ {
68
+ "stage": stage,
69
+ "createdAt": started_at,
70
+ "endedAt": ended_at,
71
+ "wallClockMs": wall_ms,
72
+ "activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
73
+ "resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
74
+ }
75
+ )
76
+ receipt["stage"] = next_stage
77
+ receipt["stageStartedAt"] = ended_at
78
+
79
+
80
+ def _close_current_stage(receipt: dict[str, Any]) -> None:
81
+ current = str(receipt.get("stage") or "record")
82
+ _stage_transition(receipt, current)
83
+ timings = receipt.get("stageTimings")
84
+ if isinstance(timings, list) and timings:
85
+ receipt["stageStartedAt"] = timings[-1].get("endedAt")
86
+
87
+
37
88
  def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
38
89
  path.parent.mkdir(parents=True, exist_ok=True)
39
90
  text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -46,11 +97,1472 @@ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
46
97
  handle.write(text)
47
98
  handle.flush()
48
99
  os.fsync(handle.fileno())
49
- os.replace(tmp, path)
100
+ # Windows can transiently deny replacement while another process has
101
+ # just finished reading the receipt. Keep the atomic replace contract,
102
+ # but retry the sharing violation within a small bounded window.
103
+ for attempt in range(20):
104
+ try:
105
+ os.replace(tmp, path)
106
+ break
107
+ except PermissionError:
108
+ if attempt == 19:
109
+ raise
110
+ time.sleep(0.01 * (attempt + 1))
50
111
  finally:
51
112
  tmp.unlink(missing_ok=True)
52
113
 
53
114
 
115
+ def _sha256_json(value: Any) -> str:
116
+ encoded = json.dumps(
117
+ value,
118
+ ensure_ascii=False,
119
+ sort_keys=True,
120
+ separators=(",", ":"),
121
+ ).encode("utf-8")
122
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()
123
+
124
+
125
+ def _publishable_tree_digest(root: Path) -> str:
126
+ """Hash publishable bytes while excluding the self-referential scan receipt."""
127
+ root = root.expanduser().resolve()
128
+ digest = hashlib.sha256()
129
+ receipt = (root / SECRET_SCAN_RECEIPT_REL).resolve()
130
+ if not root.is_dir():
131
+ raise OSError(f"publishable evidence root not found: {root}")
132
+ for path in sorted(root.rglob("*")):
133
+ if not path.is_file() or path.resolve() == receipt:
134
+ continue
135
+ relative = path.relative_to(root).as_posix()
136
+ data = path.read_bytes()
137
+ digest.update(relative.encode("utf-8"))
138
+ digest.update(b"\0")
139
+ digest.update(str(len(data)).encode("ascii"))
140
+ digest.update(b"\0")
141
+ digest.update(hashlib.sha256(data).digest())
142
+ digest.update(b"\0")
143
+ return "sha256:" + digest.hexdigest()
144
+
145
+
146
+ def _sensitive_evidence_receipt_path(change_root: Path) -> Path:
147
+ return change_root.expanduser().resolve() / SECRET_SCAN_RECEIPT_REL
148
+
149
+
150
+ def _private_evidence_root(explicit: Path | None = None) -> Path:
151
+ configured = os.environ.get("HARNESS_PRIVATE_EVIDENCE_ROOT")
152
+ return (explicit if explicit is not None else Path(configured) if configured else
153
+ Path.home() / ".harness" / "private-evidence").expanduser().resolve()
154
+
155
+
156
+ def _secure_private_path(path: Path, *, directory: bool) -> str:
157
+ """Best-effort owner-only permissions, with an explicit ACL result on Windows."""
158
+ mode = 0o700 if directory else 0o600
159
+ try:
160
+ path.chmod(mode)
161
+ except OSError:
162
+ pass
163
+ if os.name != "nt":
164
+ return "POSIX_OWNER_ONLY"
165
+ try:
166
+ account = os.environ.get("USERNAME") or os.environ.get("USER")
167
+ if not account:
168
+ account = subprocess.check_output(
169
+ ["whoami"], stderr=subprocess.DEVNULL, text=True, encoding="utf-8"
170
+ ).strip()
171
+ result = subprocess.run(
172
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{account}:F"],
173
+ capture_output=True,
174
+ text=True,
175
+ encoding="utf-8",
176
+ errors="replace",
177
+ check=False,
178
+ timeout=10,
179
+ )
180
+ return "WINDOWS_CURRENT_USER_ONLY" if result.returncode == 0 else "WINDOWS_ACL_UNVERIFIED"
181
+ except (OSError, subprocess.SubprocessError):
182
+ return "WINDOWS_ACL_UNVERIFIED"
183
+
184
+
185
+ def _sensitive_candidates(root: Path) -> list[dict[str, Any]]:
186
+ """Find high-confidence plaintext assignments without treating prose as a secret."""
187
+ receipt = _sensitive_evidence_receipt_path(root)
188
+ candidates: list[dict[str, Any]] = []
189
+ for path in sorted(root.rglob("*")):
190
+ if not path.is_file() or path.resolve() == receipt.resolve():
191
+ continue
192
+ try:
193
+ raw = path.read_bytes()
194
+ except OSError:
195
+ continue
196
+ match = _SENSITIVE_ASSIGNMENT.search(raw)
197
+ if match is None:
198
+ continue
199
+ candidates.append({
200
+ "path": path.relative_to(root).as_posix(),
201
+ "reasonCode": "PLAINTEXT_SENSITIVE_ASSIGNMENT",
202
+ "digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
203
+ })
204
+ return candidates
205
+
206
+
207
+ def publishable_tree_digest(root: Path) -> str:
208
+ """Public digest helper shared by the archive publication gate."""
209
+ return _publishable_tree_digest(root)
210
+
211
+
212
+ def sensitive_evidence_receipt_path(change_root: Path) -> Path:
213
+ return _sensitive_evidence_receipt_path(change_root)
214
+
215
+
216
+ def sensitive_evidence_candidates(root: Path) -> list[dict[str, Any]]:
217
+ return _sensitive_candidates(root)
218
+
219
+
220
+ def _write_sensitive_scan_receipt(
221
+ change_root: Path,
222
+ *,
223
+ entries: list[dict[str, Any]],
224
+ unresolved: list[dict[str, Any]],
225
+ status: str,
226
+ ) -> dict[str, Any]:
227
+ change_root = change_root.expanduser().resolve()
228
+ receipt = {
229
+ "schemaVersion": 1,
230
+ "rulesVersion": SECRET_SCAN_RULES_VERSION,
231
+ "changeId": change_root.name,
232
+ "status": status,
233
+ "unresolvedFailures": unresolved,
234
+ "entries": entries,
235
+ "publishableTreeDigest": _publishable_tree_digest(change_root),
236
+ "publicationExcluded": True,
237
+ "recordedAt": now_iso(),
238
+ }
239
+ atomic_write_json(_sensitive_evidence_receipt_path(change_root), receipt)
240
+ return receipt
241
+
242
+
243
+ def quarantine_sensitive_evidence(
244
+ source: Path,
245
+ *,
246
+ change_root: Path,
247
+ reason: str = "sensitive evidence",
248
+ private_root: Path | None = None,
249
+ ) -> dict[str, Any]:
250
+ """Atomically move legacy plaintext evidence outside any publishable tree."""
251
+ source = source.expanduser().resolve()
252
+ change_root = change_root.expanduser().resolve()
253
+ private = _private_evidence_root(private_root)
254
+ if not source.is_file():
255
+ return {"ok": False, "reasonCode": "SENSITIVE_EVIDENCE_SOURCE_MISSING", "sourcePath": str(source)}
256
+ if private == change_root or private.is_relative_to(change_root):
257
+ return {
258
+ "ok": False,
259
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
260
+ "error": "private quarantine root must be outside the publishable change root",
261
+ }
262
+ try:
263
+ raw_digest = "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest()
264
+ quarantine_dir = private / uuid.uuid4().hex
265
+ quarantine_dir.mkdir(parents=True, exist_ok=False)
266
+ acl_dir = _secure_private_path(quarantine_dir, directory=True)
267
+ target = quarantine_dir / "payload.bin"
268
+ os.replace(source, target)
269
+ acl_file = _secure_private_path(target, directory=False)
270
+ if os.name == "nt" and (
271
+ acl_dir != "WINDOWS_CURRENT_USER_ONLY"
272
+ or acl_file != "WINDOWS_CURRENT_USER_ONLY"
273
+ ):
274
+ os.replace(target, source)
275
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
276
+ return {
277
+ "ok": False,
278
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
279
+ "error": "private quarantine ACL could not be restricted to the current user",
280
+ }
281
+ moved_digest = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
282
+ if moved_digest != raw_digest:
283
+ os.replace(target, source)
284
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
285
+ return {
286
+ "ok": False,
287
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
288
+ "error": "quarantine digest verification failed",
289
+ }
290
+ try:
291
+ prior = json.loads(
292
+ _sensitive_evidence_receipt_path(change_root).read_text(
293
+ encoding="utf-8-sig"
294
+ )
295
+ )
296
+ except (OSError, json.JSONDecodeError):
297
+ prior = {}
298
+ entries = list(prior.get("entries") or []) if isinstance(prior, dict) else []
299
+ entries.append({
300
+ "sourcePath": (
301
+ source.relative_to(change_root).as_posix()
302
+ if source.is_relative_to(change_root)
303
+ else source.name
304
+ ),
305
+ "sourceDigest": raw_digest,
306
+ "privatePath": str(target),
307
+ "reason": reason[:160],
308
+ "status": "QUARANTINED",
309
+ "acl": {"directory": acl_dir, "file": acl_file},
310
+ "movedAt": now_iso(),
311
+ })
312
+ try:
313
+ receipt = _write_sensitive_scan_receipt(
314
+ change_root,
315
+ entries=entries,
316
+ unresolved=[],
317
+ status="QUARANTINED",
318
+ )
319
+ except BaseException as exc:
320
+ try:
321
+ os.replace(target, source)
322
+ except OSError:
323
+ pass
324
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
325
+ return {
326
+ "ok": False,
327
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
328
+ "error": str(exc),
329
+ }
330
+ return {
331
+ "ok": True,
332
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINED",
333
+ "sourceDigest": raw_digest,
334
+ "privatePath": str(target),
335
+ "receiptPath": str(_sensitive_evidence_receipt_path(change_root)),
336
+ "receipt": receipt,
337
+ }
338
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
339
+ # No copy is retained on failure. If os.replace crossed filesystems it
340
+ # fails before removing the source, which is the required fail-closed path.
341
+ return {
342
+ "ok": False,
343
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
344
+ "error": str(exc),
345
+ }
346
+
347
+
348
+ def ensure_sensitive_evidence_scan_receipt(change_root: Path) -> dict[str, Any]:
349
+ """Create a digest-bound scan receipt for a tree with no plaintext findings."""
350
+ change_root = change_root.expanduser().resolve()
351
+ unresolved = _sensitive_candidates(change_root)
352
+ if unresolved:
353
+ return _write_sensitive_scan_receipt(
354
+ change_root,
355
+ entries=[],
356
+ unresolved=unresolved,
357
+ status="FAIL",
358
+ )
359
+ return _write_sensitive_scan_receipt(
360
+ change_root,
361
+ entries=[],
362
+ unresolved=[],
363
+ status="OK",
364
+ )
365
+
366
+
367
+ def _run_sessions_root(state_root: Path) -> Path:
368
+ return state_root.expanduser().resolve() / "runtime" / "run-sessions"
369
+
370
+
371
+ def _run_session_root(state_root: Path, session_id: str) -> Path:
372
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
373
+ raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
374
+ return _run_sessions_root(state_root) / session_id
375
+
376
+
377
+ def _run_receipt_path(state_root: Path, session_id: str) -> Path:
378
+ return _run_session_root(state_root, session_id) / "session.json"
379
+
380
+
381
+ def _resource_lock_secrets_path(state_root: Path, session_id: str) -> Path:
382
+ return _run_session_root(state_root, session_id) / "resource-lock-secrets.json"
383
+
384
+
385
+ def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
386
+ path = _run_receipt_path(state_root, session_id)
387
+ last_error: OSError | None = None
388
+ for attempt in range(20):
389
+ try:
390
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
391
+ break
392
+ except FileNotFoundError as exc:
393
+ raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
394
+ except PermissionError as exc:
395
+ last_error = exc
396
+ if attempt == 19:
397
+ raise ValueError(
398
+ f"RUN_SESSION_RECEIPT_BUSY: {session_id}"
399
+ ) from exc
400
+ time.sleep(0.01 * (attempt + 1))
401
+ else:
402
+ raise ValueError(f"RUN_SESSION_RECEIPT_BUSY: {session_id}") from last_error
403
+ if not isinstance(value, dict):
404
+ raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
405
+ return value
406
+
407
+
408
+ def _load_resource_lock_tokens(
409
+ state_root: Path,
410
+ session_id: str,
411
+ receipt: dict[str, Any],
412
+ ) -> dict[str, str]:
413
+ """Load lock tokens from a private sidecar; legacy receipts may inline them."""
414
+ path = _resource_lock_secrets_path(state_root, session_id)
415
+ try:
416
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
417
+ if isinstance(value, dict) and isinstance(value.get("tokens"), dict):
418
+ return {
419
+ str(name): str(token)
420
+ for name, token in value["tokens"].items()
421
+ if isinstance(name, str) and isinstance(token, str)
422
+ }
423
+ except (OSError, json.JSONDecodeError, TypeError):
424
+ pass
425
+ legacy = receipt.get("resourceLockTokens")
426
+ if isinstance(legacy, dict):
427
+ return {
428
+ str(name): str(token)
429
+ for name, token in legacy.items()
430
+ if isinstance(name, str) and isinstance(token, str)
431
+ }
432
+ return {}
433
+
434
+
435
+ def _receipt_identity_state(
436
+ receipt: dict[str, Any],
437
+ *,
438
+ field: str = "workerIdentity",
439
+ ) -> bool | None:
440
+ """Verify a canonical provider attestation, with legacy facade fallback."""
441
+ identity = receipt.get(field)
442
+ pid = identity.get("pid") if isinstance(identity, dict) else receipt.get("workerPid")
443
+ if isinstance(identity, dict) and {
444
+ "schemaVersion",
445
+ "createdAt",
446
+ "fieldProvenance",
447
+ "capabilities",
448
+ } <= set(identity):
449
+ from harness_process import observe_process_identity, verify_process_identity
450
+
451
+ observed = observe_process_identity(pid)
452
+ decision = verify_process_identity(identity, observed)
453
+ if decision.get("ok") is True:
454
+ return True
455
+ if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
456
+ return None
457
+ return False
458
+ from harness_service import verify_process_identity
459
+
460
+ legacy = {
461
+ "pid": pid,
462
+ "startedAt": (
463
+ identity.get("startedAt")
464
+ if isinstance(identity, dict)
465
+ else None
466
+ ),
467
+ "processIdentity": {
468
+ "executable": (
469
+ identity.get("executable")
470
+ if isinstance(identity, dict)
471
+ else None
472
+ )
473
+ },
474
+ }
475
+ return verify_process_identity(legacy)
476
+
477
+
478
+ def _capture_provider_members(spawned: Any) -> None:
479
+ """Persist current provider-owned members before a later cleanup."""
480
+ proof = getattr(spawned, "ownershipProof", None)
481
+ leader = getattr(spawned, "attestation", None)
482
+ if not isinstance(proof, dict) or not isinstance(leader, dict):
483
+ return
484
+ from harness_process import capture_owned_members
485
+
486
+ capture_owned_members(spawned)
487
+
488
+
489
+ def _resource_lock_root() -> Path:
490
+ return Path(
491
+ os.environ.get(
492
+ "HARNESS_RESOURCE_LOCK_ROOT",
493
+ str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
494
+ )
495
+ ).expanduser().resolve()
496
+
497
+
498
+ def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
499
+ digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
500
+ return _resource_lock_root() / f"{digest}.json"
501
+
502
+
503
+ def _lock_owner_identity(owner: Any) -> bool | None:
504
+ if not isinstance(owner, dict):
505
+ return None
506
+ pid = owner.get("pid")
507
+ started_at = owner.get("startedAt")
508
+ executable = owner.get("executable")
509
+ if (
510
+ not isinstance(pid, int)
511
+ or pid <= 0
512
+ or not isinstance(started_at, str)
513
+ or not started_at
514
+ or not isinstance(executable, str)
515
+ or not executable
516
+ ):
517
+ return None
518
+ from harness_service import verify_process_identity
519
+
520
+ return verify_process_identity(
521
+ {
522
+ "pid": pid,
523
+ "startedAt": started_at,
524
+ "processIdentity": {"executable": executable},
525
+ }
526
+ )
527
+
528
+
529
+ def _valid_resource_lock_record(
530
+ record: Any,
531
+ *,
532
+ lock_name: str,
533
+ ) -> bool:
534
+ return bool(
535
+ isinstance(record, dict)
536
+ and record.get("schemaVersion") == 2
537
+ and record.get("lockName") == lock_name
538
+ and isinstance(record.get("sessionId"), str)
539
+ and record.get("sessionId")
540
+ and isinstance(record.get("token"), str)
541
+ and record.get("token")
542
+ and isinstance(record.get("heartbeatAtUnix"), (int, float))
543
+ and isinstance(record.get("expiresAtUnix"), (int, float))
544
+ and _lock_owner_identity(record.get("owner")) is not None
545
+ )
546
+
547
+
548
+ def _write_resource_lock(
549
+ path: Path,
550
+ *,
551
+ lock_name: str,
552
+ session_id: str,
553
+ token: str,
554
+ owner: dict[str, Any],
555
+ acquired_at: str,
556
+ ) -> None:
557
+ now = time.time()
558
+ atomic_write_json(
559
+ path,
560
+ {
561
+ "schemaVersion": 2,
562
+ "lockName": lock_name,
563
+ "sessionId": session_id,
564
+ "token": token,
565
+ "acquiredAt": acquired_at,
566
+ "heartbeatAtUnix": now,
567
+ "expiresAtUnix": now + 300.0,
568
+ "owner": owner,
569
+ },
570
+ )
571
+
572
+
573
+ def _refresh_resource_locks(
574
+ state_root: Path,
575
+ session_id: str,
576
+ tokens: dict[str, str],
577
+ owner: dict[str, Any],
578
+ ) -> bool:
579
+ refreshed = True
580
+ for lock_name, token in sorted(tokens.items()):
581
+ path = _resource_lock_path(state_root, lock_name)
582
+ try:
583
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
584
+ except (OSError, json.JSONDecodeError):
585
+ refreshed = False
586
+ continue
587
+ if (
588
+ not _valid_resource_lock_record(record, lock_name=lock_name)
589
+ or record.get("sessionId") != session_id
590
+ or record.get("token") != token
591
+ ):
592
+ refreshed = False
593
+ continue
594
+ _write_resource_lock(
595
+ path,
596
+ lock_name=lock_name,
597
+ session_id=session_id,
598
+ token=token,
599
+ owner=owner,
600
+ acquired_at=str(record["acquiredAt"]),
601
+ )
602
+ return refreshed
603
+
604
+
605
+ def _release_resource_locks(
606
+ state_root: Path,
607
+ session_id: str,
608
+ tokens: dict[str, str],
609
+ ) -> bool:
610
+ released = True
611
+ for lock_name, token in sorted(tokens.items()):
612
+ path = _resource_lock_path(state_root, lock_name)
613
+ try:
614
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
615
+ except FileNotFoundError:
616
+ continue
617
+ except (OSError, json.JSONDecodeError):
618
+ released = False
619
+ continue
620
+ if (
621
+ not isinstance(record, dict)
622
+ or record.get("sessionId") != session_id
623
+ or record.get("token") != token
624
+ ):
625
+ released = False
626
+ continue
627
+ try:
628
+ path.unlink()
629
+ except OSError:
630
+ released = False
631
+ return released
632
+
633
+
634
+ def _acquire_resource_locks(
635
+ state_root: Path,
636
+ session_id: str,
637
+ lock_names: list[str],
638
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
639
+ tokens: dict[str, str] = {}
640
+ for lock_name in lock_names:
641
+ path = _resource_lock_path(state_root, lock_name)
642
+ path.parent.mkdir(parents=True, exist_ok=True)
643
+ token = uuid.uuid4().hex
644
+ owner_identity = _process_identity(os.getpid(), sys.executable)
645
+ now = time.time()
646
+ record = {
647
+ "schemaVersion": 2,
648
+ "lockName": lock_name,
649
+ "sessionId": session_id,
650
+ "token": token,
651
+ "acquiredAt": now_iso(),
652
+ "heartbeatAtUnix": now,
653
+ "expiresAtUnix": now + 300.0,
654
+ "owner": owner_identity,
655
+ }
656
+ encoded = (
657
+ json.dumps(record, ensure_ascii=False, indent=2) + "\n"
658
+ ).encode("utf-8")
659
+ for attempt in range(2):
660
+ try:
661
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
662
+ except FileExistsError:
663
+ owner: dict[str, Any] | None = None
664
+ try:
665
+ loaded = json.loads(path.read_text(encoding="utf-8-sig"))
666
+ owner = loaded if isinstance(loaded, dict) else None
667
+ except (OSError, json.JSONDecodeError):
668
+ owner = None
669
+ reclaimable = bool(
670
+ _valid_resource_lock_record(owner, lock_name=lock_name)
671
+ and _lock_owner_identity(owner.get("owner")) is False
672
+ )
673
+ if reclaimable and attempt == 0:
674
+ try:
675
+ current = json.loads(path.read_text(encoding="utf-8-sig"))
676
+ if current.get("token") == owner.get("token"):
677
+ path.unlink()
678
+ continue
679
+ except (OSError, json.JSONDecodeError):
680
+ pass
681
+ _release_resource_locks(state_root, session_id, tokens)
682
+ return {}, {
683
+ "lockName": lock_name,
684
+ "ownerSessionId": (
685
+ owner.get("sessionId") if owner is not None else None
686
+ ),
687
+ "lockRecordValid": bool(
688
+ _valid_resource_lock_record(owner, lock_name=lock_name)
689
+ ),
690
+ "ownerIdentity": (
691
+ _lock_owner_identity(owner.get("owner"))
692
+ if owner is not None
693
+ else None
694
+ ),
695
+ }
696
+ else:
697
+ break
698
+ else:
699
+ raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
700
+ try:
701
+ with os.fdopen(fd, "wb") as handle:
702
+ handle.write(encoded)
703
+ handle.flush()
704
+ os.fsync(handle.fileno())
705
+ except BaseException:
706
+ path.unlink(missing_ok=True)
707
+ _release_resource_locks(state_root, session_id, tokens)
708
+ raise
709
+ tokens[lock_name] = token
710
+ return tokens, None
711
+
712
+
713
+ def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
714
+ _update_receipt_contract(receipt)
715
+ publishable = json.loads(json.dumps(receipt, ensure_ascii=False))
716
+ raw_tokens = publishable.pop("resourceLockTokens", {})
717
+ if isinstance(raw_tokens, dict):
718
+ publishable["resourceLockTokenHashes"] = {
719
+ str(name): _sha256_json(str(token))
720
+ for name, token in raw_tokens.items()
721
+ if isinstance(name, str) and isinstance(token, str)
722
+ }
723
+ atomic_write_json(
724
+ _run_receipt_path(state_root, str(receipt["sessionId"])),
725
+ publishable,
726
+ )
727
+
728
+
729
+ def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
730
+ from harness_process import observe_process_identity
731
+
732
+ observed = observe_process_identity(pid)
733
+ if not observed.get("executable"):
734
+ observed["executable"] = _absolute_executable(executable_hint)
735
+ observed["fieldProvenance"]["executable"] = "ATTESTED"
736
+ if not observed.get("createdAt"):
737
+ observed["createdAt"] = now_iso()
738
+ observed["fieldProvenance"]["createdAt"] = "ATTESTED"
739
+ # ``startedAt`` is a compatibility alias used by legacy session readers;
740
+ # canonical identity comparisons use ``createdAt`` and provenance.
741
+ observed["startedAt"] = observed.get("createdAt")
742
+ return observed
743
+
744
+
745
+ def _log_contract(path: Path) -> dict[str, Any]:
746
+ raw = b""
747
+ if path.is_file():
748
+ try:
749
+ raw = path.read_bytes()
750
+ except OSError:
751
+ raw = b""
752
+ try:
753
+ raw.decode("utf-8")
754
+ decode_status = "OK"
755
+ except UnicodeDecodeError:
756
+ decode_status = "LOG_DECODE_DEGRADED"
757
+ return {
758
+ "cursor": len(raw),
759
+ "rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
760
+ "decodeStatus": decode_status,
761
+ }
762
+
763
+
764
+ def _update_receipt_contract(receipt: dict[str, Any]) -> None:
765
+ worker = receipt.get("workerIdentity")
766
+ writer_source = worker if isinstance(worker, dict) else {
767
+ "pid": receipt.get("workerPid") or receipt.get("launcherPid")
768
+ }
769
+ heartbeat_kind = "WORKER" if isinstance(worker, dict) else "SUPERVISOR"
770
+ receipt["heartbeat"] = {
771
+ "kind": heartbeat_kind,
772
+ "writerIdentity": _sha256_json(writer_source),
773
+ "lastSeenAt": str(receipt.get("lastHeartbeatAt") or now_iso()),
774
+ "ttlSeconds": max(1, int(float(receipt.get("heartbeatSeconds") or 30))),
775
+ "staleReason": (
776
+ "SERVICE_HEARTBEAT_STALE"
777
+ if receipt.get("reasonCode") == "HEARTBEAT_LOST"
778
+ else None
779
+ ),
780
+ }
781
+ receipt["logs"] = {
782
+ "stdout": _log_contract(Path(str(receipt.get("stdoutPath") or ""))),
783
+ "stderr": _log_contract(Path(str(receipt.get("stderrPath") or ""))),
784
+ }
785
+ cleanup_status = str(receipt.get("cleanupStatus") or "")
786
+ complete = cleanup_status in {
787
+ "NO_CHILD",
788
+ "PROCESS_EXITED",
789
+ "PROCESS_TREE_TERMINATED",
790
+ }
791
+ receipt["cleanup"] = {
792
+ "complete": complete,
793
+ "reasonCode": None if complete else receipt.get("reasonCode"),
794
+ }
795
+
796
+
797
+ def _spawn_detached_worker(
798
+ argv: list[str],
799
+ *,
800
+ cwd: Path,
801
+ env: dict[str, str],
802
+ output: Any = subprocess.DEVNULL,
803
+ ) -> subprocess.Popen[bytes]:
804
+ kwargs: dict[str, Any] = {
805
+ "cwd": str(cwd),
806
+ "env": env,
807
+ "stdin": subprocess.DEVNULL,
808
+ "stdout": output,
809
+ "stderr": output,
810
+ "close_fds": True,
811
+ }
812
+ if os.name == "nt":
813
+ detached = 0x00000008
814
+ new_group = 0x00000200
815
+ no_window = 0x08000000
816
+ breakaway = 0x01000000
817
+ try:
818
+ return subprocess.Popen(
819
+ argv,
820
+ creationflags=detached | new_group | no_window | breakaway,
821
+ **kwargs,
822
+ )
823
+ except OSError:
824
+ return subprocess.Popen(
825
+ argv,
826
+ creationflags=detached | new_group | no_window,
827
+ **kwargs,
828
+ )
829
+ return subprocess.Popen(argv, start_new_session=True, **kwargs)
830
+
831
+
832
+ def start_run_session(
833
+ *,
834
+ state_root: Path,
835
+ verification: str,
836
+ argv: list[str],
837
+ working_directory: Path,
838
+ environment: dict[str, str] | None = None,
839
+ timeout_seconds: float | None = None,
840
+ heartbeat_seconds: float = 30.0,
841
+ expected_duration_seconds: float | None = None,
842
+ product_identity: str | None = None,
843
+ resource_locks: list[str] | None = None,
844
+ run_id: str | None = None,
845
+ session_id: str | None = None,
846
+ ) -> dict[str, Any]:
847
+ """Start a re-attachable managed command without shell re-parsing.
848
+
849
+ The short-lived launch specification is removed by the worker immediately
850
+ after spawn. Environment values are inherited by the worker and hashed in
851
+ the durable receipt; they are never serialized.
852
+ """
853
+ if not verification.strip():
854
+ raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
855
+ if not argv or not all(isinstance(item, str) and item for item in argv):
856
+ raise ValueError("RUN_SESSION_ARGV_REQUIRED")
857
+ if heartbeat_seconds <= 0:
858
+ raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
859
+ if timeout_seconds is not None and timeout_seconds <= 0:
860
+ raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
861
+ normalized_resource_locks: list[str] = []
862
+ for raw_lock in resource_locks or []:
863
+ if (
864
+ not isinstance(raw_lock, str)
865
+ or not raw_lock.strip()
866
+ or len(raw_lock.strip()) > 256
867
+ ):
868
+ raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
869
+ normalized_resource_locks.append(raw_lock.strip())
870
+ normalized_resource_locks = sorted(set(normalized_resource_locks))
871
+
872
+ state_root = state_root.expanduser().resolve()
873
+ working_directory = working_directory.expanduser().resolve()
874
+ if not working_directory.is_dir():
875
+ raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
876
+ session_id = session_id or f"run-{uuid.uuid4().hex}"
877
+ run_id = run_id or session_id
878
+ session_root = _run_session_root(state_root, session_id)
879
+ session_root.mkdir(parents=True, exist_ok=False)
880
+ created_at = now_iso()
881
+ stdout_path = session_root / "stdout.log"
882
+ stderr_path = session_root / "stderr.log"
883
+ worker_log_path = session_root / "worker.log"
884
+ spec_path = session_root / "launch-spec.json"
885
+ overrides = dict(environment or {})
886
+ command_hash = _sha256_json(argv)
887
+ environment_hash = _sha256_json(overrides)
888
+ receipt: dict[str, Any] = {
889
+ "schemaVersion": RUN_SESSION_SCHEMA_VERSION,
890
+ "sessionId": session_id,
891
+ "runId": run_id,
892
+ "verification": verification,
893
+ "commandHash": command_hash,
894
+ "workingDirectory": str(working_directory),
895
+ "environmentHash": environment_hash,
896
+ "launcherPid": os.getpid(),
897
+ "workerPid": None,
898
+ "servicePid": None,
899
+ "processIdentity": None,
900
+ "createdAt": created_at,
901
+ "lastHeartbeatAt": created_at,
902
+ "startedAt": None,
903
+ "endedAt": None,
904
+ "exitCode": None,
905
+ "status": "STARTING",
906
+ "reasonCode": "WORKER_STARTING",
907
+ "stage": "prepare",
908
+ "stageStartedAt": created_at,
909
+ "stageTimings": [],
910
+ "stdoutPath": str(stdout_path),
911
+ "stderrPath": str(stderr_path),
912
+ "workerLogPath": str(worker_log_path),
913
+ "resultDigest": None,
914
+ "cleanupStatus": "PENDING",
915
+ "testProcessStarted": False,
916
+ "timeoutSeconds": timeout_seconds,
917
+ "heartbeatSeconds": heartbeat_seconds,
918
+ "expectedDurationSeconds": expected_duration_seconds,
919
+ "productIdentity": product_identity,
920
+ "resourceLocks": normalized_resource_locks,
921
+ "resourceLockTokens": {},
922
+ "resourceWaitMs": 0,
923
+ "activeTimeMs": 0,
924
+ "wallClockMs": 0,
925
+ "diagnosticPath": None,
926
+ }
927
+ _write_run_receipt(state_root, receipt)
928
+ lock_tokens, lock_conflict = _acquire_resource_locks(
929
+ state_root,
930
+ session_id,
931
+ normalized_resource_locks,
932
+ )
933
+ if lock_conflict is not None:
934
+ receipt.update(
935
+ {
936
+ "status": "INCOMPLETE",
937
+ "reasonCode": "RESOURCE_LOCK_BUSY",
938
+ "endedAt": now_iso(),
939
+ "cleanupStatus": "NO_CHILD",
940
+ "resourceLockConflict": lock_conflict,
941
+ }
942
+ )
943
+ _write_run_receipt(state_root, receipt)
944
+ return receipt
945
+ receipt["resourceLockTokens"] = lock_tokens
946
+ atomic_write_json(
947
+ _resource_lock_secrets_path(state_root, session_id),
948
+ {"schemaVersion": 1, "tokens": lock_tokens},
949
+ )
950
+ try:
951
+ _resource_lock_secrets_path(state_root, session_id).chmod(0o600)
952
+ except OSError:
953
+ pass
954
+ _write_run_receipt(state_root, receipt)
955
+ atomic_write_json(
956
+ spec_path,
957
+ {
958
+ "schemaVersion": 1,
959
+ "stateRoot": str(state_root),
960
+ "sessionId": session_id,
961
+ "argv": argv,
962
+ "heartbeatSeconds": heartbeat_seconds,
963
+ },
964
+ )
965
+ try:
966
+ spec_path.chmod(0o600)
967
+ except OSError:
968
+ pass
969
+
970
+ worker_env = {**os.environ, **overrides}
971
+ worker_env["PYTHONUTF8"] = "1"
972
+ worker_env["PYTHONIOENCODING"] = "utf-8"
973
+ worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
974
+ worker_argv = [
975
+ _absolute_executable(sys.executable),
976
+ str(Path(__file__).resolve()),
977
+ "_worker",
978
+ "--state-root",
979
+ str(state_root),
980
+ "--session-id",
981
+ session_id,
982
+ ]
983
+ try:
984
+ worker_log_path.touch()
985
+ worker = _spawn_detached_worker(
986
+ worker_argv,
987
+ cwd=working_directory,
988
+ env=worker_env,
989
+ )
990
+ except OSError as exc:
991
+ spec_path.unlink(missing_ok=True)
992
+ _resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
993
+ locks_released = _release_resource_locks(
994
+ state_root,
995
+ session_id,
996
+ lock_tokens,
997
+ )
998
+ receipt.update(
999
+ {
1000
+ "status": "INCOMPLETE",
1001
+ "reasonCode": "WORKER_LAUNCH_FAILED",
1002
+ "endedAt": now_iso(),
1003
+ "cleanupStatus": (
1004
+ "NO_CHILD"
1005
+ if locks_released
1006
+ else "RESOURCE_LOCK_RELEASE_FAILED"
1007
+ ),
1008
+ "error": str(exc),
1009
+ }
1010
+ )
1011
+ _write_run_receipt(state_root, receipt)
1012
+ return receipt
1013
+ receipt["workerPid"] = worker.pid
1014
+ receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
1015
+ # Best-effort early handoff. The worker repeats and verifies this transfer
1016
+ # before spawning the managed command. Do not rewrite the session receipt
1017
+ # here: the detached worker may already have advanced it to RUNNING.
1018
+ _refresh_resource_locks(
1019
+ state_root,
1020
+ session_id,
1021
+ lock_tokens,
1022
+ dict(receipt["workerIdentity"]),
1023
+ )
1024
+ threading.Thread(target=worker.wait, daemon=True).start()
1025
+ return receipt
1026
+
1027
+
1028
+ def _terminate_managed_child(
1029
+ process: subprocess.Popen[bytes],
1030
+ job: Any,
1031
+ *,
1032
+ attestation: dict[str, Any] | None = None,
1033
+ ownership_proof: dict[str, Any] | None = None,
1034
+ ) -> bool:
1035
+ from harness_process import terminate_owned_tree
1036
+
1037
+ if isinstance(attestation, dict) and isinstance(ownership_proof, dict):
1038
+ result = terminate_owned_tree(
1039
+ attestation,
1040
+ ownership_proof,
1041
+ {"graceSeconds": 3.0},
1042
+ )
1043
+ if not result.get("cleanupComplete"):
1044
+ return False
1045
+ elif job is not None and getattr(job, "handle", None) is not None:
1046
+ # Compatibility for callers still passing the old Windows job object;
1047
+ # this branch is only used for an already-owned handle, never a PID.
1048
+ job.terminate_and_wait()
1049
+ else:
1050
+ # PID-only cleanup is intentionally forbidden by the process provider.
1051
+ return False
1052
+ try:
1053
+ process.wait(timeout=5)
1054
+ except subprocess.TimeoutExpired:
1055
+ return False
1056
+ return process.poll() is not None
1057
+
1058
+
1059
+ def _run_session_worker(state_root: Path, session_id: str) -> int:
1060
+ from harness_service import verify_process_identity
1061
+
1062
+ session_root = _run_session_root(state_root, session_id)
1063
+ spec_path = session_root / "launch-spec.json"
1064
+ receipt = _load_run_receipt(state_root, session_id)
1065
+ receipt["resourceLockTokens"] = _load_resource_lock_tokens(
1066
+ state_root,
1067
+ session_id,
1068
+ receipt,
1069
+ )
1070
+ try:
1071
+ spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
1072
+ except (OSError, json.JSONDecodeError) as exc:
1073
+ _release_resource_locks(
1074
+ state_root,
1075
+ session_id,
1076
+ dict(receipt.get("resourceLockTokens") or {}),
1077
+ )
1078
+ receipt.update(
1079
+ {
1080
+ "status": "INCOMPLETE",
1081
+ "reasonCode": "LAUNCH_SPEC_INVALID",
1082
+ "endedAt": now_iso(),
1083
+ "cleanupStatus": "NO_CHILD",
1084
+ "error": str(exc),
1085
+ }
1086
+ )
1087
+ _write_run_receipt(state_root, receipt)
1088
+ return 1
1089
+ argv = [str(item) for item in spec.get("argv", [])]
1090
+ heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
1091
+ spec_path.unlink(missing_ok=True)
1092
+ started_monotonic = time.monotonic()
1093
+ _stage_transition(receipt, "spawn")
1094
+ receipt.update(
1095
+ {
1096
+ "workerPid": os.getpid(),
1097
+ "workerIdentity": _process_identity(os.getpid(), sys.executable),
1098
+ "lastHeartbeatAt": now_iso(),
1099
+ }
1100
+ )
1101
+ if not _refresh_resource_locks(
1102
+ state_root,
1103
+ session_id,
1104
+ dict(receipt.get("resourceLockTokens") or {}),
1105
+ dict(receipt["workerIdentity"]),
1106
+ ):
1107
+ receipt.update(
1108
+ {
1109
+ "status": "INCOMPLETE",
1110
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
1111
+ "endedAt": now_iso(),
1112
+ "cleanupStatus": "NO_CHILD",
1113
+ }
1114
+ )
1115
+ _write_run_receipt(state_root, receipt)
1116
+ return 1
1117
+ _write_run_receipt(state_root, receipt)
1118
+
1119
+ stdout_path = Path(str(receipt["stdoutPath"]))
1120
+ stderr_path = Path(str(receipt["stderrPath"]))
1121
+ stdout_path.parent.mkdir(parents=True, exist_ok=True)
1122
+ job = None
1123
+ spawned = None
1124
+ ownership_proof: dict[str, Any] | None = None
1125
+ identity: dict[str, Any] | None = None
1126
+ process: subprocess.Popen[bytes] | None = None
1127
+ try:
1128
+ with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
1129
+ "wb", buffering=0
1130
+ ) as stderr:
1131
+ try:
1132
+ from harness_process import spawn_structured_argv
1133
+
1134
+ spawned = spawn_structured_argv(
1135
+ argv,
1136
+ cwd=Path(str(receipt["workingDirectory"])),
1137
+ environment={
1138
+ "PYTHONUTF8": "1",
1139
+ "PYTHONIOENCODING": "utf-8",
1140
+ },
1141
+ owner_token=f"run:{session_id}",
1142
+ stdout=stdout,
1143
+ stderr=stderr,
1144
+ )
1145
+ process = spawned.process
1146
+ ownership_proof = spawned.ownershipProof
1147
+ _capture_provider_members(spawned)
1148
+ except (OSError, ValueError, RuntimeError) as exc:
1149
+ receipt.update(
1150
+ {
1151
+ "status": "INCOMPLETE",
1152
+ "reasonCode": "LAUNCHER_FAILED",
1153
+ "endedAt": now_iso(),
1154
+ "exitCode": None,
1155
+ "testProcessStarted": False,
1156
+ "cleanupStatus": "NO_CHILD",
1157
+ "error": str(exc),
1158
+ }
1159
+ )
1160
+ return 1
1161
+ assert spawned is not None and process is not None
1162
+ identity = spawned.attestation
1163
+ receipt["processOwnershipProof"] = ownership_proof
1164
+ _stage_transition(receipt, "execute")
1165
+ receipt.update(
1166
+ {
1167
+ "servicePid": process.pid,
1168
+ "processIdentity": identity,
1169
+ "startedAt": identity.get("createdAt"),
1170
+ "status": "RUNNING",
1171
+ "reasonCode": "CHILD_RUNNING",
1172
+ "testProcessStarted": True,
1173
+ "processTreeIsolated": ownership_proof is not None,
1174
+ }
1175
+ )
1176
+ _write_run_receipt(state_root, receipt)
1177
+
1178
+ timeout_seconds = receipt.get("timeoutSeconds")
1179
+ next_heartbeat = time.monotonic()
1180
+ termination_reason: str | None = None
1181
+ while process.poll() is None:
1182
+ now = time.monotonic()
1183
+ if timeout_seconds is not None and (
1184
+ now - started_monotonic >= float(timeout_seconds)
1185
+ ):
1186
+ termination_reason = "TIMEOUT"
1187
+ cancel_path = session_root / "cancel-request.json"
1188
+ if cancel_path.is_file():
1189
+ termination_reason = "CANCEL_REQUESTED"
1190
+ if termination_reason is not None:
1191
+ diagnostic_path = session_root / "diagnostic.json"
1192
+ from harness_process import (
1193
+ observe_process_identity,
1194
+ verify_process_identity as verify_attestation,
1195
+ )
1196
+
1197
+ verified = verify_attestation(
1198
+ identity,
1199
+ observe_process_identity(process.pid),
1200
+ ).get("ok") is True
1201
+ job_owned = ownership_proof is not None
1202
+ if spawned is not None:
1203
+ _capture_provider_members(spawned)
1204
+ diagnostic = {
1205
+ "schemaVersion": 1,
1206
+ "sessionId": session_id,
1207
+ "reasonCode": termination_reason,
1208
+ "observedAt": now_iso(),
1209
+ "processIdentityVerified": verified,
1210
+ "terminationAuthority": (
1211
+ "JOB_OBJECT_OWNED"
1212
+ if job_owned
1213
+ else (
1214
+ "PROCESS_IDENTITY_VERIFIED"
1215
+ if verified
1216
+ else "NONE"
1217
+ )
1218
+ ),
1219
+ "pid": process.pid,
1220
+ "stdoutBytes": (
1221
+ stdout_path.stat().st_size if stdout_path.exists() else 0
1222
+ ),
1223
+ "stderrBytes": (
1224
+ stderr_path.stat().st_size if stderr_path.exists() else 0
1225
+ ),
1226
+ }
1227
+ atomic_write_json(diagnostic_path, diagnostic)
1228
+ receipt["diagnosticPath"] = str(diagnostic_path)
1229
+ if job_owned and verified:
1230
+ terminated = _terminate_managed_child(
1231
+ process,
1232
+ job,
1233
+ attestation=identity,
1234
+ ownership_proof=ownership_proof,
1235
+ )
1236
+ job = None
1237
+ if not terminated:
1238
+ receipt.update(
1239
+ {
1240
+ "status": "INCOMPLETE",
1241
+ "reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
1242
+ "cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
1243
+ }
1244
+ )
1245
+ else:
1246
+ receipt.update(
1247
+ {
1248
+ "status": "INCOMPLETE",
1249
+ "reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
1250
+ "cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
1251
+ }
1252
+ )
1253
+ break
1254
+ if now >= next_heartbeat:
1255
+ receipt["lastHeartbeatAt"] = now_iso()
1256
+ receipt["activeTimeMs"] = int(
1257
+ (now - started_monotonic) * 1000
1258
+ )
1259
+ receipt["wallClockMs"] = receipt["activeTimeMs"]
1260
+ if not _refresh_resource_locks(
1261
+ state_root,
1262
+ session_id,
1263
+ dict(receipt.get("resourceLockTokens") or {}),
1264
+ dict(receipt["workerIdentity"]),
1265
+ ):
1266
+ receipt.update(
1267
+ {
1268
+ "status": "INCOMPLETE",
1269
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
1270
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
1271
+ }
1272
+ )
1273
+ if spawned is not None:
1274
+ _capture_provider_members(spawned)
1275
+ _terminate_managed_child(
1276
+ process,
1277
+ job,
1278
+ attestation=identity,
1279
+ ownership_proof=ownership_proof,
1280
+ )
1281
+ job = None
1282
+ break
1283
+ _write_run_receipt(state_root, receipt)
1284
+ next_heartbeat = now + heartbeat_seconds
1285
+ time.sleep(min(heartbeat_seconds, 0.05))
1286
+
1287
+ exit_code = process.poll()
1288
+ _stage_transition(receipt, "summarize")
1289
+ if termination_reason is not None and receipt["reasonCode"] not in {
1290
+ "PROCESS_IDENTITY_UNVERIFIED",
1291
+ "PROCESS_TERMINATION_UNCONFIRMED",
1292
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
1293
+ }:
1294
+ receipt.update(
1295
+ {
1296
+ "status": "CANCELLED",
1297
+ "reasonCode": termination_reason,
1298
+ "exitCode": exit_code,
1299
+ "cleanupStatus": "PROCESS_TREE_TERMINATED",
1300
+ }
1301
+ )
1302
+ elif receipt["reasonCode"] not in {
1303
+ "PROCESS_IDENTITY_UNVERIFIED",
1304
+ "PROCESS_TERMINATION_UNCONFIRMED",
1305
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
1306
+ }:
1307
+ receipt.update(
1308
+ {
1309
+ "status": "OK" if exit_code == 0 else "FAIL",
1310
+ "reasonCode": (
1311
+ "CHILD_EXIT_ZERO"
1312
+ if exit_code == 0
1313
+ else "CHILD_EXIT_NONZERO"
1314
+ ),
1315
+ "exitCode": exit_code,
1316
+ "cleanupStatus": "PROCESS_EXITED",
1317
+ }
1318
+ )
1319
+ except BaseException as exc:
1320
+ receipt.update(
1321
+ {
1322
+ "status": "INCOMPLETE",
1323
+ "reasonCode": "WORKER_FAILED",
1324
+ "exitCode": None if process is None else process.poll(),
1325
+ "cleanupStatus": "WORKER_EXCEPTION",
1326
+ "error": str(exc),
1327
+ }
1328
+ )
1329
+ finally:
1330
+ if process is not None and process.poll() is None and ownership_proof is not None:
1331
+ if spawned is not None:
1332
+ _capture_provider_members(spawned)
1333
+ tree_drained = _terminate_managed_child(
1334
+ process,
1335
+ job,
1336
+ attestation=identity if isinstance(identity, dict) else None,
1337
+ ownership_proof=ownership_proof,
1338
+ )
1339
+ if not tree_drained:
1340
+ receipt.update(
1341
+ {
1342
+ "status": "INCOMPLETE",
1343
+ "reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
1344
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
1345
+ }
1346
+ )
1347
+ elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
1348
+ receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
1349
+ ended = time.monotonic()
1350
+ _stage_transition(receipt, "record")
1351
+ receipt["endedAt"] = now_iso()
1352
+ receipt["lastHeartbeatAt"] = receipt["endedAt"]
1353
+ receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
1354
+ receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
1355
+ receipt.get("resourceWaitMs") or 0
1356
+ )
1357
+ digest = hashlib.sha256()
1358
+ for path in (stdout_path, stderr_path):
1359
+ if path.is_file():
1360
+ digest.update(path.read_bytes())
1361
+ receipt["resultDigest"] = "sha256:" + digest.hexdigest()
1362
+ locks_released = _release_resource_locks(
1363
+ state_root,
1364
+ session_id,
1365
+ dict(receipt.get("resourceLockTokens") or {}),
1366
+ )
1367
+ try:
1368
+ _resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
1369
+ except OSError:
1370
+ locks_released = False
1371
+ if not locks_released:
1372
+ receipt.update(
1373
+ {
1374
+ "status": "INCOMPLETE",
1375
+ "reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
1376
+ "cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
1377
+ }
1378
+ )
1379
+ _close_current_stage(receipt)
1380
+ _write_run_receipt(state_root, receipt)
1381
+ return 0 if receipt.get("status") == "OK" else 1
1382
+
1383
+
1384
+ def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
1385
+ worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
1386
+ try:
1387
+ worker_log = worker_log_path.open(
1388
+ "a",
1389
+ encoding="utf-8",
1390
+ errors="replace",
1391
+ buffering=1,
1392
+ )
1393
+ except OSError:
1394
+ return _run_session_worker(state_root, session_id)
1395
+ with worker_log:
1396
+ with (
1397
+ contextlib.redirect_stdout(worker_log),
1398
+ contextlib.redirect_stderr(worker_log),
1399
+ ):
1400
+ return _run_session_worker(state_root, session_id)
1401
+
1402
+
1403
+ def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
1404
+ from harness_service import is_pid_alive
1405
+
1406
+ receipt = _load_run_receipt(state_root, session_id)
1407
+ worker_pid = receipt.get("workerPid")
1408
+ worker_identity = receipt.get("workerIdentity")
1409
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1410
+ if (
1411
+ isinstance(worker_pid, int)
1412
+ and worker_pid > 0
1413
+ and is_pid_alive(worker_pid)
1414
+ and _receipt_identity_state(receipt) is True
1415
+ ):
1416
+ finalizing = dict(receipt)
1417
+ finalizing.update(
1418
+ {
1419
+ "status": "FINALIZING",
1420
+ "reasonCode": "WORKER_FINALIZING",
1421
+ }
1422
+ )
1423
+ return finalizing
1424
+ return receipt
1425
+ heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
1426
+ try:
1427
+ heartbeat_at = dt.datetime.fromisoformat(
1428
+ heartbeat_text.replace("Z", "+00:00")
1429
+ )
1430
+ if heartbeat_at.tzinfo is None:
1431
+ heartbeat_at = heartbeat_at.astimezone()
1432
+ heartbeat_age = (
1433
+ dt.datetime.now().astimezone() - heartbeat_at
1434
+ ).total_seconds()
1435
+ except ValueError:
1436
+ heartbeat_age = float("inf")
1437
+ grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
1438
+ startup_grace = max(grace, 10.0)
1439
+ # The detached worker owns the durable identity record. The launcher can
1440
+ # return before the worker has published it, so STARTING is intentionally
1441
+ # treated as a bounded handshake window rather than an identity failure.
1442
+ if (
1443
+ receipt.get("status") == "STARTING"
1444
+ and receipt.get("workerIdentity") is None
1445
+ and heartbeat_age < startup_grace
1446
+ ):
1447
+ return receipt
1448
+ identity_state = _receipt_identity_state(receipt)
1449
+ worker_gone = (
1450
+ isinstance(worker_pid, int)
1451
+ and worker_pid > 0
1452
+ and not is_pid_alive(worker_pid)
1453
+ )
1454
+ if worker_gone or identity_state is False or heartbeat_age >= grace:
1455
+ latest = _load_run_receipt(state_root, session_id)
1456
+ if latest.get("status") in RUN_TERMINAL_STATUSES:
1457
+ return latest
1458
+ if heartbeat_age < grace and worker_gone and identity_state is not False:
1459
+ return latest
1460
+ receipt.update(
1461
+ {
1462
+ "status": "INCOMPLETE",
1463
+ "reasonCode": (
1464
+ "WORKER_IDENTITY_MISMATCH"
1465
+ if identity_state is False and not worker_gone
1466
+ else "HEARTBEAT_LOST"
1467
+ ),
1468
+ "endedAt": now_iso(),
1469
+ "cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
1470
+ }
1471
+ )
1472
+ _write_run_receipt(state_root, receipt)
1473
+ return receipt
1474
+
1475
+
1476
+ def read_run_session_log(
1477
+ state_root: Path,
1478
+ session_id: str,
1479
+ *,
1480
+ stream: str = "stdout",
1481
+ cursor: int = 0,
1482
+ max_bytes: int = 64 * 1024,
1483
+ ) -> dict[str, Any]:
1484
+ if stream not in {"stdout", "stderr"}:
1485
+ raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
1486
+ if cursor < 0 or max_bytes <= 0:
1487
+ raise ValueError("RUN_SESSION_CURSOR_INVALID")
1488
+ receipt = _load_run_receipt(state_root, session_id)
1489
+ path = Path(str(receipt[f"{stream}Path"]))
1490
+ raw = b""
1491
+ decode_status = "OK"
1492
+ size = path.stat().st_size if path.is_file() else 0
1493
+ if path.is_file() and cursor < size:
1494
+ with path.open("rb") as handle:
1495
+ handle.seek(cursor)
1496
+ raw = handle.read(max_bytes)
1497
+ while raw:
1498
+ try:
1499
+ text = raw.decode("utf-8")
1500
+ break
1501
+ except UnicodeDecodeError as exc:
1502
+ if exc.end != len(raw):
1503
+ # An invalid sequence in the middle is durable evidence,
1504
+ # so consume it and expose replacement text instead of
1505
+ # silently dropping bytes from the cursor stream.
1506
+ text = raw.decode("utf-8", errors="replace")
1507
+ decode_status = "LOG_DECODE_DEGRADED"
1508
+ break
1509
+ if (
1510
+ receipt.get("status") in RUN_TERMINAL_STATUSES
1511
+ and cursor + len(raw) >= size
1512
+ ):
1513
+ # Once the producer is terminal, an incomplete trailing
1514
+ # sequence cannot be completed by a later write. Preserve
1515
+ # and consume it with an explicit degraded status.
1516
+ text = raw.decode("utf-8", errors="replace")
1517
+ decode_status = "LOG_DECODE_DEGRADED"
1518
+ break
1519
+ # A live writer may have split a multibyte character across
1520
+ # this read. Keep the incomplete suffix for the next cursor
1521
+ # request so callers never see duplicate replacement glyphs.
1522
+ raw = raw[: exc.start]
1523
+ else:
1524
+ text = ""
1525
+ else:
1526
+ text = ""
1527
+ next_cursor = cursor + len(raw)
1528
+ terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
1529
+ return {
1530
+ "ok": True,
1531
+ "sessionId": session_id,
1532
+ "stream": stream,
1533
+ "cursor": cursor,
1534
+ "nextCursor": next_cursor,
1535
+ "text": text,
1536
+ "eof": terminal and next_cursor >= size,
1537
+ "status": receipt.get("status"),
1538
+ "rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
1539
+ "decodeStatus": decode_status,
1540
+ }
1541
+
1542
+
1543
+ def cancel_run_session(
1544
+ state_root: Path,
1545
+ session_id: str,
1546
+ *,
1547
+ reason: str = "CANCEL_REQUESTED",
1548
+ ) -> dict[str, Any]:
1549
+ receipt = _load_run_receipt(state_root, session_id)
1550
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1551
+ return receipt
1552
+ if _receipt_identity_state(receipt) is not True:
1553
+ return {
1554
+ **receipt,
1555
+ "ok": False,
1556
+ "code": "WORKER_IDENTITY_UNVERIFIED",
1557
+ "unknownProcessUntouched": True,
1558
+ }
1559
+ atomic_write_json(
1560
+ _run_session_root(state_root, session_id) / "cancel-request.json",
1561
+ {"reasonCode": reason, "requestedAt": now_iso()},
1562
+ )
1563
+ return {**receipt, "ok": True, "action": "cancel-requested"}
1564
+
1565
+
54
1566
  def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
55
1567
  if agent not in _ADAPTERS:
56
1568
  raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
@@ -221,6 +1733,35 @@ def build_parser() -> argparse.ArgumentParser:
221
1733
  p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
222
1734
  p_adapter.add_argument("--change", required=True)
223
1735
  p_adapter.add_argument("--json", action="store_true")
1736
+ p_start = sub.add_parser("run-start", help="start a managed background run")
1737
+ p_start.add_argument("--state-root", required=True)
1738
+ p_start.add_argument("--verification", required=True)
1739
+ p_start.add_argument("--working-directory", required=True)
1740
+ p_start.add_argument("--timeout-seconds", type=float)
1741
+ p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
1742
+ p_start.add_argument("--expected-duration-seconds", type=float)
1743
+ p_start.add_argument("--product-identity")
1744
+ p_start.add_argument("--resource-lock", action="append", default=[])
1745
+ p_start.add_argument("--json", action="store_true")
1746
+ p_start.add_argument("argv", nargs=argparse.REMAINDER)
1747
+ p_status = sub.add_parser("run-status", help="read a managed run receipt")
1748
+ p_status.add_argument("--state-root", required=True)
1749
+ p_status.add_argument("--session-id", required=True)
1750
+ p_status.add_argument("--json", action="store_true")
1751
+ p_log = sub.add_parser("run-log", help="read an incremental log page")
1752
+ p_log.add_argument("--state-root", required=True)
1753
+ p_log.add_argument("--session-id", required=True)
1754
+ p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
1755
+ p_log.add_argument("--cursor", type=int, default=0)
1756
+ p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
1757
+ p_log.add_argument("--json", action="store_true")
1758
+ p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
1759
+ p_cancel.add_argument("--state-root", required=True)
1760
+ p_cancel.add_argument("--session-id", required=True)
1761
+ p_cancel.add_argument("--json", action="store_true")
1762
+ p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
1763
+ p_worker.add_argument("--state-root", required=True)
1764
+ p_worker.add_argument("--session-id", required=True)
224
1765
  return parser
225
1766
 
226
1767
 
@@ -229,15 +1770,47 @@ def main(argv: list[str] | None = None) -> int:
229
1770
  try:
230
1771
  if args.command == "doctor":
231
1772
  result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
232
- else:
1773
+ elif args.command == "adapter":
233
1774
  result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
1775
+ elif args.command == "_worker":
1776
+ return _run_session_worker_with_log(
1777
+ Path(args.state_root),
1778
+ args.session_id,
1779
+ )
1780
+ elif args.command == "run-start":
1781
+ command_argv = list(args.argv)
1782
+ if command_argv and command_argv[0] == "--":
1783
+ command_argv = command_argv[1:]
1784
+ result = start_run_session(
1785
+ state_root=Path(args.state_root),
1786
+ verification=args.verification,
1787
+ argv=command_argv,
1788
+ working_directory=Path(args.working_directory),
1789
+ timeout_seconds=args.timeout_seconds,
1790
+ heartbeat_seconds=args.heartbeat_seconds,
1791
+ expected_duration_seconds=args.expected_duration_seconds,
1792
+ product_identity=args.product_identity,
1793
+ resource_locks=list(args.resource_lock),
1794
+ )
1795
+ elif args.command == "run-status":
1796
+ result = run_session_status(Path(args.state_root), args.session_id)
1797
+ elif args.command == "run-log":
1798
+ result = read_run_session_log(
1799
+ Path(args.state_root),
1800
+ args.session_id,
1801
+ stream=args.stream,
1802
+ cursor=args.cursor,
1803
+ max_bytes=args.max_bytes,
1804
+ )
1805
+ else:
1806
+ result = cancel_run_session(Path(args.state_root), args.session_id)
234
1807
  except (OSError, ValueError) as exc:
235
1808
  code = str(exc).split(":", 1)[0]
236
1809
  result = {"ok": False, "code": code, "error": str(exc)}
237
1810
  stream = sys.stderr
238
1811
  stream.write(json.dumps(result, ensure_ascii=False) + "\n")
239
1812
  return 1
240
- if args.json:
1813
+ if getattr(args, "json", False):
241
1814
  sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
242
1815
  else:
243
1816
  sys.stdout.write(str(result.get("action")) + "\n")