@hunter-harness/workflow-harness 0.2.39 → 0.2.41

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 (230) 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-archive/checklist.md +3 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/reference.md +16 -16
  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-run/reference.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
  16. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +10 -0
  17. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +12 -0
  18. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +21 -0
  19. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +11 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +413 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +10 -51
  22. package/harness/bundles/general/claude-code/scripts/harness_environment.py +556 -39
  23. package/harness/bundles/general/claude-code/scripts/harness_gate.py +145 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +581 -55
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -6
  26. package/harness/bundles/general/claude-code/scripts/harness_service.py +291 -22
  27. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +405 -25
  28. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  29. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +3 -0
  31. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  32. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-plan/reference.md +16 -16
  37. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-run/reference.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
  43. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +10 -0
  44. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +12 -0
  45. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +21 -0
  46. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +11 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +413 -2
  48. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +10 -51
  49. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +556 -39
  50. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +145 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +581 -55
  52. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -6
  53. package/harness/bundles/general/codebuddy/scripts/harness_service.py +291 -22
  54. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +405 -25
  55. package/harness/bundles/general/codex/.harness-build.json +1 -1
  56. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-archive/checklist.md +3 -0
  58. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +22 -0
  59. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-plan/reference.md +16 -16
  64. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-run/reference.md +1 -1
  67. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
  70. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +10 -0
  71. package/harness/bundles/general/codex/protocols/ledger-protocol.md +12 -0
  72. package/harness/bundles/general/codex/protocols/powershell-protocol.md +21 -0
  73. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +11 -0
  74. package/harness/bundles/general/codex/scripts/harness_archive.py +413 -2
  75. package/harness/bundles/general/codex/scripts/harness_deploy.py +10 -51
  76. package/harness/bundles/general/codex/scripts/harness_environment.py +556 -39
  77. package/harness/bundles/general/codex/scripts/harness_gate.py +145 -0
  78. package/harness/bundles/general/codex/scripts/harness_ledger.py +581 -55
  79. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -6
  80. package/harness/bundles/general/codex/scripts/harness_service.py +291 -22
  81. package/harness/bundles/general/codex/scripts/harness_test_runner.py +405 -25
  82. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  83. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-archive/checklist.md +3 -0
  85. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +22 -0
  86. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  90. package/harness/bundles/general/cursor/harness-plan/reference.md +16 -16
  91. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-run/reference.md +1 -1
  94. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
  97. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +10 -0
  98. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +12 -0
  99. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +21 -0
  100. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +11 -0
  101. package/harness/bundles/general/cursor/scripts/harness_archive.py +413 -2
  102. package/harness/bundles/general/cursor/scripts/harness_deploy.py +10 -51
  103. package/harness/bundles/general/cursor/scripts/harness_environment.py +556 -39
  104. package/harness/bundles/general/cursor/scripts/harness_gate.py +145 -0
  105. package/harness/bundles/general/cursor/scripts/harness_ledger.py +581 -55
  106. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -6
  107. package/harness/bundles/general/cursor/scripts/harness_service.py +291 -22
  108. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +405 -25
  109. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  110. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-archive/checklist.md +3 -0
  113. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  114. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-plan/reference.md +16 -16
  120. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  125. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +10 -0
  126. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +12 -0
  127. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +21 -0
  128. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +11 -0
  129. package/harness/bundles/java/claude-code/scripts/harness_archive.py +413 -2
  130. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +10 -51
  131. package/harness/bundles/java/claude-code/scripts/harness_environment.py +556 -39
  132. package/harness/bundles/java/claude-code/scripts/harness_gate.py +145 -0
  133. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +581 -55
  134. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -6
  135. package/harness/bundles/java/claude-code/scripts/harness_service.py +291 -22
  136. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +405 -25
  137. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  138. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +3 -0
  141. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  142. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-plan/reference.md +16 -16
  148. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  153. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +10 -0
  154. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +12 -0
  155. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +21 -0
  156. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +11 -0
  157. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +413 -2
  158. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +10 -51
  159. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +556 -39
  160. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +145 -0
  161. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +581 -55
  162. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -6
  163. package/harness/bundles/java/codebuddy/scripts/harness_service.py +291 -22
  164. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +405 -25
  165. package/harness/bundles/java/codex/.harness-build.json +1 -1
  166. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-archive/checklist.md +3 -0
  169. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +22 -0
  170. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-plan/reference.md +16 -16
  176. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  181. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +10 -0
  182. package/harness/bundles/java/codex/protocols/ledger-protocol.md +12 -0
  183. package/harness/bundles/java/codex/protocols/powershell-protocol.md +21 -0
  184. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +11 -0
  185. package/harness/bundles/java/codex/scripts/harness_archive.py +413 -2
  186. package/harness/bundles/java/codex/scripts/harness_deploy.py +10 -51
  187. package/harness/bundles/java/codex/scripts/harness_environment.py +556 -39
  188. package/harness/bundles/java/codex/scripts/harness_gate.py +145 -0
  189. package/harness/bundles/java/codex/scripts/harness_ledger.py +581 -55
  190. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -6
  191. package/harness/bundles/java/codex/scripts/harness_service.py +291 -22
  192. package/harness/bundles/java/codex/scripts/harness_test_runner.py +405 -25
  193. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  194. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-archive/checklist.md +3 -0
  197. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +22 -0
  198. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  202. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-plan/reference.md +16 -16
  204. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  209. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +10 -0
  210. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +12 -0
  211. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +21 -0
  212. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +11 -0
  213. package/harness/bundles/java/cursor/scripts/harness_archive.py +413 -2
  214. package/harness/bundles/java/cursor/scripts/harness_deploy.py +10 -51
  215. package/harness/bundles/java/cursor/scripts/harness_environment.py +556 -39
  216. package/harness/bundles/java/cursor/scripts/harness_gate.py +145 -0
  217. package/harness/bundles/java/cursor/scripts/harness_ledger.py +581 -55
  218. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -6
  219. package/harness/bundles/java/cursor/scripts/harness_service.py +291 -22
  220. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +405 -25
  221. package/harness/manifests/general/claude-code.json +29 -29
  222. package/harness/manifests/general/codebuddy.json +29 -29
  223. package/harness/manifests/general/codex.json +29 -29
  224. package/harness/manifests/general/cursor.json +29 -29
  225. package/harness/manifests/java/claude-code.json +30 -30
  226. package/harness/manifests/java/codebuddy.json +30 -30
  227. package/harness/manifests/java/codex.json +30 -30
  228. package/harness/manifests/java/cursor.json +30 -30
  229. package/hunter-workflow-family.json +4 -4
  230. package/package.json +1 -1
