@hunter-harness/workflow-harness 0.2.56 → 0.2.58

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 (190) 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-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-review/SKILL.md +5 -3
  8. package/harness/bundles/general/claude-code/harness-review/protocols.md +2 -1
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-test/SKILL.md +5 -3
  13. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -0
  14. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +2 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_context.py +423 -77
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +102 -12
  17. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +15 -14
  18. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +529 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +161 -6
  20. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +81 -8
  21. package/harness/bundles/general/claude-code/scripts/harness_review.py +25 -2
  22. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +155 -1
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +5 -3
  30. package/harness/bundles/general/codebuddy/harness-review/protocols.md +2 -1
  31. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  32. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +5 -3
  35. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -0
  36. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  37. package/harness/bundles/general/codebuddy/scripts/harness_context.py +423 -77
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +102 -12
  39. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +15 -14
  40. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +529 -1
  41. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +161 -6
  42. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +81 -8
  43. package/harness/bundles/general/codebuddy/scripts/harness_review.py +25 -2
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +155 -1
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -1
  52. package/harness/bundles/general/codex/harness-review/protocols.md +2 -1
  53. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  54. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-test/SKILL.md +5 -3
  57. package/harness/bundles/general/codex/harness-test/reference.md +2 -0
  58. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +2 -0
  59. package/harness/bundles/general/codex/scripts/harness_context.py +423 -77
  60. package/harness/bundles/general/codex/scripts/harness_events.py +102 -12
  61. package/harness/bundles/general/codex/scripts/harness_events_sync.py +15 -14
  62. package/harness/bundles/general/codex/scripts/harness_fixback.py +529 -1
  63. package/harness/bundles/general/codex/scripts/harness_gate.py +161 -6
  64. package/harness/bundles/general/codex/scripts/harness_ledger.py +81 -8
  65. package/harness/bundles/general/codex/scripts/harness_review.py +25 -2
  66. package/harness/bundles/general/codex/scripts/harness_test_runner.py +155 -1
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -1
  74. package/harness/bundles/general/cursor/harness-review/protocols.md +2 -1
  75. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  76. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-test/SKILL.md +5 -3
  79. package/harness/bundles/general/cursor/harness-test/reference.md +2 -0
  80. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +2 -0
  81. package/harness/bundles/general/cursor/scripts/harness_context.py +423 -77
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +102 -12
  83. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +15 -14
  84. package/harness/bundles/general/cursor/scripts/harness_fixback.py +529 -1
  85. package/harness/bundles/general/cursor/scripts/harness_gate.py +161 -6
  86. package/harness/bundles/general/cursor/scripts/harness_ledger.py +81 -8
  87. package/harness/bundles/general/cursor/scripts/harness_review.py +25 -2
  88. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +155 -1
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  97. package/harness/bundles/java/claude-code/harness-review/SKILL.md +5 -3
  98. package/harness/bundles/java/claude-code/harness-review/protocols.md +2 -1
  99. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  100. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-test/SKILL.md +5 -3
  103. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +2 -0
  104. package/harness/bundles/java/claude-code/scripts/harness_context.py +423 -77
  105. package/harness/bundles/java/claude-code/scripts/harness_events.py +102 -12
  106. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +15 -14
  107. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +529 -1
  108. package/harness/bundles/java/claude-code/scripts/harness_gate.py +161 -6
  109. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +81 -8
  110. package/harness/bundles/java/claude-code/scripts/harness_review.py +25 -2
  111. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +155 -1
  112. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  113. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  114. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  116. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  117. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +5 -3
  121. package/harness/bundles/java/codebuddy/harness-review/protocols.md +2 -1
  122. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  123. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +5 -3
  126. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  127. package/harness/bundles/java/codebuddy/scripts/harness_context.py +423 -77
  128. package/harness/bundles/java/codebuddy/scripts/harness_events.py +102 -12
  129. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +15 -14
  130. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +529 -1
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +161 -6
  132. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +81 -8
  133. package/harness/bundles/java/codebuddy/scripts/harness_review.py +25 -2
  134. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +155 -1
  135. package/harness/bundles/java/codex/.harness-build.json +1 -1
  136. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  137. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  138. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  140. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  141. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  142. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -1
  144. package/harness/bundles/java/codex/harness-review/protocols.md +2 -1
  145. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  146. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-test/SKILL.md +5 -3
  149. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +2 -0
  150. package/harness/bundles/java/codex/scripts/harness_context.py +423 -77
  151. package/harness/bundles/java/codex/scripts/harness_events.py +102 -12
  152. package/harness/bundles/java/codex/scripts/harness_events_sync.py +15 -14
  153. package/harness/bundles/java/codex/scripts/harness_fixback.py +529 -1
  154. package/harness/bundles/java/codex/scripts/harness_gate.py +161 -6
  155. package/harness/bundles/java/codex/scripts/harness_ledger.py +81 -8
  156. package/harness/bundles/java/codex/scripts/harness_review.py +25 -2
  157. package/harness/bundles/java/codex/scripts/harness_test_runner.py +155 -1
  158. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  159. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  160. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  161. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  162. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  164. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  165. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  166. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -1
  167. package/harness/bundles/java/cursor/harness-review/protocols.md +2 -1
  168. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  169. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-test/SKILL.md +5 -3
  172. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +2 -0
  173. package/harness/bundles/java/cursor/scripts/harness_context.py +423 -77
  174. package/harness/bundles/java/cursor/scripts/harness_events.py +102 -12
  175. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +15 -14
  176. package/harness/bundles/java/cursor/scripts/harness_fixback.py +529 -1
  177. package/harness/bundles/java/cursor/scripts/harness_gate.py +161 -6
  178. package/harness/bundles/java/cursor/scripts/harness_ledger.py +81 -8
  179. package/harness/bundles/java/cursor/scripts/harness_review.py +25 -2
  180. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +155 -1
  181. package/harness/manifests/general/claude-code.json +24 -24
  182. package/harness/manifests/general/codebuddy.json +24 -24
  183. package/harness/manifests/general/codex.json +24 -24
  184. package/harness/manifests/general/cursor.json +24 -24
  185. package/harness/manifests/java/claude-code.json +25 -25
  186. package/harness/manifests/java/codebuddy.json +25 -25
  187. package/harness/manifests/java/codex.json +25 -25
  188. package/harness/manifests/java/cursor.json +25 -25
  189. package/hunter-workflow-family.json +5 -5
  190. package/package.json +1 -1
