@hunter-harness/workflow-harness 0.2.42 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/build-profile-v3.schema.json +56 -1
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -0
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +6 -1
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
  17. package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
  18. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
  19. package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +4 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +290 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_environment.py +719 -35
  23. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_profile.py +34 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1165 -3
  26. package/harness/bundles/general/claude-code/scripts/harness_service.py +76 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +127 -39
  28. package/harness/bundles/general/claude-code/scripts/harness_verification.py +408 -8
  29. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  30. package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +56 -1
  31. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -0
  33. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  34. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -1
  42. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
  45. package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
  46. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
  47. package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +4 -0
  49. package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +290 -0
  50. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +719 -35
  51. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
  52. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +34 -0
  53. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1165 -3
  54. package/harness/bundles/general/codebuddy/scripts/harness_service.py +76 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +127 -39
  56. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +408 -8
  57. package/harness/bundles/general/codex/.harness-build.json +1 -1
  58. package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +56 -1
  59. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-archive/reference.md +1 -0
  61. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
  62. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
  63. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-run/reference.md +6 -1
  70. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  71. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
  73. package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
  74. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
  75. package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
  76. package/harness/bundles/general/codex/scripts/harness_archive.py +4 -0
  77. package/harness/bundles/general/codex/scripts/harness_efficiency.py +290 -0
  78. package/harness/bundles/general/codex/scripts/harness_environment.py +719 -35
  79. package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
  80. package/harness/bundles/general/codex/scripts/harness_profile.py +34 -0
  81. package/harness/bundles/general/codex/scripts/harness_runtime.py +1165 -3
  82. package/harness/bundles/general/codex/scripts/harness_service.py +76 -0
  83. package/harness/bundles/general/codex/scripts/harness_test_runner.py +127 -39
  84. package/harness/bundles/general/codex/scripts/harness_verification.py +408 -8
  85. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  86. package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +56 -1
  87. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-archive/reference.md +1 -0
  89. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
  90. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
  91. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  94. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  97. package/harness/bundles/general/cursor/harness-run/reference.md +6 -1
  98. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  99. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  100. package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
  101. package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
  102. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
  103. package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
  104. package/harness/bundles/general/cursor/scripts/harness_archive.py +4 -0
  105. package/harness/bundles/general/cursor/scripts/harness_efficiency.py +290 -0
  106. package/harness/bundles/general/cursor/scripts/harness_environment.py +719 -35
  107. package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
  108. package/harness/bundles/general/cursor/scripts/harness_profile.py +34 -0
  109. package/harness/bundles/general/cursor/scripts/harness_runtime.py +1165 -3
  110. package/harness/bundles/general/cursor/scripts/harness_service.py +76 -0
  111. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +127 -39
  112. package/harness/bundles/general/cursor/scripts/harness_verification.py +408 -8
  113. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  114. package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +56 -1
  115. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-archive/reference.md +1 -0
  118. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
  119. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -0
  120. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
  130. package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
  132. package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
  133. package/harness/bundles/java/claude-code/scripts/harness_archive.py +4 -0
  134. package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +290 -0
  135. package/harness/bundles/java/claude-code/scripts/harness_environment.py +719 -35
  136. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
  137. package/harness/bundles/java/claude-code/scripts/harness_profile.py +34 -0
  138. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1165 -3
  139. package/harness/bundles/java/claude-code/scripts/harness_service.py +76 -0
  140. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +127 -39
  141. package/harness/bundles/java/claude-code/scripts/harness_verification.py +408 -8
  142. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  143. package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +56 -1
  144. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-archive/reference.md +1 -0
  147. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
  148. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
  149. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  153. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  156. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  157. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  158. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
  159. package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
  160. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
  161. package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
  162. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +4 -0
  163. package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +290 -0
  164. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +719 -35
  165. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
  166. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +34 -0
  167. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1165 -3
  168. package/harness/bundles/java/codebuddy/scripts/harness_service.py +76 -0
  169. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +127 -39
  170. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +408 -8
  171. package/harness/bundles/java/codex/.harness-build.json +1 -1
  172. package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +56 -1
  173. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-archive/reference.md +1 -0
  176. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
  177. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
  178. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  181. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  182. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  183. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  184. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  186. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  187. package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
  188. package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
  189. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
  190. package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
  191. package/harness/bundles/java/codex/scripts/harness_archive.py +4 -0
  192. package/harness/bundles/java/codex/scripts/harness_efficiency.py +290 -0
  193. package/harness/bundles/java/codex/scripts/harness_environment.py +719 -35
  194. package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
  195. package/harness/bundles/java/codex/scripts/harness_profile.py +34 -0
  196. package/harness/bundles/java/codex/scripts/harness_runtime.py +1165 -3
  197. package/harness/bundles/java/codex/scripts/harness_service.py +76 -0
  198. package/harness/bundles/java/codex/scripts/harness_test_runner.py +127 -39
  199. package/harness/bundles/java/codex/scripts/harness_verification.py +408 -8
  200. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  201. package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +56 -1
  202. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  204. package/harness/bundles/java/cursor/harness-archive/reference.md +1 -0
  205. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
  206. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
  207. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  209. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  210. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  211. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  212. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  213. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  214. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  216. package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
  217. package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
  218. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
  219. package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
  220. package/harness/bundles/java/cursor/scripts/harness_archive.py +4 -0
  221. package/harness/bundles/java/cursor/scripts/harness_efficiency.py +290 -0
  222. package/harness/bundles/java/cursor/scripts/harness_environment.py +719 -35
  223. package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
  224. package/harness/bundles/java/cursor/scripts/harness_profile.py +34 -0
  225. package/harness/bundles/java/cursor/scripts/harness_runtime.py +1165 -3
  226. package/harness/bundles/java/cursor/scripts/harness_service.py +76 -0
  227. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +127 -39
  228. package/harness/bundles/java/cursor/scripts/harness_verification.py +408 -8
  229. package/harness/manifests/general/claude-code.json +40 -27
  230. package/harness/manifests/general/codebuddy.json +40 -27
  231. package/harness/manifests/general/codex.json +40 -27
  232. package/harness/manifests/general/cursor.json +40 -27
  233. package/harness/manifests/java/claude-code.json +41 -28
  234. package/harness/manifests/java/codebuddy.json +41 -28
  235. package/harness/manifests/java/codex.json +41 -28
  236. package/harness/manifests/java/cursor.json +41 -28
  237. package/hunter-workflow-family.json +6 -4
  238. package/package.json +1 -1