@@ -24,6 +24,12 @@ from dataclasses import dataclass
24
24
  from pathlib import Path
25
25
  from typing import Mapping, Sequence
26
26
 
27
+ SCRIPT_DIR = Path(__file__).resolve().parent
28
+ if str(SCRIPT_DIR) not in sys.path:
29
+ sys.path.insert(0, str(SCRIPT_DIR))
30
+
31
+ import harness_environment as henv
32
+
27
33
  DEFAULT_MAX_WORKERS = 2
28
34
  DEFAULT_TIMEOUT_SECONDS = 300.0
29
35
  RESOURCE_INTENSIVE_PROFILES = frozenset({"system", "full"})
@@ -161,6 +167,115 @@ def _pid_is_running(pid: int) -> bool:
161
167
  return True
162
168
 
163
169
 
170
+ def classify_test_lock(path: Path) -> dict[str, object]:
171
+ """Classify one lock without mutating it or terminating a process."""
172
+ try:
173
+ payload = json.loads(path.read_text(encoding="utf-8"))
174
+ except FileNotFoundError:
175
+ return {"classification": "ABSENT", "path": str(path)}
176
+ except (OSError, json.JSONDecodeError) as exc:
177
+ return {
178
+ "classification": "LOCK_UNREADABLE",
179
+ "path": str(path),
180
+ "message": str(exc),
181
+ }
182
+ if not isinstance(payload, dict):
183
+ return {"classification": "LOCK_UNREADABLE", "path": str(path)}
184
+ owner = payload.get("owner")
185
+ expires = payload.get("expiresAtUnix")
186
+ if not isinstance(expires, (int, float)) or isinstance(expires, bool):
187
+ return {
188
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
189
+ "path": str(path),
190
+ "token": payload.get("token"),
191
+ }
192
+ if time.time() < float(expires):
193
+ return {
194
+ "classification": "ACTIVE",
195
+ "path": str(path),
196
+ "token": payload.get("token"),
197
+ "owner": owner,
198
+ }
199
+ if not isinstance(owner, dict) or any(
200
+ not owner.get(field) for field in ("pid", "executable", "startedAt")
201
+ ):
202
+ return {
203
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
204
+ "path": str(path),
205
+ "token": payload.get("token"),
206
+ }
207
+ try:
208
+ owner_pid = int(owner["pid"])
209
+ except (TypeError, ValueError):
210
+ return {
211
+ "classification": "OWNER_IDENTITY_INCOMPLETE",
212
+ "path": str(path),
213
+ "token": payload.get("token"),
214
+ }
215
+ if not _pid_is_running(owner_pid):
216
+ return {
217
+ "classification": "RECLAIMABLE",
218
+ "path": str(path),
219
+ "token": payload.get("token"),
220
+ "owner": owner,
221
+ }
222
+ verified = henv.hservice.verify_process_identity(
223
+ {
224
+ "pid": owner_pid,
225
+ "startedAt": owner["startedAt"],
226
+ "processIdentity": {"executable": owner["executable"]},
227
+ }
228
+ )
229
+ classification = (
230
+ "OWNER_ACTIVE"
231
+ if verified is True
232
+ else (
233
+ "OWNER_IDENTITY_UNCONFIRMED"
234
+ if verified is None
235
+ else "OWNER_IDENTITY_MISMATCH"
236
+ )
237
+ )
238
+ return {
239
+ "classification": classification,
240
+ "path": str(path),
241
+ "token": payload.get("token"),
242
+ "owner": owner,
243
+ }
244
+
245
+
246
+ def reap_test_lock(path: Path) -> dict[str, object]:
247
+ """Reap only an expired lock whose exact recorded owner is absent."""
248
+ classification = classify_test_lock(path)
249
+ reaped = classification.get("classification") == "RECLAIMABLE"
250
+ if reaped:
251
+ try:
252
+ path.unlink()
253
+ except FileNotFoundError:
254
+ pass
255
+ receipt = {
256
+ "schemaVersion": 1,
257
+ "action": "test-run-lock-reap",
258
+ "status": "REAPED",
259
+ "completedAtUnix": time.time(),
260
+ "lockPath": str(path),
261
+ "prior": classification,
262
+ }
263
+ receipt_dir = path.parent / "receipts"
264
+ receipt_dir.mkdir(parents=True, exist_ok=True)
265
+ target = receipt_dir / f"{path.stem}-reap-{uuid.uuid4().hex}.json"
266
+ target.write_text(
267
+ json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
268
+ encoding="utf-8",
269
+ newline="\n",
270
+ )
271
+ return {
272
+ "ok": True,
273
+ "code": "TEST_RUN_LOCK_REAPED" if reaped else "TEST_RUN_LOCK_REPORT_ONLY",
274
+ "reaped": reaped,
275
+ "lock": classification,
276
+ }
277
+
278
+
164
279
  class TestRunLock:
