easy-coding-harness 0.10.0-beta.4 → 0.10.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +3 -1
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +15 -7
- package/templates/common/skills/ec-analysis/SKILL.md +14 -4
- package/templates/common/skills/ec-git/SKILL.md +7 -1
- package/templates/common/skills/ec-implementing/SKILL.md +16 -1
- package/templates/common/skills/ec-memory/SKILL.md +65 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +6 -0
- package/templates/common/skills/ec-task-close/SKILL.md +4 -0
- package/templates/common/skills/ec-task-management/SKILL.md +7 -1
- package/templates/common/skills/ec-verification/SKILL.md +11 -1
- package/templates/common/skills/ec-workflow/SKILL.md +16 -2
- package/templates/main-constraint/AGENTS.md.tpl +7 -0
- package/templates/main-constraint/CLAUDE.md.tpl +7 -0
- package/templates/runtime/templates/dev-spec-skeleton.md +3 -1
- package/templates/shared-hooks/easy_coding_state.py +1963 -96
- package/templates/shared-hooks/easy_dev_spec.py +87 -12
- package/templates/shared-hooks/easy_dev_spec_execution.py +1014 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1426 -18
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared execution-state writer for a single Canonical Dev Spec.
|
|
3
|
+
|
|
4
|
+
The design portion of a Spec remains human-editable. This module owns only the
|
|
5
|
+
strict JSON execution block at the end of the same Markdown file and applies
|
|
6
|
+
optimistic concurrency plus a short-lived adjacent lock while rewriting it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable, Iterator
|
|
21
|
+
|
|
22
|
+
from easy_dev_spec_protocol import (
|
|
23
|
+
EXECUTION_BEGIN,
|
|
24
|
+
EXECUTION_END,
|
|
25
|
+
EXECUTION_SCHEMA,
|
|
26
|
+
CanonicalSpecError,
|
|
27
|
+
design_sha256,
|
|
28
|
+
parse_manifest,
|
|
29
|
+
parse_sections,
|
|
30
|
+
split_execution_region,
|
|
31
|
+
validate_model,
|
|
32
|
+
validate_spec,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExecutionStateError(ValueError):
|
|
37
|
+
"""The execution ledger cannot be safely read or changed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutionConflictError(ExecutionStateError):
|
|
41
|
+
"""The caller's expected design or execution revision is stale."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
TASK_TRANSITIONS = {
|
|
45
|
+
"not_started": {"in_progress", "cancelled"},
|
|
46
|
+
"in_progress": {"in_progress", "blocked", "implemented", "cancelled"},
|
|
47
|
+
"blocked": {"blocked", "in_progress", "cancelled"},
|
|
48
|
+
"implemented": {"in_progress", "blocked", "verified"},
|
|
49
|
+
"verified": {"in_progress", "blocked", "completed"},
|
|
50
|
+
"completed": {"in_progress"},
|
|
51
|
+
"cancelled": {"in_progress"},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now_iso() -> str:
|
|
56
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _event_id() -> str:
|
|
60
|
+
return f"EV-{uuid.uuid4()}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _manifest_tasks(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
64
|
+
return {
|
|
65
|
+
str(task["task_id"]): task
|
|
66
|
+
for task in manifest.get("tasks", [])
|
|
67
|
+
if isinstance(task, dict) and isinstance(task.get("task_id"), str)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _new_snapshot(task: dict[str, Any], design_ready: bool) -> dict[str, Any]:
|
|
72
|
+
dependencies = []
|
|
73
|
+
for dependency in task.get("depends_on", []):
|
|
74
|
+
dependency_type = str(dependency["type"])
|
|
75
|
+
dependencies.append(
|
|
76
|
+
{
|
|
77
|
+
"task_id": str(dependency["task_id"]),
|
|
78
|
+
"type": dependency_type,
|
|
79
|
+
"status": (
|
|
80
|
+
"satisfied"
|
|
81
|
+
if dependency_type == "contract" and design_ready
|
|
82
|
+
else "pending"
|
|
83
|
+
),
|
|
84
|
+
"evidence_event_id": None,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return {
|
|
88
|
+
"task_id": str(task["task_id"]),
|
|
89
|
+
"status": "not_started",
|
|
90
|
+
"completed_step_ids": [],
|
|
91
|
+
"failed_step_ids": [],
|
|
92
|
+
"dependencies": dependencies,
|
|
93
|
+
"blockers": [],
|
|
94
|
+
"evidence": [],
|
|
95
|
+
"last_event_id": None,
|
|
96
|
+
"updated_at": None,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_initial_execution(manifest: dict[str, Any], current_design_sha256: str) -> dict[str, Any]:
|
|
101
|
+
"""Create the revision-zero execution ledger for a validated design."""
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"schema": EXECUTION_SCHEMA,
|
|
105
|
+
"spec_id": manifest["spec_id"],
|
|
106
|
+
"design_revision": manifest["revision"],
|
|
107
|
+
"design_sha256": current_design_sha256,
|
|
108
|
+
"execution_revision": 0,
|
|
109
|
+
"updated_at": None,
|
|
110
|
+
"tasks": [
|
|
111
|
+
_new_snapshot(task, manifest.get("status") == "READY")
|
|
112
|
+
for task in manifest["tasks"]
|
|
113
|
+
],
|
|
114
|
+
"events": [],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render_execution_block(execution: dict[str, Any]) -> str:
|
|
119
|
+
payload = json.dumps(execution, ensure_ascii=False, indent=2)
|
|
120
|
+
if EXECUTION_BEGIN in payload or EXECUTION_END in payload:
|
|
121
|
+
raise ExecutionStateError("execution 数据不得包含保留的 EDS execution 边界标记")
|
|
122
|
+
return f"{EXECUTION_BEGIN}\n```json\n{payload}\n```\n{EXECUTION_END}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_document(design_text: str, execution: dict[str, Any]) -> str:
|
|
126
|
+
return design_text.rstrip() + "\n\n" + render_execution_block(execution) + "\n"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load(path: Path) -> tuple[str, str, dict[str, Any], dict[str, Any] | None]:
|
|
130
|
+
if not path.is_file():
|
|
131
|
+
raise ExecutionStateError(f"Spec 文件不存在:{path}")
|
|
132
|
+
try:
|
|
133
|
+
text = path.read_text(encoding="utf-8")
|
|
134
|
+
design_text, execution = split_execution_region(text)
|
|
135
|
+
manifest = parse_manifest(design_text)
|
|
136
|
+
except (OSError, UnicodeError, CanonicalSpecError) as exc:
|
|
137
|
+
raise ExecutionStateError(str(exc)) from exc
|
|
138
|
+
if manifest is None:
|
|
139
|
+
raise ExecutionStateError("legacy Dev Spec 不支持共享执行状态")
|
|
140
|
+
return text, design_text, manifest, execution
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_static_design(design_text: str, manifest: dict[str, Any]) -> None:
|
|
144
|
+
try:
|
|
145
|
+
sections = parse_sections(design_text)
|
|
146
|
+
except CanonicalSpecError as exc:
|
|
147
|
+
raise ExecutionStateError(str(exc)) from exc
|
|
148
|
+
report = validate_model(
|
|
149
|
+
manifest,
|
|
150
|
+
sections,
|
|
151
|
+
text=design_text,
|
|
152
|
+
require_ready=manifest.get("status") == "READY",
|
|
153
|
+
)
|
|
154
|
+
if not report.ok:
|
|
155
|
+
details = ";".join(issue.message for issue in report.issues[:8])
|
|
156
|
+
raise ExecutionStateError(f"静态设计校验失败:{details}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _assert_expected(
|
|
160
|
+
execution: dict[str, Any],
|
|
161
|
+
expected_design_sha256: str,
|
|
162
|
+
expected_execution_revision: int,
|
|
163
|
+
) -> None:
|
|
164
|
+
if not isinstance(expected_design_sha256, str) or not expected_design_sha256.strip():
|
|
165
|
+
raise ExecutionStateError("expected_design_sha256 必须是非空字符串")
|
|
166
|
+
if not isinstance(expected_execution_revision, int) or isinstance(
|
|
167
|
+
expected_execution_revision, bool
|
|
168
|
+
):
|
|
169
|
+
raise ExecutionStateError("expected_execution_revision 必须是整数")
|
|
170
|
+
if execution.get("design_sha256") != expected_design_sha256:
|
|
171
|
+
raise ExecutionConflictError("设计指纹已变化,请重新读取 Spec 后再写入")
|
|
172
|
+
if execution.get("execution_revision") != expected_execution_revision:
|
|
173
|
+
raise ExecutionConflictError("执行修订号已变化,请重新读取 Spec 后重放本次更新")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _assert_current_design(design_text: str, execution: dict[str, Any]) -> None:
|
|
177
|
+
if execution.get("design_sha256") != design_sha256(design_text):
|
|
178
|
+
raise ExecutionConflictError("静态设计已经编辑但尚未 sync-design,不能继续写入进度")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_existing_execution(path: Path) -> None:
|
|
182
|
+
report = validate_spec(path, require_execution=True)
|
|
183
|
+
if not report.ok:
|
|
184
|
+
details = ";".join(issue.message for issue in report.issues[:8])
|
|
185
|
+
raise ExecutionStateError(f"现有执行状态非法:{details}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_execution_envelope(manifest: dict[str, Any], execution: dict[str, Any]) -> None:
|
|
189
|
+
events = execution.get("events")
|
|
190
|
+
tasks = execution.get("tasks")
|
|
191
|
+
execution_revision = execution.get("execution_revision")
|
|
192
|
+
design_revision = execution.get("design_revision")
|
|
193
|
+
if execution.get("schema") != EXECUTION_SCHEMA:
|
|
194
|
+
raise ExecutionStateError("现有 execution.schema 非法")
|
|
195
|
+
if execution.get("spec_id") != manifest.get("spec_id"):
|
|
196
|
+
raise ExecutionStateError("现有 execution.spec_id 与静态设计不一致")
|
|
197
|
+
if not isinstance(events, list) or not isinstance(tasks, list):
|
|
198
|
+
raise ExecutionStateError("现有 execution.events/tasks 必须是数组")
|
|
199
|
+
if (
|
|
200
|
+
not isinstance(execution_revision, int)
|
|
201
|
+
or isinstance(execution_revision, bool)
|
|
202
|
+
or execution_revision != len(events)
|
|
203
|
+
):
|
|
204
|
+
raise ExecutionStateError("现有 execution_revision 与事件数量不一致")
|
|
205
|
+
if not isinstance(design_revision, int) or isinstance(design_revision, bool):
|
|
206
|
+
raise ExecutionStateError("现有 execution.design_revision 非法")
|
|
207
|
+
if not isinstance(execution.get("design_sha256"), str):
|
|
208
|
+
raise ExecutionStateError("现有 execution.design_sha256 非法")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalize_evidence(evidence: Iterable[dict[str, Any]] | None) -> list[dict[str, str]]:
|
|
212
|
+
normalized: list[dict[str, str]] = []
|
|
213
|
+
for index, value in enumerate(evidence or []):
|
|
214
|
+
if not isinstance(value, dict):
|
|
215
|
+
raise ExecutionStateError(f"evidence[{index}] 必须是 object")
|
|
216
|
+
kind = value.get("kind")
|
|
217
|
+
status_value = value.get("status")
|
|
218
|
+
reference = value.get("ref")
|
|
219
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
220
|
+
raise ExecutionStateError(f"evidence[{index}].kind 必须是非空字符串")
|
|
221
|
+
if status_value not in {"passed", "failed", "recorded"}:
|
|
222
|
+
raise ExecutionStateError(
|
|
223
|
+
f"evidence[{index}].status 必须是 passed、failed 或 recorded"
|
|
224
|
+
)
|
|
225
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
226
|
+
raise ExecutionStateError(f"evidence[{index}].ref 必须是非空字符串")
|
|
227
|
+
item = {"kind": kind.strip(), "status": str(status_value), "ref": reference.strip()}
|
|
228
|
+
for optional_field in ("test_id", "sha256"):
|
|
229
|
+
optional_value = value.get(optional_field)
|
|
230
|
+
if optional_value is not None:
|
|
231
|
+
if not isinstance(optional_value, str) or not optional_value.strip():
|
|
232
|
+
raise ExecutionStateError(
|
|
233
|
+
f"evidence[{index}].{optional_field} 必须是非空字符串"
|
|
234
|
+
)
|
|
235
|
+
item[optional_field] = optional_value.strip()
|
|
236
|
+
if item["kind"] == "test" and "test_id" not in item:
|
|
237
|
+
raise ExecutionStateError(f"evidence[{index}] 的 test 证据必须声明 test_id")
|
|
238
|
+
normalized.append(item)
|
|
239
|
+
return normalized
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _required_text(value: Any, label: str) -> str:
|
|
243
|
+
if not isinstance(value, str) or not value.strip():
|
|
244
|
+
raise ExecutionStateError(f"{label} 必须是非空字符串")
|
|
245
|
+
return value.strip()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _optional_text(value: Any, label: str) -> str | None:
|
|
249
|
+
if value is None:
|
|
250
|
+
return None
|
|
251
|
+
return _required_text(value, label)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _passed_test_ids(evidence: Iterable[dict[str, Any]]) -> set[str]:
|
|
255
|
+
return {
|
|
256
|
+
str(value["test_id"])
|
|
257
|
+
for value in evidence
|
|
258
|
+
if isinstance(value, dict)
|
|
259
|
+
and value.get("kind") == "test"
|
|
260
|
+
and value.get("status") == "passed"
|
|
261
|
+
and isinstance(value.get("test_id"), str)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _new_event(
|
|
266
|
+
event_type: str,
|
|
267
|
+
app: str,
|
|
268
|
+
agent: str,
|
|
269
|
+
summary: str,
|
|
270
|
+
evidence: Iterable[dict[str, Any]] | None,
|
|
271
|
+
*,
|
|
272
|
+
task_id: str | None = None,
|
|
273
|
+
task_ids: list[str] | None = None,
|
|
274
|
+
run_id: str | None = None,
|
|
275
|
+
idempotency_key: str | None = None,
|
|
276
|
+
**fields: Any,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
for label, value in (("app", app), ("agent", agent), ("summary", summary)):
|
|
279
|
+
if not isinstance(value, str) or not value.strip():
|
|
280
|
+
raise ExecutionStateError(f"{label} 必须是非空字符串")
|
|
281
|
+
event: dict[str, Any] = {
|
|
282
|
+
"event_id": _event_id(),
|
|
283
|
+
"type": event_type,
|
|
284
|
+
"timestamp": _now_iso(),
|
|
285
|
+
"app": app.strip(),
|
|
286
|
+
"agent": agent.strip(),
|
|
287
|
+
"summary": summary.strip(),
|
|
288
|
+
"evidence": _normalize_evidence(evidence),
|
|
289
|
+
}
|
|
290
|
+
if task_id is not None:
|
|
291
|
+
event["task_id"] = task_id
|
|
292
|
+
if task_ids is not None:
|
|
293
|
+
event["task_ids"] = task_ids
|
|
294
|
+
if run_id:
|
|
295
|
+
event["run_id"] = run_id
|
|
296
|
+
if idempotency_key:
|
|
297
|
+
event["idempotency_key"] = idempotency_key
|
|
298
|
+
event.update(fields)
|
|
299
|
+
return event
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _existing_idempotent_event(
|
|
303
|
+
execution: dict[str, Any], idempotency_key: str | None
|
|
304
|
+
) -> dict[str, Any] | None:
|
|
305
|
+
if not idempotency_key:
|
|
306
|
+
return None
|
|
307
|
+
return next(
|
|
308
|
+
(
|
|
309
|
+
event
|
|
310
|
+
for event in execution.get("events", [])
|
|
311
|
+
if isinstance(event, dict) and event.get("idempotency_key") == idempotency_key
|
|
312
|
+
),
|
|
313
|
+
None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _idempotent_result(
|
|
318
|
+
execution: dict[str, Any],
|
|
319
|
+
idempotency_key: str | None,
|
|
320
|
+
expected_fields: dict[str, Any],
|
|
321
|
+
) -> dict[str, Any] | None:
|
|
322
|
+
"""Return an existing equivalent event, rejecting key reuse for another action."""
|
|
323
|
+
|
|
324
|
+
existing = _existing_idempotent_event(execution, idempotency_key)
|
|
325
|
+
if existing is None:
|
|
326
|
+
return None
|
|
327
|
+
mismatched = [
|
|
328
|
+
field_name
|
|
329
|
+
for field_name, expected_value in expected_fields.items()
|
|
330
|
+
if existing.get(field_name) != expected_value
|
|
331
|
+
]
|
|
332
|
+
if mismatched:
|
|
333
|
+
raise ExecutionConflictError(
|
|
334
|
+
"幂等键已被不同事件使用,冲突字段:" + ", ".join(sorted(mismatched))
|
|
335
|
+
)
|
|
336
|
+
return execution
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _snapshot_by_id(execution: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
340
|
+
return {
|
|
341
|
+
str(snapshot["task_id"]): snapshot
|
|
342
|
+
for snapshot in execution.get("tasks", [])
|
|
343
|
+
if isinstance(snapshot, dict) and isinstance(snapshot.get("task_id"), str)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _require_ready_task(manifest: dict[str, Any], task_id: str) -> dict[str, Any]:
|
|
348
|
+
task = _manifest_tasks(manifest).get(task_id)
|
|
349
|
+
if task is None:
|
|
350
|
+
raise ExecutionStateError(f"执行任务不存在:{task_id}")
|
|
351
|
+
if manifest.get("status") != "READY" or task.get("status") != "READY":
|
|
352
|
+
raise ExecutionStateError(
|
|
353
|
+
f"Canonical Spec 和任务 {task_id} 必须是 READY 才能写入开发执行状态"
|
|
354
|
+
)
|
|
355
|
+
return task
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _dependency_is_satisfied(
|
|
359
|
+
manifest: dict[str, Any],
|
|
360
|
+
execution: dict[str, Any],
|
|
361
|
+
dependency: dict[str, Any],
|
|
362
|
+
) -> bool:
|
|
363
|
+
dependency_type = dependency.get("type")
|
|
364
|
+
if dependency_type == "contract":
|
|
365
|
+
return manifest.get("status") == "READY"
|
|
366
|
+
if dependency.get("status") == "satisfied":
|
|
367
|
+
return True
|
|
368
|
+
if dependency_type == "hard":
|
|
369
|
+
dependency_task = _snapshot_by_id(execution).get(str(dependency.get("task_id")))
|
|
370
|
+
return dependency_task is not None and dependency_task.get("status") == "completed"
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _unsatisfied_dependencies(
|
|
375
|
+
manifest: dict[str, Any],
|
|
376
|
+
execution: dict[str, Any],
|
|
377
|
+
snapshot: dict[str, Any],
|
|
378
|
+
dependency_types: set[str],
|
|
379
|
+
) -> list[dict[str, Any]]:
|
|
380
|
+
return [
|
|
381
|
+
dependency
|
|
382
|
+
for dependency in snapshot.get("dependencies", [])
|
|
383
|
+
if isinstance(dependency, dict)
|
|
384
|
+
and dependency.get("type") in dependency_types
|
|
385
|
+
and not _dependency_is_satisfied(manifest, execution, dependency)
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _attach_event_evidence(snapshot: dict[str, Any], event: dict[str, Any]) -> None:
|
|
390
|
+
for evidence in event.get("evidence", []):
|
|
391
|
+
snapshot["evidence"].append({**evidence, "event_id": event["event_id"]})
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _finalize_event(execution: dict[str, Any], event: dict[str, Any]) -> None:
|
|
395
|
+
execution["events"].append(event)
|
|
396
|
+
execution["execution_revision"] = int(execution["execution_revision"]) + 1
|
|
397
|
+
execution["updated_at"] = event["timestamp"]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@contextmanager
|
|
401
|
+
def _exclusive_lock(path: Path, timeout_seconds: float = 10.0) -> Iterator[None]:
|
|
402
|
+
lock_path = path.with_name(f".{path.name}.eds.lock")
|
|
403
|
+
deadline = time.monotonic() + timeout_seconds
|
|
404
|
+
descriptor: int | None = None
|
|
405
|
+
while descriptor is None:
|
|
406
|
+
try:
|
|
407
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
408
|
+
os.write(descriptor, f"pid={os.getpid()} created={_now_iso()}\n".encode("utf-8"))
|
|
409
|
+
except FileExistsError:
|
|
410
|
+
try:
|
|
411
|
+
stale = time.time() - lock_path.stat().st_mtime > 300
|
|
412
|
+
except FileNotFoundError:
|
|
413
|
+
continue
|
|
414
|
+
if stale:
|
|
415
|
+
try:
|
|
416
|
+
lock_path.unlink()
|
|
417
|
+
except FileNotFoundError:
|
|
418
|
+
pass
|
|
419
|
+
continue
|
|
420
|
+
if time.monotonic() >= deadline:
|
|
421
|
+
raise ExecutionConflictError("Spec 正由其他应用写入,请稍后重新读取并重试")
|
|
422
|
+
time.sleep(0.05)
|
|
423
|
+
try:
|
|
424
|
+
yield
|
|
425
|
+
finally:
|
|
426
|
+
if descriptor is not None:
|
|
427
|
+
os.close(descriptor)
|
|
428
|
+
try:
|
|
429
|
+
lock_path.unlink()
|
|
430
|
+
except FileNotFoundError:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _write_atomic(path: Path, content: str) -> None:
|
|
435
|
+
file_mode = stat.S_IMODE(path.stat().st_mode)
|
|
436
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
437
|
+
prefix=f".{path.name}.eds-", suffix=".tmp", dir=path.parent
|
|
438
|
+
)
|
|
439
|
+
temporary_path = Path(temporary_name)
|
|
440
|
+
try:
|
|
441
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
442
|
+
handle.write(content)
|
|
443
|
+
handle.flush()
|
|
444
|
+
os.fsync(handle.fileno())
|
|
445
|
+
os.chmod(temporary_path, file_mode)
|
|
446
|
+
os.replace(temporary_path, path)
|
|
447
|
+
directory_descriptor = os.open(path.parent, os.O_RDONLY)
|
|
448
|
+
try:
|
|
449
|
+
os.fsync(directory_descriptor)
|
|
450
|
+
finally:
|
|
451
|
+
os.close(directory_descriptor)
|
|
452
|
+
finally:
|
|
453
|
+
if temporary_path.exists():
|
|
454
|
+
temporary_path.unlink()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _persist(path: Path, design_text: str, execution: dict[str, Any]) -> dict[str, Any]:
|
|
458
|
+
content = render_document(design_text, execution)
|
|
459
|
+
report = validate_spec(content, require_execution=True)
|
|
460
|
+
if not report.ok:
|
|
461
|
+
details = ";".join(issue.message for issue in report.issues[:8])
|
|
462
|
+
raise ExecutionStateError(f"写入前执行状态校验失败:{details}")
|
|
463
|
+
_write_atomic(path, content)
|
|
464
|
+
return execution
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def initialize_execution(
|
|
468
|
+
spec_path: str | Path,
|
|
469
|
+
*,
|
|
470
|
+
expected_design_sha256: str | None = None,
|
|
471
|
+
) -> dict[str, Any]:
|
|
472
|
+
path = Path(spec_path).expanduser().resolve()
|
|
473
|
+
with _exclusive_lock(path):
|
|
474
|
+
_, design_text, manifest, execution = _load(path)
|
|
475
|
+
_validate_static_design(design_text, manifest)
|
|
476
|
+
current_design_sha256 = design_sha256(design_text)
|
|
477
|
+
if expected_design_sha256 and expected_design_sha256 != current_design_sha256:
|
|
478
|
+
raise ExecutionConflictError("设计指纹与调用方预期不一致")
|
|
479
|
+
if execution is not None:
|
|
480
|
+
report = validate_spec(path, require_execution=True)
|
|
481
|
+
if not report.ok:
|
|
482
|
+
details = ";".join(issue.message for issue in report.issues[:8])
|
|
483
|
+
raise ExecutionStateError(f"现有执行状态非法:{details}")
|
|
484
|
+
return execution
|
|
485
|
+
execution = build_initial_execution(manifest, current_design_sha256)
|
|
486
|
+
return _persist(path, design_text, execution)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def record_task_status(
|
|
490
|
+
spec_path: str | Path,
|
|
491
|
+
task_id: str,
|
|
492
|
+
status_value: str,
|
|
493
|
+
summary: str,
|
|
494
|
+
app: str,
|
|
495
|
+
agent: str,
|
|
496
|
+
expected_design_sha256: str,
|
|
497
|
+
expected_execution_revision: int,
|
|
498
|
+
*,
|
|
499
|
+
evidence: Iterable[dict[str, Any]] | None = None,
|
|
500
|
+
run_id: str | None = None,
|
|
501
|
+
idempotency_key: str | None = None,
|
|
502
|
+
) -> dict[str, Any]:
|
|
503
|
+
task_id = _required_text(task_id, "task_id")
|
|
504
|
+
status_value = _required_text(status_value, "status")
|
|
505
|
+
summary = _required_text(summary, "summary")
|
|
506
|
+
app = _required_text(app, "app")
|
|
507
|
+
agent = _required_text(agent, "agent")
|
|
508
|
+
run_id = _optional_text(run_id, "run_id")
|
|
509
|
+
idempotency_key = _optional_text(idempotency_key, "idempotency_key")
|
|
510
|
+
path = Path(spec_path).expanduser().resolve()
|
|
511
|
+
with _exclusive_lock(path):
|
|
512
|
+
_, design_text, manifest, execution = _load(path)
|
|
513
|
+
if execution is None:
|
|
514
|
+
raise ExecutionStateError("Spec 尚未初始化共享执行状态")
|
|
515
|
+
_assert_current_design(design_text, execution)
|
|
516
|
+
_validate_existing_execution(path)
|
|
517
|
+
normalized_evidence = _normalize_evidence(evidence)
|
|
518
|
+
existing = _idempotent_result(
|
|
519
|
+
execution,
|
|
520
|
+
idempotency_key,
|
|
521
|
+
{
|
|
522
|
+
"type": "task_status_changed",
|
|
523
|
+
"task_id": task_id,
|
|
524
|
+
"to_status": status_value,
|
|
525
|
+
"app": app,
|
|
526
|
+
"agent": agent,
|
|
527
|
+
"summary": summary,
|
|
528
|
+
"evidence": normalized_evidence,
|
|
529
|
+
"run_id": run_id,
|
|
530
|
+
"design_revision": manifest.get("revision"),
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
if existing is not None:
|
|
534
|
+
return existing
|
|
535
|
+
_assert_expected(execution, expected_design_sha256, expected_execution_revision)
|
|
536
|
+
manifest_task = _require_ready_task(manifest, task_id)
|
|
537
|
+
snapshots = _snapshot_by_id(execution)
|
|
538
|
+
snapshot = snapshots.get(task_id)
|
|
539
|
+
if snapshot is None: # execution validation normally catches this first
|
|
540
|
+
raise ExecutionStateError(f"执行任务快照不存在:{task_id}")
|
|
541
|
+
current_status = str(snapshot.get("status"))
|
|
542
|
+
if status_value not in TASK_TRANSITIONS.get(current_status, set()):
|
|
543
|
+
raise ExecutionStateError(f"非法任务状态迁移:{current_status} -> {status_value}")
|
|
544
|
+
if status_value in {"in_progress", "implemented", "verified", "completed"}:
|
|
545
|
+
missing_dependencies = _unsatisfied_dependencies(
|
|
546
|
+
manifest,
|
|
547
|
+
execution,
|
|
548
|
+
snapshot,
|
|
549
|
+
{"hard", "contract"},
|
|
550
|
+
)
|
|
551
|
+
if missing_dependencies:
|
|
552
|
+
labels = [
|
|
553
|
+
f"{dependency.get('type')}:{dependency.get('task_id')}"
|
|
554
|
+
for dependency in missing_dependencies
|
|
555
|
+
]
|
|
556
|
+
raise ExecutionStateError(
|
|
557
|
+
f"任务 {task_id} 的前置依赖未满足:{', '.join(labels)}"
|
|
558
|
+
)
|
|
559
|
+
expected_steps = set(str(value) for value in manifest_task.get("step_ids", []))
|
|
560
|
+
completed_steps = set(str(value) for value in snapshot.get("completed_step_ids", []))
|
|
561
|
+
if status_value in {"implemented", "verified", "completed"} and completed_steps != expected_steps:
|
|
562
|
+
missing = sorted(expected_steps - completed_steps)
|
|
563
|
+
raise ExecutionStateError(
|
|
564
|
+
f"任务 {task_id} 尚有未完成 Step,不能进入 {status_value}:{', '.join(missing)}"
|
|
565
|
+
)
|
|
566
|
+
cumulative_evidence = [*snapshot.get("evidence", []), *normalized_evidence]
|
|
567
|
+
if status_value == "verified":
|
|
568
|
+
expected_test_ids = set(str(value) for value in manifest_task.get("test_ids", []))
|
|
569
|
+
missing_test_ids = expected_test_ids - _passed_test_ids(cumulative_evidence)
|
|
570
|
+
if missing_test_ids:
|
|
571
|
+
raise ExecutionStateError(
|
|
572
|
+
"进入 verified 前缺少通过的 Canonical Test 证据:"
|
|
573
|
+
+ ", ".join(sorted(missing_test_ids))
|
|
574
|
+
)
|
|
575
|
+
if status_value == "completed" and _unsatisfied_dependencies(
|
|
576
|
+
manifest,
|
|
577
|
+
execution,
|
|
578
|
+
snapshot,
|
|
579
|
+
{"integration"},
|
|
580
|
+
):
|
|
581
|
+
raise ExecutionStateError("integration 依赖尚未满足,任务不能标记 completed")
|
|
582
|
+
event = _new_event(
|
|
583
|
+
"task_status_changed",
|
|
584
|
+
app,
|
|
585
|
+
agent,
|
|
586
|
+
summary,
|
|
587
|
+
normalized_evidence,
|
|
588
|
+
task_id=task_id,
|
|
589
|
+
run_id=run_id,
|
|
590
|
+
idempotency_key=idempotency_key,
|
|
591
|
+
from_status=current_status,
|
|
592
|
+
to_status=status_value,
|
|
593
|
+
design_revision=manifest["revision"],
|
|
594
|
+
)
|
|
595
|
+
snapshot["status"] = status_value
|
|
596
|
+
snapshot["last_event_id"] = event["event_id"]
|
|
597
|
+
snapshot["updated_at"] = event["timestamp"]
|
|
598
|
+
if status_value == "blocked":
|
|
599
|
+
if summary not in snapshot["blockers"]:
|
|
600
|
+
snapshot["blockers"].append(summary)
|
|
601
|
+
elif status_value == "in_progress":
|
|
602
|
+
snapshot["blockers"] = []
|
|
603
|
+
if current_status in {"completed", "cancelled"}:
|
|
604
|
+
snapshot["evidence"] = []
|
|
605
|
+
elif status_value == "cancelled":
|
|
606
|
+
snapshot["blockers"] = []
|
|
607
|
+
_attach_event_evidence(snapshot, event)
|
|
608
|
+
_finalize_event(execution, event)
|
|
609
|
+
return _persist(path, design_text, execution)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def record_step_status(
|
|
613
|
+
spec_path: str | Path,
|
|
614
|
+
task_id: str,
|
|
615
|
+
step_id: str,
|
|
616
|
+
status_value: str,
|
|
617
|
+
summary: str,
|
|
618
|
+
app: str,
|
|
619
|
+
agent: str,
|
|
620
|
+
expected_design_sha256: str,
|
|
621
|
+
expected_execution_revision: int,
|
|
622
|
+
*,
|
|
623
|
+
evidence: Iterable[dict[str, Any]] | None = None,
|
|
624
|
+
run_id: str | None = None,
|
|
625
|
+
idempotency_key: str | None = None,
|
|
626
|
+
) -> dict[str, Any]:
|
|
627
|
+
task_id = _required_text(task_id, "task_id")
|
|
628
|
+
step_id = _required_text(step_id, "step_id")
|
|
629
|
+
status_value = _required_text(status_value, "status")
|
|
630
|
+
summary = _required_text(summary, "summary")
|
|
631
|
+
app = _required_text(app, "app")
|
|
632
|
+
agent = _required_text(agent, "agent")
|
|
633
|
+
run_id = _optional_text(run_id, "run_id")
|
|
634
|
+
idempotency_key = _optional_text(idempotency_key, "idempotency_key")
|
|
635
|
+
if status_value not in {"completed", "failed"}:
|
|
636
|
+
raise ExecutionStateError("Step 状态必须是 completed 或 failed")
|
|
637
|
+
path = Path(spec_path).expanduser().resolve()
|
|
638
|
+
with _exclusive_lock(path):
|
|
639
|
+
_, design_text, manifest, execution = _load(path)
|
|
640
|
+
if execution is None:
|
|
641
|
+
raise ExecutionStateError("Spec 尚未初始化共享执行状态")
|
|
642
|
+
_assert_current_design(design_text, execution)
|
|
643
|
+
_validate_existing_execution(path)
|
|
644
|
+
normalized_evidence = _normalize_evidence(evidence)
|
|
645
|
+
existing = _idempotent_result(
|
|
646
|
+
execution,
|
|
647
|
+
idempotency_key,
|
|
648
|
+
{
|
|
649
|
+
"type": "step_status_changed",
|
|
650
|
+
"task_id": task_id,
|
|
651
|
+
"step_id": step_id,
|
|
652
|
+
"step_status": status_value,
|
|
653
|
+
"app": app,
|
|
654
|
+
"agent": agent,
|
|
655
|
+
"summary": summary,
|
|
656
|
+
"evidence": normalized_evidence,
|
|
657
|
+
"run_id": run_id,
|
|
658
|
+
"design_revision": manifest.get("revision"),
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
if existing is not None:
|
|
662
|
+
return existing
|
|
663
|
+
_assert_expected(execution, expected_design_sha256, expected_execution_revision)
|
|
664
|
+
task = _require_ready_task(manifest, task_id)
|
|
665
|
+
if step_id not in task.get("step_ids", []):
|
|
666
|
+
raise ExecutionStateError(f"Step 不属于任务 {task_id}:{step_id}")
|
|
667
|
+
snapshot = _snapshot_by_id(execution)[task_id]
|
|
668
|
+
if snapshot.get("status") != "in_progress":
|
|
669
|
+
raise ExecutionStateError("只有 in_progress 任务可以更新 Step;请先显式恢复任务")
|
|
670
|
+
missing_dependencies = _unsatisfied_dependencies(
|
|
671
|
+
manifest,
|
|
672
|
+
execution,
|
|
673
|
+
snapshot,
|
|
674
|
+
{"hard", "contract"},
|
|
675
|
+
)
|
|
676
|
+
if missing_dependencies:
|
|
677
|
+
labels = [
|
|
678
|
+
f"{dependency.get('type')}:{dependency.get('task_id')}"
|
|
679
|
+
for dependency in missing_dependencies
|
|
680
|
+
]
|
|
681
|
+
raise ExecutionStateError(
|
|
682
|
+
f"任务 {task_id} 的前置依赖未满足:{', '.join(labels)}"
|
|
683
|
+
)
|
|
684
|
+
step = next(
|
|
685
|
+
(
|
|
686
|
+
value
|
|
687
|
+
for value in manifest.get("steps", [])
|
|
688
|
+
if isinstance(value, dict) and value.get("step_id") == step_id
|
|
689
|
+
),
|
|
690
|
+
None,
|
|
691
|
+
)
|
|
692
|
+
if step is None:
|
|
693
|
+
raise ExecutionStateError(f"manifest 中缺少 Step 定义:{step_id}")
|
|
694
|
+
missing_step_dependencies = set(
|
|
695
|
+
str(value) for value in step.get("depends_on_step_ids", [])
|
|
696
|
+
) - set(str(value) for value in snapshot.get("completed_step_ids", []))
|
|
697
|
+
if missing_step_dependencies:
|
|
698
|
+
raise ExecutionStateError(
|
|
699
|
+
f"Step {step_id} 的前置 Step 尚未完成:"
|
|
700
|
+
+ ", ".join(sorted(missing_step_dependencies))
|
|
701
|
+
)
|
|
702
|
+
if status_value == "completed":
|
|
703
|
+
expected_test_ids = set(str(value) for value in step.get("test_ids", []))
|
|
704
|
+
missing_test_ids = expected_test_ids - _passed_test_ids(normalized_evidence)
|
|
705
|
+
if missing_test_ids:
|
|
706
|
+
raise ExecutionStateError(
|
|
707
|
+
f"Step {step_id} 缺少通过的绑定 Test 证据:"
|
|
708
|
+
+ ", ".join(sorted(missing_test_ids))
|
|
709
|
+
)
|
|
710
|
+
event = _new_event(
|
|
711
|
+
"step_status_changed",
|
|
712
|
+
app,
|
|
713
|
+
agent,
|
|
714
|
+
summary,
|
|
715
|
+
normalized_evidence,
|
|
716
|
+
task_id=task_id,
|
|
717
|
+
run_id=run_id,
|
|
718
|
+
idempotency_key=idempotency_key,
|
|
719
|
+
step_id=step_id,
|
|
720
|
+
step_status=status_value,
|
|
721
|
+
design_revision=manifest["revision"],
|
|
722
|
+
)
|
|
723
|
+
completed = set(snapshot["completed_step_ids"])
|
|
724
|
+
failed = set(snapshot["failed_step_ids"])
|
|
725
|
+
if status_value == "completed":
|
|
726
|
+
completed.add(step_id)
|
|
727
|
+
failed.discard(step_id)
|
|
728
|
+
else:
|
|
729
|
+
failed.add(step_id)
|
|
730
|
+
completed.discard(step_id)
|
|
731
|
+
snapshot["status"] = "blocked"
|
|
732
|
+
if summary not in snapshot["blockers"]:
|
|
733
|
+
snapshot["blockers"].append(summary)
|
|
734
|
+
snapshot["completed_step_ids"] = sorted(completed)
|
|
735
|
+
snapshot["failed_step_ids"] = sorted(failed)
|
|
736
|
+
snapshot["last_event_id"] = event["event_id"]
|
|
737
|
+
snapshot["updated_at"] = event["timestamp"]
|
|
738
|
+
_attach_event_evidence(snapshot, event)
|
|
739
|
+
_finalize_event(execution, event)
|
|
740
|
+
return _persist(path, design_text, execution)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def record_dependency_status(
|
|
744
|
+
spec_path: str | Path,
|
|
745
|
+
source_task_id: str,
|
|
746
|
+
dependency_task_id: str,
|
|
747
|
+
status_value: str,
|
|
748
|
+
summary: str,
|
|
749
|
+
app: str,
|
|
750
|
+
agent: str,
|
|
751
|
+
expected_design_sha256: str,
|
|
752
|
+
expected_execution_revision: int,
|
|
753
|
+
*,
|
|
754
|
+
evidence: Iterable[dict[str, Any]] | None = None,
|
|
755
|
+
run_id: str | None = None,
|
|
756
|
+
idempotency_key: str | None = None,
|
|
757
|
+
) -> dict[str, Any]:
|
|
758
|
+
source_task_id = _required_text(source_task_id, "source_task_id")
|
|
759
|
+
dependency_task_id = _required_text(dependency_task_id, "dependency_task_id")
|
|
760
|
+
status_value = _required_text(status_value, "status")
|
|
761
|
+
summary = _required_text(summary, "summary")
|
|
762
|
+
app = _required_text(app, "app")
|
|
763
|
+
agent = _required_text(agent, "agent")
|
|
764
|
+
run_id = _optional_text(run_id, "run_id")
|
|
765
|
+
idempotency_key = _optional_text(idempotency_key, "idempotency_key")
|
|
766
|
+
if status_value not in {"pending", "satisfied"}:
|
|
767
|
+
raise ExecutionStateError("依赖状态必须是 pending 或 satisfied")
|
|
768
|
+
normalized_evidence = _normalize_evidence(evidence)
|
|
769
|
+
if status_value == "satisfied" and not normalized_evidence:
|
|
770
|
+
raise ExecutionStateError("依赖置为 satisfied 时必须提供证据")
|
|
771
|
+
path = Path(spec_path).expanduser().resolve()
|
|
772
|
+
with _exclusive_lock(path):
|
|
773
|
+
_, design_text, manifest, execution = _load(path)
|
|
774
|
+
if execution is None:
|
|
775
|
+
raise ExecutionStateError("Spec 尚未初始化共享执行状态")
|
|
776
|
+
_assert_current_design(design_text, execution)
|
|
777
|
+
_validate_existing_execution(path)
|
|
778
|
+
existing = _idempotent_result(
|
|
779
|
+
execution,
|
|
780
|
+
idempotency_key,
|
|
781
|
+
{
|
|
782
|
+
"type": "dependency_status_changed",
|
|
783
|
+
"task_id": source_task_id,
|
|
784
|
+
"dependency_task_id": dependency_task_id,
|
|
785
|
+
"dependency_status": status_value,
|
|
786
|
+
"app": app,
|
|
787
|
+
"agent": agent,
|
|
788
|
+
"summary": summary,
|
|
789
|
+
"evidence": normalized_evidence,
|
|
790
|
+
"run_id": run_id,
|
|
791
|
+
"design_revision": manifest.get("revision"),
|
|
792
|
+
},
|
|
793
|
+
)
|
|
794
|
+
if existing is not None:
|
|
795
|
+
return existing
|
|
796
|
+
_assert_expected(execution, expected_design_sha256, expected_execution_revision)
|
|
797
|
+
_require_ready_task(manifest, source_task_id)
|
|
798
|
+
snapshot = _snapshot_by_id(execution).get(source_task_id)
|
|
799
|
+
if snapshot is None:
|
|
800
|
+
raise ExecutionStateError(f"执行任务不存在:{source_task_id}")
|
|
801
|
+
matches = [
|
|
802
|
+
dependency
|
|
803
|
+
for dependency in snapshot.get("dependencies", [])
|
|
804
|
+
if dependency.get("task_id") == dependency_task_id
|
|
805
|
+
]
|
|
806
|
+
if len(matches) != 1:
|
|
807
|
+
raise ExecutionStateError(
|
|
808
|
+
f"未找到唯一依赖边:{source_task_id}->{dependency_task_id}"
|
|
809
|
+
)
|
|
810
|
+
dependency = matches[0]
|
|
811
|
+
if dependency.get("type") == "contract" and status_value == "pending":
|
|
812
|
+
raise ExecutionStateError("READY 设计中的 contract 依赖不能手工改为 pending")
|
|
813
|
+
if status_value == "pending" and snapshot.get("status") == "completed":
|
|
814
|
+
raise ExecutionStateError("completed 任务必须先重新进入 in_progress 才能重开依赖")
|
|
815
|
+
if (
|
|
816
|
+
status_value == "pending"
|
|
817
|
+
and dependency.get("type") == "hard"
|
|
818
|
+
and _snapshot_by_id(execution).get(dependency_task_id, {}).get("status")
|
|
819
|
+
== "completed"
|
|
820
|
+
):
|
|
821
|
+
raise ExecutionStateError("前置任务仍是 completed,hard 依赖不能改为 pending")
|
|
822
|
+
event = _new_event(
|
|
823
|
+
"dependency_status_changed",
|
|
824
|
+
app,
|
|
825
|
+
agent,
|
|
826
|
+
summary,
|
|
827
|
+
normalized_evidence,
|
|
828
|
+
task_id=source_task_id,
|
|
829
|
+
run_id=run_id,
|
|
830
|
+
idempotency_key=idempotency_key,
|
|
831
|
+
dependency_task_id=dependency_task_id,
|
|
832
|
+
dependency_type=dependency["type"],
|
|
833
|
+
dependency_status=status_value,
|
|
834
|
+
design_revision=manifest["revision"],
|
|
835
|
+
)
|
|
836
|
+
dependency["status"] = status_value
|
|
837
|
+
dependency["evidence_event_id"] = (
|
|
838
|
+
event["event_id"] if status_value == "satisfied" else None
|
|
839
|
+
)
|
|
840
|
+
snapshot["last_event_id"] = event["event_id"]
|
|
841
|
+
snapshot["updated_at"] = event["timestamp"]
|
|
842
|
+
if status_value == "pending" and snapshot.get("status") in {
|
|
843
|
+
"in_progress",
|
|
844
|
+
"implemented",
|
|
845
|
+
"verified",
|
|
846
|
+
}:
|
|
847
|
+
snapshot["status"] = "blocked"
|
|
848
|
+
if summary not in snapshot["blockers"]:
|
|
849
|
+
snapshot["blockers"].append(summary)
|
|
850
|
+
_attach_event_evidence(snapshot, event)
|
|
851
|
+
_finalize_event(execution, event)
|
|
852
|
+
return _persist(path, design_text, execution)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _affected_closure(manifest: dict[str, Any], affected_task_ids: set[str]) -> set[str]:
|
|
856
|
+
tasks = _manifest_tasks(manifest)
|
|
857
|
+
reverse_dependencies: dict[str, set[str]] = {task_id: set() for task_id in tasks}
|
|
858
|
+
for task_id, task in tasks.items():
|
|
859
|
+
for dependency in task.get("depends_on", []):
|
|
860
|
+
dependency_id = str(dependency.get("task_id"))
|
|
861
|
+
if dependency_id in reverse_dependencies:
|
|
862
|
+
reverse_dependencies[dependency_id].add(task_id)
|
|
863
|
+
closure = set(affected_task_ids)
|
|
864
|
+
pending = list(affected_task_ids)
|
|
865
|
+
while pending:
|
|
866
|
+
task_id = pending.pop()
|
|
867
|
+
for dependent in reverse_dependencies.get(task_id, set()):
|
|
868
|
+
if dependent not in closure:
|
|
869
|
+
closure.add(dependent)
|
|
870
|
+
pending.append(dependent)
|
|
871
|
+
return closure
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def sync_design(
|
|
875
|
+
spec_path: str | Path,
|
|
876
|
+
affected_task_ids: Iterable[str],
|
|
877
|
+
summary: str,
|
|
878
|
+
app: str,
|
|
879
|
+
agent: str,
|
|
880
|
+
expected_design_sha256: str,
|
|
881
|
+
expected_execution_revision: int,
|
|
882
|
+
*,
|
|
883
|
+
run_id: str | None = None,
|
|
884
|
+
idempotency_key: str | None = None,
|
|
885
|
+
) -> dict[str, Any]:
|
|
886
|
+
"""Accept a validated static revision and reconcile its execution snapshots."""
|
|
887
|
+
|
|
888
|
+
summary = _required_text(summary, "summary")
|
|
889
|
+
app = _required_text(app, "app")
|
|
890
|
+
agent = _required_text(agent, "agent")
|
|
891
|
+
run_id = _optional_text(run_id, "run_id")
|
|
892
|
+
idempotency_key = _optional_text(idempotency_key, "idempotency_key")
|
|
893
|
+
path = Path(spec_path).expanduser().resolve()
|
|
894
|
+
with _exclusive_lock(path):
|
|
895
|
+
_, design_text, manifest, execution = _load(path)
|
|
896
|
+
if execution is None:
|
|
897
|
+
raise ExecutionStateError("Spec 尚未初始化共享执行状态")
|
|
898
|
+
_validate_execution_envelope(manifest, execution)
|
|
899
|
+
if execution.get("design_revision") == manifest.get("revision"):
|
|
900
|
+
_assert_current_design(design_text, execution)
|
|
901
|
+
_validate_existing_execution(path)
|
|
902
|
+
requested_task_ids = sorted(
|
|
903
|
+
{_required_text(task_id, "affected_task_id") for task_id in affected_task_ids}
|
|
904
|
+
)
|
|
905
|
+
existing = _idempotent_result(
|
|
906
|
+
execution,
|
|
907
|
+
idempotency_key,
|
|
908
|
+
{
|
|
909
|
+
"type": "spec_revised",
|
|
910
|
+
"requested_task_ids": requested_task_ids,
|
|
911
|
+
"app": app,
|
|
912
|
+
"agent": agent,
|
|
913
|
+
"summary": summary,
|
|
914
|
+
"evidence": [],
|
|
915
|
+
"run_id": run_id,
|
|
916
|
+
"design_revision": manifest.get("revision"),
|
|
917
|
+
},
|
|
918
|
+
)
|
|
919
|
+
if existing is not None:
|
|
920
|
+
return existing
|
|
921
|
+
_assert_expected(execution, expected_design_sha256, expected_execution_revision)
|
|
922
|
+
_validate_static_design(design_text, manifest)
|
|
923
|
+
old_revision = execution.get("design_revision")
|
|
924
|
+
new_revision = manifest.get("revision")
|
|
925
|
+
if not isinstance(old_revision, int) or new_revision != old_revision + 1:
|
|
926
|
+
raise ExecutionStateError(
|
|
927
|
+
"静态设计 revision 必须在每次 sync-design 时恰好递增 1"
|
|
928
|
+
)
|
|
929
|
+
tasks = _manifest_tasks(manifest)
|
|
930
|
+
requested = set(requested_task_ids)
|
|
931
|
+
if not requested:
|
|
932
|
+
requested = set(tasks)
|
|
933
|
+
unknown = sorted(requested - set(tasks))
|
|
934
|
+
if unknown:
|
|
935
|
+
raise ExecutionStateError("受影响任务不存在:" + ", ".join(unknown))
|
|
936
|
+
reset_task_ids = _affected_closure(manifest, requested)
|
|
937
|
+
old_snapshots = _snapshot_by_id(execution)
|
|
938
|
+
reconciled: list[dict[str, Any]] = []
|
|
939
|
+
for task_id, task in tasks.items():
|
|
940
|
+
fresh = _new_snapshot(task, manifest.get("status") == "READY")
|
|
941
|
+
previous = old_snapshots.get(task_id)
|
|
942
|
+
if previous is not None and task_id not in reset_task_ids:
|
|
943
|
+
fresh.update(
|
|
944
|
+
{
|
|
945
|
+
key: previous[key]
|
|
946
|
+
for key in (
|
|
947
|
+
"status",
|
|
948
|
+
"completed_step_ids",
|
|
949
|
+
"failed_step_ids",
|
|
950
|
+
"blockers",
|
|
951
|
+
"evidence",
|
|
952
|
+
"last_event_id",
|
|
953
|
+
"updated_at",
|
|
954
|
+
)
|
|
955
|
+
if key in previous
|
|
956
|
+
}
|
|
957
|
+
)
|
|
958
|
+
previous_dependencies = {
|
|
959
|
+
(value.get("task_id"), value.get("type")): value
|
|
960
|
+
for value in previous.get("dependencies", [])
|
|
961
|
+
if isinstance(value, dict)
|
|
962
|
+
}
|
|
963
|
+
for dependency in fresh["dependencies"]:
|
|
964
|
+
prior = previous_dependencies.get(
|
|
965
|
+
(dependency["task_id"], dependency["type"])
|
|
966
|
+
)
|
|
967
|
+
if prior:
|
|
968
|
+
dependency.update(
|
|
969
|
+
{
|
|
970
|
+
"status": prior.get("status", dependency["status"]),
|
|
971
|
+
"evidence_event_id": prior.get("evidence_event_id"),
|
|
972
|
+
}
|
|
973
|
+
)
|
|
974
|
+
reconciled.append(fresh)
|
|
975
|
+
event = _new_event(
|
|
976
|
+
"spec_revised",
|
|
977
|
+
app,
|
|
978
|
+
agent,
|
|
979
|
+
summary,
|
|
980
|
+
[],
|
|
981
|
+
task_ids=sorted(reset_task_ids),
|
|
982
|
+
run_id=run_id,
|
|
983
|
+
idempotency_key=idempotency_key,
|
|
984
|
+
from_design_revision=old_revision,
|
|
985
|
+
to_design_revision=new_revision,
|
|
986
|
+
requested_task_ids=requested_task_ids,
|
|
987
|
+
design_revision=new_revision,
|
|
988
|
+
)
|
|
989
|
+
for snapshot in reconciled:
|
|
990
|
+
if snapshot["task_id"] in reset_task_ids:
|
|
991
|
+
snapshot["last_event_id"] = event["event_id"]
|
|
992
|
+
snapshot["updated_at"] = event["timestamp"]
|
|
993
|
+
execution["tasks"] = reconciled
|
|
994
|
+
execution["design_revision"] = new_revision
|
|
995
|
+
execution["design_sha256"] = design_sha256(design_text)
|
|
996
|
+
_finalize_event(execution, event)
|
|
997
|
+
return _persist(path, design_text, execution)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def show_execution(spec_path: str | Path) -> dict[str, Any]:
|
|
1001
|
+
path = Path(spec_path).expanduser().resolve()
|
|
1002
|
+
report = validate_spec(path, require_execution=True)
|
|
1003
|
+
if not report.ok or report.manifest is None or report.execution is None:
|
|
1004
|
+
details = ";".join(issue.message for issue in report.issues[:8])
|
|
1005
|
+
raise ExecutionStateError(f"Spec 执行状态不可用:{details}")
|
|
1006
|
+
return {
|
|
1007
|
+
"protocol": report.protocol,
|
|
1008
|
+
"schema": report.manifest["schema"],
|
|
1009
|
+
"spec_id": report.manifest["spec_id"],
|
|
1010
|
+
"design_revision": report.manifest["revision"],
|
|
1011
|
+
"design_sha256": report.design_sha256,
|
|
1012
|
+
"document_sha256": report.document_sha256,
|
|
1013
|
+
"execution": report.execution,
|
|
1014
|
+
}
|