easy-coding-harness 0.10.0-beta.4 → 0.10.0-beta.5
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/CHANGELOG.md +30 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +3 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -7
- package/templates/common/skills/ec-analysis/SKILL.md +14 -4
- package/templates/common/skills/ec-git/SKILL.md +7 -1
- package/templates/common/skills/ec-implementing/SKILL.md +16 -1
- package/templates/common/skills/ec-memory/SKILL.md +65 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -0
- package/templates/common/skills/ec-task-close/SKILL.md +4 -0
- package/templates/common/skills/ec-task-management/SKILL.md +7 -1
- package/templates/common/skills/ec-verification/SKILL.md +11 -1
- package/templates/common/skills/ec-workflow/SKILL.md +16 -2
- package/templates/main-constraint/AGENTS.md.tpl +7 -0
- package/templates/main-constraint/CLAUDE.md.tpl +7 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +3 -1
- package/templates/shared-hooks/easy_coding_state.py +1963 -96
- package/templates/shared-hooks/easy_dev_spec.py +87 -12
- package/templates/shared-hooks/easy_dev_spec_execution.py +1014 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1426 -18
|
@@ -7,6 +7,7 @@ import re
|
|
|
7
7
|
import secrets
|
|
8
8
|
import shlex
|
|
9
9
|
import subprocess
|
|
10
|
+
import tempfile
|
|
10
11
|
import time
|
|
11
12
|
import uuid
|
|
12
13
|
from datetime import datetime, timezone
|
|
@@ -20,6 +21,17 @@ from easy_dev_spec import (
|
|
|
20
21
|
select_consumption_scopes,
|
|
21
22
|
select_tasks,
|
|
22
23
|
)
|
|
24
|
+
from easy_dev_spec_execution import (
|
|
25
|
+
ExecutionConflictError,
|
|
26
|
+
ExecutionStateError,
|
|
27
|
+
initialize_execution,
|
|
28
|
+
record_dependency_status,
|
|
29
|
+
record_step_status,
|
|
30
|
+
record_task_status,
|
|
31
|
+
show_execution,
|
|
32
|
+
sync_design,
|
|
33
|
+
)
|
|
34
|
+
from easy_dev_spec_protocol import split_execution_region
|
|
23
35
|
|
|
24
36
|
|
|
25
37
|
TERMINAL_STATUSES = {"COMPLETE", "CLOSED"}
|
|
@@ -106,6 +118,12 @@ LEGACY_STAGE_MAP = {
|
|
|
106
118
|
|
|
107
119
|
DEFAULT_SHORT_TERM_MAX = 10
|
|
108
120
|
DEFAULT_SHORT_TERM_KEEP = 5
|
|
121
|
+
# 架构认知正文的项目相对路径,用于冻结与复核 ABSTRACT 内容指纹。
|
|
122
|
+
ARCHITECTURE_ABSTRACT_PATH = Path(".easy-coding/ABSTRACT.md")
|
|
123
|
+
# 架构认知变更日志的项目相对路径,用于验证 backfill/update 留下审计记录。
|
|
124
|
+
ARCHITECTURE_CHANGELOG_PATH = Path(".easy-coding/CHANGELOG.md")
|
|
125
|
+
# MEMORY 架构评估唯一允许的动作集合;状态 API 和 CLI 参数共享该契约。
|
|
126
|
+
ARCHITECTURE_ACTIONS = {"no-op", "backfill", "update"}
|
|
109
127
|
SESSION_STALE_THRESHOLD_HOURS = 30 * 24
|
|
110
128
|
SESSION_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
111
129
|
SESSION_AGENT_NAMESPACES = {"claude-code", "codex", "qoder", "unknown"}
|
|
@@ -812,6 +830,76 @@ def validate_recorded_short_memory(
|
|
|
812
830
|
validate_short_memory_file(root, task_id, memory_file, expected_sha256)
|
|
813
831
|
|
|
814
832
|
|
|
833
|
+
def architecture_asset_baseline(root: Path, relative_path: Path) -> dict:
|
|
834
|
+
path = root / relative_path
|
|
835
|
+
if not path.exists():
|
|
836
|
+
return {
|
|
837
|
+
"path": str(relative_path),
|
|
838
|
+
"exists": False,
|
|
839
|
+
"non_empty": False,
|
|
840
|
+
"sha256": None,
|
|
841
|
+
}
|
|
842
|
+
if not path.is_file():
|
|
843
|
+
raise StateError(f"Architecture asset is not a file: {relative_path}")
|
|
844
|
+
try:
|
|
845
|
+
content = path.read_text(encoding="utf-8")
|
|
846
|
+
except (OSError, UnicodeError) as error:
|
|
847
|
+
raise StateError(f"Cannot read architecture asset as UTF-8: {relative_path}") from error
|
|
848
|
+
return {
|
|
849
|
+
"path": str(relative_path),
|
|
850
|
+
"exists": True,
|
|
851
|
+
"non_empty": bool(content.strip()),
|
|
852
|
+
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def read_project_mode(root: Path) -> str | None:
|
|
857
|
+
project_profile = root / ".easy-coding" / "project.yaml"
|
|
858
|
+
if not project_profile.is_file():
|
|
859
|
+
return None
|
|
860
|
+
try:
|
|
861
|
+
content = project_profile.read_text(encoding="utf-8")
|
|
862
|
+
except (OSError, UnicodeError) as error:
|
|
863
|
+
raise StateError("Cannot read .easy-coding/project.yaml as UTF-8.") from error
|
|
864
|
+
for raw_line in content.splitlines():
|
|
865
|
+
match = re.fullmatch(
|
|
866
|
+
r"\s*mode\s*:\s*(['\"]?)(startup|iterative)\1\s*(?:#.*)?", raw_line
|
|
867
|
+
)
|
|
868
|
+
if match:
|
|
869
|
+
return match.group(2)
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def build_architecture_assessment_instruction(root: Path, memory_action: str) -> dict:
|
|
874
|
+
abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
|
|
875
|
+
changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
|
|
876
|
+
if not abstract["non_empty"] and read_project_mode(root) == "startup":
|
|
877
|
+
required = True
|
|
878
|
+
trigger = "missing-abstract"
|
|
879
|
+
allowed_actions = ["backfill"]
|
|
880
|
+
elif not abstract["non_empty"]:
|
|
881
|
+
raise StateError(
|
|
882
|
+
"ABSTRACT.md is missing or empty outside the startup backfill exception; "
|
|
883
|
+
"run ec-init supplementary initialization before completing MEMORY."
|
|
884
|
+
)
|
|
885
|
+
elif memory_action == "distill":
|
|
886
|
+
required = True
|
|
887
|
+
trigger = "distillation"
|
|
888
|
+
allowed_actions = ["no-op", "update"]
|
|
889
|
+
else:
|
|
890
|
+
required = False
|
|
891
|
+
trigger = "none"
|
|
892
|
+
allowed_actions = []
|
|
893
|
+
instruction = {
|
|
894
|
+
"required": required,
|
|
895
|
+
"trigger": trigger,
|
|
896
|
+
"allowed_actions": allowed_actions,
|
|
897
|
+
"abstract": abstract,
|
|
898
|
+
"changelog": changelog,
|
|
899
|
+
}
|
|
900
|
+
return instruction
|
|
901
|
+
|
|
902
|
+
|
|
815
903
|
def build_memory_instruction(
|
|
816
904
|
root: Path,
|
|
817
905
|
checkpoint_file: str | None = None,
|
|
@@ -832,7 +920,7 @@ def build_memory_instruction(
|
|
|
832
920
|
checkpoint_disposition = "kept"
|
|
833
921
|
else:
|
|
834
922
|
raise StateError("Recorded short-memory checkpoint is absent from the frozen memory set.")
|
|
835
|
-
|
|
923
|
+
instruction = {
|
|
836
924
|
"short_count": short_count,
|
|
837
925
|
"short_term_max": config["short_term_max"],
|
|
838
926
|
"short_term_keep": config["short_term_keep"],
|
|
@@ -842,6 +930,216 @@ def build_memory_instruction(
|
|
|
842
930
|
"kept_files": kept_files,
|
|
843
931
|
"checkpoint_disposition": checkpoint_disposition,
|
|
844
932
|
}
|
|
933
|
+
if not legacy_checkpoint:
|
|
934
|
+
instruction["architecture_assessment"] = build_architecture_assessment_instruction(
|
|
935
|
+
root, action
|
|
936
|
+
)
|
|
937
|
+
return instruction
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def require_architecture_instruction(instruction: dict) -> dict | None:
|
|
941
|
+
assessment_instruction = instruction.get("architecture_assessment")
|
|
942
|
+
if assessment_instruction is None:
|
|
943
|
+
# 0.10.0-beta.5 之前已冻结的指令继续按旧契约完成,避免升级中断在途任务。
|
|
944
|
+
return None
|
|
945
|
+
if not isinstance(assessment_instruction, dict):
|
|
946
|
+
raise StateError("Memory instruction has an invalid architecture assessment contract.")
|
|
947
|
+
return assessment_instruction
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def validate_architecture_asset_changed(
|
|
951
|
+
baseline: dict,
|
|
952
|
+
current: dict,
|
|
953
|
+
label: str,
|
|
954
|
+
) -> None:
|
|
955
|
+
if not current.get("exists") or not current.get("non_empty") or not current.get("sha256"):
|
|
956
|
+
raise StateError(f"Architecture {label} must exist and be non-empty after this action.")
|
|
957
|
+
if baseline.get("sha256") == current.get("sha256"):
|
|
958
|
+
raise StateError(f"Architecture {label} did not change after this action.")
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def validate_architecture_assets_unchanged(root: Path, instruction: dict) -> None:
|
|
962
|
+
for key, relative_path in (
|
|
963
|
+
("abstract", ARCHITECTURE_ABSTRACT_PATH),
|
|
964
|
+
("changelog", ARCHITECTURE_CHANGELOG_PATH),
|
|
965
|
+
):
|
|
966
|
+
baseline = instruction.get(key)
|
|
967
|
+
if not isinstance(baseline, dict):
|
|
968
|
+
raise StateError(f"Architecture assessment is missing the {key} baseline.")
|
|
969
|
+
if architecture_asset_baseline(root, relative_path) != baseline:
|
|
970
|
+
raise StateError(f"Architecture asset changed during a no-op assessment: {relative_path}")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def validate_architecture_action_result(
|
|
974
|
+
root: Path,
|
|
975
|
+
instruction: dict,
|
|
976
|
+
action: str,
|
|
977
|
+
) -> tuple[dict, dict]:
|
|
978
|
+
abstract_before = instruction.get("abstract")
|
|
979
|
+
changelog_before = instruction.get("changelog")
|
|
980
|
+
if not isinstance(abstract_before, dict) or not isinstance(changelog_before, dict):
|
|
981
|
+
raise StateError("Architecture assessment is missing frozen asset baselines.")
|
|
982
|
+
abstract = architecture_asset_baseline(root, ARCHITECTURE_ABSTRACT_PATH)
|
|
983
|
+
changelog = architecture_asset_baseline(root, ARCHITECTURE_CHANGELOG_PATH)
|
|
984
|
+
if action == "no-op":
|
|
985
|
+
validate_architecture_assets_unchanged(root, instruction)
|
|
986
|
+
elif action == "backfill":
|
|
987
|
+
if abstract_before.get("non_empty") is True:
|
|
988
|
+
raise StateError(
|
|
989
|
+
"Architecture backfill is allowed only when ABSTRACT.md was missing or empty."
|
|
990
|
+
)
|
|
991
|
+
validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
|
|
992
|
+
validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
|
|
993
|
+
elif action == "update":
|
|
994
|
+
if abstract_before.get("non_empty") is not True:
|
|
995
|
+
raise StateError("Architecture update requires an existing ABSTRACT.md baseline.")
|
|
996
|
+
validate_architecture_asset_changed(abstract_before, abstract, "ABSTRACT.md")
|
|
997
|
+
validate_architecture_asset_changed(changelog_before, changelog, "CHANGELOG.md")
|
|
998
|
+
else:
|
|
999
|
+
raise StateError(f"Unknown architecture assessment action: {action}")
|
|
1000
|
+
return abstract, changelog
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def allowed_architecture_evidence(progress: dict, instruction: dict) -> set[str]:
|
|
1004
|
+
allowed_evidence = set(instruction.get("candidate_files") or [])
|
|
1005
|
+
if not allowed_evidence:
|
|
1006
|
+
checkpoint_file = progress.get("short_memory_file")
|
|
1007
|
+
if isinstance(checkpoint_file, str):
|
|
1008
|
+
allowed_evidence.add(checkpoint_file)
|
|
1009
|
+
return allowed_evidence
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def record_architecture_assessment(
|
|
1013
|
+
root: Path,
|
|
1014
|
+
action: str,
|
|
1015
|
+
reason: str,
|
|
1016
|
+
evidence: list[str],
|
|
1017
|
+
affected_sections: list[str],
|
|
1018
|
+
agent: str,
|
|
1019
|
+
task_id: str | None = None,
|
|
1020
|
+
session_file: str | Path | None = None,
|
|
1021
|
+
) -> dict:
|
|
1022
|
+
if action not in ARCHITECTURE_ACTIONS:
|
|
1023
|
+
raise StateError(f"Unknown architecture assessment action: {action}")
|
|
1024
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
1025
|
+
if task.get("status") != "MEMORY":
|
|
1026
|
+
raise StateError("Architecture assessment is only available during MEMORY.")
|
|
1027
|
+
progress = task.get("memory_progress")
|
|
1028
|
+
if not isinstance(progress, dict) or progress.get("short_memory_written") is not True:
|
|
1029
|
+
raise StateError("Short memory must be recorded before architecture assessment.")
|
|
1030
|
+
instruction = progress.get("instruction")
|
|
1031
|
+
if not isinstance(instruction, dict):
|
|
1032
|
+
raise StateError("Request the authoritative memory instruction before architecture assessment.")
|
|
1033
|
+
validate_recorded_short_memory(root, resolved_task_id, progress)
|
|
1034
|
+
for candidate_file in instruction.get("candidate_files") or []:
|
|
1035
|
+
if not resolve_short_memory_path(root, candidate_file).is_file():
|
|
1036
|
+
raise StateError(
|
|
1037
|
+
"Keep every frozen distillation candidate until architecture assessment succeeds: "
|
|
1038
|
+
f"{candidate_file}"
|
|
1039
|
+
)
|
|
1040
|
+
assessment_instruction = require_architecture_instruction(instruction)
|
|
1041
|
+
if assessment_instruction is None:
|
|
1042
|
+
raise StateError("Legacy memory instructions do not require an architecture assessment.")
|
|
1043
|
+
if assessment_instruction.get("required") is not True:
|
|
1044
|
+
raise StateError("Architecture assessment is not required for this memory instruction.")
|
|
1045
|
+
allowed_actions = assessment_instruction.get("allowed_actions")
|
|
1046
|
+
if not isinstance(allowed_actions, list) or action not in allowed_actions:
|
|
1047
|
+
raise StateError(
|
|
1048
|
+
f"Architecture action {action} is not allowed for trigger "
|
|
1049
|
+
f"{assessment_instruction.get('trigger')}."
|
|
1050
|
+
)
|
|
1051
|
+
normalized_reason = reason.strip()
|
|
1052
|
+
normalized_evidence = list(dict.fromkeys(item.strip() for item in evidence if item.strip()))
|
|
1053
|
+
normalized_sections = list(
|
|
1054
|
+
dict.fromkeys(item.strip() for item in affected_sections if item.strip())
|
|
1055
|
+
)
|
|
1056
|
+
if not normalized_reason:
|
|
1057
|
+
raise StateError("Architecture assessment requires a non-empty reason.")
|
|
1058
|
+
if not normalized_evidence:
|
|
1059
|
+
raise StateError("Architecture assessment requires at least one frozen memory evidence file.")
|
|
1060
|
+
allowed_evidence = allowed_architecture_evidence(progress, instruction)
|
|
1061
|
+
invalid_evidence = [item for item in normalized_evidence if item not in allowed_evidence]
|
|
1062
|
+
if invalid_evidence:
|
|
1063
|
+
raise StateError(
|
|
1064
|
+
"Architecture assessment evidence must come from the frozen memory set: "
|
|
1065
|
+
+ ", ".join(invalid_evidence)
|
|
1066
|
+
)
|
|
1067
|
+
if action in {"backfill", "update"} and not normalized_sections:
|
|
1068
|
+
raise StateError("Architecture backfill/update requires at least one affected section.")
|
|
1069
|
+
if action == "no-op" and normalized_sections:
|
|
1070
|
+
raise StateError("Architecture no-op must not declare affected sections.")
|
|
1071
|
+
|
|
1072
|
+
abstract, changelog = validate_architecture_action_result(
|
|
1073
|
+
root, assessment_instruction, action
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
assessment = {
|
|
1077
|
+
"action": action,
|
|
1078
|
+
"trigger": assessment_instruction.get("trigger"),
|
|
1079
|
+
"reason": normalized_reason,
|
|
1080
|
+
"evidence": normalized_evidence,
|
|
1081
|
+
"affected_sections": normalized_sections,
|
|
1082
|
+
"abstract_sha256": abstract.get("sha256"),
|
|
1083
|
+
"changelog_sha256": changelog.get("sha256"),
|
|
1084
|
+
"recorded_at": now_iso(),
|
|
1085
|
+
"recorded_by": agent,
|
|
1086
|
+
}
|
|
1087
|
+
progress["architecture_assessment"] = assessment
|
|
1088
|
+
progress["updated_at"] = now_iso()
|
|
1089
|
+
task["memory_progress"] = progress
|
|
1090
|
+
task["last_agent"] = agent
|
|
1091
|
+
write_task(root, resolved_task_id, task)
|
|
1092
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
1093
|
+
snapshot["memory"] = instruction
|
|
1094
|
+
snapshot["architecture_assessment"] = assessment
|
|
1095
|
+
snapshot["action"] = "memory-architecture-assessment"
|
|
1096
|
+
return snapshot
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def validate_recorded_architecture_assessment(root: Path, progress: dict, instruction: dict) -> None:
|
|
1100
|
+
assessment_instruction = require_architecture_instruction(instruction)
|
|
1101
|
+
if assessment_instruction is None:
|
|
1102
|
+
return
|
|
1103
|
+
if assessment_instruction.get("required") is not True:
|
|
1104
|
+
validate_architecture_assets_unchanged(root, assessment_instruction)
|
|
1105
|
+
if progress.get("architecture_assessment") is not None:
|
|
1106
|
+
raise StateError("Unexpected architecture assessment for a no-op memory instruction.")
|
|
1107
|
+
return
|
|
1108
|
+
assessment = progress.get("architecture_assessment")
|
|
1109
|
+
if not isinstance(assessment, dict):
|
|
1110
|
+
raise StateError("Complete the required architecture assessment before MEMORY completion.")
|
|
1111
|
+
action = assessment.get("action")
|
|
1112
|
+
allowed_actions = assessment_instruction.get("allowed_actions")
|
|
1113
|
+
if not isinstance(allowed_actions, list) or action not in allowed_actions:
|
|
1114
|
+
raise StateError("Recorded architecture assessment has an invalid action.")
|
|
1115
|
+
if assessment.get("trigger") != assessment_instruction.get("trigger"):
|
|
1116
|
+
raise StateError("Recorded architecture assessment trigger does not match its instruction.")
|
|
1117
|
+
reason = assessment.get("reason")
|
|
1118
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
1119
|
+
raise StateError("Recorded architecture assessment is missing its reason.")
|
|
1120
|
+
evidence = assessment.get("evidence")
|
|
1121
|
+
if not isinstance(evidence, list) or not evidence or not all(
|
|
1122
|
+
isinstance(item, str) for item in evidence
|
|
1123
|
+
):
|
|
1124
|
+
raise StateError("Recorded architecture assessment has invalid evidence.")
|
|
1125
|
+
if any(item not in allowed_architecture_evidence(progress, instruction) for item in evidence):
|
|
1126
|
+
raise StateError("Recorded architecture assessment evidence is outside the frozen set.")
|
|
1127
|
+
affected_sections = assessment.get("affected_sections")
|
|
1128
|
+
if not isinstance(affected_sections, list) or not all(
|
|
1129
|
+
isinstance(item, str) and item.strip() for item in affected_sections
|
|
1130
|
+
):
|
|
1131
|
+
raise StateError("Recorded architecture assessment has invalid affected sections.")
|
|
1132
|
+
if action == "no-op" and affected_sections:
|
|
1133
|
+
raise StateError("Recorded architecture no-op must not declare affected sections.")
|
|
1134
|
+
if action in {"backfill", "update"} and not affected_sections:
|
|
1135
|
+
raise StateError("Recorded architecture backfill/update requires affected sections.")
|
|
1136
|
+
abstract, changelog = validate_architecture_action_result(
|
|
1137
|
+
root, assessment_instruction, action
|
|
1138
|
+
)
|
|
1139
|
+
if assessment.get("abstract_sha256") != abstract.get("sha256"):
|
|
1140
|
+
raise StateError("ABSTRACT.md changed after the architecture assessment was recorded.")
|
|
1141
|
+
if assessment.get("changelog_sha256") != changelog.get("sha256"):
|
|
1142
|
+
raise StateError("Architecture CHANGELOG.md changed after the assessment was recorded.")
|
|
845
1143
|
|
|
846
1144
|
|
|
847
1145
|
def validate_distillation_file_sets(root: Path, instruction: dict) -> None:
|
|
@@ -923,7 +1221,28 @@ def normalize_legacy_task(task: dict) -> bool:
|
|
|
923
1221
|
|
|
924
1222
|
def write_json(path: Path, data: dict) -> None:
|
|
925
1223
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
926
|
-
|
|
1224
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1225
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
1226
|
+
)
|
|
1227
|
+
temporary_path = Path(temporary_name)
|
|
1228
|
+
try:
|
|
1229
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
1230
|
+
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
1231
|
+
handle.flush()
|
|
1232
|
+
os.fsync(handle.fileno())
|
|
1233
|
+
os.replace(temporary_path, path)
|
|
1234
|
+
try:
|
|
1235
|
+
directory_descriptor = os.open(path.parent, os.O_RDONLY)
|
|
1236
|
+
try:
|
|
1237
|
+
os.fsync(directory_descriptor)
|
|
1238
|
+
finally:
|
|
1239
|
+
os.close(directory_descriptor)
|
|
1240
|
+
except OSError:
|
|
1241
|
+
# Some platforms do not allow opening directories; file replacement is still atomic.
|
|
1242
|
+
pass
|
|
1243
|
+
finally:
|
|
1244
|
+
if temporary_path.exists():
|
|
1245
|
+
temporary_path.unlink()
|
|
927
1246
|
|
|
928
1247
|
|
|
929
1248
|
def acquire_legacy_state_lock(root: Path) -> Path | None:
|
|
@@ -1195,6 +1514,8 @@ def append_execution_record(root: Path, task_id: str, record: dict) -> None:
|
|
|
1195
1514
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1196
1515
|
with path.open("a", encoding="utf-8") as handle:
|
|
1197
1516
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
1517
|
+
handle.flush()
|
|
1518
|
+
os.fsync(handle.fileno())
|
|
1198
1519
|
|
|
1199
1520
|
|
|
1200
1521
|
def is_non_empty_string(value: object) -> bool:
|
|
@@ -1341,25 +1662,58 @@ def stored_spec_path(root: Path, task: dict) -> Path:
|
|
|
1341
1662
|
source = task.get("spec_source")
|
|
1342
1663
|
if not isinstance(source, dict) or not is_non_empty_string(source.get("path")):
|
|
1343
1664
|
raise StateError("Spec-backed task is missing spec_source.path.")
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1665
|
+
path_mode = source.get("path_mode")
|
|
1666
|
+
raw_path = Path(str(source["path"])).expanduser()
|
|
1667
|
+
if path_mode is None:
|
|
1668
|
+
path_mode = "absolute" if raw_path.is_absolute() else "project-relative"
|
|
1669
|
+
if path_mode not in {"project-relative", "absolute"}:
|
|
1670
|
+
raise StateError("Spec-backed task has an invalid spec_source.path_mode.")
|
|
1671
|
+
if path_mode == "absolute" and not raw_path.is_absolute():
|
|
1672
|
+
raise StateError("Absolute Spec binding must store an absolute path.")
|
|
1673
|
+
if path_mode == "project-relative" and raw_path.is_absolute():
|
|
1674
|
+
raise StateError("Project-relative Spec binding must not store an absolute path.")
|
|
1675
|
+
resolved = (raw_path if path_mode == "absolute" else root / raw_path).resolve()
|
|
1676
|
+
if path_mode == "project-relative":
|
|
1677
|
+
try:
|
|
1678
|
+
resolved.relative_to(root.resolve())
|
|
1679
|
+
except ValueError as exc:
|
|
1680
|
+
raise StateError("Project-relative Spec source escapes the project root.") from exc
|
|
1681
|
+
if not resolved.is_file():
|
|
1682
|
+
raise StateError(
|
|
1683
|
+
"Canonical Spec source is unavailable; run rebind-spec-source with an explicit path."
|
|
1684
|
+
)
|
|
1351
1685
|
return resolved
|
|
1352
1686
|
|
|
1353
1687
|
|
|
1688
|
+
def legacy_source_digest_matches(
|
|
1689
|
+
spec_path: Path, legacy_sha256: object, current_source_sha256: object
|
|
1690
|
+
) -> bool:
|
|
1691
|
+
if not is_non_empty_string(legacy_sha256):
|
|
1692
|
+
return False
|
|
1693
|
+
if legacy_sha256 == current_source_sha256:
|
|
1694
|
+
return True
|
|
1695
|
+
try:
|
|
1696
|
+
design_text, execution = split_execution_region(
|
|
1697
|
+
spec_path.read_text(encoding="utf-8")
|
|
1698
|
+
)
|
|
1699
|
+
except (OSError, UnicodeError, ValueError):
|
|
1700
|
+
return False
|
|
1701
|
+
if execution is None:
|
|
1702
|
+
return False
|
|
1703
|
+
design_document_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
|
|
1704
|
+
return legacy_sha256 == design_document_sha256
|
|
1705
|
+
|
|
1706
|
+
|
|
1354
1707
|
def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
1355
1708
|
source = task.get("spec_source")
|
|
1356
1709
|
selected = task.get("selected_spec_tasks")
|
|
1357
1710
|
repo_paths = task.get("repo_paths")
|
|
1358
1711
|
if not isinstance(source, dict) or not is_string_list(selected, allow_empty=False):
|
|
1359
1712
|
raise StateError("Spec-backed task source and selected task metadata are incomplete.")
|
|
1713
|
+
spec_path = stored_spec_path(root, task)
|
|
1360
1714
|
try:
|
|
1361
1715
|
inspection = inspect_spec(
|
|
1362
|
-
|
|
1716
|
+
spec_path,
|
|
1363
1717
|
root,
|
|
1364
1718
|
repo_paths if isinstance(repo_paths, dict) else {},
|
|
1365
1719
|
selected,
|
|
@@ -1375,6 +1729,10 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1375
1729
|
selection = select_tasks(inspection, selected, satisfied)
|
|
1376
1730
|
except EasyDevSpecError as exc:
|
|
1377
1731
|
raise StateError(f"Canonical Spec validation failed: {exc}") from exc
|
|
1732
|
+
if not isinstance(inspection.get("execution"), dict):
|
|
1733
|
+
raise StateError(
|
|
1734
|
+
"Canonical Spec shared execution is not initialized; run initialize-spec-execution."
|
|
1735
|
+
)
|
|
1378
1736
|
stored_dependencies = task.get("spec_dependency_evidence")
|
|
1379
1737
|
if not isinstance(stored_dependencies, list):
|
|
1380
1738
|
raise StateError("Spec-backed task dependency metadata is incomplete.")
|
|
@@ -1387,30 +1745,44 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1387
1745
|
for record in stored_dependencies
|
|
1388
1746
|
if isinstance(record, dict)
|
|
1389
1747
|
}
|
|
1390
|
-
if (
|
|
1391
|
-
len(stored_by_edge) != len(stored_dependencies)
|
|
1392
|
-
or set(stored_by_edge) != set(expected_by_edge)
|
|
1393
|
-
):
|
|
1748
|
+
if len(stored_by_edge) != len(stored_dependencies) or set(stored_by_edge) != set(expected_by_edge):
|
|
1394
1749
|
raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
|
|
1750
|
+
refreshed_dependencies: list[dict] = []
|
|
1395
1751
|
for edge, expected in expected_by_edge.items():
|
|
1396
1752
|
stored = stored_by_edge[edge]
|
|
1397
|
-
for field in ("dependency_type", "required_evidence"
|
|
1753
|
+
for field in ("dependency_type", "required_evidence"):
|
|
1398
1754
|
if stored.get(field) != expected.get(field):
|
|
1399
|
-
raise StateError(
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1755
|
+
raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
|
|
1756
|
+
refreshed = dict(stored)
|
|
1757
|
+
refreshed["status"] = expected.get("status")
|
|
1758
|
+
refreshed["shared_status"] = expected.get("shared_status")
|
|
1759
|
+
if expected.get("evidence"):
|
|
1760
|
+
refreshed["evidence"] = expected.get("evidence")
|
|
1761
|
+
refreshed_dependencies.append(refreshed)
|
|
1406
1762
|
if source.get("schema") != inspection.get("schema"):
|
|
1407
1763
|
raise StateError("Canonical Spec schema no longer matches task.json.")
|
|
1408
1764
|
if source.get("spec_id") != inspection.get("spec_id"):
|
|
1409
1765
|
raise StateError("Canonical Spec ID no longer matches task.json.")
|
|
1410
1766
|
if source.get("revision") != inspection.get("revision"):
|
|
1411
|
-
raise StateError("Canonical Spec revision
|
|
1412
|
-
|
|
1413
|
-
|
|
1767
|
+
raise StateError("Canonical Spec design revision changed; return the task to ANALYSIS.")
|
|
1768
|
+
stored_design_sha256 = source.get("design_sha256")
|
|
1769
|
+
if stored_design_sha256 is None:
|
|
1770
|
+
if not legacy_source_digest_matches(
|
|
1771
|
+
spec_path, source.get("sha256"), inspection.get("source_sha256")
|
|
1772
|
+
):
|
|
1773
|
+
raise StateError(
|
|
1774
|
+
"Legacy Canonical Spec digest changed before migration; rebind or recreate the task."
|
|
1775
|
+
)
|
|
1776
|
+
stored_design_sha256 = inspection.get("design_sha256")
|
|
1777
|
+
if stored_design_sha256 != inspection.get("design_sha256"):
|
|
1778
|
+
raise StateError("Canonical Spec static design changed; return the task to ANALYSIS.")
|
|
1779
|
+
stored_execution_revision = source.get("execution_revision")
|
|
1780
|
+
current_execution_revision = inspection.get("execution_revision")
|
|
1781
|
+
if isinstance(stored_execution_revision, int) and isinstance(current_execution_revision, int):
|
|
1782
|
+
if current_execution_revision < stored_execution_revision:
|
|
1783
|
+
raise StateError(
|
|
1784
|
+
"Canonical Spec execution revision moved backwards; restore the latest shared Spec."
|
|
1785
|
+
)
|
|
1414
1786
|
selected_repo_ids = set(selection["selected_repo_ids"])
|
|
1415
1787
|
stored_bindings = task.get("spec_repositories")
|
|
1416
1788
|
if not isinstance(stored_bindings, list):
|
|
@@ -1438,6 +1810,18 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
|
|
|
1438
1810
|
for field in ("repo_id", "name", "path", "baseline_commit"):
|
|
1439
1811
|
if stored.get(field) != current.get(field):
|
|
1440
1812
|
raise StateError("Canonical Spec repository bindings no longer match task.json.")
|
|
1813
|
+
source.update(
|
|
1814
|
+
{
|
|
1815
|
+
"path_mode": source.get("path_mode")
|
|
1816
|
+
or ("absolute" if Path(str(source.get("path"))).is_absolute() else "project-relative"),
|
|
1817
|
+
"design_sha256": inspection.get("design_sha256"),
|
|
1818
|
+
"document_sha256": inspection.get("document_sha256"),
|
|
1819
|
+
"execution_revision": inspection.get("execution_revision"),
|
|
1820
|
+
}
|
|
1821
|
+
)
|
|
1822
|
+
source.pop("sha256", None)
|
|
1823
|
+
task["spec_source"] = source
|
|
1824
|
+
task["spec_dependency_evidence"] = refreshed_dependencies
|
|
1441
1825
|
return inspection, selection
|
|
1442
1826
|
|
|
1443
1827
|
|
|
@@ -1696,6 +2080,8 @@ def has_valid_execution_plan(root: Path, task_id: str) -> bool:
|
|
|
1696
2080
|
return False
|
|
1697
2081
|
if isinstance(record, dict) and record.get("type") == "plan":
|
|
1698
2082
|
latest_plan = record
|
|
2083
|
+
elif isinstance(record, dict) and record.get("type") == "spec-design-sync":
|
|
2084
|
+
latest_plan = None
|
|
1699
2085
|
except OSError:
|
|
1700
2086
|
return False
|
|
1701
2087
|
task = load_task(root, task_id)
|
|
@@ -1735,6 +2121,8 @@ def latest_execution_plan(root: Path, task_id: str) -> dict | None:
|
|
|
1735
2121
|
for record in execution_records(root, task_id):
|
|
1736
2122
|
if record.get("type") == "plan":
|
|
1737
2123
|
latest = record
|
|
2124
|
+
elif record.get("type") == "spec-design-sync":
|
|
2125
|
+
latest = None
|
|
1738
2126
|
if latest is None or not is_valid_execution_plan(latest, allow_empty_files=True):
|
|
1739
2127
|
return None
|
|
1740
2128
|
return latest
|
|
@@ -2190,10 +2578,16 @@ def implementation_fingerprint(root: Path, task_id: str) -> str:
|
|
|
2190
2578
|
digest.update(b"\0")
|
|
2191
2579
|
if task and isinstance(task.get("spec_source"), dict):
|
|
2192
2580
|
digest.update(b"canonical-spec\0")
|
|
2581
|
+
source = task.get("spec_source") or {}
|
|
2193
2582
|
digest.update(
|
|
2194
2583
|
json.dumps(
|
|
2195
2584
|
{
|
|
2196
|
-
"source":
|
|
2585
|
+
"source": {
|
|
2586
|
+
"schema": source.get("schema"),
|
|
2587
|
+
"spec_id": source.get("spec_id"),
|
|
2588
|
+
"revision": source.get("revision"),
|
|
2589
|
+
"design_sha256": source.get("design_sha256"),
|
|
2590
|
+
},
|
|
2197
2591
|
"selected_tasks": task.get("selected_spec_tasks"),
|
|
2198
2592
|
},
|
|
2199
2593
|
ensure_ascii=False,
|
|
@@ -3509,6 +3903,7 @@ def spec_task_summary(task: dict | None) -> dict | None:
|
|
|
3509
3903
|
"selected_spec_tasks": task.get("selected_spec_tasks", []),
|
|
3510
3904
|
"repositories": task.get("spec_repositories", []),
|
|
3511
3905
|
"pending_dependencies": pending_dependencies,
|
|
3906
|
+
"writeback": task.get("spec_writeback_progress"),
|
|
3512
3907
|
}
|
|
3513
3908
|
|
|
3514
3909
|
|
|
@@ -4222,15 +4617,6 @@ def create_task(
|
|
|
4222
4617
|
return {"task_id": task_id, "task": task}
|
|
4223
4618
|
|
|
4224
4619
|
|
|
4225
|
-
def ensure_path_inside_root(root: Path, path: Path, label: str) -> Path:
|
|
4226
|
-
resolved = path.resolve()
|
|
4227
|
-
try:
|
|
4228
|
-
resolved.relative_to(root.resolve())
|
|
4229
|
-
except ValueError as exc:
|
|
4230
|
-
raise StateError(f"{label} must be inside the Easy Coding project root.") from exc
|
|
4231
|
-
return resolved
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
4620
|
def create_task_from_spec(
|
|
4235
4621
|
root: Path,
|
|
4236
4622
|
spec_path: str,
|
|
@@ -4244,12 +4630,14 @@ def create_task_from_spec(
|
|
|
4244
4630
|
set_current: bool = True,
|
|
4245
4631
|
session_file: str | Path | None = None,
|
|
4246
4632
|
) -> dict:
|
|
4247
|
-
raw_spec_path = Path(spec_path)
|
|
4248
|
-
resolved_spec_path =
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4633
|
+
raw_spec_path = Path(spec_path).expanduser()
|
|
4634
|
+
resolved_spec_path = (
|
|
4635
|
+
raw_spec_path.resolve()
|
|
4636
|
+
if raw_spec_path.is_absolute()
|
|
4637
|
+
else (root / raw_spec_path).resolve()
|
|
4252
4638
|
)
|
|
4639
|
+
if not resolved_spec_path.is_file():
|
|
4640
|
+
raise StateError("Canonical Spec path must be an explicitly selected UTF-8 file.")
|
|
4253
4641
|
try:
|
|
4254
4642
|
inspection = inspect_spec(
|
|
4255
4643
|
resolved_spec_path,
|
|
@@ -4273,7 +4661,16 @@ def create_task_from_spec(
|
|
|
4273
4661
|
str(binding["repo_id"]): str(binding["path"])
|
|
4274
4662
|
for binding in bindings
|
|
4275
4663
|
}
|
|
4276
|
-
|
|
4664
|
+
if not isinstance(inspection.get("execution"), dict):
|
|
4665
|
+
raise StateError(
|
|
4666
|
+
"Canonical Spec shared execution is not initialized; run initialize-spec-execution first."
|
|
4667
|
+
)
|
|
4668
|
+
try:
|
|
4669
|
+
source_path = resolved_spec_path.relative_to(root.resolve()).as_posix()
|
|
4670
|
+
path_mode = "project-relative"
|
|
4671
|
+
except ValueError:
|
|
4672
|
+
source_path = str(resolved_spec_path)
|
|
4673
|
+
path_mode = "absolute"
|
|
4277
4674
|
fields = {
|
|
4278
4675
|
"repos": list(selection["selected_repo_ids"]),
|
|
4279
4676
|
"repo_paths": stored_repo_paths,
|
|
@@ -4282,11 +4679,19 @@ def create_task_from_spec(
|
|
|
4282
4679
|
"spec_id": inspection["spec_id"],
|
|
4283
4680
|
"revision": inspection["revision"],
|
|
4284
4681
|
"path": source_path,
|
|
4285
|
-
"
|
|
4682
|
+
"path_mode": path_mode,
|
|
4683
|
+
"design_sha256": inspection["design_sha256"],
|
|
4684
|
+
"document_sha256": inspection["document_sha256"],
|
|
4685
|
+
"execution_revision": inspection["execution_revision"],
|
|
4286
4686
|
},
|
|
4287
4687
|
"selected_spec_tasks": selection["selected_task_ids"],
|
|
4288
4688
|
"spec_repositories": bindings,
|
|
4289
4689
|
"spec_dependency_evidence": selection["dependency_records"],
|
|
4690
|
+
"spec_writeback_progress": {
|
|
4691
|
+
"last_execution_revision": inspection["execution_revision"],
|
|
4692
|
+
"status": "ok",
|
|
4693
|
+
"updated_at": now_iso(),
|
|
4694
|
+
},
|
|
4290
4695
|
}
|
|
4291
4696
|
return create_task(
|
|
4292
4697
|
root,
|
|
@@ -4300,74 +4705,1317 @@ def create_task_from_spec(
|
|
|
4300
4705
|
)
|
|
4301
4706
|
|
|
4302
4707
|
|
|
4303
|
-
|
|
4708
|
+
SPEC_WRITEBACK_APP = "easy-coding"
|
|
4709
|
+
|
|
4710
|
+
|
|
4711
|
+
def spec_writeback_agent(agent: str) -> str:
|
|
4712
|
+
raw_agent = str(agent).strip()
|
|
4713
|
+
if raw_agent.endswith(" with Easy Coding"):
|
|
4714
|
+
return raw_agent
|
|
4715
|
+
normalized = normalize_agent_identity(raw_agent)
|
|
4716
|
+
display_name = {
|
|
4717
|
+
"claude-code": "Claude Code",
|
|
4718
|
+
"codex": "Codex",
|
|
4719
|
+
"qoder": "Qoder",
|
|
4720
|
+
"unknown": "Unknown Agent",
|
|
4721
|
+
}.get(normalized, normalized)
|
|
4722
|
+
return f"{display_name} with Easy Coding"
|
|
4723
|
+
|
|
4724
|
+
|
|
4725
|
+
def initialize_spec_execution_state(root: Path, spec_path: str) -> dict:
|
|
4726
|
+
raw_path = Path(spec_path).expanduser()
|
|
4727
|
+
resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
|
|
4728
|
+
if not resolved.is_file():
|
|
4729
|
+
raise StateError("Canonical Spec path must identify an explicit UTF-8 file.")
|
|
4730
|
+
try:
|
|
4731
|
+
execution = initialize_execution(resolved)
|
|
4732
|
+
details = show_execution(resolved)
|
|
4733
|
+
except (ExecutionStateError, ExecutionConflictError) as exc:
|
|
4734
|
+
raise StateError(f"Cannot initialize Canonical Spec execution: {exc}") from exc
|
|
4735
|
+
return {
|
|
4736
|
+
"action": "initialize-spec-execution",
|
|
4737
|
+
"spec": str(resolved),
|
|
4738
|
+
"design_sha256": details["design_sha256"],
|
|
4739
|
+
"document_sha256": details["document_sha256"],
|
|
4740
|
+
"execution_revision": execution["execution_revision"],
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
|
|
4744
|
+
def _spec_event(execution: dict, idempotency_key: str) -> dict:
|
|
4745
|
+
matches = [
|
|
4746
|
+
event
|
|
4747
|
+
for event in execution.get("events", [])
|
|
4748
|
+
if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
|
|
4749
|
+
]
|
|
4750
|
+
if len(matches) != 1:
|
|
4751
|
+
raise StateError("Shared Spec writeback did not expose one matching idempotent event.")
|
|
4752
|
+
return matches[0]
|
|
4753
|
+
|
|
4754
|
+
|
|
4755
|
+
def _writeback_progress(task: dict) -> dict:
|
|
4756
|
+
progress = task.get("spec_writeback_progress")
|
|
4757
|
+
if not isinstance(progress, dict):
|
|
4758
|
+
progress = {}
|
|
4759
|
+
task["spec_writeback_progress"] = progress
|
|
4760
|
+
return progress
|
|
4761
|
+
|
|
4762
|
+
|
|
4763
|
+
def _is_idempotency_key_conflict(exc: ExecutionConflictError) -> bool:
|
|
4764
|
+
return str(exc).startswith("幂等键已被不同事件使用")
|
|
4765
|
+
|
|
4766
|
+
|
|
4767
|
+
def _execute_spec_writeback(
|
|
4304
4768
|
root: Path,
|
|
4305
|
-
|
|
4306
|
-
|
|
4769
|
+
harness_task_id: str,
|
|
4770
|
+
task: dict,
|
|
4771
|
+
action: dict,
|
|
4772
|
+
idempotency_key: str,
|
|
4773
|
+
invoke,
|
|
4774
|
+
) -> dict:
|
|
4775
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
4776
|
+
source = task["spec_source"]
|
|
4777
|
+
progress = _writeback_progress(task)
|
|
4778
|
+
serialized_action = json.dumps(action, ensure_ascii=False, sort_keys=True)
|
|
4779
|
+
existing_pending = progress.get("pending_action")
|
|
4780
|
+
if isinstance(existing_pending, str) and existing_pending.strip():
|
|
4781
|
+
try:
|
|
4782
|
+
existing_action = json.loads(existing_pending)
|
|
4783
|
+
except json.JSONDecodeError as exc:
|
|
4784
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
4785
|
+
if existing_action != action:
|
|
4786
|
+
raise StateError(
|
|
4787
|
+
"A different Canonical Spec writeback is pending; run "
|
|
4788
|
+
"reconcile-spec-execution before starting another action."
|
|
4789
|
+
)
|
|
4790
|
+
progress.update(
|
|
4791
|
+
{
|
|
4792
|
+
"last_execution_revision": source["execution_revision"],
|
|
4793
|
+
"pending_action": serialized_action,
|
|
4794
|
+
"status": "pending",
|
|
4795
|
+
"updated_at": now_iso(),
|
|
4796
|
+
}
|
|
4797
|
+
)
|
|
4798
|
+
write_task(root, harness_task_id, task)
|
|
4799
|
+
|
|
4800
|
+
def call_writer(current_inspection: dict) -> dict:
|
|
4801
|
+
return invoke(
|
|
4802
|
+
str(current_inspection["design_sha256"]),
|
|
4803
|
+
int(current_inspection["execution_revision"]),
|
|
4804
|
+
)
|
|
4805
|
+
|
|
4806
|
+
try:
|
|
4807
|
+
execution = call_writer(inspection)
|
|
4808
|
+
except ExecutionConflictError:
|
|
4809
|
+
try:
|
|
4810
|
+
refreshed = inspect_spec(
|
|
4811
|
+
stored_spec_path(root, task),
|
|
4812
|
+
root,
|
|
4813
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
4814
|
+
task.get("selected_spec_tasks") or [],
|
|
4815
|
+
)
|
|
4816
|
+
except EasyDevSpecError as exc:
|
|
4817
|
+
progress["status"] = "error"
|
|
4818
|
+
progress["updated_at"] = now_iso()
|
|
4819
|
+
progress.pop("pending_action", None)
|
|
4820
|
+
write_task(root, harness_task_id, task)
|
|
4821
|
+
raise StateError(f"Cannot refresh Canonical Spec after CAS conflict: {exc}") from exc
|
|
4822
|
+
if refreshed.get("design_sha256") != source.get("design_sha256"):
|
|
4823
|
+
progress["status"] = "error"
|
|
4824
|
+
progress["updated_at"] = now_iso()
|
|
4825
|
+
progress.pop("pending_action", None)
|
|
4826
|
+
write_task(root, harness_task_id, task)
|
|
4827
|
+
raise StateError("Canonical Spec design changed during writeback; return to ANALYSIS.")
|
|
4828
|
+
if int(refreshed.get("execution_revision", -1)) < int(source["execution_revision"]):
|
|
4829
|
+
progress["status"] = "conflict"
|
|
4830
|
+
progress["updated_at"] = now_iso()
|
|
4831
|
+
write_task(root, harness_task_id, task)
|
|
4832
|
+
raise StateError("Canonical Spec execution revision moved backwards during writeback.")
|
|
4833
|
+
try:
|
|
4834
|
+
execution = call_writer(refreshed)
|
|
4835
|
+
except (ExecutionStateError, ExecutionConflictError) as exc:
|
|
4836
|
+
terminal_conflict = isinstance(
|
|
4837
|
+
exc, ExecutionConflictError
|
|
4838
|
+
) and _is_idempotency_key_conflict(exc)
|
|
4839
|
+
progress["status"] = "error" if terminal_conflict else "conflict"
|
|
4840
|
+
progress["updated_at"] = now_iso()
|
|
4841
|
+
if terminal_conflict:
|
|
4842
|
+
progress.pop("pending_action", None)
|
|
4843
|
+
write_task(root, harness_task_id, task)
|
|
4844
|
+
raise StateError(f"Canonical Spec CAS retry failed: {exc}") from exc
|
|
4845
|
+
except ExecutionStateError as exc:
|
|
4846
|
+
progress["status"] = "error"
|
|
4847
|
+
progress["updated_at"] = now_iso()
|
|
4848
|
+
progress.pop("pending_action", None)
|
|
4849
|
+
write_task(root, harness_task_id, task)
|
|
4850
|
+
raise StateError(f"Canonical Spec writeback failed: {exc}") from exc
|
|
4851
|
+
|
|
4852
|
+
event = _spec_event(execution, idempotency_key)
|
|
4853
|
+
try:
|
|
4854
|
+
details = show_execution(stored_spec_path(root, task))
|
|
4855
|
+
except ExecutionStateError as exc:
|
|
4856
|
+
raise StateError(f"Canonical Spec writeback cannot be verified: {exc}") from exc
|
|
4857
|
+
source.update(
|
|
4858
|
+
{
|
|
4859
|
+
"revision": details["design_revision"],
|
|
4860
|
+
"design_sha256": details["design_sha256"],
|
|
4861
|
+
"document_sha256": details["document_sha256"],
|
|
4862
|
+
"execution_revision": execution["execution_revision"],
|
|
4863
|
+
}
|
|
4864
|
+
)
|
|
4865
|
+
inspect_task_spec(root, task)
|
|
4866
|
+
progress.update(
|
|
4867
|
+
{
|
|
4868
|
+
"last_execution_revision": execution["execution_revision"],
|
|
4869
|
+
"last_event_id": event["event_id"],
|
|
4870
|
+
"last_idempotency_key": idempotency_key,
|
|
4871
|
+
"status": "ok",
|
|
4872
|
+
"updated_at": now_iso(),
|
|
4873
|
+
}
|
|
4874
|
+
)
|
|
4875
|
+
progress.pop("pending_action", None)
|
|
4876
|
+
acknowledgment = {
|
|
4877
|
+
"type": "spec-writeback",
|
|
4878
|
+
"action": action,
|
|
4879
|
+
"event_id": event["event_id"],
|
|
4880
|
+
"execution_revision": execution["execution_revision"],
|
|
4881
|
+
"idempotency_key": idempotency_key,
|
|
4882
|
+
"timestamp": now_iso(),
|
|
4883
|
+
}
|
|
4884
|
+
already_acknowledged = any(
|
|
4885
|
+
record.get("type") == "spec-writeback"
|
|
4886
|
+
and record.get("idempotency_key") == idempotency_key
|
|
4887
|
+
for record in execution_records(root, harness_task_id)
|
|
4888
|
+
)
|
|
4889
|
+
if not already_acknowledged:
|
|
4890
|
+
append_execution_record(root, harness_task_id, acknowledgment)
|
|
4891
|
+
write_task(root, harness_task_id, task)
|
|
4892
|
+
return acknowledgment
|
|
4893
|
+
|
|
4894
|
+
|
|
4895
|
+
def writeback_spec_task(
|
|
4896
|
+
root: Path,
|
|
4897
|
+
source_task_id: str,
|
|
4898
|
+
status_value: str,
|
|
4899
|
+
summary: str,
|
|
4900
|
+
evidence: list[dict],
|
|
4901
|
+
idempotency_key: str,
|
|
4307
4902
|
agent: str,
|
|
4308
|
-
source_task_id: str | None = None,
|
|
4309
4903
|
task_id: str | None = None,
|
|
4310
4904
|
session_file: str | Path | None = None,
|
|
4311
4905
|
) -> dict:
|
|
4312
4906
|
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
4313
|
-
if
|
|
4314
|
-
raise StateError("
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4907
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
4908
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
4909
|
+
action = {
|
|
4910
|
+
"kind": "task",
|
|
4911
|
+
"source_task_id": source_task_id,
|
|
4912
|
+
"status": status_value,
|
|
4913
|
+
"summary": summary,
|
|
4914
|
+
"evidence": evidence,
|
|
4915
|
+
"idempotency_key": idempotency_key,
|
|
4916
|
+
"agent": agent,
|
|
4917
|
+
}
|
|
4918
|
+
acknowledgment = _execute_spec_writeback(
|
|
4919
|
+
root,
|
|
4920
|
+
resolved_task_id,
|
|
4921
|
+
task,
|
|
4922
|
+
action,
|
|
4923
|
+
idempotency_key,
|
|
4924
|
+
lambda design_digest, execution_revision: record_task_status(
|
|
4925
|
+
stored_spec_path(root, task),
|
|
4926
|
+
source_task_id,
|
|
4927
|
+
status_value,
|
|
4928
|
+
summary,
|
|
4929
|
+
SPEC_WRITEBACK_APP,
|
|
4930
|
+
spec_writeback_agent(agent),
|
|
4931
|
+
design_digest,
|
|
4932
|
+
execution_revision,
|
|
4933
|
+
evidence=evidence,
|
|
4934
|
+
run_id=resolved_task_id,
|
|
4935
|
+
idempotency_key=idempotency_key,
|
|
4936
|
+
),
|
|
4937
|
+
)
|
|
4343
4938
|
snapshot = snapshot_state(root, session_file, session)
|
|
4344
|
-
snapshot["
|
|
4939
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
4940
|
+
snapshot["action"] = "writeback-spec-task"
|
|
4345
4941
|
return snapshot
|
|
4346
4942
|
|
|
4347
4943
|
|
|
4348
|
-
def
|
|
4349
|
-
history = task.setdefault("stage_history", [])
|
|
4350
|
-
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
def resolve_current_task(
|
|
4944
|
+
def writeback_spec_step(
|
|
4354
4945
|
root: Path,
|
|
4946
|
+
source_task_id: str,
|
|
4947
|
+
step_id: str,
|
|
4948
|
+
status_value: str,
|
|
4949
|
+
summary: str,
|
|
4950
|
+
evidence: list[dict],
|
|
4951
|
+
idempotency_key: str,
|
|
4952
|
+
agent: str,
|
|
4355
4953
|
task_id: str | None = None,
|
|
4356
4954
|
session_file: str | Path | None = None,
|
|
4357
|
-
) ->
|
|
4358
|
-
session =
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4955
|
+
) -> dict:
|
|
4956
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
4957
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
4958
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
4959
|
+
action = {
|
|
4960
|
+
"kind": "step",
|
|
4961
|
+
"source_task_id": source_task_id,
|
|
4962
|
+
"step_id": step_id,
|
|
4963
|
+
"status": status_value,
|
|
4964
|
+
"summary": summary,
|
|
4965
|
+
"evidence": evidence,
|
|
4966
|
+
"idempotency_key": idempotency_key,
|
|
4967
|
+
"agent": agent,
|
|
4968
|
+
}
|
|
4969
|
+
acknowledgment = _execute_spec_writeback(
|
|
4970
|
+
root,
|
|
4971
|
+
resolved_task_id,
|
|
4972
|
+
task,
|
|
4973
|
+
action,
|
|
4974
|
+
idempotency_key,
|
|
4975
|
+
lambda design_digest, execution_revision: record_step_status(
|
|
4976
|
+
stored_spec_path(root, task),
|
|
4977
|
+
source_task_id,
|
|
4978
|
+
step_id,
|
|
4979
|
+
status_value,
|
|
4980
|
+
summary,
|
|
4981
|
+
SPEC_WRITEBACK_APP,
|
|
4982
|
+
spec_writeback_agent(agent),
|
|
4983
|
+
design_digest,
|
|
4984
|
+
execution_revision,
|
|
4985
|
+
evidence=evidence,
|
|
4986
|
+
run_id=resolved_task_id,
|
|
4987
|
+
idempotency_key=idempotency_key,
|
|
4988
|
+
),
|
|
4989
|
+
)
|
|
4990
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
4991
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
4992
|
+
snapshot["action"] = "writeback-spec-step"
|
|
4993
|
+
return snapshot
|
|
4366
4994
|
|
|
4367
4995
|
|
|
4368
|
-
def
|
|
4996
|
+
def writeback_spec_dependency(
|
|
4369
4997
|
root: Path,
|
|
4370
|
-
|
|
4998
|
+
source_task_id: str,
|
|
4999
|
+
dependency_task_id: str,
|
|
5000
|
+
status_value: str,
|
|
5001
|
+
summary: str,
|
|
5002
|
+
evidence: list[dict],
|
|
5003
|
+
idempotency_key: str,
|
|
5004
|
+
agent: str,
|
|
5005
|
+
task_id: str | None = None,
|
|
5006
|
+
session_file: str | Path | None = None,
|
|
5007
|
+
) -> dict:
|
|
5008
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5009
|
+
if source_task_id not in set(task.get("selected_spec_tasks") or []):
|
|
5010
|
+
raise StateError("Canonical source task is outside the Harness task selection.")
|
|
5011
|
+
action = {
|
|
5012
|
+
"kind": "dependency",
|
|
5013
|
+
"source_task_id": source_task_id,
|
|
5014
|
+
"dependency_task_id": dependency_task_id,
|
|
5015
|
+
"status": status_value,
|
|
5016
|
+
"summary": summary,
|
|
5017
|
+
"evidence": evidence,
|
|
5018
|
+
"idempotency_key": idempotency_key,
|
|
5019
|
+
"agent": agent,
|
|
5020
|
+
}
|
|
5021
|
+
acknowledgment = _execute_spec_writeback(
|
|
5022
|
+
root,
|
|
5023
|
+
resolved_task_id,
|
|
5024
|
+
task,
|
|
5025
|
+
action,
|
|
5026
|
+
idempotency_key,
|
|
5027
|
+
lambda design_digest, execution_revision: record_dependency_status(
|
|
5028
|
+
stored_spec_path(root, task),
|
|
5029
|
+
source_task_id,
|
|
5030
|
+
dependency_task_id,
|
|
5031
|
+
status_value,
|
|
5032
|
+
summary,
|
|
5033
|
+
SPEC_WRITEBACK_APP,
|
|
5034
|
+
spec_writeback_agent(agent),
|
|
5035
|
+
design_digest,
|
|
5036
|
+
execution_revision,
|
|
5037
|
+
evidence=evidence,
|
|
5038
|
+
run_id=resolved_task_id,
|
|
5039
|
+
idempotency_key=idempotency_key,
|
|
5040
|
+
),
|
|
5041
|
+
)
|
|
5042
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5043
|
+
snapshot["spec_writeback"] = acknowledgment
|
|
5044
|
+
snapshot["action"] = "writeback-spec-dependency"
|
|
5045
|
+
return snapshot
|
|
5046
|
+
|
|
5047
|
+
|
|
5048
|
+
def rebind_spec_source(
|
|
5049
|
+
root: Path,
|
|
5050
|
+
spec_path: str,
|
|
5051
|
+
agent: str,
|
|
5052
|
+
task_id: str | None = None,
|
|
5053
|
+
session_file: str | Path | None = None,
|
|
5054
|
+
) -> dict:
|
|
5055
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5056
|
+
source = task.get("spec_source")
|
|
5057
|
+
if not isinstance(source, dict):
|
|
5058
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
5059
|
+
raw_path = Path(spec_path).expanduser()
|
|
5060
|
+
resolved = raw_path.resolve() if raw_path.is_absolute() else (root / raw_path).resolve()
|
|
5061
|
+
try:
|
|
5062
|
+
inspection = inspect_spec(
|
|
5063
|
+
resolved,
|
|
5064
|
+
root,
|
|
5065
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
5066
|
+
task.get("selected_spec_tasks") or [],
|
|
5067
|
+
)
|
|
5068
|
+
except EasyDevSpecError as exc:
|
|
5069
|
+
raise StateError(f"Cannot rebind Canonical Spec: {exc}") from exc
|
|
5070
|
+
for field in ("schema", "spec_id", "revision", "design_sha256"):
|
|
5071
|
+
expected = source.get(field)
|
|
5072
|
+
if field == "design_sha256" and expected is None and source.get("sha256") == inspection.get("source_sha256"):
|
|
5073
|
+
expected = inspection.get("design_sha256")
|
|
5074
|
+
if expected != inspection.get(field):
|
|
5075
|
+
raise StateError(f"Rebind rejected because Canonical Spec {field} does not match.")
|
|
5076
|
+
previous_execution_revision = source.get("execution_revision", 0)
|
|
5077
|
+
if int(inspection.get("execution_revision", -1)) < int(previous_execution_revision):
|
|
5078
|
+
raise StateError("Rebind rejected because Canonical execution revision moved backwards.")
|
|
5079
|
+
try:
|
|
5080
|
+
source_path = resolved.relative_to(root.resolve()).as_posix()
|
|
5081
|
+
path_mode = "project-relative"
|
|
5082
|
+
except ValueError:
|
|
5083
|
+
source_path = str(resolved)
|
|
5084
|
+
path_mode = "absolute"
|
|
5085
|
+
source.update({"path": source_path, "path_mode": path_mode})
|
|
5086
|
+
inspect_task_spec(root, task)
|
|
5087
|
+
task["last_agent"] = agent
|
|
5088
|
+
write_task(root, resolved_task_id, task)
|
|
5089
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5090
|
+
snapshot["action"] = "rebind-spec-source"
|
|
5091
|
+
return snapshot
|
|
5092
|
+
|
|
5093
|
+
|
|
5094
|
+
def reconcile_local_result_evidence(
|
|
5095
|
+
root: Path,
|
|
5096
|
+
resolved_task_id: str,
|
|
5097
|
+
task: dict,
|
|
5098
|
+
agent: str,
|
|
5099
|
+
session_file: str | Path | None,
|
|
5100
|
+
) -> tuple[int, list[str]]:
|
|
5101
|
+
plan = latest_execution_plan(root, resolved_task_id)
|
|
5102
|
+
if not isinstance(plan, dict):
|
|
5103
|
+
return 0, []
|
|
5104
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
5105
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5106
|
+
units = {
|
|
5107
|
+
str(unit.get("id")): unit
|
|
5108
|
+
for unit in plan.get("units", [])
|
|
5109
|
+
if isinstance(unit, dict) and is_non_empty_string(unit.get("id"))
|
|
5110
|
+
}
|
|
5111
|
+
records = execution_records(root, resolved_task_id)
|
|
5112
|
+
last_plan_index = max(
|
|
5113
|
+
(index for index, record in enumerate(records) if record.get("type") == "plan"),
|
|
5114
|
+
default=-1,
|
|
5115
|
+
)
|
|
5116
|
+
lifecycle_by_unit: dict[str, list[tuple[int, dict]]] = {
|
|
5117
|
+
unit_id: [] for unit_id in units
|
|
5118
|
+
}
|
|
5119
|
+
for record_index, record in enumerate(records[last_plan_index + 1 :], last_plan_index + 1):
|
|
5120
|
+
unit_id = str(record.get("unit_id") or "")
|
|
5121
|
+
if record.get("type") in {"dispatch", "result"} and unit_id in lifecycle_by_unit:
|
|
5122
|
+
lifecycle_by_unit[unit_id].append((record_index, record))
|
|
5123
|
+
latest_results = {
|
|
5124
|
+
unit_id: lifecycle[-1]
|
|
5125
|
+
for unit_id, lifecycle in lifecycle_by_unit.items()
|
|
5126
|
+
if lifecycle and lifecycle[-1][1].get("type") == "result"
|
|
5127
|
+
}
|
|
5128
|
+
step_by_id = {
|
|
5129
|
+
str(step.get("step_id")): step
|
|
5130
|
+
for step in selection.get("selected_steps", [])
|
|
5131
|
+
if isinstance(step, dict)
|
|
5132
|
+
}
|
|
5133
|
+
test_by_id = {
|
|
5134
|
+
str(test.get("test_id")): test
|
|
5135
|
+
for test in selection.get("selected_tests", [])
|
|
5136
|
+
if isinstance(test, dict)
|
|
5137
|
+
}
|
|
5138
|
+
reconciled = 0
|
|
5139
|
+
unresolved: list[str] = []
|
|
5140
|
+
for unit_id, (result_index, result) in latest_results.items():
|
|
5141
|
+
unit = units.get(unit_id)
|
|
5142
|
+
if not unit:
|
|
5143
|
+
continue
|
|
5144
|
+
lifecycle = lifecycle_by_unit.get(unit_id, [])
|
|
5145
|
+
if len(lifecycle) < 2 or lifecycle[-2][1].get("type") != "dispatch":
|
|
5146
|
+
unresolved.append(f"{unit_id}:missing-matching-dispatch")
|
|
5147
|
+
continue
|
|
5148
|
+
dispatch_index, dispatch = lifecycle[-2]
|
|
5149
|
+
source_task_id = str(unit.get("source_task_id") or "")
|
|
5150
|
+
source_steps = [str(value) for value in unit.get("source_step_ids", [])]
|
|
5151
|
+
if source_task_id not in snapshots or not source_steps:
|
|
5152
|
+
continue
|
|
5153
|
+
if (
|
|
5154
|
+
dispatch.get("source_task_id") != source_task_id
|
|
5155
|
+
or dispatch.get("repo_id") != unit.get("repo_id")
|
|
5156
|
+
or result.get("source_task_id") != source_task_id
|
|
5157
|
+
or result.get("repo_id") != unit.get("repo_id")
|
|
5158
|
+
or not isinstance(result.get("changed_files"), list)
|
|
5159
|
+
or not set(result.get("changed_files", [])).issubset(set(unit.get("files", [])))
|
|
5160
|
+
or not is_non_empty_string(result.get("summary"))
|
|
5161
|
+
):
|
|
5162
|
+
unresolved.append(f"{unit_id}:source-ownership-mismatch")
|
|
5163
|
+
continue
|
|
5164
|
+
current_status = snapshots[source_task_id].get("status")
|
|
5165
|
+
if current_status != "in_progress":
|
|
5166
|
+
unresolved.append(
|
|
5167
|
+
f"{unit_id}:shared-task-status={current_status or 'missing'}"
|
|
5168
|
+
)
|
|
5169
|
+
continue
|
|
5170
|
+
attempt_id, attempt_completed_steps = _shared_attempt_projection(
|
|
5171
|
+
inspection, source_task_id
|
|
5172
|
+
)
|
|
5173
|
+
if not attempt_id:
|
|
5174
|
+
unresolved.append(f"{unit_id}:missing-in-progress-attempt")
|
|
5175
|
+
continue
|
|
5176
|
+
attempt_ack_index = max(
|
|
5177
|
+
(
|
|
5178
|
+
index
|
|
5179
|
+
for index, record in enumerate(records)
|
|
5180
|
+
if record.get("type") == "spec-writeback"
|
|
5181
|
+
and record.get("event_id") == attempt_id
|
|
5182
|
+
and isinstance(record.get("action"), dict)
|
|
5183
|
+
and record["action"].get("kind") == "task"
|
|
5184
|
+
and record["action"].get("source_task_id") == source_task_id
|
|
5185
|
+
and record["action"].get("status") == "in_progress"
|
|
5186
|
+
),
|
|
5187
|
+
default=-1,
|
|
5188
|
+
)
|
|
5189
|
+
if attempt_ack_index < 0:
|
|
5190
|
+
unresolved.append(f"{unit_id}:missing-in-progress-acknowledgment")
|
|
5191
|
+
continue
|
|
5192
|
+
if dispatch_index <= attempt_ack_index or result_index <= attempt_ack_index:
|
|
5193
|
+
unresolved.append(f"{unit_id}:no-result-for-current-attempt")
|
|
5194
|
+
continue
|
|
5195
|
+
result_status = result.get("status")
|
|
5196
|
+
successful = (
|
|
5197
|
+
result_status == "completed"
|
|
5198
|
+
and result.get("issues") == []
|
|
5199
|
+
and result.get("needs_attention") == []
|
|
5200
|
+
)
|
|
5201
|
+
failed = result_status == "failed"
|
|
5202
|
+
if not successful and not failed:
|
|
5203
|
+
unresolved.append(f"{unit_id}:invalid-result-status-or-issues")
|
|
5204
|
+
continue
|
|
5205
|
+
if failed:
|
|
5206
|
+
if len(source_steps) != 1:
|
|
5207
|
+
unresolved.append(f"{unit_id}:ambiguous-failed-source-step")
|
|
5208
|
+
continue
|
|
5209
|
+
step_id = source_steps[0]
|
|
5210
|
+
key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-failed"
|
|
5211
|
+
writeback_spec_step(
|
|
5212
|
+
root,
|
|
5213
|
+
source_task_id,
|
|
5214
|
+
step_id,
|
|
5215
|
+
"failed",
|
|
5216
|
+
str(result.get("summary") or f"Unit {unit_id} failed"),
|
|
5217
|
+
[
|
|
5218
|
+
{
|
|
5219
|
+
"kind": "result",
|
|
5220
|
+
"status": "failed",
|
|
5221
|
+
"ref": f"execution.jsonl#unit={unit_id}",
|
|
5222
|
+
}
|
|
5223
|
+
],
|
|
5224
|
+
key,
|
|
5225
|
+
agent,
|
|
5226
|
+
resolved_task_id,
|
|
5227
|
+
session_file,
|
|
5228
|
+
)
|
|
5229
|
+
reconciled += 1
|
|
5230
|
+
unresolved.extend(
|
|
5231
|
+
f"{unit_id}:{remaining_step}:blocked-after-unit-failure"
|
|
5232
|
+
for remaining_step in source_steps[1:]
|
|
5233
|
+
)
|
|
5234
|
+
task = load_task(root, resolved_task_id) or task
|
|
5235
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
5236
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5237
|
+
continue
|
|
5238
|
+
passed_commands = {
|
|
5239
|
+
str(check.get("command"))
|
|
5240
|
+
for check in result.get("checks", [])
|
|
5241
|
+
if isinstance(check, dict)
|
|
5242
|
+
and check.get("passed") is True
|
|
5243
|
+
and is_non_empty_string(check.get("command"))
|
|
5244
|
+
}
|
|
5245
|
+
missing_unit_commands = sorted(set(unit.get("test_commands", [])) - passed_commands)
|
|
5246
|
+
if missing_unit_commands:
|
|
5247
|
+
unresolved.append(
|
|
5248
|
+
f"{unit_id}:missing-passed-command=" + ",".join(missing_unit_commands)
|
|
5249
|
+
)
|
|
5250
|
+
continue
|
|
5251
|
+
pending_steps = list(dict.fromkeys(source_steps))
|
|
5252
|
+
while pending_steps:
|
|
5253
|
+
ready_step_id = next(
|
|
5254
|
+
(
|
|
5255
|
+
step_id
|
|
5256
|
+
for step_id in pending_steps
|
|
5257
|
+
if step_id in attempt_completed_steps
|
|
5258
|
+
or set((step_by_id.get(step_id) or {}).get("depends_on_step_ids", []))
|
|
5259
|
+
.issubset(attempt_completed_steps)
|
|
5260
|
+
),
|
|
5261
|
+
None,
|
|
5262
|
+
)
|
|
5263
|
+
if ready_step_id is None:
|
|
5264
|
+
unresolved.extend(
|
|
5265
|
+
f"{unit_id}:{step_id}:dependency-pending" for step_id in pending_steps
|
|
5266
|
+
)
|
|
5267
|
+
break
|
|
5268
|
+
step_id = ready_step_id
|
|
5269
|
+
pending_steps.remove(step_id)
|
|
5270
|
+
if step_id in attempt_completed_steps:
|
|
5271
|
+
continue
|
|
5272
|
+
step = step_by_id.get(step_id)
|
|
5273
|
+
if not step:
|
|
5274
|
+
unresolved.append(f"{unit_id}:{step_id}:missing-step")
|
|
5275
|
+
continue
|
|
5276
|
+
tests = [test_by_id.get(str(test_id)) for test_id in step.get("test_ids", [])]
|
|
5277
|
+
if any(not isinstance(test, dict) for test in tests):
|
|
5278
|
+
unresolved.append(f"{unit_id}:{step_id}:missing-test")
|
|
5279
|
+
continue
|
|
5280
|
+
missing_commands = [
|
|
5281
|
+
str(test.get("command"))
|
|
5282
|
+
for test in tests
|
|
5283
|
+
if str(test.get("command")) not in passed_commands
|
|
5284
|
+
]
|
|
5285
|
+
if missing_commands:
|
|
5286
|
+
unresolved.append(
|
|
5287
|
+
f"{unit_id}:{step_id}:missing-passed-command=" + ",".join(missing_commands)
|
|
5288
|
+
)
|
|
5289
|
+
continue
|
|
5290
|
+
evidence = [
|
|
5291
|
+
{
|
|
5292
|
+
"kind": "test",
|
|
5293
|
+
"status": "passed",
|
|
5294
|
+
"ref": f"execution.jsonl#unit={unit_id};command={test.get('command')}",
|
|
5295
|
+
"test_id": str(test.get("test_id")),
|
|
5296
|
+
}
|
|
5297
|
+
for test in tests
|
|
5298
|
+
]
|
|
5299
|
+
key = f"{resolved_task_id}:{unit_id}:{step_id}:{attempt_id}:result-completed"
|
|
5300
|
+
writeback_spec_step(
|
|
5301
|
+
root,
|
|
5302
|
+
source_task_id,
|
|
5303
|
+
step_id,
|
|
5304
|
+
"completed",
|
|
5305
|
+
str(result.get("summary") or f"Unit {unit_id} completed"),
|
|
5306
|
+
evidence,
|
|
5307
|
+
key,
|
|
5308
|
+
agent,
|
|
5309
|
+
resolved_task_id,
|
|
5310
|
+
session_file,
|
|
5311
|
+
)
|
|
5312
|
+
reconciled += 1
|
|
5313
|
+
task = load_task(root, resolved_task_id) or task
|
|
5314
|
+
inspection, selection = inspect_task_spec(root, task)
|
|
5315
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5316
|
+
_, attempt_completed_steps = _shared_attempt_projection(
|
|
5317
|
+
inspection, source_task_id
|
|
5318
|
+
)
|
|
5319
|
+
task = load_task(root, resolved_task_id) or task
|
|
5320
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5321
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5322
|
+
selected_tasks = {
|
|
5323
|
+
str(item.get("task_id")): item
|
|
5324
|
+
for item in selection.get("selected_tasks", [])
|
|
5325
|
+
if isinstance(item, dict)
|
|
5326
|
+
}
|
|
5327
|
+
for source_task_id, snapshot in snapshots.items():
|
|
5328
|
+
if snapshot.get("status") != "in_progress":
|
|
5329
|
+
continue
|
|
5330
|
+
expected_steps = set(selected_tasks.get(source_task_id, {}).get("step_ids", []))
|
|
5331
|
+
attempt_id, attempt_completed_steps = _shared_attempt_projection(
|
|
5332
|
+
inspection, source_task_id
|
|
5333
|
+
)
|
|
5334
|
+
if attempt_id and expected_steps and attempt_completed_steps == expected_steps:
|
|
5335
|
+
key = (
|
|
5336
|
+
f"{resolved_task_id}:{source_task_id}:{attempt_id}:"
|
|
5337
|
+
"implemented-from-results"
|
|
5338
|
+
)
|
|
5339
|
+
writeback_spec_task(
|
|
5340
|
+
root,
|
|
5341
|
+
source_task_id,
|
|
5342
|
+
"implemented",
|
|
5343
|
+
"All Canonical Steps have passed local implementation evidence",
|
|
5344
|
+
[],
|
|
5345
|
+
key,
|
|
5346
|
+
agent,
|
|
5347
|
+
resolved_task_id,
|
|
5348
|
+
session_file,
|
|
5349
|
+
)
|
|
5350
|
+
reconciled += 1
|
|
5351
|
+
return reconciled, unresolved
|
|
5352
|
+
|
|
5353
|
+
|
|
5354
|
+
def reconcile_spec_execution(
|
|
5355
|
+
root: Path,
|
|
5356
|
+
agent: str,
|
|
5357
|
+
task_id: str | None = None,
|
|
5358
|
+
session_file: str | Path | None = None,
|
|
5359
|
+
) -> dict:
|
|
5360
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5361
|
+
progress = _writeback_progress(task)
|
|
5362
|
+
pending = progress.get("pending_action")
|
|
5363
|
+
if not isinstance(pending, str) or not pending.strip():
|
|
5364
|
+
reconciled, unresolved = reconcile_local_result_evidence(
|
|
5365
|
+
root,
|
|
5366
|
+
resolved_task_id,
|
|
5367
|
+
task,
|
|
5368
|
+
agent,
|
|
5369
|
+
session_file,
|
|
5370
|
+
)
|
|
5371
|
+
task = load_task(root, resolved_task_id) or task
|
|
5372
|
+
inspect_task_spec(root, task)
|
|
5373
|
+
progress.update(
|
|
5374
|
+
{
|
|
5375
|
+
"last_execution_revision": task["spec_source"]["execution_revision"],
|
|
5376
|
+
"status": "ok",
|
|
5377
|
+
"updated_at": now_iso(),
|
|
5378
|
+
}
|
|
5379
|
+
)
|
|
5380
|
+
write_task(root, resolved_task_id, task)
|
|
5381
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5382
|
+
snapshot["action"] = "reconcile-spec-execution"
|
|
5383
|
+
snapshot["reconciled"] = reconciled > 0
|
|
5384
|
+
snapshot["reconciled_actions"] = reconciled
|
|
5385
|
+
snapshot["unresolved_local_evidence"] = unresolved
|
|
5386
|
+
return snapshot
|
|
5387
|
+
try:
|
|
5388
|
+
action = json.loads(pending)
|
|
5389
|
+
except json.JSONDecodeError as exc:
|
|
5390
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
5391
|
+
kind = action.get("kind")
|
|
5392
|
+
if kind == "sync-design":
|
|
5393
|
+
affected_task_ids = action.get("affected_task_ids")
|
|
5394
|
+
if not is_string_list(affected_task_ids):
|
|
5395
|
+
raise StateError("Pending Canonical Spec design sync has invalid affected tasks.")
|
|
5396
|
+
result = sync_spec_design_state(
|
|
5397
|
+
root,
|
|
5398
|
+
affected_task_ids,
|
|
5399
|
+
str(action.get("summary") or "Reconciled Canonical Spec design sync"),
|
|
5400
|
+
str(action.get("idempotency_key") or ""),
|
|
5401
|
+
str(action.get("agent") or agent),
|
|
5402
|
+
resolved_task_id,
|
|
5403
|
+
session_file,
|
|
5404
|
+
)
|
|
5405
|
+
result["action"] = "reconcile-spec-execution"
|
|
5406
|
+
result["reconciled"] = True
|
|
5407
|
+
return result
|
|
5408
|
+
try:
|
|
5409
|
+
design_text, _ = split_execution_region(
|
|
5410
|
+
stored_spec_path(root, task).read_text(encoding="utf-8")
|
|
5411
|
+
)
|
|
5412
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
5413
|
+
raise StateError(f"Cannot inspect pending Canonical Spec writeback: {exc}") from exc
|
|
5414
|
+
current_design_sha256 = hashlib.sha256(design_text.encode("utf-8")).hexdigest()
|
|
5415
|
+
source = task.get("spec_source")
|
|
5416
|
+
if not isinstance(source, dict):
|
|
5417
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
5418
|
+
if current_design_sha256 != source.get("design_sha256"):
|
|
5419
|
+
# 旧设计上的进度事件不能重放到新设计;清除单槽 pending,允许后续 sync-design。
|
|
5420
|
+
progress["status"] = "error"
|
|
5421
|
+
progress["updated_at"] = now_iso()
|
|
5422
|
+
progress.pop("pending_action", None)
|
|
5423
|
+
write_task(root, resolved_task_id, task)
|
|
5424
|
+
raise StateError(
|
|
5425
|
+
"Pending Canonical Spec writeback belongs to an obsolete design and was "
|
|
5426
|
+
"discarded; return to ANALYSIS and run sync-spec-design."
|
|
5427
|
+
)
|
|
5428
|
+
common = {
|
|
5429
|
+
"root": root,
|
|
5430
|
+
"summary": str(action.get("summary") or "Reconciled shared Spec writeback"),
|
|
5431
|
+
"evidence": action.get("evidence") if isinstance(action.get("evidence"), list) else [],
|
|
5432
|
+
"idempotency_key": str(action.get("idempotency_key") or ""),
|
|
5433
|
+
"agent": str(action.get("agent") or agent),
|
|
5434
|
+
"task_id": resolved_task_id,
|
|
5435
|
+
"session_file": session_file,
|
|
5436
|
+
}
|
|
5437
|
+
if not common["idempotency_key"]:
|
|
5438
|
+
raise StateError("Pending Canonical Spec writeback has no idempotency key.")
|
|
5439
|
+
if kind == "task":
|
|
5440
|
+
result = writeback_spec_task(
|
|
5441
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
5442
|
+
status_value=str(action.get("status") or ""),
|
|
5443
|
+
**common,
|
|
5444
|
+
)
|
|
5445
|
+
elif kind == "step":
|
|
5446
|
+
result = writeback_spec_step(
|
|
5447
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
5448
|
+
step_id=str(action.get("step_id") or ""),
|
|
5449
|
+
status_value=str(action.get("status") or ""),
|
|
5450
|
+
**common,
|
|
5451
|
+
)
|
|
5452
|
+
elif kind == "dependency":
|
|
5453
|
+
result = writeback_spec_dependency(
|
|
5454
|
+
source_task_id=str(action.get("source_task_id") or ""),
|
|
5455
|
+
dependency_task_id=str(action.get("dependency_task_id") or ""),
|
|
5456
|
+
status_value=str(action.get("status") or ""),
|
|
5457
|
+
**common,
|
|
5458
|
+
)
|
|
5459
|
+
else:
|
|
5460
|
+
raise StateError("Pending Canonical Spec writeback kind is unsupported.")
|
|
5461
|
+
result["action"] = "reconcile-spec-execution"
|
|
5462
|
+
result["reconciled"] = True
|
|
5463
|
+
return result
|
|
5464
|
+
|
|
5465
|
+
|
|
5466
|
+
def sync_spec_design_state(
|
|
5467
|
+
root: Path,
|
|
5468
|
+
affected_task_ids: list[str],
|
|
5469
|
+
summary: str,
|
|
5470
|
+
idempotency_key: str,
|
|
5471
|
+
agent: str,
|
|
5472
|
+
task_id: str | None = None,
|
|
5473
|
+
session_file: str | Path | None = None,
|
|
5474
|
+
) -> dict:
|
|
5475
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5476
|
+
source = task.get("spec_source")
|
|
5477
|
+
if not isinstance(source, dict):
|
|
5478
|
+
raise StateError("Current task is not backed by a Canonical Spec.")
|
|
5479
|
+
spec_path = stored_spec_path(root, task)
|
|
5480
|
+
requested_task_ids = sorted(set(affected_task_ids))
|
|
5481
|
+
|
|
5482
|
+
def current_execution_envelope() -> dict:
|
|
5483
|
+
try:
|
|
5484
|
+
from easy_dev_spec_protocol import split_execution_region
|
|
5485
|
+
|
|
5486
|
+
_, execution = split_execution_region(spec_path.read_text(encoding="utf-8"))
|
|
5487
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
5488
|
+
raise StateError(f"Cannot inspect pre-sync Canonical execution state: {exc}") from exc
|
|
5489
|
+
if not isinstance(execution, dict):
|
|
5490
|
+
raise StateError("Canonical Spec shared execution is missing before sync-design.")
|
|
5491
|
+
if execution.get("design_sha256") != source.get("design_sha256"):
|
|
5492
|
+
matching_events = [
|
|
5493
|
+
event
|
|
5494
|
+
for event in execution.get("events", [])
|
|
5495
|
+
if isinstance(event, dict)
|
|
5496
|
+
and event.get("type") == "spec_revised"
|
|
5497
|
+
and event.get("idempotency_key") == idempotency_key
|
|
5498
|
+
and event.get("requested_task_ids") == requested_task_ids
|
|
5499
|
+
and event.get("run_id") == resolved_task_id
|
|
5500
|
+
]
|
|
5501
|
+
if len(matching_events) != 1:
|
|
5502
|
+
raise StateError(
|
|
5503
|
+
"Canonical Spec execution baseline no longer matches the bound design."
|
|
5504
|
+
)
|
|
5505
|
+
return execution
|
|
5506
|
+
|
|
5507
|
+
current_revision = int(current_execution_envelope().get("execution_revision", -1))
|
|
5508
|
+
progress = _writeback_progress(task)
|
|
5509
|
+
pending_action = {
|
|
5510
|
+
"kind": "sync-design",
|
|
5511
|
+
"affected_task_ids": requested_task_ids,
|
|
5512
|
+
"summary": summary,
|
|
5513
|
+
"idempotency_key": idempotency_key,
|
|
5514
|
+
"agent": agent,
|
|
5515
|
+
}
|
|
5516
|
+
serialized_pending_action = json.dumps(
|
|
5517
|
+
pending_action, ensure_ascii=False, sort_keys=True
|
|
5518
|
+
)
|
|
5519
|
+
existing_pending = progress.get("pending_action")
|
|
5520
|
+
if isinstance(existing_pending, str) and existing_pending.strip():
|
|
5521
|
+
try:
|
|
5522
|
+
existing_action = json.loads(existing_pending)
|
|
5523
|
+
except json.JSONDecodeError as exc:
|
|
5524
|
+
raise StateError("Pending Canonical Spec writeback metadata is invalid JSON.") from exc
|
|
5525
|
+
if existing_action != pending_action:
|
|
5526
|
+
raise StateError(
|
|
5527
|
+
"A different Canonical Spec writeback is pending; run "
|
|
5528
|
+
"reconcile-spec-execution before sync-design."
|
|
5529
|
+
)
|
|
5530
|
+
progress.update(
|
|
5531
|
+
{
|
|
5532
|
+
"last_execution_revision": current_revision,
|
|
5533
|
+
"pending_action": serialized_pending_action,
|
|
5534
|
+
"status": "pending",
|
|
5535
|
+
"updated_at": now_iso(),
|
|
5536
|
+
}
|
|
5537
|
+
)
|
|
5538
|
+
write_task(root, resolved_task_id, task)
|
|
5539
|
+
|
|
5540
|
+
def invoke_sync(execution_revision: int) -> dict:
|
|
5541
|
+
return sync_design(
|
|
5542
|
+
spec_path,
|
|
5543
|
+
requested_task_ids,
|
|
5544
|
+
summary,
|
|
5545
|
+
SPEC_WRITEBACK_APP,
|
|
5546
|
+
spec_writeback_agent(agent),
|
|
5547
|
+
str(source.get("design_sha256")),
|
|
5548
|
+
execution_revision,
|
|
5549
|
+
run_id=resolved_task_id,
|
|
5550
|
+
idempotency_key=idempotency_key,
|
|
5551
|
+
)
|
|
5552
|
+
|
|
5553
|
+
try:
|
|
5554
|
+
execution = invoke_sync(current_revision)
|
|
5555
|
+
except ExecutionConflictError:
|
|
5556
|
+
try:
|
|
5557
|
+
execution = invoke_sync(
|
|
5558
|
+
int(current_execution_envelope().get("execution_revision", -1))
|
|
5559
|
+
)
|
|
5560
|
+
except ExecutionStateError as exc:
|
|
5561
|
+
progress["status"] = "error"
|
|
5562
|
+
progress["updated_at"] = now_iso()
|
|
5563
|
+
progress.pop("pending_action", None)
|
|
5564
|
+
write_task(root, resolved_task_id, task)
|
|
5565
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
5566
|
+
except ExecutionConflictError as exc:
|
|
5567
|
+
terminal_conflict = _is_idempotency_key_conflict(exc)
|
|
5568
|
+
progress["status"] = "error" if terminal_conflict else "conflict"
|
|
5569
|
+
progress["updated_at"] = now_iso()
|
|
5570
|
+
if terminal_conflict:
|
|
5571
|
+
progress.pop("pending_action", None)
|
|
5572
|
+
write_task(root, resolved_task_id, task)
|
|
5573
|
+
raise StateError(f"Cannot synchronize Canonical Spec design after CAS retry: {exc}") from exc
|
|
5574
|
+
except ExecutionStateError as exc:
|
|
5575
|
+
progress["status"] = "error"
|
|
5576
|
+
progress["updated_at"] = now_iso()
|
|
5577
|
+
progress.pop("pending_action", None)
|
|
5578
|
+
write_task(root, resolved_task_id, task)
|
|
5579
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
5580
|
+
try:
|
|
5581
|
+
details = show_execution(spec_path)
|
|
5582
|
+
inspection = inspect_spec(
|
|
5583
|
+
spec_path,
|
|
5584
|
+
root,
|
|
5585
|
+
task.get("repo_paths") if isinstance(task.get("repo_paths"), dict) else {},
|
|
5586
|
+
task.get("selected_spec_tasks") or [],
|
|
5587
|
+
)
|
|
5588
|
+
except (ExecutionStateError, EasyDevSpecError) as exc:
|
|
5589
|
+
raise StateError(f"Cannot synchronize Canonical Spec design: {exc}") from exc
|
|
5590
|
+
if inspection.get("spec_id") != source.get("spec_id"):
|
|
5591
|
+
raise StateError("Synchronized Canonical Spec identity changed unexpectedly.")
|
|
5592
|
+
binding_was_synchronized = (
|
|
5593
|
+
source.get("revision") == inspection.get("revision")
|
|
5594
|
+
and source.get("design_sha256") == inspection.get("design_sha256")
|
|
5595
|
+
)
|
|
5596
|
+
source.update(
|
|
5597
|
+
{
|
|
5598
|
+
"revision": inspection["revision"],
|
|
5599
|
+
"design_sha256": inspection["design_sha256"],
|
|
5600
|
+
"document_sha256": inspection["document_sha256"],
|
|
5601
|
+
"execution_revision": execution["execution_revision"],
|
|
5602
|
+
}
|
|
5603
|
+
)
|
|
5604
|
+
event = _spec_event(execution, idempotency_key)
|
|
5605
|
+
if not binding_was_synchronized:
|
|
5606
|
+
reset_task_ids = set(event.get("task_ids", []))
|
|
5607
|
+
refreshed_dependencies: list[dict] = []
|
|
5608
|
+
for dependency in task.get("spec_dependency_evidence", []):
|
|
5609
|
+
if not isinstance(dependency, dict):
|
|
5610
|
+
continue
|
|
5611
|
+
refreshed = dict(dependency)
|
|
5612
|
+
if refreshed.get("source_task_id") in reset_task_ids:
|
|
5613
|
+
refreshed["status"] = "pending"
|
|
5614
|
+
refreshed["shared_status"] = "pending"
|
|
5615
|
+
for field in ("evidence", "satisfied_at", "satisfied_by"):
|
|
5616
|
+
refreshed.pop(field, None)
|
|
5617
|
+
refreshed_dependencies.append(refreshed)
|
|
5618
|
+
task["spec_dependency_evidence"] = refreshed_dependencies
|
|
5619
|
+
inspect_task_spec(root, task)
|
|
5620
|
+
progress.update(
|
|
5621
|
+
{
|
|
5622
|
+
"last_execution_revision": execution["execution_revision"],
|
|
5623
|
+
"last_event_id": event["event_id"],
|
|
5624
|
+
"last_idempotency_key": idempotency_key,
|
|
5625
|
+
"status": "ok",
|
|
5626
|
+
"updated_at": now_iso(),
|
|
5627
|
+
}
|
|
5628
|
+
)
|
|
5629
|
+
progress.pop("pending_action", None)
|
|
5630
|
+
if task.get("status") not in {"INIT", "ANALYSIS"}:
|
|
5631
|
+
task["status"] = "ANALYSIS"
|
|
5632
|
+
append_stage_history(task, "ANALYSIS", agent)
|
|
5633
|
+
task.pop("pending_transition", None)
|
|
5634
|
+
task["last_agent"] = agent
|
|
5635
|
+
already_acknowledged = any(
|
|
5636
|
+
record.get("type") == "spec-design-sync"
|
|
5637
|
+
and record.get("idempotency_key") == idempotency_key
|
|
5638
|
+
for record in execution_records(root, resolved_task_id)
|
|
5639
|
+
)
|
|
5640
|
+
if not already_acknowledged:
|
|
5641
|
+
append_execution_record(
|
|
5642
|
+
root,
|
|
5643
|
+
resolved_task_id,
|
|
5644
|
+
{
|
|
5645
|
+
"type": "spec-design-sync",
|
|
5646
|
+
"affected_task_ids": requested_task_ids,
|
|
5647
|
+
"event_id": event["event_id"],
|
|
5648
|
+
"design_sha256": details["design_sha256"],
|
|
5649
|
+
"execution_revision": execution["execution_revision"],
|
|
5650
|
+
"idempotency_key": idempotency_key,
|
|
5651
|
+
"timestamp": now_iso(),
|
|
5652
|
+
},
|
|
5653
|
+
)
|
|
5654
|
+
write_task(root, resolved_task_id, task)
|
|
5655
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5656
|
+
snapshot["action"] = "sync-spec-design"
|
|
5657
|
+
return snapshot
|
|
5658
|
+
|
|
5659
|
+
|
|
5660
|
+
def _selected_execution_snapshots(inspection: dict, task: dict) -> dict[str, dict]:
|
|
5661
|
+
selected = set(task.get("selected_spec_tasks") or [])
|
|
5662
|
+
execution = inspection.get("execution")
|
|
5663
|
+
if not isinstance(execution, dict):
|
|
5664
|
+
raise StateError("Canonical Spec shared execution is unavailable.")
|
|
5665
|
+
return {
|
|
5666
|
+
str(snapshot.get("task_id")): snapshot
|
|
5667
|
+
for snapshot in execution.get("tasks", [])
|
|
5668
|
+
if isinstance(snapshot, dict) and snapshot.get("task_id") in selected
|
|
5669
|
+
}
|
|
5670
|
+
|
|
5671
|
+
|
|
5672
|
+
def _shared_attempt_projection(
|
|
5673
|
+
inspection: dict, source_task_id: str
|
|
5674
|
+
) -> tuple[str | None, set[str]]:
|
|
5675
|
+
execution = inspection.get("execution")
|
|
5676
|
+
if not isinstance(execution, dict):
|
|
5677
|
+
return None, set()
|
|
5678
|
+
events = [event for event in execution.get("events", []) if isinstance(event, dict)]
|
|
5679
|
+
start_index = next(
|
|
5680
|
+
(
|
|
5681
|
+
index
|
|
5682
|
+
for index in range(len(events) - 1, -1, -1)
|
|
5683
|
+
if events[index].get("type") == "task_status_changed"
|
|
5684
|
+
and events[index].get("task_id") == source_task_id
|
|
5685
|
+
and events[index].get("to_status") == "in_progress"
|
|
5686
|
+
),
|
|
5687
|
+
None,
|
|
5688
|
+
)
|
|
5689
|
+
if start_index is None:
|
|
5690
|
+
return None, set()
|
|
5691
|
+
start_event = events[start_index]
|
|
5692
|
+
completed_steps: set[str] = set()
|
|
5693
|
+
for event in events[start_index + 1 :]:
|
|
5694
|
+
if event.get("type") != "step_status_changed" or event.get("task_id") != source_task_id:
|
|
5695
|
+
continue
|
|
5696
|
+
step_id = str(event.get("step_id") or "")
|
|
5697
|
+
if not step_id:
|
|
5698
|
+
continue
|
|
5699
|
+
if event.get("step_status") == "completed":
|
|
5700
|
+
completed_steps.add(step_id)
|
|
5701
|
+
elif event.get("step_status") == "failed":
|
|
5702
|
+
completed_steps.discard(step_id)
|
|
5703
|
+
return str(start_event.get("event_id") or "") or None, completed_steps
|
|
5704
|
+
|
|
5705
|
+
|
|
5706
|
+
def _snapshot_dependencies_ready(snapshot: dict, all_snapshots: dict[str, dict]) -> bool:
|
|
5707
|
+
for dependency in snapshot.get("dependencies", []):
|
|
5708
|
+
if not isinstance(dependency, dict) or dependency.get("type") not in {"hard", "contract"}:
|
|
5709
|
+
continue
|
|
5710
|
+
if dependency.get("status") == "satisfied":
|
|
5711
|
+
continue
|
|
5712
|
+
if dependency.get("type") == "hard" and all_snapshots.get(
|
|
5713
|
+
str(dependency.get("task_id")), {}
|
|
5714
|
+
).get("status") == "completed":
|
|
5715
|
+
continue
|
|
5716
|
+
return False
|
|
5717
|
+
return True
|
|
5718
|
+
|
|
5719
|
+
|
|
5720
|
+
def writeback_ready_tasks_for_implement(
|
|
5721
|
+
root: Path,
|
|
5722
|
+
harness_task_id: str,
|
|
5723
|
+
task: dict,
|
|
5724
|
+
agent: str,
|
|
5725
|
+
restart_statuses: set[str] | None = None,
|
|
5726
|
+
) -> None:
|
|
5727
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5728
|
+
implement_attempt = 1 + sum(
|
|
5729
|
+
1
|
|
5730
|
+
for entry in task.get("stage_history", [])
|
|
5731
|
+
if isinstance(entry, dict) and entry.get("stage") == "IMPLEMENT"
|
|
5732
|
+
)
|
|
5733
|
+
all_snapshots = {
|
|
5734
|
+
str(snapshot.get("task_id")): snapshot
|
|
5735
|
+
for snapshot in inspection["execution"].get("tasks", [])
|
|
5736
|
+
if isinstance(snapshot, dict)
|
|
5737
|
+
}
|
|
5738
|
+
selected_snapshots = _selected_execution_snapshots(inspection, task)
|
|
5739
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
5740
|
+
snapshot = selected_snapshots.get(str(source_task_id))
|
|
5741
|
+
if not snapshot or snapshot.get("status") == "in_progress":
|
|
5742
|
+
continue
|
|
5743
|
+
if restart_statuses is not None and snapshot.get("status") not in restart_statuses:
|
|
5744
|
+
continue
|
|
5745
|
+
if not _snapshot_dependencies_ready(snapshot, all_snapshots):
|
|
5746
|
+
continue
|
|
5747
|
+
key = (
|
|
5748
|
+
f"{harness_task_id}:{source_task_id}:enter-implement:"
|
|
5749
|
+
f"{task['spec_source']['revision']}:attempt-{implement_attempt}"
|
|
5750
|
+
)
|
|
5751
|
+
action = {
|
|
5752
|
+
"kind": "task",
|
|
5753
|
+
"source_task_id": source_task_id,
|
|
5754
|
+
"status": "in_progress",
|
|
5755
|
+
"summary": "Harness entered IMPLEMENT for a dependency-ready Canonical task",
|
|
5756
|
+
"evidence": [],
|
|
5757
|
+
"idempotency_key": key,
|
|
5758
|
+
"agent": agent,
|
|
5759
|
+
}
|
|
5760
|
+
_execute_spec_writeback(
|
|
5761
|
+
root,
|
|
5762
|
+
harness_task_id,
|
|
5763
|
+
task,
|
|
5764
|
+
action,
|
|
5765
|
+
key,
|
|
5766
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
5767
|
+
stored_spec_path(root, task),
|
|
5768
|
+
str(source_task_id),
|
|
5769
|
+
"in_progress",
|
|
5770
|
+
"Harness entered IMPLEMENT for a dependency-ready Canonical task",
|
|
5771
|
+
SPEC_WRITEBACK_APP,
|
|
5772
|
+
spec_writeback_agent(agent),
|
|
5773
|
+
design_digest,
|
|
5774
|
+
execution_revision,
|
|
5775
|
+
run_id=harness_task_id,
|
|
5776
|
+
idempotency_key=key,
|
|
5777
|
+
),
|
|
5778
|
+
)
|
|
5779
|
+
|
|
5780
|
+
|
|
5781
|
+
def require_shared_task_statuses(root: Path, task: dict, allowed: set[str]) -> None:
|
|
5782
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5783
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5784
|
+
invalid = [
|
|
5785
|
+
f"{task_id}:{snapshots.get(str(task_id), {}).get('status', 'missing')}"
|
|
5786
|
+
for task_id in task.get("selected_spec_tasks") or []
|
|
5787
|
+
if snapshots.get(str(task_id), {}).get("status") not in allowed
|
|
5788
|
+
]
|
|
5789
|
+
if invalid:
|
|
5790
|
+
raise StateError(
|
|
5791
|
+
"Canonical Spec writeback is incomplete for selected tasks: " + ", ".join(invalid)
|
|
5792
|
+
)
|
|
5793
|
+
|
|
5794
|
+
|
|
5795
|
+
def writeback_completed_tasks(
|
|
5796
|
+
root: Path,
|
|
5797
|
+
harness_task_id: str,
|
|
5798
|
+
task: dict,
|
|
5799
|
+
agent: str,
|
|
5800
|
+
) -> None:
|
|
5801
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5802
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5803
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
5804
|
+
status_value = snapshots.get(str(source_task_id), {}).get("status")
|
|
5805
|
+
if status_value == "completed":
|
|
5806
|
+
continue
|
|
5807
|
+
if status_value != "verified":
|
|
5808
|
+
raise StateError(
|
|
5809
|
+
f"Canonical task {source_task_id} must be verified before Harness COMPLETE."
|
|
5810
|
+
)
|
|
5811
|
+
key = f"{harness_task_id}:{source_task_id}:complete:{task['spec_source']['revision']}"
|
|
5812
|
+
action = {
|
|
5813
|
+
"kind": "task",
|
|
5814
|
+
"source_task_id": source_task_id,
|
|
5815
|
+
"status": "completed",
|
|
5816
|
+
"summary": "Harness MEMORY completed and the Canonical task is complete",
|
|
5817
|
+
"evidence": [],
|
|
5818
|
+
"idempotency_key": key,
|
|
5819
|
+
"agent": agent,
|
|
5820
|
+
}
|
|
5821
|
+
_execute_spec_writeback(
|
|
5822
|
+
root,
|
|
5823
|
+
harness_task_id,
|
|
5824
|
+
task,
|
|
5825
|
+
action,
|
|
5826
|
+
key,
|
|
5827
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
5828
|
+
stored_spec_path(root, task),
|
|
5829
|
+
str(source_task_id),
|
|
5830
|
+
"completed",
|
|
5831
|
+
"Harness MEMORY completed and the Canonical task is complete",
|
|
5832
|
+
SPEC_WRITEBACK_APP,
|
|
5833
|
+
spec_writeback_agent(agent),
|
|
5834
|
+
design_digest,
|
|
5835
|
+
execution_revision,
|
|
5836
|
+
run_id=harness_task_id,
|
|
5837
|
+
idempotency_key=key,
|
|
5838
|
+
),
|
|
5839
|
+
)
|
|
5840
|
+
|
|
5841
|
+
|
|
5842
|
+
def cancel_shared_tasks(
|
|
5843
|
+
root: Path,
|
|
5844
|
+
harness_task_id: str,
|
|
5845
|
+
task: dict,
|
|
5846
|
+
reason: str,
|
|
5847
|
+
agent: str,
|
|
5848
|
+
) -> None:
|
|
5849
|
+
inspection, _ = inspect_task_spec(root, task)
|
|
5850
|
+
snapshots = _selected_execution_snapshots(inspection, task)
|
|
5851
|
+
for source_task_id in task.get("selected_spec_tasks") or []:
|
|
5852
|
+
current = snapshots.get(str(source_task_id), {}).get("status")
|
|
5853
|
+
if current in {"completed", "cancelled"}:
|
|
5854
|
+
continue
|
|
5855
|
+
if current in {"implemented", "verified"}:
|
|
5856
|
+
blocked_key = f"{harness_task_id}:{source_task_id}:close-blocked"
|
|
5857
|
+
blocked_action = {
|
|
5858
|
+
"kind": "task",
|
|
5859
|
+
"source_task_id": source_task_id,
|
|
5860
|
+
"status": "blocked",
|
|
5861
|
+
"summary": reason,
|
|
5862
|
+
"evidence": [],
|
|
5863
|
+
"idempotency_key": blocked_key,
|
|
5864
|
+
"agent": agent,
|
|
5865
|
+
}
|
|
5866
|
+
_execute_spec_writeback(
|
|
5867
|
+
root,
|
|
5868
|
+
harness_task_id,
|
|
5869
|
+
task,
|
|
5870
|
+
blocked_action,
|
|
5871
|
+
blocked_key,
|
|
5872
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
5873
|
+
stored_spec_path(root, task),
|
|
5874
|
+
str(source_task_id),
|
|
5875
|
+
"blocked",
|
|
5876
|
+
reason,
|
|
5877
|
+
SPEC_WRITEBACK_APP,
|
|
5878
|
+
spec_writeback_agent(agent),
|
|
5879
|
+
design_digest,
|
|
5880
|
+
execution_revision,
|
|
5881
|
+
run_id=harness_task_id,
|
|
5882
|
+
idempotency_key=blocked_key,
|
|
5883
|
+
),
|
|
5884
|
+
)
|
|
5885
|
+
cancel_key = f"{harness_task_id}:{source_task_id}:cancel"
|
|
5886
|
+
cancel_action = {
|
|
5887
|
+
"kind": "task",
|
|
5888
|
+
"source_task_id": source_task_id,
|
|
5889
|
+
"status": "cancelled",
|
|
5890
|
+
"summary": reason,
|
|
5891
|
+
"evidence": [],
|
|
5892
|
+
"idempotency_key": cancel_key,
|
|
5893
|
+
"agent": agent,
|
|
5894
|
+
}
|
|
5895
|
+
_execute_spec_writeback(
|
|
5896
|
+
root,
|
|
5897
|
+
harness_task_id,
|
|
5898
|
+
task,
|
|
5899
|
+
cancel_action,
|
|
5900
|
+
cancel_key,
|
|
5901
|
+
lambda design_digest, execution_revision, source_task_id=source_task_id: record_task_status(
|
|
5902
|
+
stored_spec_path(root, task),
|
|
5903
|
+
str(source_task_id),
|
|
5904
|
+
"cancelled",
|
|
5905
|
+
reason,
|
|
5906
|
+
SPEC_WRITEBACK_APP,
|
|
5907
|
+
spec_writeback_agent(agent),
|
|
5908
|
+
design_digest,
|
|
5909
|
+
execution_revision,
|
|
5910
|
+
run_id=harness_task_id,
|
|
5911
|
+
idempotency_key=cancel_key,
|
|
5912
|
+
),
|
|
5913
|
+
)
|
|
5914
|
+
|
|
5915
|
+
|
|
5916
|
+
def satisfy_spec_dependency(
|
|
5917
|
+
root: Path,
|
|
5918
|
+
dependency_task_id: str,
|
|
5919
|
+
evidence: str,
|
|
5920
|
+
agent: str,
|
|
5921
|
+
source_task_id: str | None = None,
|
|
5922
|
+
task_id: str | None = None,
|
|
5923
|
+
session_file: str | Path | None = None,
|
|
5924
|
+
) -> dict:
|
|
5925
|
+
session, resolved_task_id, task = resolve_current_task(root, task_id, session_file)
|
|
5926
|
+
if task.get("status") in TERMINAL_STATUSES or task.get("status") == "MEMORY":
|
|
5927
|
+
raise StateError("Spec dependency evidence cannot change after MEMORY begins.")
|
|
5928
|
+
if not is_non_empty_string(evidence):
|
|
5929
|
+
raise StateError("Spec dependency evidence must be non-empty.")
|
|
5930
|
+
inspect_task_spec(root, task)
|
|
5931
|
+
records = task.get("spec_dependency_evidence")
|
|
5932
|
+
if not isinstance(records, list):
|
|
5933
|
+
raise StateError("Current task is not backed by Canonical Spec dependency metadata.")
|
|
5934
|
+
matches = [
|
|
5935
|
+
record
|
|
5936
|
+
for record in records
|
|
5937
|
+
if isinstance(record, dict)
|
|
5938
|
+
and record.get("task_id") == dependency_task_id
|
|
5939
|
+
and (source_task_id is None or record.get("source_task_id") == source_task_id)
|
|
5940
|
+
]
|
|
5941
|
+
if not matches:
|
|
5942
|
+
raise StateError("Canonical Spec dependency edge was not found.")
|
|
5943
|
+
if source_task_id is None and len(matches) > 1:
|
|
5944
|
+
raise StateError(
|
|
5945
|
+
"Canonical Spec dependency is ambiguous; pass --source-task to identify the edge."
|
|
5946
|
+
)
|
|
5947
|
+
record = matches[0]
|
|
5948
|
+
if record.get("dependency_type") == "contract":
|
|
5949
|
+
raise StateError("Contract dependencies are satisfied by the frozen READY Spec.")
|
|
5950
|
+
record["status"] = "satisfied"
|
|
5951
|
+
record["evidence"] = evidence.strip()
|
|
5952
|
+
record["satisfied_at"] = now_iso()
|
|
5953
|
+
record["satisfied_by"] = agent
|
|
5954
|
+
task["last_agent"] = agent
|
|
5955
|
+
evidence_digest = hashlib.sha256(evidence.strip().encode("utf-8")).hexdigest()[:16]
|
|
5956
|
+
idempotency_key = (
|
|
5957
|
+
f"{resolved_task_id}:{record.get('source_task_id')}:{dependency_task_id}:"
|
|
5958
|
+
f"dependency-satisfied:revision-{task['spec_source']['revision']}:{evidence_digest}"
|
|
5959
|
+
)
|
|
5960
|
+
action = {
|
|
5961
|
+
"kind": "dependency",
|
|
5962
|
+
"source_task_id": str(record.get("source_task_id")),
|
|
5963
|
+
"dependency_task_id": dependency_task_id,
|
|
5964
|
+
"status": "satisfied",
|
|
5965
|
+
"summary": evidence.strip(),
|
|
5966
|
+
"evidence": [{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
|
|
5967
|
+
"idempotency_key": idempotency_key,
|
|
5968
|
+
"agent": agent,
|
|
5969
|
+
}
|
|
5970
|
+
_execute_spec_writeback(
|
|
5971
|
+
root,
|
|
5972
|
+
resolved_task_id,
|
|
5973
|
+
task,
|
|
5974
|
+
action,
|
|
5975
|
+
idempotency_key,
|
|
5976
|
+
lambda design_digest, execution_revision: record_dependency_status(
|
|
5977
|
+
stored_spec_path(root, task),
|
|
5978
|
+
str(record.get("source_task_id")),
|
|
5979
|
+
dependency_task_id,
|
|
5980
|
+
"satisfied",
|
|
5981
|
+
evidence.strip(),
|
|
5982
|
+
SPEC_WRITEBACK_APP,
|
|
5983
|
+
spec_writeback_agent(agent),
|
|
5984
|
+
design_digest,
|
|
5985
|
+
execution_revision,
|
|
5986
|
+
evidence=[{"kind": "dependency", "status": "passed", "ref": evidence.strip()}],
|
|
5987
|
+
run_id=resolved_task_id,
|
|
5988
|
+
idempotency_key=idempotency_key,
|
|
5989
|
+
),
|
|
5990
|
+
)
|
|
5991
|
+
snapshot = snapshot_state(root, session_file, session)
|
|
5992
|
+
snapshot["action"] = "satisfy-spec-dependency"
|
|
5993
|
+
return snapshot
|
|
5994
|
+
|
|
5995
|
+
|
|
5996
|
+
def append_stage_history(task: dict, stage: str, agent: str) -> None:
|
|
5997
|
+
history = task.setdefault("stage_history", [])
|
|
5998
|
+
history.append({"stage": stage, "agent": agent, "entered_at": now_iso()})
|
|
5999
|
+
|
|
6000
|
+
|
|
6001
|
+
def resolve_current_task(
|
|
6002
|
+
root: Path,
|
|
6003
|
+
task_id: str | None = None,
|
|
6004
|
+
session_file: str | Path | None = None,
|
|
6005
|
+
) -> tuple[dict, str, dict]:
|
|
6006
|
+
session = ensure_session(root, session_file)
|
|
6007
|
+
resolved_task_id = task_id or session.get("current_task")
|
|
6008
|
+
if not resolved_task_id:
|
|
6009
|
+
raise StateError("No current task is set.")
|
|
6010
|
+
task = load_task(root, str(resolved_task_id))
|
|
6011
|
+
if task is None:
|
|
6012
|
+
raise StateError(f"Task not found: {resolved_task_id}")
|
|
6013
|
+
return session, str(resolved_task_id), task
|
|
6014
|
+
|
|
6015
|
+
|
|
6016
|
+
def validate_workflow_mode_proposal(
|
|
6017
|
+
root: Path,
|
|
6018
|
+
session: dict,
|
|
4371
6019
|
proposal: object,
|
|
4372
6020
|
task_id: str | None = None,
|
|
4373
6021
|
) -> dict:
|
|
@@ -4680,14 +6328,27 @@ def apply_transition(
|
|
|
4680
6328
|
if task.get("workflow_mode_legacy") is not True:
|
|
4681
6329
|
freeze_workflow_mode(root, session, resolved_task_id, task, agent)
|
|
4682
6330
|
freeze_tdd_mode(root, session, resolved_task_id, task, agent)
|
|
6331
|
+
if stage == "IMPLEMENT" and previous != "IMPLEMENT":
|
|
6332
|
+
if isinstance(task.get("spec_source"), dict):
|
|
6333
|
+
writeback_ready_tasks_for_implement(
|
|
6334
|
+
root,
|
|
6335
|
+
resolved_task_id,
|
|
6336
|
+
task,
|
|
6337
|
+
agent,
|
|
6338
|
+
{"blocked"} if previous in {"REVIEW", "VERIFICATION"} else None,
|
|
6339
|
+
)
|
|
4683
6340
|
if previous == "REVIEW" and stage == "VERIFICATION":
|
|
4684
6341
|
validate_review_readiness(root, resolved_task_id, task)
|
|
4685
6342
|
if previous == "VERIFICATION" and stage == "MEMORY":
|
|
4686
6343
|
validate_verification_readiness(root, resolved_task_id, task)
|
|
6344
|
+
if isinstance(task.get("spec_source"), dict):
|
|
6345
|
+
require_shared_task_statuses(root, task, {"verified", "completed"})
|
|
4687
6346
|
if previous == "MEMORY" and stage == "COMPLETE":
|
|
4688
6347
|
progress = task.get("memory_progress")
|
|
4689
6348
|
if not isinstance(progress, dict) or progress.get("completed") is not True:
|
|
4690
6349
|
raise StateError("MEMORY cannot advance to COMPLETE before memory processing completes.")
|
|
6350
|
+
if isinstance(task.get("spec_source"), dict):
|
|
6351
|
+
writeback_completed_tasks(root, resolved_task_id, task, agent)
|
|
4691
6352
|
if (previous, stage) == READ_ONLY_COMPLETION_TRANSITION:
|
|
4692
6353
|
validate_read_only_completion(root, resolved_task_id)
|
|
4693
6354
|
if previous != stage:
|
|
@@ -4914,6 +6575,7 @@ def memory_complete(
|
|
|
4914
6575
|
action == "distill" and instruction.get("checkpoint_disposition") == "candidate"
|
|
4915
6576
|
),
|
|
4916
6577
|
)
|
|
6578
|
+
validate_recorded_architecture_assessment(root, progress, instruction)
|
|
4917
6579
|
if action == "distill":
|
|
4918
6580
|
validate_distillation_file_sets(root, instruction)
|
|
4919
6581
|
progress["long_memory_action"] = action
|
|
@@ -4941,6 +6603,8 @@ def close_current_task(
|
|
|
4941
6603
|
task = load_task(root, str(task_id))
|
|
4942
6604
|
if task is None:
|
|
4943
6605
|
raise StateError(f"Task not found: {task_id}")
|
|
6606
|
+
if isinstance(task.get("spec_source"), dict) and task.get("status") not in TERMINAL_STATUSES:
|
|
6607
|
+
cancel_shared_tasks(root, str(task_id), task, reason, agent)
|
|
4944
6608
|
if task.get("status") != "CLOSED":
|
|
4945
6609
|
task["status"] = "CLOSED"
|
|
4946
6610
|
append_stage_history(task, "CLOSED", agent)
|
|
@@ -5042,6 +6706,19 @@ def parse_mapping_args(values: list[str], label: str) -> dict[str, str]:
|
|
|
5042
6706
|
return mappings
|
|
5043
6707
|
|
|
5044
6708
|
|
|
6709
|
+
def parse_evidence_args(values: list[str]) -> list[dict]:
|
|
6710
|
+
evidence: list[dict] = []
|
|
6711
|
+
for value in values:
|
|
6712
|
+
try:
|
|
6713
|
+
parsed = json.loads(value)
|
|
6714
|
+
except json.JSONDecodeError as exc:
|
|
6715
|
+
raise StateError(f"--evidence must be a JSON object: {exc}") from exc
|
|
6716
|
+
if not isinstance(parsed, dict):
|
|
6717
|
+
raise StateError("--evidence must be a JSON object.")
|
|
6718
|
+
evidence.append(parsed)
|
|
6719
|
+
return evidence
|
|
6720
|
+
|
|
6721
|
+
|
|
5045
6722
|
def main() -> int:
|
|
5046
6723
|
configure_stdio()
|
|
5047
6724
|
common = argparse.ArgumentParser(add_help=False)
|
|
@@ -5059,6 +6736,9 @@ def main() -> int:
|
|
|
5059
6736
|
inspect_spec_parser.add_argument("--spec", required=True)
|
|
5060
6737
|
inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
|
|
5061
6738
|
|
|
6739
|
+
initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
|
|
6740
|
+
initialize_spec.add_argument("--spec", required=True)
|
|
6741
|
+
|
|
5062
6742
|
select_spec_scope = subcommands.add_parser("select-dev-spec-scope", parents=[common])
|
|
5063
6743
|
select_spec_scope.add_argument("--spec", required=True)
|
|
5064
6744
|
select_spec_scope.add_argument("--spec-task", required=True, action="append")
|
|
@@ -5081,6 +6761,66 @@ def main() -> int:
|
|
|
5081
6761
|
create_from_spec.add_argument("--agent", required=True)
|
|
5082
6762
|
create_from_spec.add_argument("--no-set-current", action="store_true")
|
|
5083
6763
|
|
|
6764
|
+
rebind_spec = subcommands.add_parser("rebind-spec-source", parents=[common])
|
|
6765
|
+
rebind_spec.add_argument("--spec", required=True)
|
|
6766
|
+
rebind_spec.add_argument("--agent", required=True)
|
|
6767
|
+
rebind_spec.add_argument("--task-id")
|
|
6768
|
+
|
|
6769
|
+
writeback_task = subcommands.add_parser("writeback-spec-task", parents=[common])
|
|
6770
|
+
writeback_task.add_argument("--spec-task", required=True)
|
|
6771
|
+
writeback_task.add_argument(
|
|
6772
|
+
"--status",
|
|
6773
|
+
required=True,
|
|
6774
|
+
choices=[
|
|
6775
|
+
"in_progress",
|
|
6776
|
+
"blocked",
|
|
6777
|
+
"implemented",
|
|
6778
|
+
"verified",
|
|
6779
|
+
"completed",
|
|
6780
|
+
"cancelled",
|
|
6781
|
+
],
|
|
6782
|
+
)
|
|
6783
|
+
writeback_task.add_argument("--summary", required=True)
|
|
6784
|
+
writeback_task.add_argument("--evidence", action="append", default=[])
|
|
6785
|
+
writeback_task.add_argument("--idempotency-key", required=True)
|
|
6786
|
+
writeback_task.add_argument("--agent", required=True)
|
|
6787
|
+
writeback_task.add_argument("--task-id")
|
|
6788
|
+
|
|
6789
|
+
writeback_step = subcommands.add_parser("writeback-spec-step", parents=[common])
|
|
6790
|
+
writeback_step.add_argument("--spec-task", required=True)
|
|
6791
|
+
writeback_step.add_argument("--step", required=True)
|
|
6792
|
+
writeback_step.add_argument("--status", required=True, choices=["completed", "failed"])
|
|
6793
|
+
writeback_step.add_argument("--summary", required=True)
|
|
6794
|
+
writeback_step.add_argument("--evidence", action="append", default=[])
|
|
6795
|
+
writeback_step.add_argument("--idempotency-key", required=True)
|
|
6796
|
+
writeback_step.add_argument("--agent", required=True)
|
|
6797
|
+
writeback_step.add_argument("--task-id")
|
|
6798
|
+
|
|
6799
|
+
writeback_dependency = subcommands.add_parser(
|
|
6800
|
+
"writeback-spec-dependency", parents=[common]
|
|
6801
|
+
)
|
|
6802
|
+
writeback_dependency.add_argument("--source-task", required=True)
|
|
6803
|
+
writeback_dependency.add_argument("--dependency-task", required=True)
|
|
6804
|
+
writeback_dependency.add_argument(
|
|
6805
|
+
"--status", required=True, choices=["pending", "satisfied"]
|
|
6806
|
+
)
|
|
6807
|
+
writeback_dependency.add_argument("--summary", required=True)
|
|
6808
|
+
writeback_dependency.add_argument("--evidence", action="append", default=[])
|
|
6809
|
+
writeback_dependency.add_argument("--idempotency-key", required=True)
|
|
6810
|
+
writeback_dependency.add_argument("--agent", required=True)
|
|
6811
|
+
writeback_dependency.add_argument("--task-id")
|
|
6812
|
+
|
|
6813
|
+
sync_spec = subcommands.add_parser("sync-spec-design", parents=[common])
|
|
6814
|
+
sync_spec.add_argument("--affected-task", action="append", default=[])
|
|
6815
|
+
sync_spec.add_argument("--summary", required=True)
|
|
6816
|
+
sync_spec.add_argument("--idempotency-key", required=True)
|
|
6817
|
+
sync_spec.add_argument("--agent", required=True)
|
|
6818
|
+
sync_spec.add_argument("--task-id")
|
|
6819
|
+
|
|
6820
|
+
reconcile_spec = subcommands.add_parser("reconcile-spec-execution", parents=[common])
|
|
6821
|
+
reconcile_spec.add_argument("--agent", required=True)
|
|
6822
|
+
reconcile_spec.add_argument("--task-id")
|
|
6823
|
+
|
|
5084
6824
|
set_current = subcommands.add_parser("set-current", parents=[common])
|
|
5085
6825
|
set_current.add_argument("--task-id", required=True)
|
|
5086
6826
|
set_current.add_argument("--agent", required=True)
|
|
@@ -5210,6 +6950,18 @@ def main() -> int:
|
|
|
5210
6950
|
memory_instruction_parser.add_argument("--agent")
|
|
5211
6951
|
memory_instruction_parser.add_argument("--task-id")
|
|
5212
6952
|
|
|
6953
|
+
memory_architecture_parser = subcommands.add_parser(
|
|
6954
|
+
"memory-architecture-assessment", parents=[common]
|
|
6955
|
+
)
|
|
6956
|
+
memory_architecture_parser.add_argument(
|
|
6957
|
+
"--action", required=True, choices=sorted(ARCHITECTURE_ACTIONS)
|
|
6958
|
+
)
|
|
6959
|
+
memory_architecture_parser.add_argument("--reason", required=True)
|
|
6960
|
+
memory_architecture_parser.add_argument("--evidence", action="append", default=[])
|
|
6961
|
+
memory_architecture_parser.add_argument("--affected-section", action="append", default=[])
|
|
6962
|
+
memory_architecture_parser.add_argument("--agent", required=True)
|
|
6963
|
+
memory_architecture_parser.add_argument("--task-id")
|
|
6964
|
+
|
|
5213
6965
|
memory_complete_parser = subcommands.add_parser("memory-complete", parents=[common])
|
|
5214
6966
|
memory_complete_parser.add_argument("--action", required=True, choices=["no-op", "distill"])
|
|
5215
6967
|
memory_complete_parser.add_argument("--agent", required=True)
|
|
@@ -5251,6 +7003,7 @@ def main() -> int:
|
|
|
5251
7003
|
)
|
|
5252
7004
|
if session_file is None and command not in {
|
|
5253
7005
|
"inspect-dev-spec",
|
|
7006
|
+
"initialize-spec-execution",
|
|
5254
7007
|
"select-dev-spec-scope",
|
|
5255
7008
|
"list-tasks",
|
|
5256
7009
|
"memory-new-id",
|
|
@@ -5263,7 +7016,7 @@ def main() -> int:
|
|
|
5263
7016
|
if command == "snapshot":
|
|
5264
7017
|
emit(snapshot_state(root, session_file))
|
|
5265
7018
|
elif command == "inspect-dev-spec":
|
|
5266
|
-
spec_path = Path(args.spec)
|
|
7019
|
+
spec_path = Path(args.spec).expanduser()
|
|
5267
7020
|
emit(
|
|
5268
7021
|
inspection_summary(
|
|
5269
7022
|
inspect_spec(
|
|
@@ -5273,8 +7026,10 @@ def main() -> int:
|
|
|
5273
7026
|
)
|
|
5274
7027
|
)
|
|
5275
7028
|
)
|
|
7029
|
+
elif command == "initialize-spec-execution":
|
|
7030
|
+
emit(initialize_spec_execution_state(root, args.spec))
|
|
5276
7031
|
elif command == "select-dev-spec-scope":
|
|
5277
|
-
spec_path = Path(args.spec)
|
|
7032
|
+
spec_path = Path(args.spec).expanduser()
|
|
5278
7033
|
emit(
|
|
5279
7034
|
select_consumption_scopes(
|
|
5280
7035
|
spec_path if spec_path.is_absolute() else root / spec_path,
|
|
@@ -5325,6 +7080,100 @@ def main() -> int:
|
|
|
5325
7080
|
session_file,
|
|
5326
7081
|
)
|
|
5327
7082
|
)
|
|
7083
|
+
elif command == "rebind-spec-source":
|
|
7084
|
+
emit(
|
|
7085
|
+
attach_status_context(
|
|
7086
|
+
root,
|
|
7087
|
+
rebind_spec_source(root, args.spec, agent, args.task_id, session_file),
|
|
7088
|
+
agent,
|
|
7089
|
+
session_file,
|
|
7090
|
+
)
|
|
7091
|
+
)
|
|
7092
|
+
elif command == "writeback-spec-task":
|
|
7093
|
+
emit(
|
|
7094
|
+
attach_status_context(
|
|
7095
|
+
root,
|
|
7096
|
+
writeback_spec_task(
|
|
7097
|
+
root,
|
|
7098
|
+
args.spec_task,
|
|
7099
|
+
args.status,
|
|
7100
|
+
args.summary,
|
|
7101
|
+
parse_evidence_args(args.evidence),
|
|
7102
|
+
args.idempotency_key,
|
|
7103
|
+
agent,
|
|
7104
|
+
args.task_id,
|
|
7105
|
+
session_file,
|
|
7106
|
+
),
|
|
7107
|
+
agent,
|
|
7108
|
+
session_file,
|
|
7109
|
+
)
|
|
7110
|
+
)
|
|
7111
|
+
elif command == "writeback-spec-step":
|
|
7112
|
+
emit(
|
|
7113
|
+
attach_status_context(
|
|
7114
|
+
root,
|
|
7115
|
+
writeback_spec_step(
|
|
7116
|
+
root,
|
|
7117
|
+
args.spec_task,
|
|
7118
|
+
args.step,
|
|
7119
|
+
args.status,
|
|
7120
|
+
args.summary,
|
|
7121
|
+
parse_evidence_args(args.evidence),
|
|
7122
|
+
args.idempotency_key,
|
|
7123
|
+
agent,
|
|
7124
|
+
args.task_id,
|
|
7125
|
+
session_file,
|
|
7126
|
+
),
|
|
7127
|
+
agent,
|
|
7128
|
+
session_file,
|
|
7129
|
+
)
|
|
7130
|
+
)
|
|
7131
|
+
elif command == "writeback-spec-dependency":
|
|
7132
|
+
emit(
|
|
7133
|
+
attach_status_context(
|
|
7134
|
+
root,
|
|
7135
|
+
writeback_spec_dependency(
|
|
7136
|
+
root,
|
|
7137
|
+
args.source_task,
|
|
7138
|
+
args.dependency_task,
|
|
7139
|
+
args.status,
|
|
7140
|
+
args.summary,
|
|
7141
|
+
parse_evidence_args(args.evidence),
|
|
7142
|
+
args.idempotency_key,
|
|
7143
|
+
agent,
|
|
7144
|
+
args.task_id,
|
|
7145
|
+
session_file,
|
|
7146
|
+
),
|
|
7147
|
+
agent,
|
|
7148
|
+
session_file,
|
|
7149
|
+
)
|
|
7150
|
+
)
|
|
7151
|
+
elif command == "sync-spec-design":
|
|
7152
|
+
emit(
|
|
7153
|
+
attach_status_context(
|
|
7154
|
+
root,
|
|
7155
|
+
sync_spec_design_state(
|
|
7156
|
+
root,
|
|
7157
|
+
args.affected_task,
|
|
7158
|
+
args.summary,
|
|
7159
|
+
args.idempotency_key,
|
|
7160
|
+
agent,
|
|
7161
|
+
args.task_id,
|
|
7162
|
+
session_file,
|
|
7163
|
+
),
|
|
7164
|
+
agent,
|
|
7165
|
+
session_file,
|
|
7166
|
+
)
|
|
7167
|
+
)
|
|
7168
|
+
elif command == "reconcile-spec-execution":
|
|
7169
|
+
emit(
|
|
7170
|
+
attach_status_context(
|
|
7171
|
+
root,
|
|
7172
|
+
reconcile_spec_execution(root, agent, args.task_id, session_file),
|
|
7173
|
+
agent,
|
|
7174
|
+
session_file,
|
|
7175
|
+
)
|
|
7176
|
+
)
|
|
5328
7177
|
elif command == "set-current":
|
|
5329
7178
|
emit(
|
|
5330
7179
|
attach_status_context(
|
|
@@ -5591,6 +7440,24 @@ def main() -> int:
|
|
|
5591
7440
|
session_file,
|
|
5592
7441
|
)
|
|
5593
7442
|
)
|
|
7443
|
+
elif command == "memory-architecture-assessment":
|
|
7444
|
+
emit(
|
|
7445
|
+
attach_status_context(
|
|
7446
|
+
root,
|
|
7447
|
+
record_architecture_assessment(
|
|
7448
|
+
root,
|
|
7449
|
+
args.action,
|
|
7450
|
+
args.reason,
|
|
7451
|
+
args.evidence,
|
|
7452
|
+
args.affected_section,
|
|
7453
|
+
agent,
|
|
7454
|
+
args.task_id,
|
|
7455
|
+
session_file,
|
|
7456
|
+
),
|
|
7457
|
+
agent,
|
|
7458
|
+
session_file,
|
|
7459
|
+
)
|
|
7460
|
+
)
|
|
5594
7461
|
elif command == "memory-complete":
|
|
5595
7462
|
emit(
|
|
5596
7463
|
attach_status_context(
|