165
280
  """Atomic per-project single-instance lock with stale-owner recovery."""
166
281
 
@@ -182,6 +297,7 @@ class TestRunLock:
182
297
  self.path = root / f"{digest}.lock"
183
298
  self.token = uuid.uuid4().hex
184
299
  self._owned = False
300
+ self._ttl_seconds = 3600.0
185
301
 
186
302
  def _owner(self) -> dict[str, object]:
187
303
  try:
@@ -199,34 +315,42 @@ class TestRunLock:
199
315
  0o600,
200
316
  )
201
317
  except FileExistsError:
202
- owner = self._owner()
203
- try:
204
- owner_pid = int(owner.get("pid", 0))
205
- except (TypeError, ValueError):
206
- owner_pid = 0
207
- try:
208
- age_seconds = time.time() - self.path.stat().st_mtime
209
- except OSError:
210
- age_seconds = 0
211
- if _pid_is_running(owner_pid) or age_seconds < 30:
318
+ classification = classify_test_lock(self.path)
319
+ if classification.get("classification") != "RECLAIMABLE":
212
320
  raise TestRunAlreadyActive(
213
321
  "TEST_RUN_ALREADY_ACTIVE: "
214
- f"lock={self.path} ownerPid={owner_pid or 'unknown'}"
322
+ f"lock={self.path} "
323
+ f"classification={classification.get('classification')}"
215
324
  )
