@hunter-harness/workflow-harness 0.2.67 → 0.2.70
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +401 -88
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -2
- package/harness/bundles/general/claude-code/harness-plan/reference.md +32 -2
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +73 -0
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +73 -0
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +20 -0
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/claude-code/harness-test/reference.md +9 -0
- package/harness/bundles/general/claude-code/scripts/harness_context.py +27 -7
- package/harness/bundles/general/claude-code/scripts/harness_events.py +12 -3
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/general/claude-code/scripts/harness_service.py +69 -15
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +401 -88
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -2
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +32 -2
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +55 -0
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +20 -0
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/codebuddy/harness-test/reference.md +9 -0
- package/harness/bundles/general/codebuddy/scripts/harness_context.py +27 -7
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +12 -3
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +69 -15
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +401 -88
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -2
- package/harness/bundles/general/codex/harness-plan/reference.md +32 -2
- package/harness/bundles/general/codex/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/codex/harness-push/SKILL.md +55 -0
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-run/reference.md +20 -0
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/codex/harness-test/reference.md +9 -0
- package/harness/bundles/general/codex/scripts/harness_context.py +27 -7
- package/harness/bundles/general/codex/scripts/harness_events.py +12 -3
- package/harness/bundles/general/codex/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/codex/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/general/codex/scripts/harness_service.py +69 -15
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +401 -88
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -2
- package/harness/bundles/general/cursor/harness-plan/reference.md +32 -2
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/cursor/harness-push/SKILL.md +55 -0
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +20 -0
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-test/pitfalls.md +34 -3
- package/harness/bundles/general/cursor/harness-test/reference.md +9 -0
- package/harness/bundles/general/cursor/scripts/harness_context.py +27 -7
- package/harness/bundles/general/cursor/scripts/harness_events.py +12 -3
- package/harness/bundles/general/cursor/scripts/harness_gate.py +127 -11
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +244 -5
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/general/cursor/scripts/harness_service.py +69 -15
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +401 -88
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -2
- package/harness/bundles/java/claude-code/harness-plan/reference.md +32 -2
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +73 -0
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +73 -0
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/claude-code/scripts/harness_context.py +27 -7
- package/harness/bundles/java/claude-code/scripts/harness_events.py +12 -3
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/java/claude-code/scripts/harness_service.py +69 -15
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +401 -88
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -2
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +32 -2
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +55 -0
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/codebuddy/scripts/harness_context.py +27 -7
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +12 -3
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +69 -15
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +401 -88
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -2
- package/harness/bundles/java/codex/harness-plan/reference.md +32 -2
- package/harness/bundles/java/codex/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/codex/harness-push/SKILL.md +55 -0
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/codex/scripts/harness_context.py +27 -7
- package/harness/bundles/java/codex/scripts/harness_events.py +12 -3
- package/harness/bundles/java/codex/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/codex/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/java/codex/scripts/harness_service.py +69 -15
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +48 -1
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +401 -88
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -2
- package/harness/bundles/java/cursor/harness-plan/reference.md +32 -2
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/cursor/harness-push/SKILL.md +55 -0
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-test/pitfalls.md +34 -3
- package/harness/bundles/java/cursor/scripts/harness_context.py +27 -7
- package/harness/bundles/java/cursor/scripts/harness_events.py +12 -3
- package/harness/bundles/java/cursor/scripts/harness_gate.py +127 -11
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +244 -5
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +373 -19
- package/harness/bundles/java/cursor/scripts/harness_service.py +69 -15
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +48 -1
- package/harness/manifests/general/claude-code.json +36 -26
- package/harness/manifests/general/codebuddy.json +36 -26
- package/harness/manifests/general/codex.json +36 -26
- package/harness/manifests/general/cursor.json +36 -26
- package/harness/manifests/java/claude-code.json +36 -26
- package/harness/manifests/java/codebuddy.json +36 -26
- package/harness/manifests/java/codex.json +36 -26
- package/harness/manifests/java/cursor.json +36 -26
- package/hunter-workflow-family.json +11 -7
- package/package.json +1 -1
|
@@ -728,7 +728,9 @@ def _validate_plan_start(
|
|
|
728
728
|
if not matching:
|
|
729
729
|
return _result_error(
|
|
730
730
|
"PHASE_START_MISSING",
|
|
731
|
-
"no matching plan phase.start event found for finalizer runId/attempt"
|
|
731
|
+
"no matching plan phase.start event found for finalizer runId/attempt; "
|
|
732
|
+
"append one with the same run id and attempt before finalizing, or use "
|
|
733
|
+
"the `republish` subcommand which allocates and appends both for you",
|
|
732
734
|
)
|
|
733
735
|
if len(matching) > 1:
|
|
734
736
|
return _result_error(
|
|
@@ -796,6 +798,121 @@ def _receipt_artifact_targets(
|
|
|
796
798
|
return targets, None
|
|
797
799
|
|
|
798
800
|
|
|
801
|
+
def _verify_plan_v2(change_dir: Path) -> dict[str, Any] | None:
|
|
802
|
+
"""v2 finalizer(TS)证据的只读验收。
|
|
803
|
+
|
|
804
|
+
v2 不产生 meta/plan-finalization.json;canonical 证据是
|
|
805
|
+
meta/plan-finalization-transactions/ + meta/publication-journals/。
|
|
806
|
+
本路径只做结构验收:最新事务终态、匹配 journal 的 committed/readback、
|
|
807
|
+
durable targets 存在且字节哈希与 binding 一致。内容质量由 TS 质量层裁决,
|
|
808
|
+
Python 不重复审判。无 v2 证据时返回 None(调用方回退 RECEIPT_MISSING)。
|
|
809
|
+
"""
|
|
810
|
+
transactions_dir = change_dir / "meta" / "plan-finalization-transactions"
|
|
811
|
+
if not transactions_dir.is_dir():
|
|
812
|
+
return None
|
|
813
|
+
transactions = sorted(
|
|
814
|
+
transactions_dir.glob("*.json"),
|
|
815
|
+
key=lambda path: path.stat().st_mtime,
|
|
816
|
+
)
|
|
817
|
+
if not transactions:
|
|
818
|
+
return None
|
|
819
|
+
latest: dict[str, Any] | None = None
|
|
820
|
+
for candidate in reversed(transactions):
|
|
821
|
+
try:
|
|
822
|
+
payload = json.loads(candidate.read_text(encoding="utf-8-sig"))
|
|
823
|
+
except (OSError, json.JSONDecodeError):
|
|
824
|
+
continue
|
|
825
|
+
if isinstance(payload, dict):
|
|
826
|
+
latest = payload
|
|
827
|
+
break
|
|
828
|
+
if latest is None:
|
|
829
|
+
return None
|
|
830
|
+
if latest.get("change_key") != change_dir.name:
|
|
831
|
+
return _result_error(
|
|
832
|
+
"RECEIPT_CHANGE_NAME_INVALID",
|
|
833
|
+
"v2 transaction change_key does not match the change directory",
|
|
834
|
+
)
|
|
835
|
+
if latest.get("status") != "publication_committed_event_complete":
|
|
836
|
+
return _result_error(
|
|
837
|
+
"RECEIPT_NOT_FINALIZED",
|
|
838
|
+
f"v2 transaction status is {latest.get('status')!r}, "
|
|
839
|
+
"expected 'publication_committed_event_complete'",
|
|
840
|
+
)
|
|
841
|
+
operation_id = str(latest.get("operation_id") or "")
|
|
842
|
+
journals_dir = change_dir / "meta" / "publication-journals"
|
|
843
|
+
journal: dict[str, Any] | None = None
|
|
844
|
+
if journals_dir.is_dir():
|
|
845
|
+
for candidate in sorted(journals_dir.glob("*.json")):
|
|
846
|
+
try:
|
|
847
|
+
payload = json.loads(candidate.read_text(encoding="utf-8-sig"))
|
|
848
|
+
except (OSError, json.JSONDecodeError):
|
|
849
|
+
continue
|
|
850
|
+
if isinstance(payload, dict) and payload.get("operation_id") == operation_id:
|
|
851
|
+
journal = payload
|
|
852
|
+
break
|
|
853
|
+
if journal is None:
|
|
854
|
+
return _result_error(
|
|
855
|
+
"PUBLICATION_JOURNAL_MISSING",
|
|
856
|
+
f"no publication journal matches v2 operation {operation_id!r}",
|
|
857
|
+
)
|
|
858
|
+
if journal.get("state") != "committed" or journal.get("readback") != "verified":
|
|
859
|
+
return _result_error(
|
|
860
|
+
"PUBLICATION_JOURNAL_NOT_COMMITTED",
|
|
861
|
+
"v2 publication journal is not committed with verified readback",
|
|
862
|
+
)
|
|
863
|
+
binding = journal.get("binding")
|
|
864
|
+
if not isinstance(binding, dict):
|
|
865
|
+
return _result_error("PUBLICATION_BINDING_INVALID", "v2 journal binding missing")
|
|
866
|
+
payload_hashes = binding.get("expected_payload_hashes")
|
|
867
|
+
ownership_paths = binding.get("ownership_paths")
|
|
868
|
+
if not isinstance(payload_hashes, dict) or not isinstance(ownership_paths, list):
|
|
869
|
+
return _result_error("PUBLICATION_BINDING_INVALID", "v2 binding fields malformed")
|
|
870
|
+
required = {
|
|
871
|
+
f"plans/{change_dir.name}-design.md",
|
|
872
|
+
f"plans/{change_dir.name}-plan.md",
|
|
873
|
+
f"plans/{change_dir.name}-implementation-detail.md",
|
|
874
|
+
f"plans/{change_dir.name}-test-scenarios.md",
|
|
875
|
+
"meta/gate-policy.json",
|
|
876
|
+
"meta/worktree.json",
|
|
877
|
+
}
|
|
878
|
+
missing_required = sorted(required - set(ownership_paths))
|
|
879
|
+
if missing_required:
|
|
880
|
+
return _result_error(
|
|
881
|
+
"RECEIPT_FILES_INCOMPLETE",
|
|
882
|
+
"v2 journal omits required artifacts: " + ", ".join(missing_required),
|
|
883
|
+
)
|
|
884
|
+
for rel, expected_hash in payload_hashes.items():
|
|
885
|
+
if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256:"):
|
|
886
|
+
return _result_error("PUBLICATION_BINDING_INVALID", f"{rel}: malformed hash")
|
|
887
|
+
target = change_dir / rel
|
|
888
|
+
try:
|
|
889
|
+
resolved = target.resolve()
|
|
890
|
+
resolved.relative_to(change_dir.resolve())
|
|
891
|
+
except (OSError, ValueError):
|
|
892
|
+
return _result_error(
|
|
893
|
+
"RECEIPT_FILE_PATH_INVALID",
|
|
894
|
+
f"v2 artifact path resolves outside the change directory: {rel}",
|
|
895
|
+
)
|
|
896
|
+
if not target.is_file():
|
|
897
|
+
return _result_error("ARTIFACT_MISSING", f"published artifact missing: {rel}")
|
|
898
|
+
actual_hash = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
|
|
899
|
+
if actual_hash != expected_hash:
|
|
900
|
+
return _result_error(
|
|
901
|
+
"ARTIFACT_HASH_DRIFT",
|
|
902
|
+
f"{rel}: content hash {actual_hash} != v2 binding {expected_hash}",
|
|
903
|
+
)
|
|
904
|
+
return {
|
|
905
|
+
"ok": True,
|
|
906
|
+
"code": "PLAN_V2_VERIFIED",
|
|
907
|
+
"v2": True,
|
|
908
|
+
"operationId": operation_id,
|
|
909
|
+
"runId": latest.get("run_id"),
|
|
910
|
+
"attempt": latest.get("attempt"),
|
|
911
|
+
"artifactCount": len(payload_hashes),
|
|
912
|
+
"status": latest.get("status"),
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
799
916
|
def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
800
917
|
"""Read-only verification of a finalized plan (retro §5.8).
|
|
801
918
|
|
|
@@ -807,6 +924,10 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
807
924
|
change_dir = change_dir.resolve()
|
|
808
925
|
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
809
926
|
if not receipt_path.is_file():
|
|
927
|
+
# v2 finalizer 证据路径(canonical 事实源是 transactions + journals)
|
|
928
|
+
v2_result = _verify_plan_v2(change_dir)
|
|
929
|
+
if v2_result is not None:
|
|
930
|
+
return v2_result
|
|
810
931
|
return _result_error("RECEIPT_MISSING", f"receipt not found: {receipt_path}")
|
|
811
932
|
|
|
812
933
|
try:
|
|
@@ -1180,6 +1301,72 @@ def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int,
|
|
|
1180
1301
|
return code, stderr.getvalue().strip()
|
|
1181
1302
|
|
|
1182
1303
|
|
|
1304
|
+
def _rollback_publish(
|
|
1305
|
+
created: list[Path], replaced: list[tuple[Path, bytes]]
|
|
1306
|
+
) -> None:
|
|
1307
|
+
"""Undo a failed publish: delete new files, restore overwritten ones."""
|
|
1308
|
+
for target in reversed(created):
|
|
1309
|
+
target.unlink(missing_ok=True)
|
|
1310
|
+
for target, original in reversed(replaced):
|
|
1311
|
+
try:
|
|
1312
|
+
target.write_bytes(original)
|
|
1313
|
+
except OSError:
|
|
1314
|
+
# Best effort: the caller already reports the publish failure.
|
|
1315
|
+
pass
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _plan_attempts(change_dir: Path) -> list[int]:
|
|
1319
|
+
"""Every attempt number already used by a plan lifecycle event."""
|
|
1320
|
+
try:
|
|
1321
|
+
events = harness_events.load_events(harness_events.events_path(change_dir))
|
|
1322
|
+
except (OSError, ValueError):
|
|
1323
|
+
return []
|
|
1324
|
+
attempts: list[int] = []
|
|
1325
|
+
for event in events:
|
|
1326
|
+
if event.get("phase") != "plan":
|
|
1327
|
+
continue
|
|
1328
|
+
value = event.get("attempt")
|
|
1329
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
1330
|
+
attempts.append(value)
|
|
1331
|
+
return attempts
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
|
|
1335
|
+
try:
|
|
1336
|
+
events = harness_events.load_events(harness_events.events_path(change_dir))
|
|
1337
|
+
except (OSError, ValueError):
|
|
1338
|
+
return False
|
|
1339
|
+
return any(event.get("run_id") == run_id for event in events)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _append_plan_start(
|
|
1343
|
+
change_dir: Path, run_id: str, attempt: int, reason: str
|
|
1344
|
+
) -> tuple[int, str]:
|
|
1345
|
+
stdout = io.StringIO()
|
|
1346
|
+
stderr = io.StringIO()
|
|
1347
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
1348
|
+
code = harness_events.main(
|
|
1349
|
+
[
|
|
1350
|
+
"append",
|
|
1351
|
+
"--change-dir",
|
|
1352
|
+
str(change_dir),
|
|
1353
|
+
"--phase",
|
|
1354
|
+
"plan",
|
|
1355
|
+
"--type",
|
|
1356
|
+
"phase.start",
|
|
1357
|
+
# phase.start accepts only `note` beyond provenance fields.
|
|
1358
|
+
"--note",
|
|
1359
|
+
reason,
|
|
1360
|
+
"--run-id",
|
|
1361
|
+
run_id,
|
|
1362
|
+
"--attempt",
|
|
1363
|
+
str(attempt),
|
|
1364
|
+
"--json",
|
|
1365
|
+
]
|
|
1366
|
+
)
|
|
1367
|
+
return code, stderr.getvalue().strip()
|
|
1368
|
+
|
|
1369
|
+
|
|
1183
1370
|
def finalize_plan(
|
|
1184
1371
|
change_dir: Path,
|
|
1185
1372
|
staging: Path,
|
|
@@ -1187,6 +1374,8 @@ def finalize_plan(
|
|
|
1187
1374
|
change_name: str,
|
|
1188
1375
|
run_id: str,
|
|
1189
1376
|
attempt: int,
|
|
1377
|
+
allow_amend: bool = False,
|
|
1378
|
+
amend_reason: str | None = None,
|
|
1190
1379
|
) -> dict[str, Any]:
|
|
1191
1380
|
change_dir = change_dir.resolve()
|
|
1192
1381
|
staging = staging.resolve()
|
|
@@ -1212,6 +1401,7 @@ def finalize_plan(
|
|
|
1212
1401
|
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
1213
1402
|
lock_path = change_dir / "meta" / "plan-finalize.lock"
|
|
1214
1403
|
receipt: dict[str, Any] | None = None
|
|
1404
|
+
supersedes: dict[str, Any] | None = None
|
|
1215
1405
|
if receipt_path.is_file():
|
|
1216
1406
|
try:
|
|
1217
1407
|
loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
@@ -1219,11 +1409,22 @@ def finalize_plan(
|
|
|
1219
1409
|
except (OSError, json.JSONDecodeError) as exc:
|
|
1220
1410
|
return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
|
|
1221
1411
|
if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1412
|
+
if not allow_amend:
|
|
1413
|
+
return _result_error(
|
|
1414
|
+
"PLAN_FINALIZATION_HASH_CONFLICT",
|
|
1415
|
+
"finalizer was already invoked with a different artifact set; "
|
|
1416
|
+
"to publish an approved amendment run: harness_plan_finalize.py "
|
|
1417
|
+
"republish --change-dir <dir> --staging-dir <dir> --change "
|
|
1418
|
+
f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
|
|
1419
|
+
)
|
|
1420
|
+
# Authorized amendment: keep the superseded identity in the new
|
|
1421
|
+
# receipt so the published history stays auditable.
|
|
1422
|
+
supersedes = {
|
|
1423
|
+
"artifactsHash": receipt.get("artifactsHash"),
|
|
1424
|
+
"runId": receipt.get("runId"),
|
|
1425
|
+
"attempt": receipt.get("attempt"),
|
|
1426
|
+
}
|
|
1427
|
+
elif (
|
|
1227
1428
|
receipt
|
|
1228
1429
|
and receipt.get("status") == "finalized"
|
|
1229
1430
|
and _terminal_exists(change_dir, run_id, attempt)
|
|
@@ -1246,21 +1447,33 @@ def finalize_plan(
|
|
|
1246
1447
|
return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
|
|
1247
1448
|
|
|
1248
1449
|
created: list[Path] = []
|
|
1450
|
+
# Amendment overwrites replace already-published bytes. Keep the originals
|
|
1451
|
+
# so a failed publish restores them instead of deleting the published plan.
|
|
1452
|
+
replaced: list[tuple[Path, bytes]] = []
|
|
1249
1453
|
terminal_committed = False
|
|
1250
1454
|
try:
|
|
1251
1455
|
for rel_text in validation["files"]:
|
|
1252
1456
|
source = staging / rel_text
|
|
1253
1457
|
target = change_dir / rel_text
|
|
1254
|
-
if
|
|
1458
|
+
if (
|
|
1459
|
+
not allow_amend
|
|
1460
|
+
and target.exists()
|
|
1461
|
+
and target.read_bytes() != source.read_bytes()
|
|
1462
|
+
):
|
|
1255
1463
|
return _result_error(
|
|
1256
|
-
"PLAN_TARGET_CONFLICT",
|
|
1464
|
+
"PLAN_TARGET_CONFLICT",
|
|
1465
|
+
f"refusing to overwrite {rel_text}; publish an approved "
|
|
1466
|
+
"amendment with the `republish` subcommand instead",
|
|
1257
1467
|
)
|
|
1258
1468
|
|
|
1259
1469
|
for rel_text in validation["files"]:
|
|
1260
1470
|
source = staging / rel_text
|
|
1261
1471
|
target = change_dir / rel_text
|
|
1262
|
-
|
|
1263
|
-
|
|
1472
|
+
existed = target.exists()
|
|
1473
|
+
if existed:
|
|
1474
|
+
original = target.read_bytes()
|
|
1475
|
+
if not allow_amend or original == source.read_bytes():
|
|
1476
|
+
continue
|
|
1264
1477
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1265
1478
|
fd, raw_tmp = tempfile.mkstemp(
|
|
1266
1479
|
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
|
@@ -1270,7 +1483,10 @@ def finalize_plan(
|
|
|
1270
1483
|
try:
|
|
1271
1484
|
shutil.copyfile(source, tmp)
|
|
1272
1485
|
os.replace(tmp, target)
|
|
1273
|
-
|
|
1486
|
+
if existed:
|
|
1487
|
+
replaced.append((target, original))
|
|
1488
|
+
else:
|
|
1489
|
+
created.append(target)
|
|
1274
1490
|
finally:
|
|
1275
1491
|
tmp.unlink(missing_ok=True)
|
|
1276
1492
|
|
|
@@ -1284,8 +1500,11 @@ def finalize_plan(
|
|
|
1284
1500
|
"tasks": tasks,
|
|
1285
1501
|
"foundationGate": "approved",
|
|
1286
1502
|
}
|
|
1503
|
+
if checkpoints_path.is_file():
|
|
1504
|
+
replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
|
|
1505
|
+
else:
|
|
1506
|
+
created.append(checkpoints_path)
|
|
1287
1507
|
_atomic_write_json(checkpoints_path, checkpoints_payload)
|
|
1288
|
-
created.append(checkpoints_path)
|
|
1289
1508
|
|
|
1290
1509
|
# C9: write scenario-manifest.json with parsed scenarios.
|
|
1291
1510
|
scenarios = validation.get("scenarios") or []
|
|
@@ -1302,8 +1521,11 @@ def finalize_plan(
|
|
|
1302
1521
|
for scenario in scenarios
|
|
1303
1522
|
],
|
|
1304
1523
|
}
|
|
1524
|
+
if manifest_path.is_file():
|
|
1525
|
+
replaced.append((manifest_path, manifest_path.read_bytes()))
|
|
1526
|
+
else:
|
|
1527
|
+
created.append(manifest_path)
|
|
1305
1528
|
_atomic_write_json(manifest_path, manifest_payload)
|
|
1306
|
-
created.append(manifest_path)
|
|
1307
1529
|
|
|
1308
1530
|
pending_receipt = {
|
|
1309
1531
|
"schemaVersion": SCHEMA_VERSION,
|
|
@@ -1314,12 +1536,14 @@ def finalize_plan(
|
|
|
1314
1536
|
"runId": run_id,
|
|
1315
1537
|
"attempt": attempt,
|
|
1316
1538
|
}
|
|
1539
|
+
if supersedes is not None:
|
|
1540
|
+
pending_receipt["supersedes"] = supersedes
|
|
1541
|
+
pending_receipt["amendReason"] = amend_reason or ""
|
|
1317
1542
|
_atomic_write_json(receipt_path, pending_receipt)
|
|
1318
1543
|
terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
|
|
1319
1544
|
terminal_committed = _terminal_exists(change_dir, run_id, attempt)
|
|
1320
1545
|
if terminal_code != 0 and not terminal_committed:
|
|
1321
|
-
|
|
1322
|
-
target.unlink(missing_ok=True)
|
|
1546
|
+
_rollback_publish(created, replaced)
|
|
1323
1547
|
receipt_path.unlink(missing_ok=True)
|
|
1324
1548
|
return _result_error(
|
|
1325
1549
|
"PLAN_TERMINAL_APPEND_FAILED",
|
|
@@ -1327,9 +1551,9 @@ def finalize_plan(
|
|
|
1327
1551
|
)
|
|
1328
1552
|
pending_receipt["status"] = "finalized"
|
|
1329
1553
|
_atomic_write_json(receipt_path, pending_receipt)
|
|
1330
|
-
|
|
1554
|
+
result: dict[str, Any] = {
|
|
1331
1555
|
"ok": True,
|
|
1332
|
-
"action": "finalize",
|
|
1556
|
+
"action": "republish" if supersedes is not None else "finalize",
|
|
1333
1557
|
"idempotent": False,
|
|
1334
1558
|
"artifactsHash": validation["artifactsHash"],
|
|
1335
1559
|
"files": validation["files"],
|
|
@@ -1337,23 +1561,145 @@ def finalize_plan(
|
|
|
1337
1561
|
"artifactRef": "meta/plan-finalization.json",
|
|
1338
1562
|
"executionLogPath": str(harness_events.execution_log_path(change_dir)),
|
|
1339
1563
|
}
|
|
1564
|
+
if supersedes is not None:
|
|
1565
|
+
result["supersedes"] = supersedes
|
|
1566
|
+
result["scenarioCount"] = len(scenarios)
|
|
1567
|
+
result["taskCount"] = len(tasks)
|
|
1568
|
+
return result
|
|
1340
1569
|
except OSError as exc:
|
|
1341
1570
|
if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
|
|
1342
1571
|
return _result_error(
|
|
1343
1572
|
"PLAN_FINALIZATION_RECOVERY_REQUIRED",
|
|
1344
1573
|
f"terminal committed; retry finalization to complete receipt: {exc}",
|
|
1345
1574
|
)
|
|
1346
|
-
|
|
1347
|
-
target.unlink(missing_ok=True)
|
|
1575
|
+
_rollback_publish(created, replaced)
|
|
1348
1576
|
receipt_path.unlink(missing_ok=True)
|
|
1349
1577
|
return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
|
|
1350
1578
|
finally:
|
|
1351
1579
|
lock_path.unlink(missing_ok=True)
|
|
1352
1580
|
|
|
1353
1581
|
|
|
1582
|
+
def republish_plan(
|
|
1583
|
+
change_dir: Path,
|
|
1584
|
+
staging: Path,
|
|
1585
|
+
*,
|
|
1586
|
+
change_name: str,
|
|
1587
|
+
run_id: str,
|
|
1588
|
+
reason: str,
|
|
1589
|
+
) -> dict[str, Any]:
|
|
1590
|
+
"""Publish an approved amendment to an already-finalized plan.
|
|
1591
|
+
|
|
1592
|
+
Amending a published plan (adding a scenario, correcting a task) used to
|
|
1593
|
+
require reverse-engineering a five-step lifecycle dance across three
|
|
1594
|
+
modules — new run id, incremented attempt, stale receipt removal, dangling
|
|
1595
|
+
attempt sealing — and every wrong turn produced a different error code
|
|
1596
|
+
(PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
|
|
1597
|
+
EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
|
|
1598
|
+
hand-edited meta/scenario-manifest.json, creating exactly the drift the
|
|
1599
|
+
hash guard exists to prevent.
|
|
1600
|
+
|
|
1601
|
+
This performs the whole sequence atomically and keeps the superseded
|
|
1602
|
+
identity in the receipt, so amendment stays auditable rather than silent.
|
|
1603
|
+
"""
|
|
1604
|
+
change_dir = change_dir.resolve()
|
|
1605
|
+
staging = staging.resolve()
|
|
1606
|
+
|
|
1607
|
+
if not str(reason).strip():
|
|
1608
|
+
return _result_error(
|
|
1609
|
+
"PLAN_AMEND_REASON_REQUIRED",
|
|
1610
|
+
"--reason is required: an amendment to a published plan must record why",
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
1614
|
+
if not receipt_path.is_file():
|
|
1615
|
+
return _result_error(
|
|
1616
|
+
"PLAN_NOT_FINALIZED",
|
|
1617
|
+
"no plan-finalization.json to amend; use `finalize` for the first "
|
|
1618
|
+
"publication",
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
if _run_id_in_use(change_dir, run_id):
|
|
1622
|
+
return _result_error(
|
|
1623
|
+
"PLAN_AMEND_RUN_ID_IN_USE",
|
|
1624
|
+
f"run id {run_id} already appears in this change's events; "
|
|
1625
|
+
"an amendment needs a fresh run id",
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
# Nothing to amend is a no-op, not a new attempt: probe the staged hash
|
|
1629
|
+
# before writing any lifecycle event.
|
|
1630
|
+
reclassify = _reclassify_gate_policy(staging, change_name)
|
|
1631
|
+
if not reclassify.get("ok"):
|
|
1632
|
+
return _result_error(
|
|
1633
|
+
"CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
|
|
1634
|
+
)
|
|
1635
|
+
probe = validate_staging(staging, change_name)
|
|
1636
|
+
if not probe["ok"]:
|
|
1637
|
+
return probe
|
|
1638
|
+
try:
|
|
1639
|
+
published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
1640
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1641
|
+
return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
|
|
1642
|
+
if (
|
|
1643
|
+
isinstance(published, dict)
|
|
1644
|
+
and published.get("status") == "finalized"
|
|
1645
|
+
and published.get("artifactsHash") == probe["artifactsHash"]
|
|
1646
|
+
):
|
|
1647
|
+
return {
|
|
1648
|
+
"ok": True,
|
|
1649
|
+
"action": "republish",
|
|
1650
|
+
"idempotent": True,
|
|
1651
|
+
"artifactsHash": probe["artifactsHash"],
|
|
1652
|
+
"files": probe["files"],
|
|
1653
|
+
"receiptPath": str(receipt_path),
|
|
1654
|
+
"artifactRef": "meta/plan-finalization.json",
|
|
1655
|
+
"note": "published plan already matches staging; nothing to amend",
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
attempt = max(_plan_attempts(change_dir), default=0) + 1
|
|
1659
|
+
start_code, start_error = _append_plan_start(
|
|
1660
|
+
change_dir, run_id, attempt, f"plan amendment: {reason}"
|
|
1661
|
+
)
|
|
1662
|
+
if start_code != 0:
|
|
1663
|
+
return _result_error(
|
|
1664
|
+
"PLAN_AMEND_START_FAILED",
|
|
1665
|
+
start_error or "failed to append plan phase.start for the amendment",
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
result = finalize_plan(
|
|
1669
|
+
change_dir,
|
|
1670
|
+
staging,
|
|
1671
|
+
change_name=change_name,
|
|
1672
|
+
run_id=run_id,
|
|
1673
|
+
attempt=attempt,
|
|
1674
|
+
allow_amend=True,
|
|
1675
|
+
amend_reason=reason,
|
|
1676
|
+
)
|
|
1677
|
+
if isinstance(result, dict):
|
|
1678
|
+
result.setdefault("runId", run_id)
|
|
1679
|
+
result.setdefault("attempt", attempt)
|
|
1680
|
+
return result
|
|
1681
|
+
|
|
1682
|
+
|
|
1354
1683
|
def build_parser() -> argparse.ArgumentParser:
|
|
1355
1684
|
parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
|
|
1356
1685
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
1686
|
+
republish = sub.add_parser(
|
|
1687
|
+
"republish",
|
|
1688
|
+
help=(
|
|
1689
|
+
"publish an approved amendment to an already-finalized plan "
|
|
1690
|
+
"(allocates a fresh attempt and records what it supersedes)"
|
|
1691
|
+
),
|
|
1692
|
+
)
|
|
1693
|
+
republish.add_argument("--change-dir", required=True)
|
|
1694
|
+
republish.add_argument("--staging-dir", required=True)
|
|
1695
|
+
republish.add_argument("--change", required=True)
|
|
1696
|
+
republish.add_argument(
|
|
1697
|
+
"--run-id", required=True, help="a fresh run id, unused by this change"
|
|
1698
|
+
)
|
|
1699
|
+
republish.add_argument(
|
|
1700
|
+
"--reason", required=True, help="why the published plan is being amended"
|
|
1701
|
+
)
|
|
1702
|
+
republish.add_argument("--json", action="store_true")
|
|
1357
1703
|
finalize = sub.add_parser("finalize")
|
|
1358
1704
|
finalize.add_argument("--change-dir", required=True)
|
|
1359
1705
|
finalize.add_argument("--staging-dir", required=True)
|
|
@@ -1371,6 +1717,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1371
1717
|
args = build_parser().parse_args(argv)
|
|
1372
1718
|
if args.command == "verify":
|
|
1373
1719
|
result = verify_plan(Path(args.change_dir))
|
|
1720
|
+
elif args.command == "republish":
|
|
1721
|
+
result = republish_plan(
|
|
1722
|
+
Path(args.change_dir),
|
|
1723
|
+
Path(args.staging_dir),
|
|
1724
|
+
change_name=args.change,
|
|
1725
|
+
run_id=args.run_id,
|
|
1726
|
+
reason=args.reason,
|
|
1727
|
+
)
|
|
1374
1728
|
else:
|
|
1375
1729
|
result = finalize_plan(
|
|
1376
1730
|
Path(args.change_dir),
|
|
@@ -1462,6 +1462,10 @@ def _job_id_path(change_dir: Path) -> Path:
|
|
|
1462
1462
|
return _runtime_dir(change_dir) / "_harness_service.job.id"
|
|
1463
1463
|
|
|
1464
1464
|
|
|
1465
|
+
def _breakaway_note_path(change_dir: Path) -> Path:
|
|
1466
|
+
return _runtime_dir(change_dir) / "_harness_service.breakaway.json"
|
|
1467
|
+
|
|
1468
|
+
|
|
1465
1469
|
def _child_pid_path(change_dir: Path) -> Path:
|
|
1466
1470
|
return _runtime_dir(change_dir) / "_harness_service.child.pid"
|
|
1467
1471
|
|
|
@@ -1574,20 +1578,56 @@ def _start_detached_service_windows(
|
|
|
1574
1578
|
str(pid_path),
|
|
1575
1579
|
job_id,
|
|
1576
1580
|
]
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
)
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1581
|
+
base_flags = _DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
|
|
1582
|
+
# CREATE_BREAKAWAY_FROM_JOB fails with ERROR_ACCESS_DENIED (WinError 5)
|
|
1583
|
+
# whenever the *caller* already runs inside a Job Object that was created
|
|
1584
|
+
# without JOB_OBJECT_LIMIT_BREAKAWAY_OK — which is exactly how agent CLIs
|
|
1585
|
+
# (Claude Code / CodeBuddy / Codex) contain their child processes. Breakaway
|
|
1586
|
+
# is an optimisation (the service outlives the agent session), not a
|
|
1587
|
+
# requirement, so fall back to spawning inside the caller's job rather than
|
|
1588
|
+
# failing the whole start.
|
|
1589
|
+
launcher_proc = None
|
|
1590
|
+
breakaway = True
|
|
1591
|
+
try:
|
|
1592
|
+
launcher_proc = subprocess.Popen(
|
|
1593
|
+
launcher_args,
|
|
1594
|
+
cwd=str(cwd),
|
|
1595
|
+
stdin=subprocess.DEVNULL,
|
|
1596
|
+
stdout=subprocess.DEVNULL,
|
|
1597
|
+
stderr=subprocess.DEVNULL,
|
|
1598
|
+
creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB,
|
|
1599
|
+
close_fds=False,
|
|
1600
|
+
)
|
|
1601
|
+
except OSError as exc:
|
|
1602
|
+
if getattr(exc, "winerror", None) != 5:
|
|
1603
|
+
raise
|
|
1604
|
+
breakaway = False
|
|
1605
|
+
launcher_proc = subprocess.Popen(
|
|
1606
|
+
launcher_args,
|
|
1607
|
+
cwd=str(cwd),
|
|
1608
|
+
stdin=subprocess.DEVNULL,
|
|
1609
|
+
stdout=subprocess.DEVNULL,
|
|
1610
|
+
stderr=subprocess.DEVNULL,
|
|
1611
|
+
creationflags=base_flags,
|
|
1612
|
+
close_fds=False,
|
|
1613
|
+
)
|
|
1614
|
+
_breakaway_note_path(change_dir).write_text(
|
|
1615
|
+
json.dumps(
|
|
1616
|
+
{
|
|
1617
|
+
"breakawayFromJob": breakaway,
|
|
1618
|
+
"note": (
|
|
1619
|
+
None
|
|
1620
|
+
if breakaway
|
|
1621
|
+
else "spawned inside the caller's Job Object "
|
|
1622
|
+
"(CREATE_BREAKAWAY_FROM_JOB denied); the service is "
|
|
1623
|
+
"terminated when the agent session ends"
|
|
1624
|
+
),
|
|
1625
|
+
},
|
|
1626
|
+
ensure_ascii=False,
|
|
1627
|
+
)
|
|
1628
|
+
+ "\n",
|
|
1629
|
+
encoding="utf-8",
|
|
1630
|
+
newline="\n",
|
|
1591
1631
|
)
|
|
1592
1632
|
_launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
|
|
1593
1633
|
_wait_for_child_pid(pid_path)
|
|
@@ -2400,7 +2440,21 @@ def _start_and_record(
|
|
|
2400
2440
|
}
|
|
2401
2441
|
)
|
|
2402
2442
|
write_json(mutation_state_path(change_dir), mutation_state)
|
|
2403
|
-
|
|
2443
|
+
if getattr(exc, "winerror", None) == 5:
|
|
2444
|
+
return emit_error(
|
|
2445
|
+
"failed to start service: Windows denied process creation "
|
|
2446
|
+
f"({exc}). The agent shell blocks detached spawns; start the "
|
|
2447
|
+
"service from a terminal the agent does not own (or use the "
|
|
2448
|
+
"documented nohup fallback) and re-run `ensure` so the harness "
|
|
2449
|
+
"adopts the running instance.",
|
|
2450
|
+
as_json=as_json,
|
|
2451
|
+
reasonCode="SERVICE_SPAWN_DENIED",
|
|
2452
|
+
)
|
|
2453
|
+
return emit_error(
|
|
2454
|
+
f"failed to start service: {exc}",
|
|
2455
|
+
as_json=as_json,
|
|
2456
|
+
reasonCode="SERVICE_START_FAILED",
|
|
2457
|
+
)
|
|
2404
2458
|
|
|
2405
2459
|
# Give the OS a moment to register the process before identity/create-time reads
|
|
2406
2460
|
time.sleep(0.15)
|