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