@hunter-harness/workflow-harness 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,1586 @@
1
+ #!/usr/bin/env python3
2
+ """Resource-safe test command runner for Hunter Harness workflows.
3
+
4
+ The runner keeps test commands sequential, lowers their scheduling priority,
5
+ enforces a single active run per project, and tears down the managed process
6
+ tree after every command. Resource-intensive profiles require an explicit
7
+ confirmation outside CI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import ctypes
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import uuid
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Mapping, Sequence
28
+
29
+ SCRIPT_DIR = Path(__file__).resolve().parent
30
+ if str(SCRIPT_DIR) not in sys.path:
31
+ sys.path.insert(0, str(SCRIPT_DIR))
32
+
33
+ import harness_environment as henv
34
+ import harness_process as hprocess
35
+
36
+ DEFAULT_MAX_WORKERS = 2
37
+ DEFAULT_TIMEOUT_SECONDS = 300.0
38
+ RESOURCE_INTENSIVE_PROFILES = frozenset({"system", "full"})
39
+ DEFAULT_RESOURCE_MODULES = (
40
+ "test_harness_integration.py",
41
+ "test_harness_service.py",
42
+ )
43
+ DEFAULT_DETACHED_SERVICE_MODULES = (
44
+ "test_harness_service.py",
45
+ "test_harness_runtime.py",
46
+ )
47
+ TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class TestModule:
52
+ """One isolated unittest module."""
53
+
54
+ name: str
55
+ path: Path
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CommandResult:
60
+ """Result of one managed command."""
61
+
62
+ returncode: int
63
+ timed_out: bool
64
+ duration_seconds: float
65
+ process_tree_isolated: bool
66
+ output_tail: str = ""
67
+
68
+
69
+ class TestRunAlreadyActive(RuntimeError):
70
+ """Raised when another resource-safe runner owns the project lock."""
71
+
72
+
73
+ class ManagedCommandNotFound(FileNotFoundError):
74
+ """Raised when a structured command cannot be resolved without a shell."""
75
+
76
+
77
+ class ManagedCommandUnsafe(ValueError):
78
+ """Raised when a Windows batch argument could be interpreted by cmd.exe."""
79
+
80
+
81
+ _WINDOWS_BATCH_SUFFIXES = {".bat", ".cmd"}
82
+ _WINDOWS_CMD_META = re.compile(r'[\r\n&|<>^%!\"]')
83
+
84
+
85
+ def validate_managed_argv(
86
+ argv: Sequence[str], *, platform_name: str | None = None
87
+ ) -> None:
88
+ """Fail closed when Windows would reinterpret a batch argv boundary."""
89
+
90
+ platform = platform_name or os.name
91
+ if (
92
+ platform != "nt"
93
+ or not argv
94
+ or Path(argv[0]).suffix.casefold() not in _WINDOWS_BATCH_SUFFIXES
95
+ ):
96
+ return
97
+ for index, argument in enumerate(argv):
98
+ if _WINDOWS_CMD_META.search(str(argument)):
99
+ raise ManagedCommandUnsafe(
100
+ "MANAGED_BATCH_ARGUMENT_UNSAFE: "
101
+ f"Windows 批处理命令的第 {index} 个参数包含命令解释符;"
102
+ "请改用原生可执行文件或不含命令解释符的参数"
103
+ )
104
+
105
+
106
+ def resolve_managed_argv(
107
+ argv: Sequence[str],
108
+ *,
109
+ environ: Mapping[str, str],
110
+ platform_name: str | None = None,
111
+ ) -> list[str]:
112
+ """Resolve argv[0] through PATH/PATHEXT while preserving argv boundaries."""
113
+
114
+ if not argv or not isinstance(argv[0], str) or not argv[0].strip():
115
+ raise ManagedCommandNotFound("MANAGED_COMMAND_NOT_FOUND: 命令不能为空")
116
+ command = argv[0].strip()
117
+ platform = platform_name or os.name
118
+ windows_extensions = [
119
+ item.strip() if item.strip().startswith(".") else f".{item.strip()}"
120
+ for item in str(
121
+ environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
122
+ ).split(";")
123
+ if item.strip()
124
+ ] if platform == "nt" else []
125
+ windows_extension_set = {item.casefold() for item in windows_extensions}
126
+ has_path = Path(command).is_absolute() or "/" in command or "\\" in command
127
+ if has_path:
128
+ requested = Path(command).expanduser()
129
+ suffix = requested.suffix.casefold()
130
+ candidates = (
131
+ [Path(str(requested) + extension) for extension in windows_extensions]
132
+ + [requested]
133
+ if platform == "nt" and suffix not in windows_extension_set
134
+ else [requested]
135
+ )
136
+ for candidate in candidates:
137
+ if candidate.is_file():
138
+ return [str(candidate.resolve()), *list(argv[1:])]
139
+ raise ManagedCommandNotFound(
140
+ f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
141
+ )
142
+
143
+ path_value = str(environ.get("PATH") or "")
144
+ resolved: str | None = None
145
+ if platform == "nt":
146
+ suffix = Path(command).suffix.casefold()
147
+ names = (
148
+ [command]
149
+ if suffix and suffix in windows_extension_set
150
+ else [*(command + item for item in windows_extensions), command]
151
+ )
152
+ for raw_directory in path_value.split(";"):
153
+ directory = raw_directory.strip().strip('"') or "."
154
+ for name in names:
155
+ candidate = Path(directory) / name
156
+ if candidate.is_file():
157
+ resolved = str(candidate.resolve())
158
+ break
159
+ if resolved is not None:
160
+ break
161
+ else:
162
+ resolved = shutil.which(command, path=path_value or None)
163
+
164
+ if resolved is None:
165
+ raise ManagedCommandNotFound(
166
+ f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
167
+ )
168
+ return [resolved, *list(argv[1:])]
169
+
170
+
171
+ def _truthy(value: object) -> bool:
172
+ return str(value or "").strip().lower() in TRUTHY_VALUES
173
+
174
+
175
+ def bounded_worker_count(
176
+ requested: int,
177
+ environ: Mapping[str, str] | None = None,
178
+ ) -> int:
179
+ """Return a positive worker count that never exceeds the global safe cap."""
180
+
181
+ env = os.environ if environ is None else environ
182
+ raw_limit = env.get("HARNESS_TEST_MAX_WORKERS", str(DEFAULT_MAX_WORKERS))
183
+ try:
184
+ configured = int(raw_limit)
185
+ except (TypeError, ValueError):
186
+ configured = DEFAULT_MAX_WORKERS
187
+ configured = max(1, min(DEFAULT_MAX_WORKERS, configured))
188
+ return max(1, min(int(requested), configured))
189
+
190
+
191
+ def resource_profile_allowed(
192
+ profile: str,
193
+ confirmed: bool,
194
+ environ: Mapping[str, str] | None = None,
195
+ ) -> bool:
196
+ """Return whether a profile may execute in the current environment."""
197
+
198
+ if profile not in RESOURCE_INTENSIVE_PROFILES:
199
+ return True
200
+ env = os.environ if environ is None else environ
201
+ return bool(
202
+ confirmed
203
+ or _truthy(env.get("CI"))
204
+ or _truthy(env.get("HARNESS_ALLOW_RESOURCE_INTENSIVE_TESTS"))
205
+ )
206
+
207
+
208
+ def discover_test_modules(tests_dir: Path) -> list[TestModule]:
209
+ """Discover deterministic top-level unittest modules."""
210
+
211
+ root = Path(tests_dir).resolve()
212
+ if not root.is_dir():
213
+ raise FileNotFoundError(f"test directory does not exist: {root}")
214
+ return [
215
+ TestModule(path.name, path)
216
+ for path in sorted(root.glob("test_*.py"), key=lambda item: item.name)
217
+ if path.is_file()
218
+ ]
219
+
220
+
221
+ def build_unittest_plan(
222
+ tests_dir: Path,
223
+ profile: str,
224
+ *,
225
+ resource_modules: Sequence[str] = DEFAULT_RESOURCE_MODULES,
226
+ ) -> list[TestModule]:
227
+ """Partition unittest modules into safe, system, or full execution plans."""
228
+
229
+ if profile not in {"safe", "system", "full"}:
230
+ raise ValueError(f"unsupported test profile: {profile}")
231
+ modules = discover_test_modules(tests_dir)
232
+ resource_names = {Path(item).name for item in resource_modules}
233
+ regular = [item for item in modules if item.name not in resource_names]
234
+ intensive = [item for item in modules if item.name in resource_names]
235
+ if profile == "safe":
236
+ return regular
237
+ if profile == "system":
238
+ return intensive
239
+ return [*regular, *intensive]
240
+
241
+
242
+ def _pid_is_running(pid: int) -> bool:
243
+ return hprocess.is_pid_alive(pid)
244
+
245
+
246
+ def _verify_recorded_owner(owner: Mapping[str, object]) -> bool | None:
247
+ """Verify a legacy lock owner through the canonical process provider."""
248
+
249
+ try:
250
+ pid = int(owner.get("pid") or 0)
251
+ except (TypeError, ValueError):
252
+ return None
253
+ observed = hprocess.observe_process_identity(pid)
254
+ decision = hprocess.verify_process_identity(
255
+ {
256
+ "pid": pid,
257
+ "createdAt": owner.get("startedAt"),
258
+ "executable": owner.get("executable"),
259
+ },
260
+ observed,
261
+ )
262
+ if decision.get("ok") is True:
263
+ return True
264
+ if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
265
+ return None
266
+ return False
267
+
268
+
269
+ def classify_test_lock(path: Path) -> dict[str, object]:
270
+ """Classify one lock without mutating it or terminating a process."""
271
+ try:
272
+ payload = json.loads(path.read_text(encoding="utf-8"))
273
+ except FileNotFoundError:
274
+ return {"classification": "ABSENT", "path": str(path)}
275
+ except (OSError, json.JSONDecodeError) as exc:
276
+ return {
277
+ "classification": "LOCK_UNREADABLE",
278
+ "path": str(path),
279
+ "message": str(exc),
280
+ }
281
+ if not isinstance(payload, dict):
282
+ return {"classification": "LOCK_UNREADABLE", "path": str(path)}
283
+ owner = payload.get("owner")
284
+ expires = payload.get("expiresAtUnix")
285
+ if not isinstance(expires, (int, float)) or isinstance(expires, bool):
286
+ return {
287
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
288
+ "path": str(path),
289
+ "token": payload.get("token"),
290
+ }
291
+ unexpired = time.time() < float(expires)
292
+ if not isinstance(owner, dict) or any(
293
+ not owner.get(field) for field in ("pid", "executable", "startedAt")
294
+ ):
295
+ if unexpired:
296
+ return {
297
+ "classification": "ACTIVE",
298
+ "path": str(path),
299
+ "token": payload.get("token"),
300
+ "owner": owner,
301
+ }
302
+ return {
303
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
304
+ "path": str(path),
305
+ "token": payload.get("token"),
306
+ }
307
+ try:
308
+ owner_pid = int(owner["pid"])
309
+ except (TypeError, ValueError):
310
+ if unexpired:
311
+ return {
312
+ "classification": "ACTIVE",
313
+ "path": str(path),
314
+ "token": payload.get("token"),
315
+ "owner": owner,
316
+ }
317
+ return {
318
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
319
+ "path": str(path),
320
+ "token": payload.get("token"),
321
+ }
322
+ if not _pid_is_running(owner_pid):
323
+ return {
324
+ "classification": "RECLAIMABLE",
325
+ "path": str(path),
326
+ "token": payload.get("token"),
327
+ "owner": owner,
328
+ }
329
+ if unexpired:
330
+ return {
331
+ "classification": "ACTIVE",
332
+ "path": str(path),
333
+ "token": payload.get("token"),
334
+ "owner": owner,
335
+ }
336
+ verified = _verify_recorded_owner(owner)
337
+ classification = (
338
+ "OWNER_ACTIVE"
339
+ if verified is True
340
+ else (
341
+ "OWNER_IDENTITY_UNCONFIRMED"
342
+ if verified is None
343
+ else "OWNER_IDENTITY_MISMATCH"
344
+ )
345
+ )
346
+ return {
347
+ "classification": classification,
348
+ "path": str(path),
349
+ "token": payload.get("token"),
350
+ "owner": owner,
351
+ }
352
+
353
+
354
+ def reap_test_lock(path: Path) -> dict[str, object]:
355
+ """Reap only a lock whose exact recorded owner is definitely absent."""
356
+ classification = classify_test_lock(path)
357
+ reaped = classification.get("classification") == "RECLAIMABLE"
358
+ if reaped:
359
+ try:
360
+ path.unlink()
361
+ except FileNotFoundError:
362
+ pass
363
+ receipt = {
364
+ "schemaVersion": 1,
365
+ "action": "test-run-lock-reap",
366
+ "status": "REAPED",
367
+ "completedAtUnix": time.time(),
368
+ "lockPath": str(path),
369
+ "prior": classification,
370
+ }
371
+ receipt_dir = path.parent / "receipts"
372
+ receipt_dir.mkdir(parents=True, exist_ok=True)
373
+ target = receipt_dir / f"{path.stem}-reap-{uuid.uuid4().hex}.json"
374
+ target.write_text(
375
+ json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
376
+ encoding="utf-8",
377
+ newline="\n",
378
+ )
379
+ return {
380
+ "ok": True,
381
+ "code": "TEST_RUN_LOCK_REAPED" if reaped else "TEST_RUN_LOCK_REPORT_ONLY",
382
+ "reaped": reaped,
383
+ "lock": classification,
384
+ }
385
+
386
+
387
+ class TestRunLock:
388
+ """Atomic per-project single-instance lock with stale-owner recovery."""
389
+
390
+ def __init__(self, project: Path, *, lock_root: Path | None = None) -> None:
391
+ resolved = str(Path(project).resolve())
392
+ if os.name == "nt":
393
+ resolved = resolved.casefold()
394
+ digest = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:24]
395
+ root = (
396
+ Path(lock_root)
397
+ if lock_root is not None
398
+ else Path(
399
+ os.environ.get(
400
+ "HARNESS_TEST_LOCK_ROOT",
401
+ str(Path(tempfile.gettempdir()) / "hunter-harness-test-locks"),
402
+ )
403
+ )
404
+ )
405
+ self.path = root / f"{digest}.lock"
406
+ self.token = uuid.uuid4().hex
407
+ self._owned = False
408
+ self._ttl_seconds = 3600.0
409
+
410
+ def _owner(self) -> dict[str, object]:
411
+ try:
412
+ return json.loads(self.path.read_text(encoding="utf-8"))
413
+ except (OSError, json.JSONDecodeError):
414
+ return {}
415
+
416
+ def acquire(self) -> None:
417
+ self.path.parent.mkdir(parents=True, exist_ok=True)
418
+ for attempt in range(2):
419
+ try:
420
+ descriptor = os.open(
421
+ self.path,
422
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
423
+ 0o600,
424
+ )
425
+ except FileExistsError:
426
+ classification = classify_test_lock(self.path)
427
+ if classification.get("classification") != "RECLAIMABLE":
428
+ raise TestRunAlreadyActive(
429
+ "TEST_RUN_ALREADY_ACTIVE: "
430
+ f"lock={self.path} "
431
+ f"classification={classification.get('classification')}"
432
+ )
433
+ reap_test_lock(self.path)
434
+ if attempt == 0:
435
+ continue
436
+ raise TestRunAlreadyActive(
437
+ f"TEST_RUN_ALREADY_ACTIVE: could not reclaim {self.path}"
438
+ )
439
+ else:
440
+ now = time.time()
441
+ created = hprocess.get_process_create_time(os.getpid())
442
+ executable = (
443
+ hprocess.get_process_executable(os.getpid())
444
+ or str(Path(sys.executable).resolve())
445
+ )
446
+ payload = {
447
+ "schemaVersion": 2,
448
+ "token": self.token,
449
+ "status": "ACTIVE",
450
+ "createdAtUnix": now,
451
+ "heartbeatAtUnix": now,
452
+ "expiresAtUnix": now + self._ttl_seconds,
453
+ "owner": {
454
+ "pid": os.getpid(),
455
+ "executable": executable,
456
+ "startedAt": (
457
+ created.isoformat(timespec="seconds")
458
+ if created is not None
459
+ else ""
460
+ ),
461
+ },
462
+ }
463
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
464
+ json.dump(payload, stream, ensure_ascii=False)
465
+ stream.flush()
466
+ self._owned = True
467
+ return
468
+ raise TestRunAlreadyActive(f"TEST_RUN_ALREADY_ACTIVE: {self.path}")
469
+
470
+ def heartbeat(self) -> None:
471
+ if not self._owned:
472
+ return
473
+ owner = self._owner()
474
+ if owner.get("token") != self.token:
475
+ raise TestRunAlreadyActive(
476
+ f"TEST_RUN_LOCK_OWNERSHIP_LOST: lock={self.path}"
477
+ )
478
+ now = time.time()
479
+ owner["heartbeatAtUnix"] = now
480
+ owner["expiresAtUnix"] = now + self._ttl_seconds
481
+ with self.path.open("w", encoding="utf-8", newline="\n") as stream:
482
+ json.dump(owner, stream, ensure_ascii=False)
483
+ stream.flush()
484
+ os.fsync(stream.fileno())
485
+
486
+ def _write_closeout_receipt(self, *, status: str) -> None:
487
+ receipt = {
488
+ "schemaVersion": 1,
489
+ "action": "test-run-lock-closeout",
490
+ "status": status,
491
+ "token": self.token,
492
+ "lockPath": str(self.path),
493
+ "closedAtUnix": time.time(),
494
+ "ownerPid": os.getpid(),
495
+ }
496
+ receipt_dir = self.path.parent / "receipts"
497
+ receipt_dir.mkdir(parents=True, exist_ok=True)
498
+ target = receipt_dir / f"{self.path.stem}-{self.token}.json"
499
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
500
+ temporary.write_text(
501
+ json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
502
+ encoding="utf-8",
503
+ newline="\n",
504
+ )
505
+ os.replace(temporary, target)
506
+
507
+ def release(self, *, status: str = "COMPLETED") -> None:
508
+ if not self._owned:
509
+ return
510
+ owner = self._owner()
511
+ if owner.get("token") == self.token:
512
+ self._write_closeout_receipt(status=status)
513
+ try:
514
+ self.path.unlink()
515
+ except FileNotFoundError:
516
+ pass
517
+ self._owned = False
518
+
519
+ def __enter__(self) -> "TestRunLock":
520
+ self.acquire()
521
+ return self
522
+
523
+ def __exit__(self, exc_type, exc, traceback) -> None:
524
+ self.release(status="ABNORMAL" if exc_type is not None else "COMPLETED")
525
+
526
+
527
+ def lower_process_priority() -> bool:
528
+ """Best-effort scheduling priority reduction inherited by child processes."""
529
+
530
+ try:
531
+ if os.name == "nt":
532
+ below_normal_priority_class = 0x00004000
533
+ kernel32 = ctypes.windll.kernel32
534
+ return bool(
535
+ kernel32.SetPriorityClass(
536
+ kernel32.GetCurrentProcess(),
537
+ below_normal_priority_class,
538
+ )
539
+ )
540
+ os.nice(5)
541
+ return True
542
+ except (AttributeError, OSError):
543
+ return False
544
+
545
+
546
+ class _WindowsIoCounters(ctypes.Structure):
547
+ _fields_ = [
548
+ ("ReadOperationCount", ctypes.c_uint64),
549
+ ("WriteOperationCount", ctypes.c_uint64),
550
+ ("OtherOperationCount", ctypes.c_uint64),
551
+ ("ReadTransferCount", ctypes.c_uint64),
552
+ ("WriteTransferCount", ctypes.c_uint64),
553
+ ("OtherTransferCount", ctypes.c_uint64),
554
+ ]
555
+
556
+
557
+ class _WindowsBasicLimitInformation(ctypes.Structure):
558
+ _fields_ = [
559
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
560
+ ("PerJobUserTimeLimit", ctypes.c_int64),
561
+ ("LimitFlags", ctypes.c_uint32),
562
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
563
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
564
+ ("ActiveProcessLimit", ctypes.c_uint32),
565
+ ("Affinity", ctypes.c_size_t),
566
+ ("PriorityClass", ctypes.c_uint32),
567
+ ("SchedulingClass", ctypes.c_uint32),
568
+ ]
569
+
570
+
571
+ class _WindowsExtendedLimitInformation(ctypes.Structure):
572
+ _fields_ = [
573
+ ("BasicLimitInformation", _WindowsBasicLimitInformation),
574
+ ("IoInfo", _WindowsIoCounters),
575
+ ("ProcessMemoryLimit", ctypes.c_size_t),
576
+ ("JobMemoryLimit", ctypes.c_size_t),
577
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
578
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
579
+ ]
580
+
581
+
582
+ class _WindowsBasicAccountingInformation(ctypes.Structure):
583
+ _fields_ = [
584
+ ("TotalUserTime", ctypes.c_int64),
585
+ ("TotalKernelTime", ctypes.c_int64),
586
+ ("ThisPeriodTotalUserTime", ctypes.c_int64),
587
+ ("ThisPeriodTotalKernelTime", ctypes.c_int64),
588
+ ("TotalPageFaultCount", ctypes.c_uint32),
589
+ ("TotalProcesses", ctypes.c_uint32),
590
+ ("ActiveProcesses", ctypes.c_uint32),
591
+ ("TotalTerminatedProcesses", ctypes.c_uint32),
592
+ ]
593
+
594
+
595
+ _WINDOWS_JOB_PROCESS_ID_CAPACITY = 4096
596
+
597
+
598
+ class _WindowsJobProcessIdList(ctypes.Structure):
599
+ _fields_ = [
600
+ ("NumberOfAssignedProcesses", ctypes.c_uint32),
601
+ ("NumberOfProcessIdsInList", ctypes.c_uint32),
602
+ (
603
+ "ProcessIdList",
604
+ ctypes.c_size_t * _WINDOWS_JOB_PROCESS_ID_CAPACITY,
605
+ ),
606
+ ]
607
+
608
+
609
+ class _WindowsKillOnCloseJob:
610
+ """Best-effort Windows Job Object that kills descendants when closed."""
611
+
612
+ _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
613
+ _JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800
614
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
615
+ _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
616
+ _JOB_OBJECT_BASIC_PROCESS_ID_LIST_CLASS = 3
617
+
618
+ def __init__(self, *, allow_breakaway: bool = False) -> None:
619
+ self.handle: int | None = None
620
+ self.allow_breakaway = allow_breakaway
621
+
622
+ def assign(self, process: subprocess.Popen[object]) -> bool:
623
+ if os.name != "nt":
624
+ return False
625
+ kernel32 = ctypes.windll.kernel32
626
+ kernel32.CreateJobObjectW.restype = ctypes.c_void_p
627
+ handle = kernel32.CreateJobObjectW(None, None)
628
+ if not handle:
629
+ return False
630
+ info = _WindowsExtendedLimitInformation()
631
+ info.BasicLimitInformation.LimitFlags = (
632
+ self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
633
+ | (
634
+ self._JOB_OBJECT_LIMIT_BREAKAWAY_OK
635
+ if self.allow_breakaway
636
+ else 0
637
+ )
638
+ )
639
+ configured = kernel32.SetInformationJobObject(
640
+ ctypes.c_void_p(handle),
641
+ self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
642
+ ctypes.byref(info),
643
+ ctypes.sizeof(info),
644
+ )
645
+ process_handle = getattr(process, "_handle", None)
646
+ assigned = bool(
647
+ configured
648
+ and process_handle
649
+ and kernel32.AssignProcessToJobObject(
650
+ ctypes.c_void_p(handle),
651
+ ctypes.c_void_p(int(process_handle)),
652
+ )
653
+ )
654
+ if not assigned:
655
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
656
+ return False
657
+ self.handle = int(handle)
658
+ return True
659
+
660
+ def close(self) -> None:
661
+ if self.handle is None:
662
+ return
663
+ ctypes.windll.kernel32.CloseHandle(ctypes.c_void_p(self.handle))
664
+ self.handle = None
665
+
666
+ def terminate_and_wait(self, timeout_seconds: float = 5.0) -> bool:
667
+ if self.handle is None:
668
+ return True
669
+ kernel32 = ctypes.windll.kernel32
670
+ handle = ctypes.c_void_p(self.handle)
671
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
672
+ process_ids = _WindowsJobProcessIdList()
673
+ process_list_queried = kernel32.QueryInformationJobObject(
674
+ handle,
675
+ self._JOB_OBJECT_BASIC_PROCESS_ID_LIST_CLASS,
676
+ ctypes.byref(process_ids),
677
+ ctypes.sizeof(process_ids),
678
+ None,
679
+ )
680
+ process_list_complete = bool(
681
+ process_list_queried
682
+ and process_ids.NumberOfAssignedProcesses
683
+ == process_ids.NumberOfProcessIdsInList
684
+ )
685
+ kernel32.OpenProcess.restype = ctypes.c_void_p
686
+ process_handles: list[int] = []
687
+ if process_list_complete:
688
+ for index in range(int(process_ids.NumberOfProcessIdsInList)):
689
+ process_handle = kernel32.OpenProcess(
690
+ 0x00100000,
691
+ False,
692
+ int(process_ids.ProcessIdList[index]),
693
+ )
694
+ if process_handle:
695
+ process_handles.append(int(process_handle))
696
+ else:
697
+ process_list_complete = False
698
+ terminated = bool(kernel32.TerminateJobObject(handle, 1))
699
+ handles_signaled = process_list_complete
700
+ try:
701
+ for process_handle in process_handles:
702
+ remaining_ms = max(
703
+ 0,
704
+ int((deadline - time.monotonic()) * 1000),
705
+ )
706
+ if (
707
+ kernel32.WaitForSingleObject(
708
+ ctypes.c_void_p(process_handle),
709
+ remaining_ms,
710
+ )
711
+ != 0
712
+ ):
713
+ handles_signaled = False
714
+ accounting_drained = False
715
+ while time.monotonic() < deadline:
716
+ accounting = _WindowsBasicAccountingInformation()
717
+ queried = kernel32.QueryInformationJobObject(
718
+ handle,
719
+ self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
720
+ ctypes.byref(accounting),
721
+ ctypes.sizeof(accounting),
722
+ None,
723
+ )
724
+ if queried and accounting.ActiveProcesses == 0:
725
+ accounting_drained = True
726
+ break
727
+ time.sleep(0.02)
728
+ finally:
729
+ for process_handle in process_handles:
730
+ kernel32.CloseHandle(ctypes.c_void_p(process_handle))
731
+ self.close()
732
+ return terminated and handles_signaled and accounting_drained
733
+
734
+
735
+ class _WindowsProcessEntry(ctypes.Structure):
736
+ _fields_ = [
737
+ ("dwSize", ctypes.c_uint32),
738
+ ("cntUsage", ctypes.c_uint32),
739
+ ("th32ProcessID", ctypes.c_uint32),
740
+ ("th32DefaultHeapID", ctypes.c_size_t),
741
+ ("th32ModuleID", ctypes.c_uint32),
742
+ ("cntThreads", ctypes.c_uint32),
743
+ ("th32ParentProcessID", ctypes.c_uint32),
744
+ ("pcPriClassBase", ctypes.c_long),
745
+ ("dwFlags", ctypes.c_uint32),
746
+ ("szExeFile", ctypes.c_wchar * 260),
747
+ ]
748
+
749
+
750
+ def _windows_process_parents() -> dict[int, int] | None:
751
+ if os.name != "nt":
752
+ return {}
753
+ kernel32 = ctypes.windll.kernel32
754
+ create_snapshot = kernel32.CreateToolhelp32Snapshot
755
+ create_snapshot.restype = ctypes.c_void_p
756
+ handle = create_snapshot(0x00000002, 0)
757
+ invalid_handle = ctypes.c_void_p(-1).value
758
+ if not handle or handle == invalid_handle:
759
+ return None
760
+ entry = _WindowsProcessEntry()
761
+ entry.dwSize = ctypes.sizeof(entry)
762
+ parents: dict[int, int] = {}
763
+ try:
764
+ if not kernel32.Process32FirstW(ctypes.c_void_p(handle), ctypes.byref(entry)):
765
+ return None
766
+ while True:
767
+ parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
768
+ if not kernel32.Process32NextW(
769
+ ctypes.c_void_p(handle),
770
+ ctypes.byref(entry),
771
+ ):
772
+ break
773
+ return parents
774
+ finally:
775
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
776
+
777
+
778
+ def _update_windows_descendants(
779
+ root_pid: int,
780
+ known_descendants: set[int],
781
+ ) -> bool:
782
+ parents = _windows_process_parents()
783
+ if parents is None:
784
+ return False
785
+ changed = True
786
+ while changed:
787
+ changed = False
788
+ for pid, parent_pid in parents.items():
789
+ if pid == root_pid or pid in known_descendants:
790
+ continue
791
+ if parent_pid == root_pid or parent_pid in known_descendants:
792
+ known_descendants.add(pid)
793
+ changed = True
794
+ return True
795
+
796
+
797
+ def _terminate_tracked_windows_descendants(pids: set[int]) -> None:
798
+ targeted: list[int] = []
799
+ kernel32 = ctypes.windll.kernel32
800
+ kernel32.OpenProcess.restype = ctypes.c_void_p
801
+ for pid in sorted(pids, reverse=True):
802
+ if not _pid_is_running(pid):
803
+ continue
804
+ targeted.append(pid)
805
+ handle = kernel32.OpenProcess(0x00100001, False, pid)
806
+ terminated = False
807
+ if handle:
808
+ try:
809
+ terminated = bool(
810
+ kernel32.TerminateProcess(ctypes.c_void_p(handle), 1)
811
+ )
812
+ if terminated:
813
+ kernel32.WaitForSingleObject(ctypes.c_void_p(handle), 5000)
814
+ finally:
815
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
816
+ if not terminated:
817
+ subprocess.run(
818
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
819
+ stdout=subprocess.DEVNULL,
820
+ stderr=subprocess.DEVNULL,
821
+ check=False,
822
+ timeout=10,
823
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
824
+ )
825
+ deadline = time.monotonic() + 5.0
826
+ while targeted and time.monotonic() < deadline:
827
+ targeted = [pid for pid in targeted if _pid_is_running(pid)]
828
+ if targeted:
829
+ time.sleep(0.05)
830
+
831
+
832
+ def detached_process_capability(cwd: Path) -> tuple[bool, str]:
833
+ """Probe the nested Windows breakaway pattern used by service tests."""
834
+
835
+ if os.name != "nt":
836
+ return True, "not-windows"
837
+ flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000
838
+ with tempfile.TemporaryDirectory(prefix="harness-detached-probe-") as raw_tmp:
839
+ marker = Path(raw_tmp) / "nested-breakaway.ok"
840
+ probe_source = (
841
+ "import pathlib,subprocess,sys;"
842
+ "flags=0x00000008|0x00000200|0x08000000|0x01000000;"
843
+ "child=subprocess.Popen([sys.executable,'-c','pass'],"
844
+ "creationflags=flags,stdin=subprocess.DEVNULL,"
845
+ "stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);"
846
+ "code=child.wait(timeout=5);"
847
+ "pathlib.Path(sys.argv[1]).write_text(str(code),encoding='utf-8')"
848
+ )
849
+ try:
850
+ process = subprocess.Popen(
851
+ [sys.executable, "-c", probe_source, str(marker)],
852
+ cwd=str(Path(cwd).resolve()),
853
+ stdin=subprocess.DEVNULL,
854
+ stdout=subprocess.PIPE,
855
+ stderr=subprocess.STDOUT,
856
+ creationflags=flags,
857
+ text=True,
858
+ encoding="utf-8",
859
+ errors="replace",
860
+ )
861
+ except OSError as exc:
862
+ return False, str(exc)
863
+ try:
864
+ output, _ = process.communicate(timeout=8)
865
+ except subprocess.TimeoutExpired:
866
+ subprocess.run(
867
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
868
+ stdout=subprocess.DEVNULL,
869
+ stderr=subprocess.DEVNULL,
870
+ check=False,
871
+ timeout=10,
872
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
873
+ )
874
+ return False, "nested breakaway probe timed out"
875
+ if process.returncode == 0 and marker.is_file():
876
+ return True, "nested breakaway available"
877
+ detail_lines = [line.strip() for line in (output or "").splitlines() if line.strip()]
878
+ detail = detail_lines[-1] if detail_lines else ""
879
+ return False, detail[-500:] or f"probe exit={process.returncode}"
880
+
881
+
882
+ def _terminate_process_tree(
883
+ process: subprocess.Popen[object],
884
+ *,
885
+ windows_job: _WindowsKillOnCloseJob | None,
886
+ ) -> None:
887
+ """Legacy seam retained for callers; ownership must be provider-backed."""
888
+
889
+ if windows_job is not None and windows_job.handle is not None:
890
+ windows_job.terminate_and_wait()
891
+ else:
892
+ raise RuntimeError("PROCESS_OWNERSHIP_REQUIRED")
893
+
894
+
895
+ def _cleanup_descendants_after_success(
896
+ process: subprocess.Popen[object],
897
+ *,
898
+ windows_job: _WindowsKillOnCloseJob | None,
899
+ ) -> bool:
900
+ if windows_job is not None and windows_job.handle is not None:
901
+ return windows_job.terminate_and_wait()
902
+ return False
903
+
904
+
905
+ def run_managed_command(
906
+ argv: Sequence[str],
907
+ *,
908
+ cwd: Path,
909
+ timeout_seconds: float,
910
+ environ: Mapping[str, str] | None = None,
911
+ capture_output: bool = False,
912
+ allow_detached_processes: bool = False,
913
+ ) -> CommandResult:
914
+ """Run one command with a timeout and guaranteed process-tree cleanup."""
915
+
916
+ if not argv:
917
+ raise ValueError("managed command must not be empty")
918
+ child_env = os.environ.copy()
919
+ if environ:
920
+ child_env.update({str(key): str(value) for key, value in environ.items()})
921
+ child_env.setdefault("PYTHONUNBUFFERED", "1")
922
+ resolved_argv = resolve_managed_argv(argv, environ=child_env)
923
+ validate_managed_argv(resolved_argv)
924
+ lower_process_priority()
925
+
926
+ output_stream = tempfile.TemporaryFile(mode="w+b") if capture_output else None
927
+ started = time.monotonic()
928
+ spawned = None
929
+ process: subprocess.Popen[object] | None = None
930
+ cleanup_complete = False
931
+ try:
932
+ spawned = hprocess.spawn_structured_argv(
933
+ resolved_argv,
934
+ cwd=Path(cwd).resolve(),
935
+ environment=child_env,
936
+ owner_token=f"test-runner:{uuid.uuid4().hex}",
937
+ stdout=output_stream,
938
+ stderr=(subprocess.STDOUT if output_stream is not None else None),
939
+ )
940
+ process = spawned.process
941
+ isolated = bool(
942
+ isinstance(spawned.ownershipProof, dict)
943
+ and spawned.ownershipProof.get("membersComplete") is True
944
+ )
945
+
946
+ def provider_cleanup() -> bool:
947
+ hprocess.capture_owned_members(spawned)
948
+ result = hprocess.terminate_owned_tree(
949
+ spawned.attestation,
950
+ spawned.ownershipProof,
951
+ {"graceSeconds": 3.0},
952
+ )
953
+ return bool(result.get("cleanupComplete"))
954
+
955
+ def wait_owned_members_gone() -> bool:
956
+ proof = spawned.ownershipProof
957
+ if not isinstance(proof, dict):
958
+ return False
959
+ members = proof.get("members")
960
+ if not isinstance(members, list):
961
+ return False
962
+ pids = {
963
+ int(item["pid"])
964
+ for item in members
965
+ if isinstance(item, dict) and isinstance(item.get("pid"), int)
966
+ }
967
+ deadline = time.monotonic() + 5.0
968
+ while pids and time.monotonic() < deadline:
969
+ pids = {pid for pid in pids if hprocess.is_pid_alive(pid)}
970
+ if pids:
971
+ time.sleep(0.05)
972
+ return not pids
973
+
974
+ timed_out = False
975
+ try:
976
+ deadline = time.monotonic() + max(0.01, float(timeout_seconds))
977
+ while True:
978
+ polled = process.poll()
979
+ if polled is not None:
980
+ returncode = polled
981
+ break
982
+ if time.monotonic() >= deadline:
983
+ timed_out = True
984
+ cleanup_complete = provider_cleanup()
985
+ returncode = 124
986
+ break
987
+ time.sleep(0.05)
988
+ except KeyboardInterrupt:
989
+ cleanup_complete = provider_cleanup()
990
+ raise
991
+ if not timed_out:
992
+ cleanup_complete = provider_cleanup()
993
+ # A named Windows Job remains an owned capability after the leader
994
+ # exits. Closing that provider handle drains any attested children;
995
+ # PID-only or POSIX group fallbacks stay fail-closed.
996
+ if not cleanup_complete and os.name == "nt":
997
+ spawned.close()
998
+ cleanup_complete = wait_owned_members_gone()
999
+ if process.poll() is None:
1000
+ try:
1001
+ process.wait(timeout=5)
1002
+ except subprocess.TimeoutExpired:
1003
+ cleanup_complete = False
1004
+ duration = time.monotonic() - started
1005
+ output_tail = ""
1006
+ if output_stream is not None:
1007
+ output_stream.flush()
1008
+ output_stream.seek(0, os.SEEK_END)
1009
+ size = output_stream.tell()
1010
+ output_stream.seek(max(0, size - 65536))
1011
+ output_tail = output_stream.read().decode("utf-8", errors="replace")
1012
+ return CommandResult(
1013
+ returncode=returncode,
1014
+ timed_out=timed_out,
1015
+ duration_seconds=duration,
1016
+ process_tree_isolated=isolated and cleanup_complete,
1017
+ output_tail=output_tail,
1018
+ )
1019
+ finally:
1020
+ if (
1021
+ not cleanup_complete
1022
+ and process is not None
1023
+ and process.poll() is None
1024
+ and spawned is not None
1025
+ ):
1026
+ try:
1027
+ hprocess.capture_owned_members(spawned)
1028
+ cleanup = hprocess.terminate_owned_tree(
1029
+ spawned.attestation,
1030
+ spawned.ownershipProof,
1031
+ {"graceSeconds": 3.0},
1032
+ )
1033
+ cleanup_complete = bool(cleanup.get("cleanupComplete"))
1034
+ except Exception:
1035
+ cleanup_complete = False
1036
+ if spawned is not None:
1037
+ spawned.close()
1038
+ if output_stream is not None:
1039
+ output_stream.close()
1040
+
1041
+
1042
+ def _execution_environment(profile: str, max_workers: int) -> dict[str, str]:
1043
+ return {
1044
+ "HARNESS_TEST_PROFILE": profile,
1045
+ "HARNESS_TEST_MAX_WORKERS": str(
1046
+ bounded_worker_count(max_workers, os.environ)
1047
+ ),
1048
+ }
1049
+
1050
+
1051
+ def load_argv_envelope(path: Path) -> tuple[list[str], dict[str, str]]:
1052
+ """Load a shell-neutral UTF-8 argument file without reparsing command text."""
1053
+ try:
1054
+ raw = json.loads(path.read_text(encoding="utf-8-sig"))
1055
+ except (OSError, json.JSONDecodeError) as exc:
1056
+ raise ValueError(f"argument file is unreadable: {exc}") from exc
1057
+ argv = raw.get("argv") if isinstance(raw, dict) else None
1058
+ powershell = raw.get("powershell") if isinstance(raw, dict) else None
1059
+ if (
1060
+ not isinstance(raw, dict)
1061
+ or raw.get("schemaVersion") != 1
1062
+ or raw.get("transport") != "utf8-json-argument-file"
1063
+ or not isinstance(argv, list)
1064
+ or not argv
1065
+ or any(not isinstance(item, str) or "\0" in item for item in argv)
1066
+ or not isinstance(powershell, dict)
1067
+ ):
1068
+ raise ValueError(
1069
+ "argument file must declare schemaVersion=1, "
1070
+ "transport=utf8-json-argument-file, argv[], and powershell metadata"
1071
+ )
1072
+ shell = {
1073
+ field: str(powershell.get(field) or "").strip()
1074
+ for field in ("edition", "version")
1075
+ }
1076
+ if not all(shell.values()):
1077
+ raise ValueError("argument file powershell edition/version are required")
1078
+ return list(argv), shell
1079
+
1080
+
1081
+ def _is_persistent_service_command(command: Sequence[str]) -> bool:
1082
+ """Recognize formal service-manager launches that must outlive this runner."""
1083
+ normalized = [str(item).replace("\\", "/").lower() for item in command]
1084
+ return "ensure" in normalized and any(
1085
+ item.rsplit("/", 1)[-1] == "harness_service.py" for item in normalized
1086
+ )
1087
+
1088
+
1089
+ def _write_exec_runtime_receipt(
1090
+ path: Path,
1091
+ *,
1092
+ command: Sequence[str],
1093
+ powershell: Mapping[str, str],
1094
+ result: CommandResult,
1095
+ ) -> None:
1096
+ canonical_argv = json.dumps(
1097
+ list(command),
1098
+ ensure_ascii=False,
1099
+ separators=(",", ":"),
1100
+ ).encode("utf-8")
1101
+ payload = {
1102
+ "schemaVersion": 1,
1103
+ "action": "managed-exec",
1104
+ "transport": "utf8-json-argument-file",
1105
+ "completedAtEpochSeconds": int(time.time()),
1106
+ "argvHash": "sha256:" + hashlib.sha256(canonical_argv).hexdigest(),
1107
+ "argumentCount": len(command),
1108
+ "powershell": {
1109
+ "edition": str(powershell["edition"]),
1110
+ "version": str(powershell["version"]),
1111
+ },
1112
+ "returncode": result.returncode,
1113
+ "timedOut": result.timed_out,
1114
+ "processTreeIsolated": result.process_tree_isolated,
1115
+ }
1116
+ path.parent.mkdir(parents=True, exist_ok=True)
1117
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
1118
+ temporary.write_text(
1119
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1120
+ encoding="utf-8",
1121
+ newline="\n",
1122
+ )
1123
+ os.replace(temporary, path)
1124
+
1125
+
1126
+ def _print_plan(profile: str, plan: Sequence[TestModule]) -> None:
1127
+ payload = {
1128
+ "profile": profile,
1129
+ "moduleCount": len(plan),
1130
+ "modules": [item.name for item in plan],
1131
+ "maxWorkers": bounded_worker_count(DEFAULT_MAX_WORKERS, os.environ),
1132
+ "processTreeCleanup": True,
1133
+ "singleInstance": True,
1134
+ }
1135
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1136
+
1137
+
1138
+ def _run_unittest(args: argparse.Namespace) -> int:
1139
+ project = Path(args.project).resolve()
1140
+ tests_dir = Path(args.tests_dir)
1141
+ if not tests_dir.is_absolute():
1142
+ tests_dir = project / tests_dir
1143
+ plan = build_unittest_plan(
1144
+ tests_dir,
1145
+ args.profile,
1146
+ resource_modules=args.resource_module,
1147
+ )
1148
+ if args.dry_run:
1149
+ _print_plan(args.profile, plan)
1150
+ return 0
1151
+ if not resource_profile_allowed(
1152
+ args.profile,
1153
+ args.confirm_resource_intensive,
1154
+ os.environ,
1155
+ ):
1156
+ print(
1157
+ "RESOURCE_CONFIRMATION_REQUIRED: "
1158
+ f"profile={args.profile}; rerun with --confirm-resource-intensive",
1159
+ file=sys.stderr,
1160
+ )
1161
+ return 2
1162
+ if not plan:
1163
+ print(f"NO_TEST_MODULES: profile={args.profile} testsDir={tests_dir}", file=sys.stderr)
1164
+ return 2
1165
+
1166
+ environment = _execution_environment(args.profile, args.max_workers)
1167
+ failures: list[str] = []
1168
+ failed_modules: set[str] = set()
1169
+ executed_count = 0
1170
+ started = time.monotonic()
1171
+ detached_service_modules = set(args.detached_service_module)
1172
+ detached_capability_checked = False
1173
+ try:
1174
+ with TestRunLock(project) as run_lock:
1175
+ for index, module in enumerate(plan, start=1):
1176
+ run_lock.heartbeat()
1177
+ if (
1178
+ module.name in detached_service_modules
1179
+ and not detached_capability_checked
1180
+ ):
1181
+ available, detail = detached_process_capability(project)
1182
+ detached_capability_checked = True
1183
+ if not available:
1184
+ print(
1185
+ "DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
1186
+ f"{detail}; rerun in an environment that permits "
1187
+ "nested Windows breakaway",
1188
+ file=sys.stderr,
1189
+ )
1190
+ return 5
1191
+ print(
1192
+ f"[harness-test] {index}/{len(plan)} {module.name} "
1193
+ f"profile={args.profile} maxWorkers={environment['HARNESS_TEST_MAX_WORKERS']}",
1194
+ flush=True,
1195
+ )
1196
+ command = [
1197
+ sys.executable,
1198
+ "-m",
1199
+ "unittest",
1200
+ "discover",
1201
+ "-s",
1202
+ str(tests_dir),
1203
+ "-p",
1204
+ module.name,
1205
+ ]
1206
+ if args.verbosity == 0:
1207
+ command.append("-q")
1208
+ elif args.verbosity == 2:
1209
+ command.append("-v")
1210
+ result = run_managed_command(
1211
+ command,
1212
+ cwd=project,
1213
+ timeout_seconds=args.timeout_seconds,
1214
+ environ=environment,
1215
+ capture_output=args.verbosity == 0,
1216
+ allow_detached_processes=module.name in detached_service_modules,
1217
+ )
1218
+ run_lock.heartbeat()
1219
+ executed_count += 1
1220
+ module_failure_count = len(failures)
1221
+ if result.timed_out:
1222
+ failures.append(f"{module.name}:TIMEOUT")
1223
+ failed_modules.add(module.name)
1224
+ elif result.returncode != 0:
1225
+ failures.append(f"{module.name}:EXIT_{result.returncode}")
1226
+ failed_modules.add(module.name)
1227
+ if not result.process_tree_isolated:
1228
+ failures.append(f"{module.name}:PROCESS_TREE_ISOLATION_UNAVAILABLE")
1229
+ failed_modules.add(module.name)
1230
+ module_failed = len(failures) > module_failure_count
1231
+ if module_failed and result.output_tail:
1232
+ print(result.output_tail, file=sys.stderr, end="")
1233
+ if module_failed and not args.keep_going:
1234
+ break
1235
+ except TestRunAlreadyActive as exc:
1236
+ print(str(exc), file=sys.stderr)
1237
+ return 3
1238
+ except ManagedCommandNotFound as exc:
1239
+ print(
1240
+ json.dumps(
1241
+ {
1242
+ "ok": False,
1243
+ "code": "MANAGED_COMMAND_NOT_FOUND",
1244
+ "message": str(exc).split(":", 1)[-1].strip(),
1245
+ },
1246
+ ensure_ascii=False,
1247
+ ),
1248
+ file=sys.stderr,
1249
+ )
1250
+ return 127
1251
+ except OSError as exc:
1252
+ print(
1253
+ json.dumps(
1254
+ {
1255
+ "ok": False,
1256
+ "code": "MANAGED_COMMAND_START_FAILED",
1257
+ "message": f"命令无法启动:{exc}",
1258
+ },
1259
+ ensure_ascii=False,
1260
+ ),
1261
+ file=sys.stderr,
1262
+ )
1263
+ return 126
1264
+
1265
+ duration = time.monotonic() - started
1266
+ status = "PASS" if not failures else "FAIL"
1267
+ print(
1268
+ f"[harness-test] {status} profile={args.profile} "
1269
+ f"modulesPassed={executed_count - len(failed_modules)} "
1270
+ f"modulesExecuted={executed_count} modulesPlanned={len(plan)} "
1271
+ f"durationSeconds={duration:.2f}",
1272
+ flush=True,
1273
+ )
1274
+ if failures:
1275
+ print("[harness-test] failures=" + ",".join(failures), file=sys.stderr)
1276
+ return 1
1277
+ return 0
1278
+
1279
+
1280
+ def _run_exec(args: argparse.Namespace) -> int:
1281
+ command = list(args.command)
1282
+ powershell: dict[str, str] | None = None
1283
+ if args.runtime_receipt and not args.argv_file:
1284
+ print(
1285
+ "RUNTIME_RECEIPT_REQUIRES_ARGUMENT_FILE: "
1286
+ "--runtime-receipt requires --argv-file",
1287
+ file=sys.stderr,
1288
+ )
1289
+ return 2
1290
+ if args.argv_file:
1291
+ if command:
1292
+ print(
1293
+ "ARGUMENT_TRANSPORT_CONFLICT: use either --argv-file or a command",
1294
+ file=sys.stderr,
1295
+ )
1296
+ return 2
1297
+ argv_file = Path(args.argv_file)
1298
+ project_for_path = Path(args.project).resolve()
1299
+ if not argv_file.is_absolute():
1300
+ argv_file = project_for_path / argv_file
1301
+ try:
1302
+ command, powershell = load_argv_envelope(argv_file)
1303
+ except ValueError as exc:
1304
+ print(f"ARGUMENT_FILE_INVALID: {exc}", file=sys.stderr)
1305
+ return 2
1306
+ elif command and command[0] == "--":
1307
+ command = command[1:]
1308
+ if not command:
1309
+ print(
1310
+ "managed exec requires a command after -- or --argv-file",
1311
+ file=sys.stderr,
1312
+ )
1313
+ return 2
1314
+ if _is_persistent_service_command(command):
1315
+ print(
1316
+ "PERSISTENT_SERVICE_MODE_REQUIRED: invoke harness_service.py ensure "
1317
+ "outside the bounded test runner",
1318
+ file=sys.stderr,
1319
+ )
1320
+ return 7
1321
+ if not resource_profile_allowed(
1322
+ args.profile,
1323
+ args.confirm_resource_intensive,
1324
+ os.environ,
1325
+ ):
1326
+ print(
1327
+ "RESOURCE_CONFIRMATION_REQUIRED: "
1328
+ f"profile={args.profile}; rerun with --confirm-resource-intensive",
1329
+ file=sys.stderr,
1330
+ )
1331
+ return 2
1332
+ project = Path(args.project).resolve()
1333
+ environment = _execution_environment(args.profile, args.max_workers)
1334
+ required_environment = list(args.required_environment_field or [])
1335
+ if required_environment and not args.environment_receipt:
1336
+ print(
1337
+ json.dumps(
1338
+ {
1339
+ "ok": False,
1340
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
1341
+ "missing": sorted(set(required_environment)),
1342
+ "changed": [],
1343
+ },
1344
+ ensure_ascii=False,
1345
+ ),
1346
+ file=sys.stderr,
1347
+ )
1348
+ return 6
1349
+ if args.environment_receipt:
1350
+ receipt_path = Path(args.environment_receipt)
1351
+ if not receipt_path.is_absolute():
1352
+ receipt_path = project / receipt_path
1353
+ try:
1354
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1355
+ except (OSError, json.JSONDecodeError) as exc:
1356
+ print(
1357
+ "VERIFICATION_ENVIRONMENT_INCOMPLETE: "
1358
+ f"environment receipt is unreadable: {exc}",
1359
+ file=sys.stderr,
1360
+ )
1361
+ return 6
1362
+ resolved = henv.resolve_verification_environment(
1363
+ receipt,
1364
+ required_fields=required_environment,
1365
+ source_environment=dict(os.environ),
1366
+ )
1367
+ if not resolved.get("ok"):
1368
+ print(json.dumps(resolved, ensure_ascii=False), file=sys.stderr)
1369
+ return 6
1370
+ environment.update(resolved["environment"])
1371
+ try:
1372
+ with TestRunLock(project) as run_lock:
1373
+ run_lock.heartbeat()
1374
+ if args.allow_detached_processes:
1375
+ available, detail = detached_process_capability(project)
1376
+ if not available:
1377
+ print(
1378
+ "DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
1379
+ f"{detail}; rerun in an environment that permits "
1380
+ "nested Windows breakaway",
1381
+ file=sys.stderr,
1382
+ )
1383
+ return 5
1384
+ result = run_managed_command(
1385
+ command,
1386
+ cwd=project,
1387
+ timeout_seconds=args.timeout_seconds,
1388
+ environ=environment,
1389
+ allow_detached_processes=args.allow_detached_processes,
1390
+ )
1391
+ run_lock.heartbeat()
1392
+ except TestRunAlreadyActive as exc:
1393
+ print(str(exc), file=sys.stderr)
1394
+ return 3
1395
+ except ManagedCommandNotFound as exc:
1396
+ print(
1397
+ json.dumps(
1398
+ {
1399
+ "ok": False,
1400
+ "code": "MANAGED_COMMAND_NOT_FOUND",
1401
+ "message": str(exc).split(":", 1)[-1].strip(),
1402
+ },
1403
+ ensure_ascii=False,
1404
+ ),
1405
+ file=sys.stderr,
1406
+ )
1407
+ return 127
1408
+ except OSError as exc:
1409
+ print(
1410
+ json.dumps(
1411
+ {
1412
+ "ok": False,
1413
+ "code": "MANAGED_COMMAND_START_FAILED",
1414
+ "message": f"命令无法启动:{exc}",
1415
+ },
1416
+ ensure_ascii=False,
1417
+ ),
1418
+ file=sys.stderr,
1419
+ )
1420
+ return 126
1421
+ if not result.process_tree_isolated:
1422
+ print("PROCESS_TREE_ISOLATION_UNAVAILABLE", file=sys.stderr)
1423
+ return 4
1424
+ if result.timed_out:
1425
+ print(
1426
+ f"TEST_COMMAND_TIMEOUT: timeoutSeconds={args.timeout_seconds}",
1427
+ file=sys.stderr,
1428
+ )
1429
+ return 124
1430
+ if args.runtime_receipt:
1431
+ assert powershell is not None
1432
+ receipt_path = Path(args.runtime_receipt)
1433
+ if not receipt_path.is_absolute():
1434
+ receipt_path = project / receipt_path
1435
+ _write_exec_runtime_receipt(
1436
+ receipt_path,
1437
+ command=command,
1438
+ powershell=powershell,
1439
+ result=result,
1440
+ )
1441
+ return result.returncode
1442
+
1443
+
1444
+ def _run_lock_status(args: argparse.Namespace) -> int:
1445
+ lock = TestRunLock(Path(args.project).resolve())
1446
+ print(
1447
+ json.dumps(
1448
+ {
1449
+ "ok": True,
1450
+ "action": "lock-status",
1451
+ "lock": classify_test_lock(lock.path),
1452
+ },
1453
+ ensure_ascii=False,
1454
+ indent=2,
1455
+ )
1456
+ )
1457
+ return 0
1458
+
1459
+
1460
+ def _run_lock_reap(args: argparse.Namespace) -> int:
1461
+ lock = TestRunLock(Path(args.project).resolve())
1462
+ print(json.dumps(reap_test_lock(lock.path), ensure_ascii=False, indent=2))
1463
+ return 0
1464
+
1465
+
1466
+ def build_parser() -> argparse.ArgumentParser:
1467
+ parser = argparse.ArgumentParser(
1468
+ description="Run tests with bounded workers, locking, timeout, and cleanup."
1469
+ )
1470
+ subparsers = parser.add_subparsers(dest="command_name", required=True)
1471
+
1472
+ common = argparse.ArgumentParser(add_help=False)
1473
+ common.add_argument("--project", default=".")
1474
+ common.add_argument(
1475
+ "--profile",
1476
+ choices=("safe", "system", "full"),
1477
+ default="safe",
1478
+ )
1479
+ common.add_argument(
1480
+ "--timeout-seconds",
1481
+ type=float,
1482
+ default=DEFAULT_TIMEOUT_SECONDS,
1483
+ )
1484
+ common.add_argument(
1485
+ "--max-workers",
1486
+ type=int,
1487
+ default=DEFAULT_MAX_WORKERS,
1488
+ )
1489
+ common.add_argument("--confirm-resource-intensive", action="store_true")
1490
+
1491
+ unittest_parser = subparsers.add_parser(
1492
+ "unittest",
1493
+ parents=[common],
1494
+ help="Run discovered Python unittest modules in isolated processes.",
1495
+ )
1496
+ unittest_parser.add_argument(
1497
+ "--tests-dir",
1498
+ default="harness/scripts/tests",
1499
+ )
1500
+ unittest_parser.add_argument(
1501
+ "--resource-module",
1502
+ action="append",
1503
+ default=list(DEFAULT_RESOURCE_MODULES),
1504
+ help="Module reserved for system/full profiles; may be repeated.",
1505
+ )
1506
+ unittest_parser.add_argument(
1507
+ "--detached-service-module",
1508
+ action="append",
1509
+ default=list(DEFAULT_DETACHED_SERVICE_MODULES),
1510
+ help="Module that intentionally tests detached services; may be repeated.",
1511
+ )
1512
+ unittest_parser.add_argument("--keep-going", action="store_true")
1513
+ unittest_parser.add_argument("--dry-run", action="store_true")
1514
+ unittest_parser.add_argument(
1515
+ "--verbosity",
1516
+ type=int,
1517
+ choices=(0, 1, 2),
1518
+ default=1,
1519
+ )
1520
+ unittest_parser.set_defaults(handler=_run_unittest)
1521
+
1522
+ exec_parser = subparsers.add_parser(
1523
+ "exec",
1524
+ parents=[common],
1525
+ help="Run one arbitrary test command under the same safety controls.",
1526
+ )
1527
+ exec_parser.add_argument("command", nargs=argparse.REMAINDER)
1528
+ exec_parser.add_argument(
1529
+ "--argv-file",
1530
+ help=(
1531
+ "Read argv from a UTF-8 JSON parameter file so PowerShell 5.1 and 7 "
1532
+ "use identical native-process argument semantics."
1533
+ ),
1534
+ )
1535
+ exec_parser.add_argument(
1536
+ "--runtime-receipt",
1537
+ help="Write a secret-free argv hash and PowerShell runtime receipt.",
1538
+ )
1539
+ exec_parser.add_argument(
1540
+ "--environment-receipt",
1541
+ help="Typed environment receipt created by harness_environment.py prepare.",
1542
+ )
1543
+ exec_parser.add_argument(
1544
+ "--required-environment-field",
1545
+ action="append",
1546
+ default=[],
1547
+ help="Dynamic environment field required by the command; may be repeated.",
1548
+ )
1549
+ exec_parser.add_argument(
1550
+ "--allow-detached-processes",
1551
+ action="store_true",
1552
+ help=(
1553
+ "Permit explicit Windows Job breakaway while retaining ordinary "
1554
+ "descendants and tracking escaped process lineages."
1555
+ ),
1556
+ )
1557
+ exec_parser.set_defaults(handler=_run_exec)
1558
+
1559
+ lock_status = subparsers.add_parser(
1560
+ "lock-status",
1561
+ help="Classify the project test lock without mutating it.",
1562
+ )
1563
+ lock_status.add_argument("--project", default=".")
1564
+ lock_status.set_defaults(handler=_run_lock_status)
1565
+
1566
+ lock_reap = subparsers.add_parser(
1567
+ "lock-reap",
1568
+ help="Reap only an expired lock whose exact owner is absent.",
1569
+ )
1570
+ lock_reap.add_argument("--project", default=".")
1571
+ lock_reap.set_defaults(handler=_run_lock_reap)
1572
+ return parser
1573
+
1574
+
1575
+ def main(argv: Sequence[str] | None = None) -> int:
1576
+ parser = build_parser()
1577
+ args = parser.parse_args(argv)
1578
+ if getattr(args, "timeout_seconds", DEFAULT_TIMEOUT_SECONDS) <= 0:
1579
+ parser.error("--timeout-seconds must be positive")
1580
+ if getattr(args, "max_workers", DEFAULT_MAX_WORKERS) <= 0:
1581
+ parser.error("--max-workers must be positive")
1582
+ return int(args.handler(args))
1583
+
1584
+
1585
+ if __name__ == "__main__":
1586
+ raise SystemExit(main())