@hunter-harness/workflow-harness 0.2.38 → 0.2.39
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/harness-archive/SKILL.md +10 -7
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-review/reference.md +20 -5
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +446 -20
- package/harness/bundles/general/claude-code/scripts/harness_events.py +273 -21
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +56 -32
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +197 -3
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +126 -2
- package/harness/bundles/general/claude-code/scripts/harness_report_model.py +28 -6
- package/harness/bundles/general/claude-code/scripts/harness_review.py +118 -17
- package/harness/bundles/general/claude-code/scripts/harness_service.py +219 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +10 -7
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-review/reference.md +20 -5
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +446 -20
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +273 -21
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +56 -32
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +197 -3
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +126 -2
- package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +28 -6
- package/harness/bundles/general/codebuddy/scripts/harness_review.py +118 -17
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +219 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +10 -7
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-review/reference.md +20 -5
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +446 -20
- package/harness/bundles/general/codex/scripts/harness_events.py +273 -21
- package/harness/bundles/general/codex/scripts/harness_gate.py +56 -32
- package/harness/bundles/general/codex/scripts/harness_integration.py +197 -3
- package/harness/bundles/general/codex/scripts/harness_ledger.py +126 -2
- package/harness/bundles/general/codex/scripts/harness_report_model.py +28 -6
- package/harness/bundles/general/codex/scripts/harness_review.py +118 -17
- package/harness/bundles/general/codex/scripts/harness_service.py +219 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +10 -7
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-review/reference.md +20 -5
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +446 -20
- package/harness/bundles/general/cursor/scripts/harness_events.py +273 -21
- package/harness/bundles/general/cursor/scripts/harness_gate.py +56 -32
- package/harness/bundles/general/cursor/scripts/harness_integration.py +197 -3
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +126 -2
- package/harness/bundles/general/cursor/scripts/harness_report_model.py +28 -6
- package/harness/bundles/general/cursor/scripts/harness_review.py +118 -17
- package/harness/bundles/general/cursor/scripts/harness_service.py +219 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +10 -7
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- 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 +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-review/reference.md +20 -5
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -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 +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +446 -20
- package/harness/bundles/java/claude-code/scripts/harness_events.py +273 -21
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +56 -32
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +197 -3
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +126 -2
- package/harness/bundles/java/claude-code/scripts/harness_report_model.py +28 -6
- package/harness/bundles/java/claude-code/scripts/harness_review.py +118 -17
- package/harness/bundles/java/claude-code/scripts/harness_service.py +219 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +10 -7
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- 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 +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-review/reference.md +20 -5
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -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 +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +446 -20
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +273 -21
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +56 -32
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +197 -3
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +126 -2
- package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +28 -6
- package/harness/bundles/java/codebuddy/scripts/harness_review.py +118 -17
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +219 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +10 -7
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- 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 +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-review/reference.md +20 -5
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -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 +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +446 -20
- package/harness/bundles/java/codex/scripts/harness_events.py +273 -21
- package/harness/bundles/java/codex/scripts/harness_gate.py +56 -32
- package/harness/bundles/java/codex/scripts/harness_integration.py +197 -3
- package/harness/bundles/java/codex/scripts/harness_ledger.py +126 -2
- package/harness/bundles/java/codex/scripts/harness_report_model.py +28 -6
- package/harness/bundles/java/codex/scripts/harness_review.py +118 -17
- package/harness/bundles/java/codex/scripts/harness_service.py +219 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +10 -7
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -4
- 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-ingest/scripts/harness_knowledge.py +283 -28
- 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 +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-review/reference.md +20 -5
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -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 +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +446 -20
- package/harness/bundles/java/cursor/scripts/harness_events.py +273 -21
- package/harness/bundles/java/cursor/scripts/harness_gate.py +56 -32
- package/harness/bundles/java/cursor/scripts/harness_integration.py +197 -3
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +126 -2
- package/harness/bundles/java/cursor/scripts/harness_report_model.py +28 -6
- package/harness/bundles/java/cursor/scripts/harness_review.py +118 -17
- package/harness/bundles/java/cursor/scripts/harness_service.py +219 -0
- package/harness/manifests/general/claude-code.json +24 -24
- package/harness/manifests/general/codebuddy.json +24 -24
- package/harness/manifests/general/codex.json +24 -24
- package/harness/manifests/general/cursor.json +24 -24
- package/harness/manifests/java/claude-code.json +26 -26
- package/harness/manifests/java/codebuddy.json +26 -26
- package/harness/manifests/java/codex.json +26 -26
- package/harness/manifests/java/cursor.json +26 -26
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -43,6 +43,7 @@ EVENT_TYPES = frozenset(
|
|
|
43
43
|
{
|
|
44
44
|
"phase.start",
|
|
45
45
|
"phase.end",
|
|
46
|
+
"phase.auto_sealed",
|
|
46
47
|
"command",
|
|
47
48
|
"verification",
|
|
48
49
|
"artifact",
|
|
@@ -124,6 +125,7 @@ _EVENT_ALLOWED_FIELDS = {
|
|
|
124
125
|
{"status", "duration_ms", "note", "reason", "issue_id"}
|
|
125
126
|
)
|
|
126
127
|
| _PROVENANCE_FIELDS,
|
|
128
|
+
"phase.auto_sealed": frozenset({"status", "reason", "note"}) | _PROVENANCE_FIELDS,
|
|
127
129
|
"command": frozenset({"command", "exit_code", "duration_ms", "note"})
|
|
128
130
|
| _PROVENANCE_FIELDS,
|
|
129
131
|
"verification": frozenset(
|
|
@@ -164,11 +166,35 @@ _EVENT_REQUIRED_FIELDS = {
|
|
|
164
166
|
"new_value_json",
|
|
165
167
|
"reason",
|
|
166
168
|
),
|
|
169
|
+
"phase.auto_sealed": ("reason",),
|
|
167
170
|
}
|
|
168
171
|
_CORRECTION_PROTECTED_FIELDS = frozenset(
|
|
169
172
|
{"schema_version", "id", "timestamp", "phase", "type"}
|
|
170
173
|
)
|
|
171
174
|
|
|
175
|
+
# HH-WF-20260730-001: reasons a write-path auto-seal may attribute to an
|
|
176
|
+
# attempt that was still open when it was closed on its behalf.
|
|
177
|
+
_AUTO_SEAL_REASONS = frozenset(
|
|
178
|
+
{"executor_lost", "user_wait", "external_wait", "superseded", "unknown"}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Best-effort mapping from wait-style metadata events (seen inside an open
|
|
182
|
+
# attempt's own event stream) to the auto-seal reason they imply.
|
|
183
|
+
_WAIT_EVENT_REASON_MAP = {
|
|
184
|
+
"user.wait": "user_wait",
|
|
185
|
+
"environment.wait": "external_wait",
|
|
186
|
+
"env.wait": "external_wait",
|
|
187
|
+
"external.wait": "external_wait",
|
|
188
|
+
"ci.wait": "external_wait",
|
|
189
|
+
}
|
|
190
|
+
_RECOVERY_EVENT_TYPES = frozenset({"recovery", "phase.recovery", "attempt.recovery"})
|
|
191
|
+
|
|
192
|
+
# Terminal event types that close a phase attempt (RET/HH-WF-20260730-001).
|
|
193
|
+
# ``phase.end`` is an explicit human/automation-reported terminal; the
|
|
194
|
+
# write path also inserts ``phase.auto_sealed`` when a new ``phase.start``
|
|
195
|
+
# (or another terminal path) finds a still-open prior attempt.
|
|
196
|
+
TERMINAL_PHASE_EVENT_TYPES = frozenset({"phase.end", "phase.auto_sealed"})
|
|
197
|
+
|
|
172
198
|
|
|
173
199
|
def now_iso() -> str:
|
|
174
200
|
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
@@ -587,12 +613,23 @@ def append_event(
|
|
|
587
613
|
if archived is not None:
|
|
588
614
|
return {"ok": False, "code": "ARCHIVED_CHANGE_IMMUTABLE", "message": str(archived)}
|
|
589
615
|
events_path_obj = events_path(change_dir)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
616
|
+
event = build_event(args, [])
|
|
617
|
+
result = append_with_auto_seal(events_path_obj, event)
|
|
618
|
+
if result.get("phaseAlreadyClosed"):
|
|
619
|
+
return {
|
|
620
|
+
"ok": False,
|
|
621
|
+
"code": "PHASE_ALREADY_CLOSED",
|
|
622
|
+
"message": "PHASE_ALREADY_CLOSED: refusing a second phase.end for the same attempt",
|
|
623
|
+
"event": event,
|
|
624
|
+
"autoSealed": result.get("autoSealed") or [],
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
"ok": True,
|
|
628
|
+
"event": event,
|
|
629
|
+
"events_path": str(events_path_obj),
|
|
630
|
+
"rendered": False,
|
|
631
|
+
"autoSealed": result.get("autoSealed") or [],
|
|
632
|
+
}
|
|
596
633
|
|
|
597
634
|
|
|
598
635
|
def batch_append_events(
|
|
@@ -791,6 +828,14 @@ def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
|
|
|
791
828
|
"EVENT_TIMING_FIELD_INVALID",
|
|
792
829
|
f"EVENT_TIMING_FIELD_INVALID: --{field.replace('_', '-')} must be a nonnegative integer",
|
|
793
830
|
)
|
|
831
|
+
if event_type == "phase.auto_sealed":
|
|
832
|
+
reason_value = str(getattr(args, "reason", "") or "").strip()
|
|
833
|
+
if reason_value and reason_value not in _AUTO_SEAL_REASONS:
|
|
834
|
+
return (
|
|
835
|
+
"EVENT_REASON_INVALID",
|
|
836
|
+
"EVENT_REASON_INVALID: phase.auto_sealed --reason must be one of "
|
|
837
|
+
f"{sorted(_AUTO_SEAL_REASONS)}",
|
|
838
|
+
)
|
|
794
839
|
if event_type == "correction":
|
|
795
840
|
target_field = str(getattr(args, "target_field", "") or "").strip()
|
|
796
841
|
if target_field in _CORRECTION_PROTECTED_FIELDS:
|
|
@@ -879,7 +924,11 @@ def group_events_by_phase(events: list[dict[str, Any]]) -> list[tuple[str, list[
|
|
|
879
924
|
|
|
880
925
|
|
|
881
926
|
def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
882
|
-
"""Split repeated starts into attempts and flag legacy events written after end.
|
|
927
|
+
"""Split repeated starts into attempts and flag legacy events written after end.
|
|
928
|
+
|
|
929
|
+
``phase.end`` and ``phase.auto_sealed`` are both terminal: either one closes
|
|
930
|
+
the current attempt (HH-WF-20260730-001).
|
|
931
|
+
"""
|
|
883
932
|
attempts: list[dict[str, Any]] = []
|
|
884
933
|
current: dict[str, Any] | None = None
|
|
885
934
|
next_attempt = 1
|
|
@@ -906,7 +955,7 @@ def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, A
|
|
|
906
955
|
if attempts:
|
|
907
956
|
prior_type = attempts[-1]["events"][-1].get("type")
|
|
908
957
|
attempts[-1]["events"].append(event)
|
|
909
|
-
if prior_type
|
|
958
|
+
if prior_type in TERMINAL_PHASE_EVENT_TYPES:
|
|
910
959
|
attempts[-1]["warnings"].append("event recorded after phase.end")
|
|
911
960
|
continue
|
|
912
961
|
if event.get("type") in pre_start_metadata_types:
|
|
@@ -924,7 +973,7 @@ def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, A
|
|
|
924
973
|
}
|
|
925
974
|
else:
|
|
926
975
|
current["events"].append(event)
|
|
927
|
-
if event.get("type")
|
|
976
|
+
if event.get("type") in TERMINAL_PHASE_EVENT_TYPES:
|
|
928
977
|
attempts.append(current)
|
|
929
978
|
current = None
|
|
930
979
|
if current is not None:
|
|
@@ -948,7 +997,7 @@ def phase_duration_ms(phase_events: list[dict[str, Any]]) -> int | None:
|
|
|
948
997
|
etype = event.get("type")
|
|
949
998
|
if etype == "phase.start" and start_ts is None:
|
|
950
999
|
start_ts = event.get("timestamp")
|
|
951
|
-
elif etype
|
|
1000
|
+
elif etype in TERMINAL_PHASE_EVENT_TYPES:
|
|
952
1001
|
end_ts = event.get("timestamp")
|
|
953
1002
|
if start_ts and end_ts:
|
|
954
1003
|
# Closed phases end at the matching phase.end. Late events appended
|
|
@@ -1031,8 +1080,12 @@ def attempt_invocations(
|
|
|
1031
1080
|
) -> list[dict[str, Any]]:
|
|
1032
1081
|
"""Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
|
|
1033
1082
|
|
|
1034
|
-
Closed attempts keep phase.end status.
|
|
1035
|
-
|
|
1083
|
+
Closed attempts keep phase.end status. Attempts closed by a write-path
|
|
1084
|
+
``phase.auto_sealed`` event (HH-WF-20260730-001) keep that event's status
|
|
1085
|
+
(typically RECOVERED) but are never activeEligible: recovered/superseded
|
|
1086
|
+
time must not count as active execution. Unclosed attempts (no terminal
|
|
1087
|
+
event at all yet) are sealed as INCOMPLETE at the next attempt start, an
|
|
1088
|
+
explicit recovery event, or cutoff.
|
|
1036
1089
|
"""
|
|
1037
1090
|
attempts = split_phase_attempts(phase_events)
|
|
1038
1091
|
invocations: list[dict[str, Any]] = []
|
|
@@ -1043,6 +1096,7 @@ def attempt_invocations(
|
|
|
1043
1096
|
status = None
|
|
1044
1097
|
terminal_status = None
|
|
1045
1098
|
closed_by_end = False
|
|
1099
|
+
closed_by_auto_seal = False
|
|
1046
1100
|
for event in events_in:
|
|
1047
1101
|
if event.get("type") == "phase.start" and start_ts is None:
|
|
1048
1102
|
start_ts = event.get("timestamp")
|
|
@@ -1052,9 +1106,15 @@ def attempt_invocations(
|
|
|
1052
1106
|
status = event.get("status")
|
|
1053
1107
|
terminal_status = str(event.get("status") or "").upper() or None
|
|
1054
1108
|
closed_by_end = True
|
|
1109
|
+
elif event.get("type") == "phase.auto_sealed":
|
|
1110
|
+
end_ts = event.get("timestamp")
|
|
1111
|
+
seal_status = event.get("status") or "RECOVERED"
|
|
1112
|
+
status = seal_status
|
|
1113
|
+
terminal_status = str(seal_status or "").upper() or "RECOVERED"
|
|
1114
|
+
closed_by_auto_seal = True
|
|
1055
1115
|
missing_start = start_ts is None
|
|
1056
1116
|
sealed_incomplete = False
|
|
1057
|
-
if not closed_by_end:
|
|
1117
|
+
if not closed_by_end and not closed_by_auto_seal:
|
|
1058
1118
|
next_start = None
|
|
1059
1119
|
if index + 1 < len(attempts):
|
|
1060
1120
|
for event in attempts[index + 1].get("events") or []:
|
|
@@ -1078,7 +1138,9 @@ def attempt_invocations(
|
|
|
1078
1138
|
duration = None
|
|
1079
1139
|
if start_ts and end_ts:
|
|
1080
1140
|
duration = duration_ms_between(start_ts, end_ts)
|
|
1081
|
-
# Active execution counts phase.end-closed attempts only
|
|
1141
|
+
# Active execution counts phase.end-closed attempts only. Both
|
|
1142
|
+
# cutoff/INCOMPLETE seals and auto-sealed (recovered/superseded)
|
|
1143
|
+
# attempts are excluded (HH-WF-20260730-001).
|
|
1082
1144
|
active_eligible = closed_by_end and not sealed_incomplete
|
|
1083
1145
|
invocations.append(
|
|
1084
1146
|
{
|
|
@@ -1090,6 +1152,7 @@ def attempt_invocations(
|
|
|
1090
1152
|
"durationMs": duration,
|
|
1091
1153
|
"activeEligible": active_eligible,
|
|
1092
1154
|
"sealedIncomplete": sealed_incomplete,
|
|
1155
|
+
"closedByAutoSeal": closed_by_auto_seal,
|
|
1093
1156
|
"warnings": list(attempt.get("warnings") or []),
|
|
1094
1157
|
}
|
|
1095
1158
|
)
|
|
@@ -1124,6 +1187,8 @@ def canonical_phase_timing(
|
|
|
1124
1187
|
"""Single reducer for every duration view (RET-20 / IA-2).
|
|
1125
1188
|
|
|
1126
1189
|
activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
|
|
1190
|
+
recoveredMs: sum of durations for attempts terminated via write-path
|
|
1191
|
+
auto-seal (HH-WF-20260730-001) — always disjoint from activeExecutionMs.
|
|
1127
1192
|
wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
|
|
1128
1193
|
"""
|
|
1129
1194
|
invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
|
|
@@ -1138,6 +1203,12 @@ def canonical_phase_timing(
|
|
|
1138
1203
|
if invocation.get("activeEligible") and invocation.get("durationMs") is not None
|
|
1139
1204
|
]
|
|
1140
1205
|
active = sum(closed_durations) if closed_durations else (0 if invocations else None)
|
|
1206
|
+
recovered_durations = [
|
|
1207
|
+
invocation["durationMs"]
|
|
1208
|
+
for invocation in invocations
|
|
1209
|
+
if invocation.get("closedByAutoSeal") and invocation.get("durationMs") is not None
|
|
1210
|
+
]
|
|
1211
|
+
recovered = sum(recovered_durations) if recovered_durations else (0 if invocations else None)
|
|
1141
1212
|
late = late_event_stats(phase_events)
|
|
1142
1213
|
if len(stamps) >= 2:
|
|
1143
1214
|
wall = duration_ms_between(stamps[0], stamps[-1])
|
|
@@ -1151,6 +1222,7 @@ def canonical_phase_timing(
|
|
|
1151
1222
|
)
|
|
1152
1223
|
return {
|
|
1153
1224
|
"activeExecutionMs": active,
|
|
1225
|
+
"recoveredMs": recovered,
|
|
1154
1226
|
"wallClockSpanMs": wall,
|
|
1155
1227
|
"lateEventCount": late["lateEventCount"],
|
|
1156
1228
|
"lateEventSpanMs": late["lateEventSpanMs"],
|
|
@@ -1231,6 +1303,14 @@ def render_event_line(event: dict[str, Any]) -> list[str]:
|
|
|
1231
1303
|
note = str(event.get("note") or "").strip()
|
|
1232
1304
|
suffix = f" — {note}" if note else ""
|
|
1233
1305
|
return [f"- phase.end @ {event.get('timestamp', '')}{suffix}"]
|
|
1306
|
+
if etype == "phase.auto_sealed":
|
|
1307
|
+
reason = str(event.get("reason") or "").strip() or "unknown"
|
|
1308
|
+
status = str(event.get("status") or "RECOVERED").strip()
|
|
1309
|
+
note = str(event.get("note") or "").strip()
|
|
1310
|
+
suffix = f" — {note}" if note else ""
|
|
1311
|
+
return [
|
|
1312
|
+
f"- phase.auto_sealed ({status}/{reason}) @ {event.get('timestamp', '')}{suffix}"
|
|
1313
|
+
]
|
|
1234
1314
|
if etype == "verification":
|
|
1235
1315
|
note = str(event.get("note") or "").strip()
|
|
1236
1316
|
name = str(event.get("name") or "").strip() or (
|
|
@@ -1422,14 +1502,19 @@ def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, A
|
|
|
1422
1502
|
def phase_end_already_recorded(
|
|
1423
1503
|
events: list[dict[str, Any]], candidate: dict[str, Any]
|
|
1424
1504
|
) -> bool:
|
|
1425
|
-
"""Return whether the candidate attempt already has a terminal event.
|
|
1505
|
+
"""Return whether the candidate attempt already has a terminal event.
|
|
1506
|
+
|
|
1507
|
+
``phase.auto_sealed`` counts as a terminal here too (HH-WF-20260730-001):
|
|
1508
|
+
a stale ``phase.end`` for an attempt that was already auto-sealed (e.g.
|
|
1509
|
+
superseded by a later ``phase.start``) must not close it a second time.
|
|
1510
|
+
"""
|
|
1426
1511
|
phase_events = [
|
|
1427
1512
|
event for event in events if event.get("phase") == candidate.get("phase")
|
|
1428
1513
|
]
|
|
1429
1514
|
candidate_attempt = candidate.get("attempt")
|
|
1430
1515
|
if isinstance(candidate_attempt, int):
|
|
1431
1516
|
return any(
|
|
1432
|
-
event.get("type")
|
|
1517
|
+
event.get("type") in TERMINAL_PHASE_EVENT_TYPES
|
|
1433
1518
|
and event.get("attempt") == candidate_attempt
|
|
1434
1519
|
for event in phase_events
|
|
1435
1520
|
)
|
|
@@ -1437,7 +1522,151 @@ def phase_end_already_recorded(
|
|
|
1437
1522
|
if not attempts:
|
|
1438
1523
|
return False
|
|
1439
1524
|
latest_events = attempts[-1].get("events") or []
|
|
1440
|
-
return any(event.get("type")
|
|
1525
|
+
return any(event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in latest_events)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def infer_auto_seal_reason(attempt_events: list[dict[str, Any]]) -> str:
|
|
1529
|
+
"""Best-effort ``phase.auto_sealed`` reason from an open attempt's own events.
|
|
1530
|
+
|
|
1531
|
+
HH-WF-20260730-001: prefer an explicit wait signal recorded while the
|
|
1532
|
+
attempt was open, then an explicit recovery marker, else fall back to
|
|
1533
|
+
``superseded`` (the default meaning: a new phase.start arrived).
|
|
1534
|
+
"""
|
|
1535
|
+
for event in attempt_events:
|
|
1536
|
+
mapped = _WAIT_EVENT_REASON_MAP.get(str(event.get("type") or "").lower())
|
|
1537
|
+
if mapped:
|
|
1538
|
+
return mapped
|
|
1539
|
+
for event in attempt_events:
|
|
1540
|
+
if str(event.get("type") or "").lower() in _RECOVERY_EVENT_TYPES:
|
|
1541
|
+
return "executor_lost"
|
|
1542
|
+
return "superseded"
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def open_attempts_for_phase(
|
|
1546
|
+
existing_events: list[dict[str, Any]], phase: str
|
|
1547
|
+
) -> list[dict[str, Any]]:
|
|
1548
|
+
"""Return ``split_phase_attempts`` records for ``phase`` that started but
|
|
1549
|
+
have no terminal (``phase.end``/``phase.auto_sealed``) event yet.
|
|
1550
|
+
"""
|
|
1551
|
+
phase_events = [event for event in existing_events if event.get("phase") == phase]
|
|
1552
|
+
if not phase_events:
|
|
1553
|
+
return []
|
|
1554
|
+
open_attempts: list[dict[str, Any]] = []
|
|
1555
|
+
for attempt in split_phase_attempts(phase_events):
|
|
1556
|
+
events_in = attempt.get("events") or []
|
|
1557
|
+
if not events_in:
|
|
1558
|
+
continue
|
|
1559
|
+
if not any(event.get("type") == "phase.start" for event in events_in):
|
|
1560
|
+
# No actual start recorded (e.g. pure metadata bucket) — nothing
|
|
1561
|
+
# to auto-seal; sealing it would manufacture a phantom attempt.
|
|
1562
|
+
continue
|
|
1563
|
+
if any(
|
|
1564
|
+
event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in events_in
|
|
1565
|
+
):
|
|
1566
|
+
# Prefer "any terminal" over "last event is terminal": late events
|
|
1567
|
+
# may be appended onto a closed attempt after phase.end/auto_sealed.
|
|
1568
|
+
continue
|
|
1569
|
+
open_attempts.append(attempt)
|
|
1570
|
+
return open_attempts
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def seal_open_phase_attempts(
|
|
1574
|
+
existing_events: list[dict[str, Any]],
|
|
1575
|
+
*,
|
|
1576
|
+
phase: str,
|
|
1577
|
+
seal_reason: str | None = None,
|
|
1578
|
+
seal_ts: str | None = None,
|
|
1579
|
+
) -> list[dict[str, Any]]:
|
|
1580
|
+
"""Build ``phase.auto_sealed`` events for still-open attempts of ``phase``.
|
|
1581
|
+
|
|
1582
|
+
HH-WF-20260730-001: write-path auto-seal. Returns the seal event dicts —
|
|
1583
|
+
callers append them (then the triggering event, e.g. the new
|
|
1584
|
+
``phase.start``) under the same lock so the whole sequence is atomic from
|
|
1585
|
+
other processes' point of view.
|
|
1586
|
+
|
|
1587
|
+
When ``seal_reason`` is omitted, each open attempt gets a best-effort
|
|
1588
|
+
inferred reason (wait / recovery / superseded).
|
|
1589
|
+
"""
|
|
1590
|
+
open_attempts = open_attempts_for_phase(existing_events, phase)
|
|
1591
|
+
if not open_attempts:
|
|
1592
|
+
return []
|
|
1593
|
+
ts = seal_ts or now_iso()
|
|
1594
|
+
seal_events: list[dict[str, Any]] = []
|
|
1595
|
+
for attempt in open_attempts:
|
|
1596
|
+
events_in = attempt.get("events") or []
|
|
1597
|
+
provenance_source = events_in[0] if events_in else {}
|
|
1598
|
+
if seal_reason is None:
|
|
1599
|
+
reason = infer_auto_seal_reason(events_in)
|
|
1600
|
+
elif seal_reason in _AUTO_SEAL_REASONS:
|
|
1601
|
+
reason = seal_reason
|
|
1602
|
+
else:
|
|
1603
|
+
reason = "unknown"
|
|
1604
|
+
seal_event: dict[str, Any] = {
|
|
1605
|
+
"schema_version": SCHEMA_VERSION,
|
|
1606
|
+
"id": new_event_id(),
|
|
1607
|
+
"timestamp": ts,
|
|
1608
|
+
"phase": phase,
|
|
1609
|
+
"type": "phase.auto_sealed",
|
|
1610
|
+
"status": "RECOVERED",
|
|
1611
|
+
"reason": reason,
|
|
1612
|
+
"note": "",
|
|
1613
|
+
"attempt": attempt.get("attempt"),
|
|
1614
|
+
}
|
|
1615
|
+
for field in ("run_id", "executor_tool", "executor_agent", "executor_model"):
|
|
1616
|
+
value = provenance_source.get(field)
|
|
1617
|
+
if value is not None:
|
|
1618
|
+
seal_event[field] = value
|
|
1619
|
+
seal_events.append(seal_event)
|
|
1620
|
+
return seal_events
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def append_with_auto_seal(
|
|
1624
|
+
events_file: Path,
|
|
1625
|
+
event: dict[str, Any],
|
|
1626
|
+
*,
|
|
1627
|
+
existing_events: list[dict[str, Any]] | None = None,
|
|
1628
|
+
) -> dict[str, Any]:
|
|
1629
|
+
"""Append ``event`` under lock; auto-seal open attempts when type is phase.start.
|
|
1630
|
+
|
|
1631
|
+
Returns ``{ok, event, autoSealed, phaseAlreadyClosed, events_path}``.
|
|
1632
|
+
Caller must NOT hold the events lock — this function acquires it.
|
|
1633
|
+
"""
|
|
1634
|
+
lock_path = events_file.with_name(events_file.name + ".lock")
|
|
1635
|
+
event_type = str(event.get("type") or "")
|
|
1636
|
+
phase = str(event.get("phase") or "")
|
|
1637
|
+
auto_sealed: list[dict[str, Any]] = []
|
|
1638
|
+
phase_already_closed = False
|
|
1639
|
+
with event_file_lock(lock_path):
|
|
1640
|
+
loaded = (
|
|
1641
|
+
existing_events
|
|
1642
|
+
if existing_events is not None
|
|
1643
|
+
else (
|
|
1644
|
+
load_events(events_file)
|
|
1645
|
+
if event_type in {"phase.start", "phase.end", "correction"}
|
|
1646
|
+
else []
|
|
1647
|
+
)
|
|
1648
|
+
)
|
|
1649
|
+
if event_type == "phase.start" and phase:
|
|
1650
|
+
auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
|
|
1651
|
+
for seal_event in auto_sealed:
|
|
1652
|
+
atomic_append_line(
|
|
1653
|
+
events_file,
|
|
1654
|
+
json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
|
|
1655
|
+
)
|
|
1656
|
+
if event_type == "phase.end":
|
|
1657
|
+
phase_already_closed = phase_end_already_recorded(loaded, event)
|
|
1658
|
+
if not phase_already_closed:
|
|
1659
|
+
atomic_append_line(
|
|
1660
|
+
events_file,
|
|
1661
|
+
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
|
1662
|
+
)
|
|
1663
|
+
return {
|
|
1664
|
+
"ok": not phase_already_closed,
|
|
1665
|
+
"event": event,
|
|
1666
|
+
"autoSealed": auto_sealed,
|
|
1667
|
+
"phaseAlreadyClosed": phase_already_closed,
|
|
1668
|
+
"events_path": str(events_file),
|
|
1669
|
+
}
|
|
1441
1670
|
|
|
1442
1671
|
|
|
1443
1672
|
def cmd_append(args: argparse.Namespace) -> int:
|
|
@@ -1487,11 +1716,12 @@ def cmd_append(args: argparse.Namespace) -> int:
|
|
|
1487
1716
|
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
1488
1717
|
phase_closed = False
|
|
1489
1718
|
projection_error: str | None = None
|
|
1719
|
+
auto_sealed_events: list[dict[str, Any]] = []
|
|
1490
1720
|
try:
|
|
1491
1721
|
with event_file_lock(lock_path):
|
|
1492
1722
|
existing_events = (
|
|
1493
1723
|
load_events(path)
|
|
1494
|
-
if args.type in {"phase.end", "correction"}
|
|
1724
|
+
if args.type in {"phase.end", "correction", "phase.start"}
|
|
1495
1725
|
else []
|
|
1496
1726
|
)
|
|
1497
1727
|
if args.type == "phase.end":
|
|
@@ -1501,7 +1731,26 @@ def cmd_append(args: argparse.Namespace) -> int:
|
|
|
1501
1731
|
apply_event_corrections([*existing_events, event])
|
|
1502
1732
|
except ValueError as exc:
|
|
1503
1733
|
projection_error = str(exc)
|
|
1734
|
+
elif args.type == "phase.start":
|
|
1735
|
+
# HH-WF-20260730-001: a new phase.start must not be appended
|
|
1736
|
+
# while a prior attempt for the same phase is still open —
|
|
1737
|
+
# auto-seal it first so timing never has two open attempts.
|
|
1738
|
+
open_attempts = open_attempts_for_phase(existing_events, args.phase)
|
|
1739
|
+
if open_attempts:
|
|
1740
|
+
inferred_reason = infer_auto_seal_reason(
|
|
1741
|
+
open_attempts[-1].get("events") or []
|
|
1742
|
+
)
|
|
1743
|
+
auto_sealed_events = seal_open_phase_attempts(
|
|
1744
|
+
existing_events,
|
|
1745
|
+
phase=args.phase,
|
|
1746
|
+
seal_reason=inferred_reason,
|
|
1747
|
+
)
|
|
1504
1748
|
if not phase_closed and projection_error is None:
|
|
1749
|
+
for seal_event in auto_sealed_events:
|
|
1750
|
+
atomic_append_line(
|
|
1751
|
+
path,
|
|
1752
|
+
json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
|
|
1753
|
+
)
|
|
1505
1754
|
atomic_append_line(path, line)
|
|
1506
1755
|
except (OSError, TimeoutError, ValueError) as exc:
|
|
1507
1756
|
return emit_error(f"append failed: {exc}", as_json=as_json)
|
|
@@ -1519,12 +1768,13 @@ def cmd_append(args: argparse.Namespace) -> int:
|
|
|
1519
1768
|
error_code=error_code,
|
|
1520
1769
|
)
|
|
1521
1770
|
|
|
1522
|
-
# §6.1: phase.end append
|
|
1523
|
-
#
|
|
1771
|
+
# §6.1: phase.end append(或写路径产生了 auto-seal)-> 追加成功后执行一次
|
|
1772
|
+
# render(从完整 events 重建 log)。普通 command/issue 等 append 不渲染
|
|
1773
|
+
# (O(1));显式 `render` 子命令随时重建。
|
|
1524
1774
|
rendered = False
|
|
1525
1775
|
log_path = None
|
|
1526
1776
|
log_lines = None
|
|
1527
|
-
if args.type == "phase.end":
|
|
1777
|
+
if args.type == "phase.end" or auto_sealed_events:
|
|
1528
1778
|
try:
|
|
1529
1779
|
events = load_events(path)
|
|
1530
1780
|
content = render_execution_log(events)
|
|
@@ -1541,6 +1791,8 @@ def cmd_append(args: argparse.Namespace) -> int:
|
|
|
1541
1791
|
"events_path": str(path),
|
|
1542
1792
|
"rendered": rendered,
|
|
1543
1793
|
}
|
|
1794
|
+
if args.type == "phase.start":
|
|
1795
|
+
payload["autoSealed"] = auto_sealed_events
|
|
1544
1796
|
if log_path is not None:
|
|
1545
1797
|
payload["execution_log_path"] = str(log_path)
|
|
1546
1798
|
payload["execution_log_lines"] = log_lines
|
|
@@ -1501,44 +1501,67 @@ def append_phase_event(
|
|
|
1501
1501
|
executor_model: str | None = None,
|
|
1502
1502
|
code: str | None = None,
|
|
1503
1503
|
) -> dict[str, Any]:
|
|
1504
|
+
"""Skill-facing phase lifecycle append (begin/close use this directly).
|
|
1505
|
+
|
|
1506
|
+
HH-WF-20260730-001: shares harness_events' write-path auto-seal helpers
|
|
1507
|
+
so a ``phase.start`` here gets the same guarantee as the CLI's
|
|
1508
|
+
``append_events.py append`` — a still-open prior attempt for the same
|
|
1509
|
+
phase is auto-sealed (``phase.auto_sealed``) before the new start is
|
|
1510
|
+
appended, atomically under the same lock.
|
|
1511
|
+
"""
|
|
1504
1512
|
events_file = he.events_path(change_dir)
|
|
1505
|
-
existing = he.load_events(events_file)
|
|
1506
|
-
args = argparse.Namespace(
|
|
1507
|
-
phase=phase,
|
|
1508
|
-
type=type_,
|
|
1509
|
-
status=status,
|
|
1510
|
-
note=note,
|
|
1511
|
-
command=None,
|
|
1512
|
-
exit_code=None,
|
|
1513
|
-
duration_ms=None,
|
|
1514
|
-
name=None,
|
|
1515
|
-
path=None,
|
|
1516
|
-
kind=None,
|
|
1517
|
-
code=code,
|
|
1518
|
-
severity=None,
|
|
1519
|
-
message=None,
|
|
1520
|
-
decision=None,
|
|
1521
|
-
reason=None,
|
|
1522
|
-
run_id=run_id,
|
|
1523
|
-
attempt=None,
|
|
1524
|
-
executor_tool=executor_tool,
|
|
1525
|
-
executor_agent=executor_agent,
|
|
1526
|
-
executor_model=executor_model,
|
|
1527
|
-
handoff_from_tool=None,
|
|
1528
|
-
handoff_reason=None,
|
|
1529
|
-
)
|
|
1530
|
-
event = he.build_event(args, existing)
|
|
1531
|
-
if identity:
|
|
1532
|
-
for key, value in identity.items():
|
|
1533
|
-
if key not in event and value is not None:
|
|
1534
|
-
event[key] = value
|
|
1535
|
-
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
1536
1513
|
lock_path = events_file.with_name(events_file.name + ".lock")
|
|
1514
|
+
auto_sealed: list[dict[str, Any]] = []
|
|
1537
1515
|
with he.event_file_lock(lock_path):
|
|
1516
|
+
existing = he.load_events(events_file)
|
|
1517
|
+
args = argparse.Namespace(
|
|
1518
|
+
phase=phase,
|
|
1519
|
+
type=type_,
|
|
1520
|
+
status=status,
|
|
1521
|
+
note=note,
|
|
1522
|
+
command=None,
|
|
1523
|
+
exit_code=None,
|
|
1524
|
+
duration_ms=None,
|
|
1525
|
+
name=None,
|
|
1526
|
+
path=None,
|
|
1527
|
+
kind=None,
|
|
1528
|
+
code=code,
|
|
1529
|
+
severity=None,
|
|
1530
|
+
message=None,
|
|
1531
|
+
decision=None,
|
|
1532
|
+
reason=None,
|
|
1533
|
+
run_id=run_id,
|
|
1534
|
+
attempt=None,
|
|
1535
|
+
executor_tool=executor_tool,
|
|
1536
|
+
executor_agent=executor_agent,
|
|
1537
|
+
executor_model=executor_model,
|
|
1538
|
+
handoff_from_tool=None,
|
|
1539
|
+
handoff_reason=None,
|
|
1540
|
+
)
|
|
1541
|
+
event = he.build_event(args, existing)
|
|
1542
|
+
if identity:
|
|
1543
|
+
for key, value in identity.items():
|
|
1544
|
+
if key not in event and value is not None:
|
|
1545
|
+
event[key] = value
|
|
1546
|
+
if type_ == "phase.start":
|
|
1547
|
+
open_attempts = he.open_attempts_for_phase(existing, phase)
|
|
1548
|
+
if open_attempts:
|
|
1549
|
+
inferred_reason = he.infer_auto_seal_reason(
|
|
1550
|
+
open_attempts[-1].get("events") or []
|
|
1551
|
+
)
|
|
1552
|
+
auto_sealed = he.seal_open_phase_attempts(
|
|
1553
|
+
existing, phase=phase, seal_reason=inferred_reason
|
|
1554
|
+
)
|
|
1555
|
+
for seal_event in auto_sealed:
|
|
1556
|
+
he.atomic_append_line(
|
|
1557
|
+
events_file,
|
|
1558
|
+
json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
|
|
1559
|
+
)
|
|
1560
|
+
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
1538
1561
|
he.atomic_append_line(events_file, line)
|
|
1539
1562
|
rendered = False
|
|
1540
1563
|
log_path = None
|
|
1541
|
-
if type_ == "phase.end":
|
|
1564
|
+
if type_ == "phase.end" or auto_sealed:
|
|
1542
1565
|
events = he.load_events(events_file)
|
|
1543
1566
|
content = he.render_execution_log(events)
|
|
1544
1567
|
log_path = he.write_execution_log(change_dir, content)
|
|
@@ -1549,6 +1572,7 @@ def append_phase_event(
|
|
|
1549
1572
|
"path": str(events_file),
|
|
1550
1573
|
"rendered": rendered,
|
|
1551
1574
|
"executionLogPath": str(log_path) if log_path else None,
|
|
1575
|
+
"autoSealed": auto_sealed,
|
|
1552
1576
|
}
|
|
1553
1577
|
|
|
1554
1578
|
|