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.
@@ -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
+ }