216
- try:
217
- self.path.unlink()
218
- except FileNotFoundError:
219
- pass
325
+ reap_test_lock(self.path)
220
326
  if attempt == 0:
221
327
  continue
222
328
  raise TestRunAlreadyActive(
223
329
  f"TEST_RUN_ALREADY_ACTIVE: could not reclaim {self.path}"
224
330
  )
225
331
  else:
332
+ now = time.time()
333
+ created = henv.hservice.get_process_create_time(os.getpid())
334
+ executable = (
335
+ henv.hservice.get_process_executable(os.getpid())
336
+ or str(Path(sys.executable).resolve())
337
+ )
226
338
  payload = {
227
- "pid": os.getpid(),
339
+ "schemaVersion": 2,
228
340
  "token": self.token,
229
- "startedAtUnix": time.time(),
341
+ "status": "ACTIVE",
342
+ "createdAtUnix": now,
343
+ "heartbeatAtUnix": now,
344
+ "expiresAtUnix": now + self._ttl_seconds,
345
+ "owner": {
346
+ "pid": os.getpid(),
347
+ "executable": executable,
348
+ "startedAt": (
349
+ created.isoformat(timespec="seconds")
350
+ if created is not None
351
+ else ""
352
+ ),
353
+ },
230
354
  }
231
355
  with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
232
356
  json.dump(payload, stream, ensure_ascii=False)
@@ -235,11 +359,49 @@ class TestRunLock:
235
359
  return
236
360
  raise TestRunAlreadyActive(f"TEST_RUN_ALREADY_ACTIVE: {self.path}")
237
361
 
238
- def release(self) -> None:
362
+ def heartbeat(self) -> None:
363
+ if not self._owned:
364
+ return
365
+ owner = self._owner()
366
+ if owner.get("token") != self.token:
367
+ raise TestRunAlreadyActive(
368
+ f"TEST_RUN_LOCK_OWNERSHIP_LOST: lock={self.path}"
369
+ )
370
+ now = time.time()
371
+ owner["heartbeatAtUnix"] = now
372
+ owner["expiresAtUnix"] = now + self._ttl_seconds
373
+ with self.path.open("w", encoding="utf-8", newline="\n") as stream:
374
+ json.dump(owner, stream, ensure_ascii=False)
375
+ stream.flush()
376
+ os.fsync(stream.fileno())
377
+
378
+ def _write_closeout_receipt(self, *, status: str) -> None:
379
+ receipt = {
380
+ "schemaVersion": 1,
381
+ "action": "test-run-lock-closeout",
382
+ "status": status,
383
+ "token": self.token,
384
+ "lockPath": str(self.path),
385
+ "closedAtUnix": time.time(),
386
+ "ownerPid": os.getpid(),
387
+ }
388
+ receipt_dir = self.path.parent / "receipts"
389
+ receipt_dir.mkdir(parents=True, exist_ok=True)
390
+ target = receipt_dir / f"{self.path.stem}-{self.token}.json"
391
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
392
+ temporary.write_text(
393
+ json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
394
+ encoding="utf-8",
395
+ newline="\n",
396
+ )
397
+ os.replace(temporary, target)
398
+
399
+ def release(self, *, status: str = "COMPLETED") -> None:
239
400
  if not self._owned:
240
401
  return
241
402
  owner = self._owner()
242
403
  if owner.get("token") == self.token:
404
+ self._write_closeout_receipt(status=status)
243
405
  try:
244
406
  self.path.unlink()
245
407
  except FileNotFoundError:
@@ -251,7 +413,7 @@ class TestRunLock:
251
413
  return self
252
414
 
253
415
  def __exit__(self, exc_type, exc, traceback) -> None:
254
- self.release()
416
+ self.release(status="ABNORMAL" if exc_type is not None else "COMPLETED")
255
417
 
256
418
 
257
419
  def lower_process_priority() -> bool:
@@ -709,6 +871,81 @@ def _execution_environment(profile: str, max_workers: int) -> dict[str, str]:
709
871
  }
710
872
 
711
873
 