@@ -13,11 +13,18 @@ import datetime as dt
13
13
  import hashlib
14
14
  import json
15
15
  import os
16
+ import shutil
17
+ import subprocess
16
18
  import sys
19
+ import tempfile
20
+ import time
21
+ import uuid
22
+ from contextlib import contextmanager
17
23
  from pathlib import Path
18
- from typing import Any
24
+ from typing import Any, Iterator
19
25
 
20
26
  import harness_service as hservice
27
+ from harness_paths import resolve_state_dir_for_contract
21
28
 
22
29
 
23
30
  if hasattr(sys.stdout, "reconfigure"):
@@ -87,11 +94,20 @@ def _read_json(path: Path) -> Any:
87
94
 
88
95
  def _write_json(path: Path, data: Any) -> None:
89
96
  path.parent.mkdir(parents=True, exist_ok=True)
90
- path.write_text(
91
- json.dumps(data, ensure_ascii=False, indent=2) + "\n",
92
- encoding="utf-8",
93
- newline="\n",
97
+ fd, raw_tmp = tempfile.mkstemp(
98
+ prefix=f".{path.name}.",
99
+ suffix=".tmp",
100
+ dir=str(path.parent),
94
101
  )
102
+ tmp = Path(raw_tmp)
103
+ try:
104
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
105
+ handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
106
+ handle.flush()
107
+ os.fsync(handle.fileno())
108
+ os.replace(tmp, path)
109
+ finally:
110
+ tmp.unlink(missing_ok=True)
95
111
 
96
112
 
97
113
  def default_lease_root(project: Path) -> Path:
@@ -231,6 +247,45 @@ def create_environment_receipt(
231
247
  return receipt
232
248
 
233
249
 
250
+ def _validate_environment_receipt(
251
+ receipt: Any,
252
+ *,
253
+ stack_id: str,
254
+ environment_hash: str,
255
+ ) -> bool:
256
+ if not isinstance(receipt, dict) or receipt.get("schemaVersion") != 1:
257
+ return False
258
+ if (
259
+ receipt.get("stackId") != stack_id
260
+ or receipt.get("environmentHash") != environment_hash
261
+ or not isinstance(receipt.get("content"), dict)
262
+ ):
263
+ return False
264
+ try:
265
+ canonical_content = json.dumps(
266
+ _canonical_content_evidence(receipt["content"]),
267
+ ensure_ascii=False,
268
+ sort_keys=True,
269
+ separators=(",", ":"),
270
+ ).encode("utf-8")
271
+ except ValueError:
272
+ return False
273
+ expected_fingerprint = "sha256:" + _sha256_bytes(canonical_content)
274
+ if receipt.get("contentFingerprint") != expected_fingerprint:
275
+ return False
276
+ identity_payload = json.dumps(
277
+ {
278
+ key: value
279
+ for key, value in receipt.items()
280
+ if key not in {"preparedAt", "receiptId"}
281
+ },
282
+ ensure_ascii=False,
283
+ sort_keys=True,
284
+ separators=(",", ":"),
285
+ ).encode("utf-8")
286
+ return receipt.get("receiptId") == "sha256:" + _sha256_bytes(identity_payload)
287
+
288
+
234
289
  def resolve_verification_environment(
235
290
  receipt: dict[str, Any],
236
291
  *,
@@ -279,9 +334,300 @@ def resolve_verification_environment(
279
334
 
280
335
  def _lease_path(lease_root: Path, stack_id: str) -> Path:
281
336
  safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
337
+ if safe != stack_id:
338
+ safe = f"{safe}-{hashlib.sha256(stack_id.encode('utf-8')).hexdigest()[:12]}"
282
339
  return lease_root / f"{safe}.json"
283
340
 
284
341
 
342
+ @contextmanager
343
+ def _lease_registry_guard(lease_root: Path) -> Iterator[None]:
344
+ lease_root.mkdir(parents=True, exist_ok=True)
345
+ lock_path = lease_root / ".acquire.lock"
346
+ deadline = time.monotonic() + 5.0
347
+ token = uuid.uuid4().hex
348
+ while True:
349
+ try:
350
+ fd = os.open(
351
+ lock_path,
352
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
353
+ 0o600,
354
+ )
355
+ except FileExistsError:
356
+ try:
357
+ holder = _read_json(lock_path)
358
+ except (OSError, json.JSONDecodeError):
359
+ holder = {}
360
+ pid = holder.get("pid") if isinstance(holder, dict) else None
361
+ if isinstance(pid, int) and pid > 0 and not hservice.is_pid_alive(pid):
362
+ try:
363
+ lock_path.unlink()
364
+ continue
365
+ except OSError:
366
+ pass
367
+ if time.monotonic() >= deadline:
368
+ raise RuntimeError("ENVIRONMENT_LEASE_BUSY")
369
+ time.sleep(0.02)
370
+ continue
371
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
372
+ json.dump(
373
+ {
374
+ "schemaVersion": 1,
375
+ "pid": os.getpid(),
376
+ "token": token,
377
+ "acquiredAt": now_iso(),
378
+ },
379
+ handle,
380
+ ensure_ascii=False,
381
+ )
382
+ handle.flush()
383
+ os.fsync(handle.fileno())
384
+ break
385
+ try:
386
+ yield
387
+ finally:
388
+ try:
389
+ holder = _read_json(lock_path)
390
+ except (OSError, json.JSONDecodeError):
391
+ holder = {}
392
+ if isinstance(holder, dict) and holder.get("token") == token:
393
+ lock_path.unlink(missing_ok=True)
394
+
395
+
396
+ def _provider_operation_root(lease_root: Path) -> Path:
397
+ return lease_root / "provider-operations"
398
+
399
+
400
+ def _provider_receipt_digest(receipt: dict[str, Any]) -> str:
401
+ payload = {
402
+ key: value
403
+ for key, value in receipt.items()
404
+ if key != "receiptDigest"
405
+ }
406
+ return "sha256:" + _sha256_bytes(
407
+ json.dumps(
408
+ payload,
409
+ ensure_ascii=False,
410
+ sort_keys=True,
411
+ separators=(",", ":"),
412
+ ).encode("utf-8")
413
+ )
414
+
415
+
416
+ def execute_provider_operation(
417
+ project: Path,
418
+ *,
419
+ change_id: str,
420
+ stack_id: str,
421
+ operation: str,
422
+ argv: list[str],
423
+ lease_root: Path | None = None,
424
+ timeout_seconds: float = 900.0,
425
+ ) -> dict[str, Any]:
426
+ """Execute a reset/cleanup provider and emit a bound, durable receipt."""
427
+ if operation not in {"reset", "cleanup"}:
428
+ raise ValueError("ENVIRONMENT_PROVIDER_OPERATION_INVALID")
429
+ if (
430
+ not argv
431
+ or not all(isinstance(item, str) and item for item in argv)
432
+ or timeout_seconds <= 0
433
+ ):
434
+ raise ValueError("ENVIRONMENT_PROVIDER_ARGV_INVALID")
435
+ root = lease_root or default_lease_root(project)
436
+ lease_path = _lease_path(root, stack_id)
437
+ lease = _read_json(lease_path)
438
+ if (
439
+ not isinstance(lease, dict)
440
+ or lease.get("changeId") != change_id
441
+ or lease.get("stackId") != stack_id
442
+ or lease.get("status") != "ACTIVE"
443
+ ):
444
+ raise ValueError("ENVIRONMENT_PROVIDER_LEASE_INVALID")
445
+ executable = shutil.which(argv[0]) or str(Path(argv[0]).expanduser().resolve())
446
+ started_at = now_iso()
447
+ process = subprocess.Popen(
448
+ argv,
449
+ cwd=str(project.resolve()),
450
+ stdin=subprocess.DEVNULL,
451
+ stdout=subprocess.PIPE,
452
+ stderr=subprocess.PIPE,
453
+ shell=False,
454
+ )
455
+ created = hservice.get_process_create_time(process.pid)
456
+ try:
457
+ stdout, stderr = process.communicate(timeout=timeout_seconds)
458
+ timed_out = False
459
+ except subprocess.TimeoutExpired:
460
+ timed_out = True
461
+ hservice.terminate_process_tree(process.pid)
462
+ stdout, stderr = process.communicate()
463
+ operation_id = uuid.uuid4().hex
464
+ receipt_path = _provider_operation_root(root) / f"{operation}-{operation_id}.json"
465
+ receipt: dict[str, Any] = {
466
+ "schemaVersion": 2,
467
+ "operation": operation,
468
+ "status": "OK" if process.returncode == 0 and not timed_out else "FAIL",
469
+ "operationId": operation_id,
470
+ "changeId": change_id,
471
+ "stackId": stack_id,
472
+ "environmentHash": str(lease.get("environmentHash") or ""),
473
+ "environmentSessionId": str(lease.get("sessionId") or ""),
474
+ "contentFingerprint": str(
475
+ lease.get("contentFingerprint") or "sha256:none"
476
+ ),
477
+ "performedAt": started_at,
478
+ "completedAt": now_iso(),
479
+ "provider": {
480
+ "pid": process.pid,
481
+ "executable": executable,
482
+ "startedAt": (
483
+ created.isoformat(timespec="seconds")
484
+ if created is not None
485
+ else started_at
486
+ ),
487
+ "argvHash": "sha256:"
488
+ + _sha256_bytes(
489
+ json.dumps(argv, ensure_ascii=False, separators=(",", ":")).encode(
490
+ "utf-8"
491
+ )
492
+ ),
493
+ },
494
+ "exitCode": process.returncode,
495
+ "timedOut": timed_out,
496
+ "stdoutDigest": "sha256:" + _sha256_bytes(stdout),
497
+ "stderrDigest": "sha256:" + _sha256_bytes(stderr),
498
+ "receiptPath": str(receipt_path.resolve()),
499
+ }
500
+ receipt["receiptDigest"] = _provider_receipt_digest(receipt)
501
+ _write_json(receipt_path, receipt)
502
+ return receipt
503
+
504
+
505
+ def _operation_evidence(
506
+ evidence: dict[str, Any] | None,
507
+ *,
508
+ operation: str,
509
+ lease_root: Path,
510
+ lease: dict[str, Any],
511
+ ) -> dict[str, Any] | None:
512
+ if not isinstance(evidence, dict):
513
+ return None
514
+ raw_path = str(evidence.get("receiptPath") or "").strip()
515
+ if not raw_path:
516
+ return None
517
+ receipt_path = Path(raw_path).expanduser().resolve()
518
+ provider_root = _provider_operation_root(lease_root).resolve()
519
+ try:
520
+ receipt_path.relative_to(provider_root)
521
+ except ValueError:
522
+ return None
523
+ try:
524
+ stored = _read_json(receipt_path)
525
+ except (OSError, json.JSONDecodeError):
526
+ return None
527
+ if stored != evidence or not isinstance(stored, dict):
528
+ return None
529
+ performed_at = _parse_expiry(stored.get("performedAt"))
530
+ age_seconds = (
531
+ (dt.datetime.now(dt.timezone.utc) - performed_at).total_seconds()
532
+ if performed_at is not None
533
+ else 1_000_000
534
+ )
535
+ if (
536
+ stored.get("schemaVersion") != 2
537
+ or stored.get("operation") != operation
538
+ or stored.get("status") != "OK"
539
+ or stored.get("changeId") != lease.get("changeId")
540
+ or stored.get("stackId") != lease.get("stackId")
541
+ or stored.get("environmentHash") != lease.get("environmentHash")
542
+ or stored.get("environmentSessionId") != lease.get("sessionId")
543
+ or stored.get("contentFingerprint")
544
+ != str(lease.get("contentFingerprint") or "sha256:none")
545
+ or not str(stored.get("operationId") or "").strip()
546
+ or not isinstance(stored.get("provider"), dict)
547
+ or not str(stored["provider"].get("executable") or "").strip()
548
+ or not str(stored["provider"].get("argvHash") or "").startswith("sha256:")
549
+ or stored.get("exitCode") != 0
550
+ or stored.get("timedOut") is not False
551
+ or age_seconds < -5
552
+ or age_seconds > 900
553
+ or stored.get("receiptDigest") != _provider_receipt_digest(stored)
554
+ ):
555
+ return None
556
+ consumed = lease_root / ".consumed-provider-operations" / (
557
+ hashlib.sha256(str(stored["operationId"]).encode("utf-8")).hexdigest()
558
+ + ".json"
559
+ )
560
+ if consumed.exists():
561
+ return None
562
+ return stored
563
+
564
+
565
+ def _consume_operation_evidence(
566
+ lease_root: Path,
567
+ evidence: dict[str, Any],
568
+ ) -> bool:
569
+ consumed = lease_root / ".consumed-provider-operations" / (
570
+ hashlib.sha256(str(evidence["operationId"]).encode("utf-8")).hexdigest()
571
+ + ".json"
572
+ )
573
+ consumed.parent.mkdir(parents=True, exist_ok=True)
574
+ try:
575
+ descriptor = os.open(
576
+ consumed,
577
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
578
+ 0o600,
579
+ )
580
+ except FileExistsError:
581
+ return False
582
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
583
+ json.dump(
584
+ {
585
+ "schemaVersion": 1,
586
+ "operationId": evidence["operationId"],
587
+ "receiptDigest": evidence["receiptDigest"],
588
+ "consumedAt": now_iso(),
589
+ },
590
+ stream,
591
+ ensure_ascii=False,
592
+ )
593
+ stream.flush()
594
+ os.fsync(stream.fileno())
595
+ return True
596
+
597
+
598
+ def _write_environment_action_receipt(
599
+ lease_root: Path,
600
+ *,
601
+ action: str,
602
+ lease: dict[str, Any],
603
+ previous_session_id: str | None = None,
604
+ receipt_dir: Path | None = None,
605
+ operation_evidence: dict[str, Any] | None = None,
606
+ ) -> Path:
607
+ receipt = {
608
+ "schemaVersion": 1,
609
+ "action": action,
610
+ "recordedAt": now_iso(),
611
+ "changeId": lease.get("changeId"),
612
+ "stackId": lease.get("stackId"),
613
+ "sessionId": lease.get("sessionId"),
614
+ "mode": lease.get("mode"),
615
+ "environmentHash": lease.get("environmentHash"),
616
+ "contentFingerprint": lease.get("contentFingerprint"),
617
+ }
618
+ if previous_session_id:
619
+ receipt["previousSessionId"] = previous_session_id
620
+ if operation_evidence is not None:
621
+ receipt["operationEvidence"] = operation_evidence
622
+ receipt_dir = receipt_dir or lease_root.parent / "environment-receipts"
623
+ stack_digest = hashlib.sha256(
624
+ str(lease.get("stackId") or "").encode("utf-8")
625
+ ).hexdigest()[:12]
626
+ target = receipt_dir / f"{action}-{stack_digest}-{uuid.uuid4().hex}.json"
627
+ _write_json(target, receipt)
628
+ return target
629
+
630
+
285
631
  def list_leases(lease_root: Path) -> list[dict[str, Any]]:
286
632
  if not lease_root.is_dir():
287
633
  return []
@@ -340,24 +686,33 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
340
686
  item["classification"] = "LEASE_EXPIRY_INVALID"
341
687
  results.append(item)
342
688
  continue
343
- if now < expires_at:
344
- item["classification"] = (
345
- "STALE_CONTENT"
346
- if lease.get("status") == "STALE_CONTENT"
347
- else "ACTIVE"
348
- )
349
- results.append(item)
350
- continue
689
+ unexpired = now < expires_at
351
690
  owner = lease.get("owner")
352
691
  if not isinstance(owner, dict) or any(
353
692
  not owner.get(field) for field in ("pid", "executable", "startedAt")
354
693
  ):
694
+ if unexpired:
695
+ item["classification"] = (
696
+ "STALE_CONTENT"
697
+ if lease.get("status") == "STALE_CONTENT"
698
+ else "ACTIVE"
699
+ )
700
+ results.append(item)
701
+ continue
355
702
  item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
356
703
  results.append(item)
357
704
  continue
358
705
  try:
359
706
  owner_pid = int(owner["pid"])
360
707
  except (TypeError, ValueError):
708
+ if unexpired:
709
+ item["classification"] = (
710
+ "STALE_CONTENT"
711
+ if lease.get("status") == "STALE_CONTENT"
712
+ else "ACTIVE"
713
+ )
714
+ results.append(item)
715
+ continue
361
716
  item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
362
717
  results.append(item)
363
718
  continue
@@ -365,6 +720,14 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
365
720
  item["classification"] = "RECLAIMABLE"
366
721
  results.append(item)
367
722
  continue
723
+ if unexpired:
724
+ item["classification"] = (
725
+ "STALE_CONTENT"
726
+ if lease.get("status") == "STALE_CONTENT"
727
+ else "ACTIVE"
728
+ )
729
+ results.append(item)
730
+ continue
368
731
  identity = hservice.verify_process_identity(
369
732
  {
370
733
  "pid": owner_pid,
@@ -386,7 +749,7 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
386
749
 
387
750
 
388
751
  def reap_expired_leases(lease_root: Path) -> dict[str, Any]:
389
- """Remove only expired leases whose recorded owner is definitely gone."""
752
+ """Remove only leases whose recorded owner is definitely gone."""
390
753
  leases = classify_leases(lease_root)
391
754
  reaped: list[str] = []
392
755
  for item in leases:
@@ -502,6 +865,16 @@ def require_writable_lease(
502
865
  "changeId": change_id,
503
866
  "stackId": stack_id,
504
867
  }
868
+ if not _validate_environment_receipt(
869
+ environment_receipt,
870
+ stack_id=stack_id,
871
+ environment_hash=str(lease.get("environmentHash") or ""),
872
+ ):
873
+ return {
874
+ "ok": False,
875
+ "code": "ENVIRONMENT_RECEIPT_INVALID",
876
+ "message": "environment receipt digest or identity is invalid",
877
+ }
505
878
  observed_content = str(
506
879
  environment_receipt.get("contentFingerprint") or ""
507
880
  ).strip()
@@ -532,7 +905,7 @@ def require_writable_lease(
532
905
  return {"ok": True, "code": "LEASE_HELD", "lease": lease}
533
906
 
534
907
 
535
- def acquire_lease(
908
+ def _acquire_lease_locked(
536
909
  project: Path,
537
910
  *,
538
911
  change_id: str,
@@ -542,8 +915,17 @@ def acquire_lease(
542
915
  writable_volumes: list[str] | None = None,
543
916
  ttl_seconds: int = 3600,
544
917
  environment_receipt: dict[str, Any] | None = None,
918
+ mode: str = "change-session",
919
+ reset_evidence: dict[str, Any] | None = None,
920
+ receipt_dir: Path | None = None,
545
921
  ) -> dict[str, Any]:
546
- """Acquire a lease; reject cross-change sharing of the same writable volume."""
922
+ """Acquire, reuse, or reset an isolated environment session."""
923
+ if mode not in {"change-session", "ephemeral"}:
924
+ return {
925
+ "ok": False,
926
+ "code": "ENVIRONMENT_SESSION_MODE_INVALID",
927
+ "message": f"unsupported environment session mode: {mode}",
928
+ }
547
929
  root = lease_root or default_lease_root(project)
548
930
  root.mkdir(parents=True, exist_ok=True)
549
931
  volumes = [str(v).replace("\\", "/") for v in (writable_volumes or [])]
@@ -577,11 +959,16 @@ def acquire_lease(
577
959
  }
578
960
 
579
961
  path = _lease_path(root, stack_id)
962
+ current: dict[str, Any] = {}
580
963
  if path.is_file():
581
964
  try:
582
965
  current = _read_json(path)
583
- except (OSError, json.JSONDecodeError):
584
- current = {}
966
+ except (OSError, json.JSONDecodeError) as exc:
967
+ return {
968
+ "ok": False,
969
+ "code": "ENVIRONMENT_LEASE_UNREADABLE",
970
+ "message": str(exc),
971
+ }
585
972
  if str(current.get("changeId") or "") not in {"", change_id}:
586
973
  return {
587
974
  "ok": False,
@@ -596,15 +983,15 @@ def acquire_lease(
596
983
  started = dt.datetime.now().astimezone()
597
984
  expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
598
985
  if environment_receipt is not None:
599
- if (
600
- environment_receipt.get("schemaVersion") != 1
601
- or environment_receipt.get("stackId") != stack_id
602
- or not str(environment_receipt.get("contentFingerprint") or "").strip()
986
+ if not _validate_environment_receipt(
987
+ environment_receipt,
988
+ stack_id=stack_id,
989
+ environment_hash=environment_hash,
603
990
  ):
604
991
  return {
605
992
  "ok": False,
606
993
  "code": "ENVIRONMENT_RECEIPT_INVALID",
607
- "message": "environment receipt identity does not match requested stack",
994
+ "message": "environment receipt digest or identity does not match",
608
995
  }
609
996
  canaries = (
610
997
  environment_receipt.get("content", {}).get("canaries")
@@ -620,10 +1007,94 @@ def acquire_lease(
620
1007
  "code": "ENVIRONMENT_CONTENT_NOT_READY",
621
1008
  "message": "all environment content canaries must pass before acquire",
622
1009
  }
1010
+ current_fingerprint = str(current.get("contentFingerprint") or "").strip()
1011
+ requested_fingerprint = str(
1012
+ (environment_receipt or {}).get("contentFingerprint") or ""
1013
+ ).strip()
1014
+ same_content = bool(current_fingerprint) and (
1015
+ current_fingerprint == requested_fingerprint
1016
+ )
1017
+ owner = current.get("owner")
1018
+ owner_verified = False
1019
+ if isinstance(owner, dict):
1020
+ try:
1021
+ owner_pid = int(owner.get("pid"))
1022
+ except (TypeError, ValueError):
1023
+ owner_pid = 0
1024
+ owner_verified = hservice.verify_process_identity(
1025
+ {
1026
+ "pid": owner_pid,
1027
+ "startedAt": owner.get("startedAt"),
1028
+ "processIdentity": {"executable": owner.get("executable")},
1029
+ }
1030
+ ) is True
1031
+ expires_at = _parse_expiry(current.get("expiresAt"))
1032
+ unexpired = (
1033
+ expires_at is not None
1034
+ and dt.datetime.now(dt.timezone.utc) < expires_at
1035
+ )
1036
+ if (
1037
+ current
1038
+ and str(current.get("changeId") or "") == change_id
1039
+ and mode == "change-session"
1040
+ and str(current.get("mode") or "change-session") == "change-session"
1041
+ and current.get("status") == "ACTIVE"
1042
+ and current.get("environmentHash") == environment_hash
1043
+ and current.get("writableVolumes") == volumes
1044
+ and owner_verified
1045
+ and unexpired
1046
+ and same_content
1047
+ ):
1048
+ current["heartbeatAt"] = started.isoformat(timespec="seconds")
1049
+ current["expiresAt"] = expires.isoformat(timespec="seconds")
1050
+ current["reuseCount"] = int(current.get("reuseCount") or 0) + 1
1051
+ _write_json(path, current)
1052
+ action_receipt = _write_environment_action_receipt(
1053
+ root,
1054
+ action="reuse",
1055
+ lease=current,
1056
+ receipt_dir=receipt_dir,
1057
+ )
1058
+ return {
1059
+ "ok": True,
1060
+ "code": "LEASE_REUSED",
1061
+ "lease": current,
1062
+ "path": str(path),
1063
+ "receiptPath": str(action_receipt),
1064
+ }
1065
+ previous_session_id = str(current.get("sessionId") or "").strip() or None
1066
+ reset_count = int(current.get("resetCount") or 0)
1067
+ is_reset = bool(current)
1068
+ reset_operation = _operation_evidence(
1069
+ reset_evidence,
1070
+ operation="reset",
1071
+ lease_root=root,
1072
+ lease=current,
1073
+ )
1074
+ if is_reset and reset_operation is None:
1075
+ return {
1076
+ "ok": False,
1077
+ "code": "ENVIRONMENT_RESET_REQUIRED",
1078
+ "message": (
1079
+ "existing environment cannot be replaced until a matching "
1080
+ "provider reset receipt is supplied"
1081
+ ),
1082
+ "changeId": change_id,
1083
+ "stackId": stack_id,
1084
+ "previousSessionId": previous_session_id,
1085
+ }
1086
+ if is_reset and not _consume_operation_evidence(root, reset_operation):
1087
+ return {
1088
+ "ok": False,
1089
+ "code": "ENVIRONMENT_RESET_EVIDENCE_REPLAYED",
1090
+ "message": "provider reset evidence was already consumed",
1091
+ }
623
1092
  lease = {
624
- "schemaVersion": 2 if environment_receipt is not None else 1,
1093
+ "schemaVersion": 3,
625
1094
  "changeId": change_id,
626
1095
  "stackId": stack_id,
1096
+ "sessionId": uuid.uuid4().hex,
1097
+ "mode": mode,
627
1098
  "environmentHash": environment_hash,
628
1099
  "writableVolumes": volumes,
629
1100
  "acquiredAt": started.isoformat(timespec="seconds"),
@@ -631,6 +1102,8 @@ def acquire_lease(
631
1102
  "expiresAt": expires.isoformat(timespec="seconds"),
632
1103
  "projectRoot": str(project.resolve()),
633
1104
  "status": "ACTIVE",
1105
+ "reuseCount": 0,
1106
+ "resetCount": reset_count + (1 if is_reset else 0),
634
1107
  "owner": {
635
1108
  "pid": os.getpid(),
636
1109
  "executable": hservice.get_process_executable(os.getpid())
@@ -645,15 +1118,79 @@ def acquire_lease(
645
1118
  lease["contentFingerprint"] = environment_receipt["contentFingerprint"]
646
1119
  lease["environmentReceiptId"] = environment_receipt.get("receiptId")
647
1120
  _write_json(path, lease)
648
- return {"ok": True, "code": "LEASE_ACQUIRED", "lease": lease, "path": str(path)}
1121
+ action = "reset" if is_reset else "prepare"
1122
+ action_receipt = _write_environment_action_receipt(
1123
+ root,
1124
+ action=action,
1125
+ lease=lease,
1126
+ previous_session_id=previous_session_id,
1127
+ receipt_dir=receipt_dir,
1128
+ operation_evidence=reset_operation,
1129
+ )
1130
+ return {
1131
+ "ok": True,
1132
+ "code": "LEASE_RESET" if is_reset else "LEASE_ACQUIRED",
1133
+ "lease": lease,
1134
+ "path": str(path),
1135
+ "receiptPath": str(action_receipt),
1136
+ }
649
1137
 
650
1138
 
651
- def release_lease(
1139
+ def acquire_lease(
652
1140
  project: Path,
653
1141
  *,
654
1142
  change_id: str,
655
1143
  stack_id: str,
1144
+ environment_hash: str,
656
1145
  lease_root: Path | None = None,
1146
+ writable_volumes: list[str] | None = None,
1147
+ ttl_seconds: int = 3600,
1148
+ environment_receipt: dict[str, Any] | None = None,
1149
+ mode: str = "change-session",
1150
+ reset_evidence: dict[str, Any] | None = None,
1151
+ ) -> dict[str, Any]:
1152
+ root = lease_root or default_lease_root(project)
1153
+ receipt_dir: Path | None = None
1154
+ if lease_root is None:
1155
+ contract_dir = project / ".harness" / "changes" / change_id
1156
+ receipt_dir = (
1157
+ resolve_state_dir_for_contract(contract_dir, project)
1158
+ / "runtime"
1159
+ / "environment-receipts"
1160
+ )
1161
+ try:
1162
+ with _lease_registry_guard(root):
1163
+ return _acquire_lease_locked(
1164
+ project,
1165
+ change_id=change_id,
1166
+ stack_id=stack_id,
1167
+ environment_hash=environment_hash,
1168
+ lease_root=root,
1169
+ writable_volumes=writable_volumes,
1170
+ ttl_seconds=ttl_seconds,
1171
+ environment_receipt=environment_receipt,
1172
+ mode=mode,
1173
+ reset_evidence=reset_evidence,
1174
+ receipt_dir=receipt_dir,
1175
+ )
1176
+ except RuntimeError as exc:
1177
+ if str(exc) != "ENVIRONMENT_LEASE_BUSY":
1178
+ raise
1179
+ return {
1180
+ "ok": False,
1181
+ "code": "ENVIRONMENT_LEASE_BUSY",
1182
+ "message": "another lease acquisition is still in progress",
1183
+ }
1184
+
1185
+
1186
+ def _release_lease_locked(
1187
+ project: Path,
1188
+ *,
1189
+ change_id: str,
1190
+ stack_id: str,
1191
+ lease_root: Path | None = None,
1192
+ cleanup_evidence: dict[str, Any] | None = None,
1193
+ receipt_dir: Path | None = None,
657
1194
  ) -> dict[str, Any]:
658
1195
  root = lease_root or default_lease_root(project)
659
1196
  path = _lease_path(root, stack_id)
@@ -677,8 +1214,78 @@ def release_lease(
677
1214
  "holder": lease.get("changeId"),
678
1215
  "changeId": change_id,
679
1216
  }
1217
+ cleanup_operation = _operation_evidence(
1218
+ cleanup_evidence,
1219
+ operation="cleanup",
1220
+ lease_root=root,
1221
+ lease=lease,
1222
+ )
1223
+ if cleanup_evidence is not None and cleanup_operation is None:
1224
+ return {
1225
+ "ok": False,
1226
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
1227
+ "message": "provider cleanup evidence does not match the held lease",
1228
+ }
1229
+ if cleanup_operation is not None and not _consume_operation_evidence(
1230
+ root, cleanup_operation
1231
+ ):
1232
+ return {
1233
+ "ok": False,
1234
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_REPLAYED",
1235
+ "message": "provider cleanup evidence was already consumed",
1236
+ }
1237
+ action_receipt = _write_environment_action_receipt(
1238
+ root,
1239
+ action="cleanup" if cleanup_operation is not None else "release",
1240
+ lease=lease,
1241
+ receipt_dir=receipt_dir,
1242
+ operation_evidence=cleanup_operation,
1243
+ )
680
1244
  path.unlink(missing_ok=True)
681
- return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "stackId": stack_id}
1245
+ return {
1246
+ "ok": True,
1247
+ "code": "LEASE_RELEASED",
1248
+ "changeId": change_id,
1249
+ "stackId": stack_id,
1250
+ "receiptPath": str(action_receipt),
1251
+ }
1252
+
1253
+
1254
+ def release_lease(
1255
+ project: Path,
1256
+ *,
1257
+ change_id: str,
1258
+ stack_id: str,
1259
+ lease_root: Path | None = None,
1260
+ cleanup_evidence: dict[str, Any] | None = None,
1261
+ ) -> dict[str, Any]:
1262
+ root = lease_root or default_lease_root(project)
1263
+ receipt_dir: Path | None = None
1264
+ if lease_root is None:
1265
+ contract_dir = project / ".harness" / "changes" / change_id
1266
+ receipt_dir = (
1267
+ resolve_state_dir_for_contract(contract_dir, project)
1268
+ / "runtime"
1269
+ / "environment-receipts"
1270
+ )
1271
+ try:
1272
+ with _lease_registry_guard(root):
1273
+ return _release_lease_locked(
1274
+ project,
1275
+ change_id=change_id,
1276
+ stack_id=stack_id,
1277
+ lease_root=root,
1278
+ cleanup_evidence=cleanup_evidence,
1279
+ receipt_dir=receipt_dir,
1280
+ )
1281
+ except RuntimeError as exc:
1282
+ if str(exc) != "ENVIRONMENT_LEASE_BUSY":
1283
+ raise
1284
+ return {
1285
+ "ok": False,
1286
+ "code": "ENVIRONMENT_LEASE_BUSY",
1287
+ "message": "another lease operation is still in progress",
1288
+ }
682
1289
 
683
1290
 
684
1291
  def cmd_fingerprint(args: argparse.Namespace) -> int:
@@ -822,11 +1429,12 @@ def cmd_prepare(args: argparse.Namespace) -> int:
822
1429
 
823
1430
  def cmd_acquire(args: argparse.Namespace) -> int:
824
1431
  project = Path(args.project).resolve()
825
- root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
1432
+ root = Path(args.lease_root).resolve() if args.lease_root else None
826
1433
  env_hash = args.environment_hash or compute_environment_hash(project)
827
1434
  volumes = [v for v in (args.writable_volume or []) if v]
828
1435
  try:
829
1436
  receipt = _load_receipt_arg(args.environment_receipt)
1437
+ reset_evidence = _load_receipt_arg(args.reset_evidence)
830
1438
  result = acquire_lease(
831
1439
  project,
832
1440
  change_id=args.change,
@@ -836,6 +1444,8 @@ def cmd_acquire(args: argparse.Namespace) -> int:
836
1444
  writable_volumes=volumes,
837
1445
  ttl_seconds=int(args.ttl_seconds or 3600),
838
1446
  environment_receipt=receipt,
1447
+ mode=args.mode,
1448
+ reset_evidence=reset_evidence,
839
1449
  )
840
1450
  except (OSError, json.JSONDecodeError, ValueError) as exc:
841
1451
  result = {
@@ -848,16 +1458,72 @@ def cmd_acquire(args: argparse.Namespace) -> int:
848
1458
 
849
1459
  def cmd_release(args: argparse.Namespace) -> int:
850
1460
  project = Path(args.project).resolve()
851
- root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
852
- result = release_lease(
853
- project,
854
- change_id=args.change,
855
- stack_id=args.stack_id,
856
- lease_root=root,
857
- )
1461
+ root = Path(args.lease_root).resolve() if args.lease_root else None
1462
+ try:
1463
+ cleanup_evidence = _load_receipt_arg(args.cleanup_evidence)
1464
+ result = release_lease(
1465
+ project,
1466
+ change_id=args.change,
1467
+ stack_id=args.stack_id,
1468
+ lease_root=root,
1469
+ cleanup_evidence=cleanup_evidence,
1470
+ )
1471
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
1472
+ result = {
1473
+ "ok": False,
1474
+ "code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
1475
+ "message": str(exc),
1476
+ }
858
1477
  return emit_json(result, ok=bool(result.get("ok")))
859
1478
 
860
1479
 
1480
+ def cmd_provider_operation(args: argparse.Namespace) -> int:
1481
+ project = Path(args.project).resolve()
1482
+ root = Path(args.lease_root).resolve() if args.lease_root else None
1483
+ try:
1484
+ argv = json.loads(args.argv_json)
1485
+ if not isinstance(argv, list):
1486
+ raise ValueError("provider argv must be a JSON array")
1487
+ result = execute_provider_operation(
1488
+ project,
1489
+ change_id=args.change,
1490
+ stack_id=args.stack_id,
1491
+ operation=args.operation,
1492
+ argv=argv,
1493
+ lease_root=root,
1494
+ timeout_seconds=float(args.timeout_seconds),
1495
+ )
1496
+ except (
1497
+ OSError,
1498
+ json.JSONDecodeError,
1499
+ ValueError,
1500
+ subprocess.SubprocessError,
1501
+ ) as exc:
1502
+ return emit_json(
1503
+ {
1504
+ "ok": False,
1505
+ "code": "ENVIRONMENT_PROVIDER_OPERATION_FAILED",
1506
+ "message": str(exc),
1507
+ },
1508
+ ok=False,
1509
+ )
1510
+ if args.output:
1511
+ _write_json(Path(args.output).resolve(), result)
1512
+ return emit_json(
1513
+ {
1514
+ "ok": result.get("status") == "OK",
1515
+ "code": (
1516
+ "ENVIRONMENT_PROVIDER_OPERATION_OK"
1517
+ if result.get("status") == "OK"
1518
+ else "ENVIRONMENT_PROVIDER_OPERATION_FAILED"
1519
+ ),
1520
+ "receiptPath": result.get("receiptPath"),
1521
+ "receiptDigest": result.get("receiptDigest"),
1522
+ },
1523
+ ok=result.get("status") == "OK",
1524
+ )
1525
+
1526
+
861
1527
  def cmd_require(args: argparse.Namespace) -> int:
862
1528
  project = Path(args.project).resolve()
863
1529
  root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
@@ -933,9 +1599,15 @@ def build_parser() -> argparse.ArgumentParser:
933
1599
  ac.add_argument("--stack-id", required=True)
934
1600
  ac.add_argument("--environment-hash")
935
1601
  ac.add_argument("--environment-receipt")
1602
+ ac.add_argument("--reset-evidence")
936
1603
  ac.add_argument("--lease-root")
937
1604
  ac.add_argument("--writable-volume", action="append", default=[])
938
1605
  ac.add_argument("--ttl-seconds", type=int, default=3600)
1606
+ ac.add_argument(
1607
+ "--mode",
1608
+ choices=("change-session", "ephemeral"),
1609
+ default="change-session",
1610
+ )
939
1611
  ac.set_defaults(func=cmd_acquire)
940
1612
 
941
1613
  rel = sub.add_parser("release")
@@ -943,8 +1615,20 @@ def build_parser() -> argparse.ArgumentParser:
943
1615
  rel.add_argument("--change", required=True)
944
1616
  rel.add_argument("--stack-id", required=True)
945
1617
  rel.add_argument("--lease-root")
1618
+ rel.add_argument("--cleanup-evidence")
946
1619
  rel.set_defaults(func=cmd_release)
947
1620
 
1621
+ provider = sub.add_parser("provider-operation")
1622
+ provider.add_argument("--project", required=True)
1623
+ provider.add_argument("--change", required=True)
1624
+ provider.add_argument("--stack-id", required=True)
1625
+ provider.add_argument("--operation", choices=("reset", "cleanup"), required=True)
1626
+ provider.add_argument("--argv-json", required=True)
1627
+ provider.add_argument("--lease-root")
1628
+ provider.add_argument("--timeout-seconds", type=float, default=900.0)
1629
+ provider.add_argument("--output")
1630
+ provider.set_defaults(func=cmd_provider_operation)
1631
+
948
1632
  req = sub.add_parser("require")
949
1633
  req.add_argument("--project", required=True)
950
1634
  req.add_argument("--change", required=True)