@@ -11,6 +11,7 @@ import os
11
11
  import subprocess
12
12
  import tempfile
13
13
  import time
14
+ from contextlib import contextmanager
14
15
  from pathlib import Path
15
16
  from typing import Any
16
17
 
@@ -122,6 +123,25 @@ def _append_ndjson(path: Path, payload: dict[str, Any]) -> None:
122
123
  lock.unlink(missing_ok=True)
123
124
 
124
125
 
126
+ @contextmanager
127
+ def _exclusive_state_lock(path: Path):
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ deadline = time.monotonic() + 2
130
+ descriptor: int | None = None
131
+ while descriptor is None:
132
+ try:
133
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
134
+ except FileExistsError:
135
+ if time.monotonic() >= deadline:
136
+ raise TimeoutError(f"context state locked: {path}")
137
+ time.sleep(0.02)
138
+ try:
139
+ yield
140
+ finally:
141
+ os.close(descriptor)
142
+ path.unlink(missing_ok=True)
143
+
144
+
125
145
  def _read_ndjson(path: Path) -> list[dict[str, Any]]:
126
146
  if not path.is_file():
127
147
  return []
@@ -387,6 +407,233 @@ def _execution_root(project: Path, contract_root: Path, state_root: Path) -> Pat
387
407
  return project.resolve()
388
408
 
389
409
 