874
+ def load_argv_envelope(path: Path) -> tuple[list[str], dict[str, str]]:
875
+ """Load a shell-neutral UTF-8 argument file without reparsing command text."""
876
+ try:
877
+ raw = json.loads(path.read_text(encoding="utf-8-sig"))
878
+ except (OSError, json.JSONDecodeError) as exc:
879
+ raise ValueError(f"argument file is unreadable: {exc}") from exc
880
+ argv = raw.get("argv") if isinstance(raw, dict) else None
881
+ powershell = raw.get("powershell") if isinstance(raw, dict) else None
882
+ if (
883
+ not isinstance(raw, dict)
884
+ or raw.get("schemaVersion") != 1
885
+ or raw.get("transport") != "utf8-json-argument-file"
886
+ or not isinstance(argv, list)
887
+ or not argv
888
+ or any(not isinstance(item, str) or "\0" in item for item in argv)
889
+ or not isinstance(powershell, dict)
890
+ ):
891
+ raise ValueError(
892
+ "argument file must declare schemaVersion=1, "
893
+ "transport=utf8-json-argument-file, argv[], and powershell metadata"
894
+ )
895
+ shell = {
896
+ field: str(powershell.get(field) or "").strip()
897
+ for field in ("edition", "version")
898
+ }
899
+ if not all(shell.values()):
900
+ raise ValueError("argument file powershell edition/version are required")
901
+ return list(argv), shell
902
+
903
+
904
+ def _is_persistent_service_command(command: Sequence[str]) -> bool:
905
+ """Recognize formal service-manager launches that must outlive this runner."""
906
+ normalized = [str(item).replace("\\", "/").lower() for item in command]
907
+ return "ensure" in normalized and any(
908
+ item.rsplit("/", 1)[-1] == "harness_service.py" for item in normalized
909
+ )
910
+
911
+
912
+ def _write_exec_runtime_receipt(
913
+ path: Path,
914
+ *,
915
+ command: Sequence[str],
916
+ powershell: Mapping[str, str],
917
+ result: CommandResult,
918
+ ) -> None:
919
+ canonical_argv = json.dumps(
920
+ list(command),
921
+ ensure_ascii=False,
922
+ separators=(",", ":"),
923
+ ).encode("utf-8")
924
+ payload = {
925
+ "schemaVersion": 1,
926
+ "action": "managed-exec",
927
+ "transport": "utf8-json-argument-file",
928
+ "completedAtEpochSeconds": int(time.time()),
929
+ "argvHash": "sha256:" + hashlib.sha256(canonical_argv).hexdigest(),
930
+ "argumentCount": len(command),
931
+ "powershell": {
932
+ "edition": str(powershell["edition"]),
933
+ "version": str(powershell["version"]),
934
+ },
935
+ "returncode": result.returncode,
936
+ "timedOut": result.timed_out,
937
+ "processTreeIsolated": result.process_tree_isolated,
938
+ }
939
+ path.parent.mkdir(parents=True, exist_ok=True)
940
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
941
+ temporary.write_text(
942
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
943
+ encoding="utf-8",
944
+ newline="\n",
945
+ )
946
+ os.replace(temporary, path)
947
+
948
+
712
949
  def _print_plan(profile: str, plan: Sequence[TestModule]) -> None:
713
950
  payload = {
714
951
  "profile": profile,
@@ -757,8 +994,9 @@ def _run_unittest(args: argparse.Namespace) -> int:
757
994
  detached_service_modules = set(args.detached_service_module)
758
995
  detached_capability_checked = False
759
996
  try:
760
- with TestRunLock(project):
997
+ with TestRunLock(project) as run_lock:
761
998
  for index, module in enumerate(plan, start=1):
999
+ run_lock.heartbeat()
762
1000
  if (
763
1001
  module.name in detached_service_modules
764
1002
  and not detached_capability_checked
@@ -800,6 +1038,7 @@ def _run_unittest(args: argparse.Namespace) -> int:
800
1038
  capture_output=args.verbosity == 0,
801
1039
  allow_detached_processes=module.name in detached_service_modules,
802
1040
  )
1041
+ run_lock.heartbeat()
803
1042
  executed_count += 1
804
1043
  module_failure_count = len(failures)
805
1044
  if result.timed_out:
@@ -837,11 +1076,45 @@ def _run_unittest(args: argparse.Namespace) -> int:
837
1076
 
838
1077
  def _run_exec(args: argparse.Namespace) -> int:
839
1078
  command = list(args.command)
840
- if command and command[0] == "--":
1079
+ powershell: dict[str, str] | None = None
1080
+ if args.runtime_receipt and not args.argv_file:
1081
+ print(
1082
+ "RUNTIME_RECEIPT_REQUIRES_ARGUMENT_FILE: "
1083
+ "--runtime-receipt requires --argv-file",
1084
+ file=sys.stderr,
1085
+ )
1086
+ return 2
1087
+ if args.argv_file:
1088
+ if command:
1089
+ print(
1090
+ "ARGUMENT_TRANSPORT_CONFLICT: use either --argv-file or a command",
1091
+ file=sys.stderr,
1092
+ )
1093
+ return 2
1094
+ argv_file = Path(args.argv_file)
1095
+ project_for_path = Path(args.project).resolve()
1096
+ if not argv_file.is_absolute():
1097
+ argv_file = project_for_path / argv_file
1098
+ try:
1099
+ command, powershell = load_argv_envelope(argv_file)
1100
+ except ValueError as exc:
1101
+ print(f"ARGUMENT_FILE_INVALID: {exc}", file=sys.stderr)
1102
+ return 2
1103
+ elif command and command[0] == "--":
841
1104
  command = command[1:]
842
1105
  if not command:
843
- print("managed exec requires a command after --", file=sys.stderr)
1106
+ print(
1107
+ "managed exec requires a command after -- or --argv-file",
1108
+ file=sys.stderr,
1109
+ )
844
1110
  return 2
1111
+ if _is_persistent_service_command(command):
1112
+ print(
1113
+ "PERSISTENT_SERVICE_MODE_REQUIRED: invoke harness_service.py ensure "
1114
+ "outside the bounded test runner",
1115
+ file=sys.stderr,
1116
+ )
1117
+ return 7
845
1118
  if not resource_profile_allowed(
846
1119
  args.profile,
847
1120
  args.confirm_resource_intensive,
@@ -855,8 +1128,46 @@ def _run_exec(args: argparse.Namespace) -> int:
855
1128
  return 2
856
1129
  project = Path(args.project).resolve()
857
1130
  environment = _execution_environment(args.profile, args.max_workers)
1131
+ required_environment = list(args.required_environment_field or [])
1132
+ if required_environment and not args.environment_receipt:
1133
+ print(
1134
+ json.dumps(
1135
+ {
1136
+ "ok": False,
1137
+ "code": "VERIFICATION_ENVIRONMENT_INCOMPLETE",
1138
+ "missing": sorted(set(required_environment)),
1139
+ "changed": [],
1140
+ },
1141
+ ensure_ascii=False,
1142
+ ),
1143
+ file=sys.stderr,
1144
+ )
1145
+ return 6
1146
+ if args.environment_receipt:
1147
+ receipt_path = Path(args.environment_receipt)
1148
+ if not receipt_path.is_absolute():
1149
+ receipt_path = project / receipt_path
1150
+ try:
1151
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1152
+ except (OSError, json.JSONDecodeError) as exc:
1153
+ print(
1154
+ "VERIFICATION_ENVIRONMENT_INCOMPLETE: "
1155
+ f"environment receipt is unreadable: {exc}",
1156
+ file=sys.stderr,
1157
+ )
1158
+ return 6
1159
+ resolved = henv.resolve_verification_environment(
1160
+ receipt,
1161
+ required_fields=required_environment,
1162
+ source_environment=dict(os.environ),
1163
+ )
1164
+ if not resolved.get("ok"):
1165
+ print(json.dumps(resolved, ensure_ascii=False), file=sys.stderr)
1166
+ return 6
1167
+ environment.update(resolved["environment"])
858
1168
  try:
859
- with TestRunLock(project):
1169
+ with TestRunLock(project) as run_lock:
1170
+ run_lock.heartbeat()
860
1171
  if args.allow_detached_processes:
861
1172
  available, detail = detached_process_capability(project)
862
1173
  if not available:
@@ -874,6 +1185,7 @@ def _run_exec(args: argparse.Namespace) -> int:
874
1185
  environ=environment,
875
1186
  allow_detached_processes=args.allow_detached_processes,
876
1187
  )
1188
+ run_lock.heartbeat()
877
1189
  except TestRunAlreadyActive as exc:
878
1190
  print(str(exc), file=sys.stderr)
879
1191
  return 3
@@ -886,9 +1198,42 @@ def _run_exec(args: argparse.Namespace) -> int:
886
1198
  file=sys.stderr,
887
1199
  )
888
1200
  return 124
1201
+ if args.runtime_receipt:
1202
+ assert powershell is not None
1203
+ receipt_path = Path(args.runtime_receipt)
1204
+ if not receipt_path.is_absolute():
1205
+ receipt_path = project / receipt_path
1206
+ _write_exec_runtime_receipt(
1207
+ receipt_path,
1208
+ command=command,
1209
+ powershell=powershell,
1210
+ result=result,
1211
+ )
889
1212
  return result.returncode
890
1213
 
891
1214
 
1215
+ def _run_lock_status(args: argparse.Namespace) -> int:
1216
+ lock = TestRunLock(Path(args.project).resolve())
1217
+ print(
1218
+ json.dumps(
1219
+ {
1220
+ "ok": True,
1221
+ "action": "lock-status",
1222
+ "lock": classify_test_lock(lock.path),
1223
+ },
1224
+ ensure_ascii=False,
1225
+ indent=2,
1226
+ )
1227
+ )
1228
+ return 0
1229
+
1230
+
1231
+ def _run_lock_reap(args: argparse.Namespace) -> int:
1232
+ lock = TestRunLock(Path(args.project).resolve())
1233
+ print(json.dumps(reap_test_lock(lock.path), ensure_ascii=False, indent=2))
1234
+ return 0
1235
+
1236
+
892
1237
  def build_parser() -> argparse.ArgumentParser:
893
1238
  parser = argparse.ArgumentParser(
894
1239
  description="Run tests with bounded workers, locking, timeout, and cleanup."
@@ -951,21 +1296,56 @@ def build_parser() -> argparse.ArgumentParser:
951
1296
  help="Run one arbitrary test command under the same safety controls.",
952
1297
  )
953
1298
  exec_parser.add_argument("command", nargs=argparse.REMAINDER)
1299
+ exec_parser.add_argument(
1300
+ "--argv-file",
1301
+ help=(
1302
+ "Read argv from a UTF-8 JSON parameter file so PowerShell 5.1 and 7 "
1303
+ "use identical native-process argument semantics."
1304
+ ),
1305
+ )
1306
+ exec_parser.add_argument(
1307
+ "--runtime-receipt",
1308
+ help="Write a secret-free argv hash and PowerShell runtime receipt.",
1309
+ )
1310
+ exec_parser.add_argument(
1311
+ "--environment-receipt",
1312
+ help="Typed environment receipt created by harness_environment.py prepare.",
1313
+ )
1314
+ exec_parser.add_argument(
1315
+ "--required-environment-field",
1316
+ action="append",
1317
+ default=[],
1318
+ help="Dynamic environment field required by the command; may be repeated.",
1319
+ )
954
1320
  exec_parser.add_argument(
955
1321
  "--allow-detached-processes",
956
1322
  action="store_true",
957
1323
  help="Use exact Windows PID-lineage tracking instead of a Job Object.",
958
1324
  )
959
1325
  exec_parser.set_defaults(handler=_run_exec)
1326
+
1327
+ lock_status = subparsers.add_parser(
1328
+ "lock-status",
1329
+ help="Classify the project test lock without mutating it.",
1330
+ )
1331
+ lock_status.add_argument("--project", default=".")
1332
+ lock_status.set_defaults(handler=_run_lock_status)
1333
+
1334
+ lock_reap = subparsers.add_parser(
1335
+ "lock-reap",
1336
+ help="Reap only an expired lock whose exact owner is absent.",
1337
+ )
1338
+ lock_reap.add_argument("--project", default=".")
1339
+ lock_reap.set_defaults(handler=_run_lock_reap)
960
1340
  return parser
961
1341
 
962
1342
 
963
1343
  def main(argv: Sequence[str] | None = None) -> int:
964
1344
  parser = build_parser()
965
1345
  args = parser.parse_args(argv)
966
- if args.timeout_seconds <= 0:
1346
+ if getattr(args, "timeout_seconds", DEFAULT_TIMEOUT_SECONDS) <= 0:
967
1347
  parser.error("--timeout-seconds must be positive")
968
- if args.max_workers <= 0:
1348
+ if getattr(args, "max_workers", DEFAULT_MAX_WORKERS) <= 0:
969
1349
  parser.error("--max-workers must be positive")
970
1350
  return int(args.handler(args))
971
1351