410
+ def _reselect_review_fixback(
411
+ project: Path,
412
+ change: str,
413
+ *,
414
+ contract_root: Path,
415
+ state_root: Path,
416
+ executor: str,
417
+ ) -> dict[str, Any]:
418
+ """Supersede an unbegun review→submit choice with review→run."""
419
+
420
+ paths = _paths(state_root)
421
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
422
+ transitions = _read_ndjson(paths["transitions"])
423
+ latest = transitions[-1] if transitions else None
424
+ if (
425
+ isinstance(latest, dict)
426
+ and latest.get("fromPhase") == "review"
427
+ and latest.get("toPhase") == "run"
428
+ and latest.get("trigger") == "review-fixback"
429
+ ):
430
+ return {
431
+ "ok": True,
432
+ "code": "FIXBACK_BRANCH_ALREADY_SELECTED",
433
+ "receipt": latest,
434
+ }
435
+ if not (
436
+ isinstance(latest, dict)
437
+ and latest.get("fromPhase") == "review"
438
+ and latest.get("toPhase") == "submit"
439
+ ):
440
+ return {
441
+ "ok": False,
442
+ "code": "FIXBACK_RESELECT_UNAVAILABLE",
443
+ "message": "当前没有可安全重选的评审后继分支。",
444
+ }
445
+ begins = _read_ndjson(paths["begins"])
446
+ submit_begun = any(
447
+ isinstance(item, dict)
448
+ and item.get("phase") == "submit"
449
+ and item.get("receiptHash") == latest.get("receiptHash")
450
+ for item in begins
451
+ )
452
+ event_paths = [state_root / "events.ndjson"]
453
+ if state_root != contract_root:
454
+ event_paths.append(contract_root / "events.ndjson")
455
+ submit_started = any(
456
+ item.get("phase") == "submit" and item.get("type") == "phase.start"
457
+ for path in event_paths
458
+ for item in _read_ndjson(path)
459
+ )
460
+ if submit_begun or submit_started:
461
+ return {
462
+ "ok": False,
463
+ "code": "FIXBACK_RESELECT_UNSAFE",
464
+ "message": "提交阶段已经开始,不能自动切回修复;请先完成或明确恢复提交现场。",
465
+ }
466
+ if paths["lease"].is_file():
467
+ try:
468
+ lease = _read_json(paths["lease"])
469
+ except (OSError, ValueError, json.JSONDecodeError):
470
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
471
+ if lease.get("phase") != "submit" or lease.get("owner") != executor:
472
+ return {
473
+ "ok": False,
474
+ "code": "FIXBACK_RESELECT_UNSAFE",
475
+ "message": "当前阶段租约不属于本次可撤销的提交准备。",
476
+ }
477
+ paths["lease"].unlink(missing_ok=True)
478
+ # Any submit context present here was only prepared, never begun. The
479
+ # new run context is written by prepare_context after this atomic choice.
480
+ paths["current"].unlink(missing_ok=True)
481
+
482
+ receipt: dict[str, Any] = {
483
+ "schemaVersion": 1,
484
+ "changeName": change,
485
+ "fromPhase": "review",
486
+ "toPhase": "run",
487
+ "status": "OK",
488
+ "executor": executor,
489
+ "productCommit": _head(project),
490
+ "artifacts": [],
491
+ "attempt": 1
492
+ + sum(
493
+ 1
494
+ for item in transitions
495
+ if item.get("fromPhase") == "review"
496
+ and item.get("toPhase") == "run"
497
+ ),
498
+ "closedAt": _now().isoformat(),
499
+ "previousReceiptHash": latest.get("receiptHash"),
500
+ "supersedesReceiptHash": latest.get("receiptHash"),
501
+ "trigger": "review-fixback",
502
+ "selectionReason": "用户选择处理评审中的代码修复项",
503
+ }
504
+ receipt["receiptHash"] = _payload_hash(receipt)
505
+ _append_ndjson(paths["transitions"], receipt)
506
+ invalidation = _invalidate_for_fixback(
507
+ state_root,
508
+ transition_hash=receipt["receiptHash"],
509
+ from_phase="review",
510
+ to_phase="run",
511
+ )
512
+ return {
513
+ "ok": True,
514
+ "code": "FIXBACK_BRANCH_RESELECTED",
515
+ "receipt": receipt,
516
+ "invalidation": invalidation,
517
+ }
518
+
519
+
520
+ def _claim_prepared_context(
521
+ project: Path,
522
+ change: str,
523
+ *,
524
+ contract_root: Path,
525
+ state_root: Path,
526
+ phase: str,
527
+ executor: str,
528
+ ttl_seconds: int,
529
+ preparation_id: str | None,
530
+ display_title: str | None,
531
+ ) -> dict[str, Any]:
532
+ """Atomically check and claim the lease/current preparation pair."""
533
+
534
+ paths = _paths(state_root)
535
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
536
+ recovery: dict[str, Any] | None = None
537
+ if paths["lease"].is_file():
538
+ try:
539
+ existing_lease = _read_json(paths["lease"])
540
+ except (OSError, ValueError, json.JSONDecodeError):
541
+ return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
542
+ expiry = _parse_time(existing_lease.get("expiresAt"))
543
+ expired = expiry is None or _now() >= expiry
544
+ if not expired and existing_lease.get("owner") != executor:
545
+ return {
546
+ "ok": False,
547
+ "code": "CONTEXT_LEASE_HELD",
548
+ "holder": existing_lease.get("owner"),
549
+ "expiresAt": existing_lease.get("expiresAt"),
550
+ }
551
+ if (
552
+ not expired
553
+ and preparation_id is not None
554
+ and existing_lease.get("preparationId") != preparation_id
555
+ ):
556
+ return {
557
+ "ok": False,
558
+ "code": "CONTEXT_PREPARATION_ACTIVE",
559
+ "message": "已有一轮修复准备或执行正在进行,本次重复启动未修改现有上下文。",
560
+ "preparationId": existing_lease.get("preparationId"),
561
+ "expiresAt": existing_lease.get("expiresAt"),
562
+ }
563
+ if expired:
564
+ recovery = {
565
+ "code": "LEASE_EXPIRED_RECOVERED",
566
+ "previousOwner": existing_lease.get("owner"),
567
+ "expiredAt": existing_lease.get("expiresAt"),
568
+ }
569
+
570
+ transitions = _read_ndjson(paths["transitions"])
571
+ latest = transitions[-1] if transitions else None
572
+ try:
573
+ current = (
574
+ _read_json(paths["current"])
575
+ if paths["current"].is_file()
576
+ else None
577
+ )
578
+ except (OSError, ValueError, json.JSONDecodeError):
579
+ return {"ok": False, "code": "CONTEXT_CURRENT_INVALID"}
580
+ if recovery is None and isinstance(current, dict) and (
581
+ current.get("executor") != executor or current.get("phase") != phase
582
+ ):
583
+ valid_receipt = (
584
+ isinstance(latest, dict)
585
+ and latest.get("toPhase") == phase
586
+ and latest.get("fromPhase") == current.get("phase")
587
+ )
588
+ if not valid_receipt:
589
+ return {
590
+ "ok": False,
591
+ "code": "HANDOFF_REQUIRED",
592
+ "fromExecutor": current.get("executor"),
593
+ "fromPhase": current.get("phase"),
594
+ "toExecutor": executor,
595
+ "toPhase": phase,
596
+ }
597
+
598
+ execution_root = _execution_root(project, contract_root, state_root)
599
+ now = _now()
600
+ lease: dict[str, Any] = {
601
+ "schemaVersion": 1,
602
+ "changeName": change,
603
+ "phase": phase,
604
+ "owner": executor,
605
+ "acquiredAt": now.isoformat(),
606
+ "expiresAt": (
607
+ now + dt.timedelta(seconds=max(1, int(ttl_seconds)))
608
+ ).isoformat(),
609
+ }
610
+ if preparation_id is not None:
611
+ lease["preparationId"] = preparation_id
612
+ current_context: dict[str, Any] = {
613
+ "schemaVersion": 1,
614
+ "changeName": change,
615
+ "phase": phase,
616
+ "executor": executor,
617
+ "executionRoot": str(execution_root),
618
+ "preparedAt": now.isoformat(),
619
+ "receiptHash": latest.get("receiptHash") if latest else None,
620
+ "displayTitle": display_title,
621
+ }
622
+ if preparation_id is not None:
623
+ current_context["preparationId"] = preparation_id
624
+ _write_json_atomic(paths["lease"], lease)
625
+ _write_json_atomic(paths["current"], current_context)
626
+ return {
627
+ "ok": True,
628
+ "lease": lease,
629
+ "current": current_context,
630
+ "transitions": transitions,
631
+ "latestTransition": latest,
632
+ "recovery": recovery,
633
+ "executionRoot": str(execution_root),
634
+ }
635
+
636
+
390
637
  def prepare_context(
391
638
  project: Path,
392
639
  *,
@@ -395,6 +642,8 @@ def prepare_context(
395
642
  change: str | None = None,
396
643
  display_title: str | None = None,
397
644
  ttl_seconds: int = 3600,
645
+ trigger: str | None = None,
646
+ preparation_id: str | None = None,
398
647
  ) -> dict[str, Any]:
399
648
  project = Path(project).resolve()
400
649
  candidates = _active_changes(project)
@@ -442,79 +691,35 @@ def prepare_context(
442
691
  }
443
692
  paths = _paths(state_root)
444
693
  paths["runtime"].mkdir(parents=True, exist_ok=True)
445
- recovery: dict[str, Any] | None = None
446
- if paths["lease"].is_file():
447
- try:
448
- lease = _read_json(paths["lease"])
449
- except (OSError, ValueError, json.JSONDecodeError):
450
- return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
451
- expiry = _parse_time(lease.get("expiresAt"))
452
- expired = expiry is None or _now() >= expiry
453
- if not expired and lease.get("owner") != executor:
454
- return {
455
- "ok": False,
456
- "code": "CONTEXT_LEASE_HELD",
457
- "holder": lease.get("owner"),
458
- "expiresAt": lease.get("expiresAt"),
459
- }
460
- if expired:
461
- recovery = {
462
- "code": "LEASE_EXPIRED_RECOVERED",
463
- "previousOwner": lease.get("owner"),
464
- "expiredAt": lease.get("expiresAt"),
465
- }
466
-
467
- transitions = _read_ndjson(paths["transitions"])
468
- latest = transitions[-1] if transitions else None
469
- current = (
470
- _read_json(paths["current"])
471
- if paths["current"].is_file()
472
- else None
473
- )
474
- if recovery is None and isinstance(current, dict) and (
475
- current.get("executor") != executor or current.get("phase") != phase
476
- ):
477
- valid_receipt = (
478
- isinstance(latest, dict)
479
- and latest.get("toPhase") == phase
480
- and latest.get("fromPhase") == current.get("phase")
694
+ reselection: dict[str, Any] | None = None
695
+ if trigger == "review-fixback" and phase == "run":
696
+ reselection = _reselect_review_fixback(
697
+ project,
698
+ change,
699
+ contract_root=contract_root,
700
+ state_root=state_root,
701
+ executor=executor,
481
702
  )
482
- if not valid_receipt:
483
- return {
484
- "ok": False,
485
- "code": "HANDOFF_REQUIRED",
486
- "fromExecutor": current.get("executor"),
487
- "fromPhase": current.get("phase"),
488
- "toExecutor": executor,
489
- "toPhase": phase,
490
- }
491
-
492
- execution_root = _execution_root(project, contract_root, state_root)
493
- now = _now()
494
- lease = {
495
- "schemaVersion": 1,
496
- "changeName": change,
497
- "phase": phase,
498
- "owner": executor,
499
- "acquiredAt": now.isoformat(),
500
- "expiresAt": (
501
- now + dt.timedelta(seconds=max(1, int(ttl_seconds)))
502
- ).isoformat(),
503
- }
504
- _write_json_atomic(paths["lease"], lease)
505
- _write_json_atomic(
506
- paths["current"],
507
- {
508
- "schemaVersion": 1,
509
- "changeName": change,
510
- "phase": phase,
511
- "executor": executor,
512
- "executionRoot": str(execution_root),
513
- "preparedAt": now.isoformat(),
514
- "receiptHash": latest.get("receiptHash") if latest else None,
515
- "displayTitle": resolved_display_title,
516
- },
703
+ if not reselection.get("ok"):
704
+ return reselection
705
+ claim = _claim_prepared_context(
706
+ project,
707
+ change,
708
+ contract_root=contract_root,
709
+ state_root=state_root,
710
+ phase=phase,
711
+ executor=executor,
712
+ ttl_seconds=ttl_seconds,
713
+ preparation_id=preparation_id,
714
+ display_title=resolved_display_title,
517
715
  )
716
+ if not claim.get("ok"):
717
+ return claim
718
+ transitions = claim["transitions"]
719
+ latest = claim["latestTransition"]
720
+ execution_root = Path(claim["executionRoot"])
721
+ lease = claim["lease"]
722
+ recovery = claim["recovery"]
518
723
  next_phases = (
519
724
  _allowed_next_phases(contract_root, phase)
520
725
  if planned_phases is not None
@@ -540,6 +745,7 @@ def prepare_context(
540
745
  "lease": lease,
541
746
  "recovery": recovery,
542
747
  "displayTitle": resolved_display_title,
748
+ "branchSelection": reselection,
543
749
  }
544
750
 
545
751
 
@@ -728,12 +934,13 @@ def close_transition(
728
934
  }
729
935
 
730
936
 
731
- def begin_transition(
937
+ def _begin_transition_unlocked(
732
938
  project: Path,
733
939
  change: str,
734
940
  *,
735
941
  phase: str,
736
942
  executor: str,
943
+ preparation_id: str | None = None,
737
944
  ) -> dict[str, Any]:
738
945
  project = Path(project).resolve()
739
946
  try:
@@ -741,6 +948,20 @@ def begin_transition(
741
948
  except (OSError, ValueError, json.JSONDecodeError) as exc:
742
949
  return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
743
950
  paths = _paths(state_root)
951
+ if preparation_id is not None:
952
+ current = _read_json(paths["current"]) if paths["current"].is_file() else None
953
+ lease = _read_json(paths["lease"]) if paths["lease"].is_file() else None
954
+ if not (
955
+ isinstance(current, dict)
956
+ and isinstance(lease, dict)
957
+ and current.get("preparationId") == preparation_id
958
+ and lease.get("preparationId") == preparation_id
959
+ ):
960
+ return {
961
+ "ok": False,
962
+ "code": "CONTEXT_PREPARATION_MISMATCH",
963
+ "message": "本次修复准备凭证与当前上下文不一致,未启动新阶段。",
964
+ }
744
965
  transitions = _read_ndjson(paths["transitions"])
745
966
  if not transitions:
746
967
  return {
@@ -792,10 +1013,10 @@ def begin_transition(
792
1013
  "receiptHash": receipt["receiptHash"],
793
1014
  "begunAt": _now().isoformat(),
794
1015
  }
1016
+ if preparation_id is not None:
1017
+ acknowledgment["preparationId"] = preparation_id
795
1018
  _append_ndjson(paths["begins"], acknowledgment)
796
- _write_json_atomic(
797
- paths["current"],
798
- {
1019
+ current_context = {
799
1020
  "schemaVersion": 1,
800
1021
  "changeName": change,
801
1022
  "phase": phase,
@@ -803,8 +1024,10 @@ def begin_transition(
803
1024
  "executionRoot": str(_execution_root(project, project / ".harness/changes" / change, state_root)),
804
1025
  "receiptHash": receipt["receiptHash"],
805
1026
  "begunAt": acknowledgment["begunAt"],
806
- },
807
- )
1027
+ }
1028
+ if preparation_id is not None:
1029
+ current_context["preparationId"] = preparation_id
1030
+ _write_json_atomic(paths["current"], current_context)
808
1031
  return {
809
1032
  "ok": True,
810
1033
  "code": "TRANSITION_BEGUN",
@@ -813,6 +1036,127 @@ def begin_transition(
813
1036
  }
814
1037
 
815
1038
 
1039
+ def begin_transition(
1040
+ project: Path,
1041
+ change: str,
1042
+ *,
1043
+ phase: str,
1044
+ executor: str,
1045
+ preparation_id: str | None = None,
1046
+ ) -> dict[str, Any]:
1047
+ """Acknowledge a transition while serializing branch selection."""
1048
+
1049
+ project = Path(project).resolve()
1050
+ try:
1051
+ _contract_root, _contract_data, state_root = _contract(project, change)
1052
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1053
+ return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
1054
+ paths = _paths(state_root)
1055
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1056
+ return _begin_transition_unlocked(
1057
+ project,
1058
+ change,
1059
+ phase=phase,
1060
+ executor=executor,
1061
+ preparation_id=preparation_id,
1062
+ )
1063
+
1064
+
1065
+ def cancel_prepared_context(
1066
+ project: Path,
1067
+ change: str,
1068
+ *,
1069
+ phase: str,
1070
+ executor: str,
1071
+ preparation_id: str | None = None,
1072
+ ) -> dict[str, Any]:
1073
+ """Remove a target-phase preparation that never obtained its gate."""
1074
+
1075
+ project = Path(project).resolve()
1076
+ try:
1077
+ _contract_root, _contract_data, state_root = _contract(project, change)
1078
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1079
+ return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
1080
+ paths = _paths(state_root)
1081
+ removed: list[str] = []
1082
+ receipt_hash: str | None = None
1083
+ with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
1084
+ if paths["current"].is_file():
1085
+ try:
1086
+ current = _read_json(paths["current"])
1087
+ except (OSError, ValueError, json.JSONDecodeError):
1088
+ current = None
1089
+ if isinstance(current, dict) and (
1090
+ current.get("phase") == phase and current.get("executor") == executor
1091
+ and (
1092
+ preparation_id is None
1093
+ or current.get("preparationId") == preparation_id
1094
+ )
1095
+ ):
1096
+ receipt_hash = str(current.get("receiptHash") or "") or None
1097
+ paths["current"].unlink(missing_ok=True)
1098
+ removed.append("current")
1099
+ if paths["lease"].is_file():
1100
+ try:
1101
+ lease = _read_json(paths["lease"])
1102
+ except (OSError, ValueError, json.JSONDecodeError):
1103
+ lease = None
1104
+ if isinstance(lease, dict) and (
1105
+ lease.get("phase") == phase and lease.get("owner") == executor
1106
+ and (
1107
+ preparation_id is None
1108
+ or lease.get("preparationId") == preparation_id
1109
+ )
1110
+ ):
1111
+ paths["lease"].unlink(missing_ok=True)
1112
+ removed.append("lease")
1113
+ begins = _read_ndjson(paths["begins"])
1114
+ retained = [
1115
+ item
1116
+ for item in begins
1117
+ if not (
1118
+ receipt_hash is not None
1119
+ and item.get("phase") == phase
1120
+ and item.get("executor") == executor
1121
+ and item.get("receiptHash") == receipt_hash
1122
+ and (
1123
+ preparation_id is None
1124
+ or item.get("preparationId") == preparation_id
1125
+ )
1126
+ )
1127
+ ]
1128
+ if len(retained) != len(begins):
1129
+ paths["begins"].parent.mkdir(parents=True, exist_ok=True)
1130
+ payload = "".join(
1131
+ json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n"
1132
+ for item in retained
1133
+ )
1134
+ fd, raw = tempfile.mkstemp(
1135
+ prefix=f".{paths['begins'].name}.",
1136
+ suffix=".tmp",
1137
+ dir=str(paths["begins"].parent),
1138
+ )
1139
+ temporary = Path(raw)
1140
+ try:
1141
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
1142
+ handle.write(payload)
1143
+ handle.flush()
1144
+ os.fsync(handle.fileno())
1145
+ os.replace(temporary, paths["begins"])
1146
+ finally:
1147
+ temporary.unlink(missing_ok=True)
1148
+ removed.append("begin")
1149
+ return {
1150
+ "ok": True,
1151
+ "code": (
1152
+ "PREPARED_CONTEXT_CANCELLED"
1153
+ if removed
1154
+ else "PREPARED_CONTEXT_NOT_OWNED"
1155
+ ),
1156
+ "removed": removed,
1157
+ }
1158
+
1159
+
816
1160
  def context_view(project: Path, change: str) -> dict[str, Any]:
817
1161
  project = Path(project).resolve()
818
1162
  try:
@@ -867,6 +1211,7 @@ def build_parser() -> argparse.ArgumentParser:
867
1211
  prepare.add_argument("--executor", required=True)
868
1212
  prepare.add_argument("--title")
869
1213
  prepare.add_argument("--ttl-seconds", type=int, default=3600)
1214
+ prepare.add_argument("--trigger", choices=["review-fixback"])
870
1215
  close = sub.add_parser("close")
871
1216
  close.add_argument("--json", action="store_true")
872
1217
  close.add_argument("--project", required=True, type=Path)
@@ -906,6 +1251,7 @@ def main(argv: list[str] | None = None) -> int:
906
1251
  change=args.change,
907
1252
  display_title=args.title,
908
1253
  ttl_seconds=args.ttl_seconds,
1254
+ trigger=args.trigger,
909
1255
  )
910
1256
  elif args.command == "close":
911
1257
  result = close_transition(