agentflow-proof 1.0.0__py3-none-any.whl

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.
agentflow/execution.py ADDED
@@ -0,0 +1,1027 @@
1
+ """Execution contract and step-run state for Agentflow v0.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from .artifacts import append_jsonl, dumps_json, read_json, read_jsonl, utc_now
13
+ from .contracts import (
14
+ ARTIFACT_COMPATIBILITY_POLICIES,
15
+ DEFAULT_COMMAND_TIMEOUT_SECONDS,
16
+ DEFAULT_LEASE_GRACE_SECONDS,
17
+ DEFAULT_LEASE_TTL_MINUTES,
18
+ EXECUTION_ARTIFACT_PATHS,
19
+ EXECUTION_CONTRACT_SCHEMA_VERSION,
20
+ HUNK_ATTRIBUTION_POLICIES,
21
+ LEASE_POLICIES,
22
+ REVIEW_GATE_POLICIES,
23
+ RISK_POLICIES,
24
+ STEP_RUNS_SCHEMA_VERSION,
25
+ )
26
+ from .git import is_git_repo
27
+ from .locks import file_lock
28
+ from .versioning import parse_schema_version, validate_schema_version_policy
29
+
30
+
31
+ ATTEMPT_OPENING_EVENTS = {"claimed", "amendment_started"}
32
+ OPEN_EVENTS = {*ATTEMPT_OPENING_EVENTS, "in_progress", "verified"}
33
+ TERMINAL_EVENTS = {"completed", "blocked", "failed", "abandoned"}
34
+ LEASE_EVENTS = {"lease_renewed"}
35
+ AMENDMENT_REASON_CODES = {
36
+ "review_feedback",
37
+ "validation_followup",
38
+ "operator_correction",
39
+ "other",
40
+ }
41
+
42
+
43
+ def _now(now: Optional[datetime] = None) -> datetime:
44
+ if now is None:
45
+ return datetime.now(timezone.utc)
46
+ return now if now.tzinfo else now.replace(tzinfo=timezone.utc)
47
+
48
+
49
+ def _utc_plus_minutes(minutes: int, now: Optional[datetime] = None) -> str:
50
+ return (_now(now).replace(microsecond=0) + timedelta(minutes=minutes)).isoformat()
51
+
52
+
53
+ def _concurrency(root: Path) -> Dict[str, Any]:
54
+ # A malformed/unreadable contract degrades to advisory defaults (the
55
+ # backward-compat guarantee) instead of crashing lease readers; audit_drift
56
+ # and verify_run already tolerate a malformed contract elsewhere.
57
+ try:
58
+ contract = load_execution_contract(root) or {}
59
+ except (ValueError, OSError):
60
+ return {}
61
+ if not isinstance(contract, dict):
62
+ return {} # structurally-wrong contract (list/scalar) reads as advisory
63
+ block = contract.get("concurrency", {})
64
+ return block if isinstance(block, dict) else {}
65
+
66
+
67
+ def lease_policy(root: Path) -> str:
68
+ value = _concurrency(root).get("lease_policy", "advisory")
69
+ return value if value in LEASE_POLICIES else "advisory"
70
+
71
+
72
+ def lease_ttl_minutes(root: Path) -> int:
73
+ value = _concurrency(root).get("lease_ttl_minutes", DEFAULT_LEASE_TTL_MINUTES)
74
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
75
+ return value
76
+ return DEFAULT_LEASE_TTL_MINUTES
77
+
78
+
79
+ def lease_grace_seconds(root: Path) -> int:
80
+ value = _concurrency(root).get("lease_grace_seconds", DEFAULT_LEASE_GRACE_SECONDS)
81
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
82
+ return value
83
+ return DEFAULT_LEASE_GRACE_SECONDS
84
+
85
+
86
+ def _parse_deadline(value: Any) -> Optional[datetime]:
87
+ if not isinstance(value, str) or not value:
88
+ return None
89
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
90
+ try:
91
+ parsed = datetime.fromisoformat(text)
92
+ except ValueError:
93
+ return None
94
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
95
+
96
+
97
+ def attempt_deadline(attempt: Dict[str, Any]) -> Optional[datetime]:
98
+ return _parse_deadline(attempt.get("lease_expires_at"))
99
+
100
+
101
+ def attempt_is_expired(attempt: Dict[str, Any], now: datetime, grace_seconds: int) -> bool:
102
+ deadline = attempt_deadline(attempt)
103
+ if deadline is None:
104
+ return False
105
+ return now > deadline + timedelta(seconds=grace_seconds)
106
+
107
+
108
+ def default_execution_contract(root: str = ".") -> Dict[str, Any]:
109
+ return {
110
+ "schema_version": EXECUTION_CONTRACT_SCHEMA_VERSION,
111
+ "contract_type": "agentflow_execution_contract",
112
+ "root": root,
113
+ "shell": {"default": "sh", "requires_posix": True},
114
+ "agent_interface": {
115
+ "minimum_capabilities": [
116
+ "read_files",
117
+ "edit_files",
118
+ "run_shell",
119
+ "report_exit_code",
120
+ ],
121
+ "forbidden_assumptions": [
122
+ "codex_skills",
123
+ "mcp_tools",
124
+ "provider_function_calls",
125
+ ],
126
+ },
127
+ "concurrency": {
128
+ "writer_model": "single_writer",
129
+ "reconcile_ignore": [".agent/", ".git/"],
130
+ "lease_policy": "advisory",
131
+ "lease_ttl_minutes": DEFAULT_LEASE_TTL_MINUTES,
132
+ "lease_grace_seconds": DEFAULT_LEASE_GRACE_SECONDS,
133
+ },
134
+ "command_policy": {
135
+ "record_outputs": True,
136
+ "max_output_bytes": 200000,
137
+ "capture_stderr": True,
138
+ "receipt_store": "by_attempt",
139
+ "risk_policy": "require-confirmation",
140
+ "command_timeout_seconds": DEFAULT_COMMAND_TIMEOUT_SECONDS,
141
+ },
142
+ "proof_policy": {
143
+ "strict_by_default": False,
144
+ "require_command_receipts_for_validation": True,
145
+ "require_file_receipts_for_changed_files": True,
146
+ "require_managed_receipts_for_validation": False,
147
+ "require_evidence_for_inspection_gates": True,
148
+ "review_gate": "warn",
149
+ "require_review_run": False,
150
+ "hunk_attribution": "enforce",
151
+ },
152
+ }
153
+
154
+
155
+ def init_execution_artifacts(root: Path, force: bool = False) -> Tuple[List[str], List[str]]:
156
+ files = {
157
+ ".agent/execution.contract.json": dumps_json(default_execution_contract(".")),
158
+ ".agent/step-runs.jsonl": "",
159
+ ".agent/command-receipts.jsonl": "",
160
+ ".agent/file-receipts.jsonl": "",
161
+ ".agent/verification-runs.jsonl": "",
162
+ }
163
+ created: List[str] = []
164
+ skipped: List[str] = []
165
+ for relative_path, content in files.items():
166
+ path = root / relative_path
167
+ if path.exists() and not force:
168
+ skipped.append(relative_path)
169
+ continue
170
+ path.parent.mkdir(parents=True, exist_ok=True)
171
+ path.write_text(content, encoding="utf-8")
172
+ created.append(relative_path)
173
+ (root / ".agent/handoffs").mkdir(parents=True, exist_ok=True)
174
+ (root / ".agent/attempts").mkdir(parents=True, exist_ok=True)
175
+ (root / ".agent/receipts").mkdir(parents=True, exist_ok=True)
176
+ return created, skipped
177
+
178
+
179
+ def load_execution_contract(root: Path) -> Optional[Dict[str, Any]]:
180
+ path = root / EXECUTION_ARTIFACT_PATHS["execution-contract"]
181
+ if not path.exists():
182
+ return None
183
+ return read_json(path)
184
+
185
+
186
+ def _positive_int(value: Any) -> bool:
187
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 1
188
+
189
+
190
+ def _non_negative_int(value: Any) -> bool:
191
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
192
+
193
+
194
+ def _non_empty_string_list(value: Any) -> bool:
195
+ return (
196
+ isinstance(value, list)
197
+ and all(isinstance(item, str) and item for item in value)
198
+ )
199
+
200
+
201
+ def _lease_minutes_or_default(value: Optional[int], default: Optional[int]) -> Optional[int]:
202
+ if value is not None:
203
+ if not _positive_int(value):
204
+ raise ValueError("lease minutes must be a positive integer")
205
+ return value
206
+ return default
207
+
208
+
209
+ def validate_execution_contract(contract: Any) -> List[Dict[str, str]]:
210
+ findings: List[Dict[str, str]] = []
211
+
212
+ def error(message: str) -> None:
213
+ findings.append({"severity": "error", "message": message})
214
+
215
+ if not isinstance(contract, dict):
216
+ error("execution contract must be an object")
217
+ return findings
218
+
219
+ required = (
220
+ "schema_version",
221
+ "contract_type",
222
+ "root",
223
+ "shell",
224
+ "agent_interface",
225
+ "concurrency",
226
+ "command_policy",
227
+ "proof_policy",
228
+ )
229
+ for field in required:
230
+ if field not in contract:
231
+ error(f"missing required field: {field}")
232
+
233
+ if "schema_version" in contract:
234
+ for message in validate_schema_version_policy(
235
+ contract["schema_version"],
236
+ EXECUTION_CONTRACT_SCHEMA_VERSION,
237
+ "execution-contract",
238
+ ARTIFACT_COMPATIBILITY_POLICIES["execution-contract"],
239
+ ):
240
+ error(message)
241
+ if (
242
+ "contract_type" in contract
243
+ and contract["contract_type"] != "agentflow_execution_contract"
244
+ ):
245
+ error("contract_type must be agentflow_execution_contract")
246
+ if "root" in contract and not isinstance(contract["root"], str):
247
+ error("root must be a string")
248
+
249
+ shell = contract.get("shell")
250
+ if "shell" in contract and not isinstance(shell, dict):
251
+ error("shell must be an object")
252
+ elif isinstance(shell, dict):
253
+ for field in ("default", "requires_posix"):
254
+ if field not in shell:
255
+ error(f"shell missing required field: {field}")
256
+ if "default" in shell and (
257
+ not isinstance(shell["default"], str) or not shell["default"]
258
+ ):
259
+ error("shell.default must be a non-empty string")
260
+ if "requires_posix" in shell and not isinstance(shell["requires_posix"], bool):
261
+ error("shell.requires_posix must be boolean")
262
+
263
+ agent_interface = contract.get("agent_interface")
264
+ if "agent_interface" in contract and not isinstance(agent_interface, dict):
265
+ error("agent_interface must be an object")
266
+ elif isinstance(agent_interface, dict):
267
+ for field in ("minimum_capabilities", "forbidden_assumptions"):
268
+ if field not in agent_interface:
269
+ error(f"agent_interface missing required field: {field}")
270
+ elif not _non_empty_string_list(agent_interface[field]):
271
+ error(
272
+ f"agent_interface.{field} must contain only non-empty strings"
273
+ )
274
+
275
+ concurrency = contract.get("concurrency")
276
+ if "concurrency" in contract and not isinstance(concurrency, dict):
277
+ error("concurrency must be an object")
278
+ elif isinstance(concurrency, dict):
279
+ for field in ("writer_model", "reconcile_ignore"):
280
+ if field not in concurrency:
281
+ error(f"concurrency missing required field: {field}")
282
+ if "writer_model" in concurrency and concurrency["writer_model"] != "single_writer":
283
+ error(
284
+ f"writer_model {concurrency['writer_model']} is not supported in v0.3"
285
+ )
286
+ if "reconcile_ignore" in concurrency and not _non_empty_string_list(
287
+ concurrency["reconcile_ignore"]
288
+ ):
289
+ error("concurrency.reconcile_ignore must contain only non-empty strings")
290
+ if "lease_policy" in concurrency and concurrency["lease_policy"] not in LEASE_POLICIES:
291
+ error(f"lease_policy {concurrency['lease_policy']} is invalid")
292
+ if "lease_ttl_minutes" in concurrency and not _positive_int(concurrency["lease_ttl_minutes"]):
293
+ error("lease_ttl_minutes must be a positive integer")
294
+ if "lease_grace_seconds" in concurrency and not _non_negative_int(
295
+ concurrency["lease_grace_seconds"]
296
+ ):
297
+ error("lease_grace_seconds must be a non-negative integer")
298
+
299
+ command_policy = contract.get("command_policy")
300
+ if "command_policy" in contract and not isinstance(command_policy, dict):
301
+ error("command_policy must be an object")
302
+ elif isinstance(command_policy, dict):
303
+ for field in (
304
+ "record_outputs",
305
+ "max_output_bytes",
306
+ "capture_stderr",
307
+ "receipt_store",
308
+ ):
309
+ if field not in command_policy:
310
+ error(f"command_policy missing required field: {field}")
311
+ for field in ("record_outputs", "capture_stderr"):
312
+ if field in command_policy and not isinstance(command_policy[field], bool):
313
+ error(f"command_policy.{field} must be boolean")
314
+ if "max_output_bytes" in command_policy and not _non_negative_int(
315
+ command_policy["max_output_bytes"]
316
+ ):
317
+ error("command_policy.max_output_bytes must be a non-negative integer")
318
+ if (
319
+ "receipt_store" in command_policy
320
+ and command_policy["receipt_store"]
321
+ not in ("by_attempt", "content_addressed")
322
+ ):
323
+ error(f"receipt_store {command_policy['receipt_store']} is invalid")
324
+ if "command_timeout_seconds" in command_policy and not _positive_int(
325
+ command_policy["command_timeout_seconds"]
326
+ ):
327
+ error("command_timeout_seconds must be a positive integer")
328
+ if (
329
+ "risk_policy" in command_policy
330
+ and command_policy["risk_policy"] not in RISK_POLICIES
331
+ ):
332
+ error(f"risk_policy {command_policy['risk_policy']} is invalid")
333
+
334
+ proof_policy = contract.get("proof_policy")
335
+ if "proof_policy" in contract and not isinstance(proof_policy, dict):
336
+ error("proof_policy must be an object")
337
+ elif isinstance(proof_policy, dict):
338
+ boolean_fields = (
339
+ "strict_by_default",
340
+ "require_command_receipts_for_validation",
341
+ "require_file_receipts_for_changed_files",
342
+ "require_managed_receipts_for_validation",
343
+ "require_evidence_for_inspection_gates",
344
+ )
345
+ for field in boolean_fields:
346
+ if field not in proof_policy:
347
+ error(f"proof_policy missing required field: {field}")
348
+ elif not isinstance(proof_policy[field], bool):
349
+ error(f"proof_policy.{field} must be boolean")
350
+ if "review_gate" in proof_policy and proof_policy["review_gate"] not in REVIEW_GATE_POLICIES:
351
+ error(f"review_gate {proof_policy['review_gate']} is invalid")
352
+ if "require_review_run" in proof_policy and not isinstance(
353
+ proof_policy["require_review_run"], bool
354
+ ):
355
+ error("proof_policy.require_review_run must be boolean")
356
+ if (
357
+ "hunk_attribution" in proof_policy
358
+ and proof_policy["hunk_attribution"] not in HUNK_ATTRIBUTION_POLICIES
359
+ ):
360
+ error(
361
+ f"hunk_attribution {proof_policy['hunk_attribution']} is invalid"
362
+ )
363
+ return findings
364
+
365
+
366
+ def _older_major_remedy(root: Path) -> str:
367
+ """The upgrade ritual, when the root's contract is a whole major behind.
368
+
369
+ docs/compatibility.md gives working state no cross-major promise, so this
370
+ build genuinely cannot read the tree. The bundle has to be produced by the
371
+ *older* Agentflow before upgrading -- this installation is the one doing the
372
+ rejecting, so it cannot export that state itself.
373
+ """
374
+ path = root / EXECUTION_ARTIFACT_PATHS["execution-contract"]
375
+ try:
376
+ recorded = json.loads(path.read_text(encoding="utf-8")).get("schema_version")
377
+ except (OSError, AttributeError, json.JSONDecodeError):
378
+ return ""
379
+ # A non-string schema_version (null, number, list, object) is exactly what
380
+ # load_execution_contract already rejected; parse_schema_version would raise
381
+ # TypeError on it, which the caller does not catch, and doctor would abandon
382
+ # its structured report for a traceback. This function only ever adds
383
+ # advice, so anything it cannot read is simply not its business.
384
+ if not isinstance(recorded, str):
385
+ return ""
386
+ try:
387
+ actual = parse_schema_version(recorded)
388
+ supported = parse_schema_version(EXECUTION_CONTRACT_SCHEMA_VERSION)
389
+ except ValueError:
390
+ return ""
391
+ if actual.major >= supported.major:
392
+ return ""
393
+ return (
394
+ f"; this working state was written by Agentflow {actual.major}.x and "
395
+ f"working state carries no cross-major promise. Run `build-proof` with "
396
+ f"the older Agentflow and retain the bundle before upgrading, then "
397
+ f"re-initialise execution state here. See docs/compatibility.md."
398
+ )
399
+
400
+
401
+ def doctor(root: Path) -> Dict[str, Any]:
402
+ findings: List[Dict[str, str]] = []
403
+ # An incompatible or unreadable contract is exactly what doctor exists to
404
+ # diagnose: report it as a finding instead of letting read_json's version
405
+ # gate escape as a traceback (same degrade rule as _concurrency above).
406
+ try:
407
+ contract = load_execution_contract(root)
408
+ except (ValueError, OSError) as exc:
409
+ # Keep the original diagnostic -- path and version gap are what make it
410
+ # actionable -- and append the remedy rather than replacing it.
411
+ findings.append(
412
+ {"severity": "error", "message": str(exc) + _older_major_remedy(root)}
413
+ )
414
+ return {"status": "failed", "contract": None, "findings": findings}
415
+ if contract is None:
416
+ findings.append(
417
+ {"severity": "error", "message": ".agent/execution.contract.json is missing"}
418
+ )
419
+ return {"status": "failed", "contract": None, "findings": findings}
420
+ findings.extend(validate_execution_contract(contract))
421
+ if any(finding["severity"] == "error" for finding in findings):
422
+ return {"status": "failed", "contract": contract, "findings": findings}
423
+ if contract.get("shell", {}).get("requires_posix") and shutil.which("sh") is None:
424
+ findings.append({"severity": "error", "message": "required POSIX sh is not available"})
425
+ if shutil.which("git") is None:
426
+ findings.append({"severity": "error", "message": "required git command is not available"})
427
+ if not (root / ".agent").exists():
428
+ findings.append({"severity": "error", "message": ".agent directory is missing"})
429
+ elif not (root / ".agent").is_dir():
430
+ findings.append({"severity": "error", "message": ".agent exists but is not a directory"})
431
+ elif not os.access(root / ".agent", os.W_OK):
432
+ findings.append({"severity": "error", "message": ".agent directory is not writable"})
433
+ if not is_git_repo(root):
434
+ findings.append({"severity": "warning", "message": "root is not a git repository"})
435
+ status = "failed" if any(finding["severity"] == "error" for finding in findings) else "passed"
436
+ return {"status": status, "contract": contract, "findings": findings}
437
+
438
+
439
+ def _step_runs_path(root: Path) -> Path:
440
+ return root / EXECUTION_ARTIFACT_PATHS["step-runs"]
441
+
442
+
443
+ def _step_runs_lock_path(root: Path) -> Path:
444
+ # `.lockfile` (not `.lock`) so audit_drift does not treat it as a dependency
445
+ # lockfile — mirrors receipts._ledger_lock_path.
446
+ return _step_runs_path(root).parent / "step-runs.jsonl.lockfile"
447
+
448
+
449
+ def _attempt_pointer(root: Path, step_id: str) -> Path:
450
+ return root / ".agent/attempts" / f"{step_id}.current"
451
+
452
+
453
+ def _next_attempt_id(events: List[Dict[str, Any]]) -> str:
454
+ opens = [
455
+ event
456
+ for event in events
457
+ if event.get("event") in ATTEMPT_OPENING_EVENTS
458
+ ]
459
+ return f"A{len(opens) + 1}"
460
+
461
+
462
+ def _append_step_event(root: Path, event: Dict[str, Any]) -> Dict[str, Any]:
463
+ event = {"schema_version": STEP_RUNS_SCHEMA_VERSION, "recorded_at": utc_now(), **event}
464
+ append_jsonl(_step_runs_path(root), event)
465
+ return event
466
+
467
+
468
+ def read_step_events(root: Path) -> List[Dict[str, Any]]:
469
+ return read_jsonl(_step_runs_path(root))
470
+
471
+
472
+ def read_step_state(root: Path) -> Dict[str, Any]:
473
+ state: Dict[str, Any] = {"steps": {}, "attempts": {}}
474
+ for event in read_step_events(root):
475
+ step_id = event.get("step_id")
476
+ attempt_id = event.get("attempt_id")
477
+ event_kind = event.get("event")
478
+ if not isinstance(step_id, str) or not isinstance(attempt_id, str):
479
+ continue
480
+ attempt = state["attempts"].setdefault(
481
+ attempt_id,
482
+ {"step_id": step_id, "status": "pending", "open": False, "events": []},
483
+ )
484
+ attempt.setdefault("agent_id", None)
485
+ attempt.setdefault("lease_expires_at", None)
486
+ attempt["events"].append(event)
487
+ if event_kind in ATTEMPT_OPENING_EVENTS:
488
+ attempt["agent_id"] = event.get("agent_id")
489
+ if event.get("lease_expires_at") is not None:
490
+ attempt["lease_expires_at"] = event.get("lease_expires_at")
491
+ if event_kind in LEASE_EVENTS:
492
+ continue # metadata only: never changes status/open/step aggregates
493
+ attempt["status"] = event_kind
494
+ attempt["open"] = event_kind in OPEN_EVENTS
495
+ step = state["steps"].setdefault(
496
+ step_id,
497
+ {
498
+ "status": "pending",
499
+ "completed": False,
500
+ "open_attempts": [],
501
+ "attempts": [],
502
+ },
503
+ )
504
+ if attempt_id not in step["attempts"]:
505
+ step["attempts"].append(attempt_id)
506
+ step["status"] = event_kind
507
+ step["completed"] = event_kind == "completed" or step["completed"]
508
+ for attempt_id, attempt in state["attempts"].items():
509
+ step = state["steps"][attempt["step_id"]]
510
+ if attempt["open"] and attempt_id not in step["open_attempts"]:
511
+ step["open_attempts"].append(attempt_id)
512
+ return state
513
+
514
+
515
+ def _find_step(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
516
+ for step in plan.get("steps", []):
517
+ if isinstance(step, dict) and step.get("id") == step_id:
518
+ return step
519
+ raise ValueError(f"unknown step id: {step_id}")
520
+
521
+
522
+ def next_step(root: Path, plan: Dict[str, Any]) -> Optional[Dict[str, Any]]:
523
+ state = read_step_state(root)
524
+ steps = plan.get("steps", [])
525
+ completed = {
526
+ step_id
527
+ for step_id, item in state["steps"].items()
528
+ if item.get("completed")
529
+ }
530
+ if any(isinstance(step, dict) and step.get("depends_on") for step in steps):
531
+ for step in steps:
532
+ if not isinstance(step, dict):
533
+ continue
534
+ step_id = step.get("id")
535
+ if not isinstance(step_id, str):
536
+ continue
537
+ current = state["steps"].get(step_id, {})
538
+ if current.get("completed") or current.get("open_attempts"):
539
+ continue
540
+ dependencies = step.get("depends_on", [])
541
+ if all(dependency in completed for dependency in dependencies):
542
+ return step
543
+ return None
544
+ for step in steps:
545
+ if not isinstance(step, dict):
546
+ continue
547
+ step_id = step.get("id")
548
+ current = state["steps"].get(step_id, {})
549
+ if not current.get("completed") and not current.get("open_attempts"):
550
+ return step
551
+ return None
552
+
553
+
554
+ def current_step_attempt(root: Path, step_id: str) -> Optional[str]:
555
+ pointer = _attempt_pointer(root, step_id)
556
+ if pointer.exists():
557
+ value = pointer.read_text(encoding="utf-8").strip()
558
+ if value:
559
+ return value
560
+ return None
561
+
562
+
563
+ def resolve_attempt(root: Path, step_id: str, attempt_id: Optional[str]) -> str:
564
+ if attempt_id:
565
+ return attempt_id
566
+ pointer_attempt = current_step_attempt(root, step_id)
567
+ if pointer_attempt:
568
+ return pointer_attempt
569
+ state = read_step_state(root)
570
+ open_attempts = state["steps"].get(step_id, {}).get("open_attempts", [])
571
+ if len(open_attempts) == 1:
572
+ return open_attempts[0]
573
+ if len(open_attempts) > 1:
574
+ raise ValueError(f"multiple open attempts for {step_id}; pass --attempt")
575
+ raise ValueError(f"{step_id} has no open attempt; run claim-step first")
576
+
577
+
578
+ def claim_step(
579
+ root: Path,
580
+ plan: Dict[str, Any],
581
+ step_id: str,
582
+ agent_id: str,
583
+ lease_minutes: Optional[int] = None,
584
+ now: Optional[datetime] = None,
585
+ ) -> Dict[str, Any]:
586
+ _find_step(plan, step_id)
587
+ enforce = lease_policy(root) == "enforce"
588
+ with file_lock(_step_runs_lock_path(root)):
589
+ state = read_step_state(root)
590
+ if state["steps"].get(step_id, {}).get("completed"):
591
+ raise ValueError(f"step {step_id} has completed work; use amend-step")
592
+ if enforce:
593
+ open_ids = state["steps"].get(step_id, {}).get("open_attempts", [])
594
+ if open_ids:
595
+ existing = state["attempts"][open_ids[0]]
596
+ deadline = existing.get("lease_expires_at")
597
+ if deadline is None:
598
+ raise ValueError(
599
+ f"step {step_id} has an open no-deadline attempt {open_ids[0]} "
600
+ f"owned by {existing.get('agent_id')}; owner must renew-lease "
601
+ "or run fail-step"
602
+ )
603
+ if not attempt_is_expired(existing, _now(now), lease_grace_seconds(root)):
604
+ raise ValueError(
605
+ f"step {step_id} is leased to {existing.get('agent_id')} "
606
+ f"until {deadline}"
607
+ )
608
+ raise ValueError(
609
+ f"step {step_id} attempt {open_ids[0]} lease expired at {deadline}; "
610
+ f"recover with: agentflow reclaim-step {step_id} "
611
+ "--agent <you> --reason <text>"
612
+ )
613
+ events = read_step_events(root)
614
+ attempt_id = _next_attempt_id(events)
615
+ minutes = _lease_minutes_or_default(
616
+ lease_minutes, lease_ttl_minutes(root) if enforce else None
617
+ )
618
+ event = {
619
+ "event": "claimed",
620
+ "step_id": step_id,
621
+ "attempt_id": attempt_id,
622
+ "agent_id": agent_id,
623
+ "lease_expires_at": _utc_plus_minutes(minutes, now) if minutes is not None else None,
624
+ }
625
+ recorded = _append_step_event(root, event)
626
+ pointer = _attempt_pointer(root, step_id)
627
+ pointer.parent.mkdir(parents=True, exist_ok=True)
628
+ pointer.write_text(f"{attempt_id}\n", encoding="utf-8")
629
+ return recorded
630
+
631
+
632
+ def reclaim_step(
633
+ root: Path,
634
+ plan: Dict[str, Any],
635
+ step_id: str,
636
+ agent_id: str,
637
+ reason: str,
638
+ lease_minutes: Optional[int] = None,
639
+ now: Optional[datetime] = None,
640
+ ) -> Dict[str, Any]:
641
+ """Abandon an expired attempt and open a fresh claim atomically."""
642
+ _find_step(plan, step_id)
643
+ if not agent_id or not agent_id.strip():
644
+ raise ValueError("reclaim-step requires --agent")
645
+ if not reason or not reason.strip():
646
+ raise ValueError("reclaim-step requires --reason")
647
+ with file_lock(_step_runs_lock_path(root)):
648
+ state = read_step_state(root)
649
+ open_ids = state["steps"].get(step_id, {}).get("open_attempts", [])
650
+ if not open_ids:
651
+ raise ValueError(f"step {step_id} has no open attempt; use claim-step")
652
+ if len(open_ids) > 1:
653
+ raise ValueError(
654
+ f"step {step_id} has multiple open attempts; "
655
+ "pass --attempt to fail-step first"
656
+ )
657
+ old_id = open_ids[0]
658
+ old = state["attempts"][old_id]
659
+ if old.get("lease_expires_at") is None:
660
+ raise ValueError(
661
+ f"attempt {old_id} has no deadline; owner may renew-lease or run fail-step"
662
+ )
663
+ if not attempt_is_expired(old, _now(now), lease_grace_seconds(root)):
664
+ raise ValueError(
665
+ f"attempt {old_id} still leased to {old.get('agent_id')} "
666
+ f"until {old.get('lease_expires_at')}; wait or coordinate"
667
+ )
668
+ minutes = _lease_minutes_or_default(lease_minutes, lease_ttl_minutes(root))
669
+ new_id = _next_attempt_id(read_step_events(root))
670
+ _append_step_event(root, {
671
+ "event": "abandoned", "step_id": step_id, "attempt_id": old_id,
672
+ "abandoned_by": agent_id, "reason": reason, "superseded_by": new_id,
673
+ })
674
+ recorded = _append_step_event(root, {
675
+ "event": "claimed", "step_id": step_id, "attempt_id": new_id,
676
+ "agent_id": agent_id, "lease_expires_at": _utc_plus_minutes(minutes, now),
677
+ })
678
+ pointer = _attempt_pointer(root, step_id)
679
+ pointer.parent.mkdir(parents=True, exist_ok=True)
680
+ pointer.write_text(f"{new_id}\n", encoding="utf-8")
681
+ return recorded
682
+
683
+
684
+ def _close_step(
685
+ root: Path,
686
+ step_id: str,
687
+ attempt_id: Optional[str],
688
+ event_kind: str,
689
+ reason: Optional[str] = None,
690
+ extra: Optional[Dict[str, Any]] = None,
691
+ ) -> Dict[str, Any]:
692
+ with file_lock(_step_runs_lock_path(root)):
693
+ resolved_attempt = resolve_attempt(root, step_id, attempt_id)
694
+ event: Dict[str, Any] = {
695
+ "event": event_kind,
696
+ "step_id": step_id,
697
+ "attempt_id": resolved_attempt,
698
+ }
699
+ if reason:
700
+ event["reason"] = reason
701
+ if extra:
702
+ event.update(extra)
703
+ recorded = _append_step_event(root, event)
704
+ pointer = _attempt_pointer(root, step_id)
705
+ if pointer.exists():
706
+ pointer.unlink()
707
+ return recorded
708
+
709
+
710
+ def latest_attempt_event(root: Path, step_id: str, attempt_id: str) -> Optional[Dict[str, Any]]:
711
+ latest = None
712
+ for event in read_step_events(root):
713
+ if event.get("step_id") == step_id and event.get("attempt_id") == attempt_id:
714
+ latest = event
715
+ return latest
716
+
717
+
718
+ def attempt_is_verified(root: Path, step_id: str, attempt_id: str) -> bool:
719
+ latest = latest_attempt_event(root, step_id, attempt_id)
720
+ return bool(latest and latest.get("event") == "verified")
721
+
722
+
723
+ def latest_completed_attempt(
724
+ root: Path, step_id: str
725
+ ) -> Optional[Tuple[str, Dict[str, Any]]]:
726
+ """Return (attempt_id, completed_event) for the step's latest completion."""
727
+ result: Optional[Tuple[str, Dict[str, Any]]] = None
728
+ for event in read_step_events(root):
729
+ if (
730
+ event.get("step_id") == step_id
731
+ and event.get("event") == "completed"
732
+ and isinstance(event.get("attempt_id"), str)
733
+ ):
734
+ result = (event["attempt_id"], event)
735
+ return result
736
+
737
+
738
+ def attempt_for_step(
739
+ root: Path, step_id: str, attempt_id: str
740
+ ) -> Optional[Dict[str, Any]]:
741
+ """Return the projected attempt only if it exists and belongs to step_id."""
742
+ attempt = read_step_state(root)["attempts"].get(attempt_id)
743
+ if attempt is None or attempt.get("step_id") != step_id:
744
+ return None
745
+ return attempt
746
+
747
+
748
+ def attempt_is_terminal(root: Path, step_id: str, attempt_id: str) -> bool:
749
+ latest = latest_attempt_event(root, step_id, attempt_id)
750
+ return bool(latest and latest.get("event") in TERMINAL_EVENTS)
751
+
752
+
753
+ def attempt_has_opener(root: Path, step_id: str, attempt_id: str) -> bool:
754
+ attempt = attempt_for_step(root, step_id, attempt_id)
755
+ if attempt is None:
756
+ return False
757
+ return any(
758
+ event.get("event") in ATTEMPT_OPENING_EVENTS
759
+ for event in attempt.get("events", [])
760
+ if isinstance(event, dict)
761
+ )
762
+
763
+
764
+ def _require_opened_attempt(root: Path, step_id: str, attempt_id: str) -> None:
765
+ if not attempt_has_opener(root, step_id, attempt_id):
766
+ raise ValueError(
767
+ f"attempt {attempt_id} for step {step_id} was never opened with "
768
+ "claim-step or amend-step"
769
+ )
770
+
771
+
772
+ def complete_step(
773
+ root: Path,
774
+ step_id: str,
775
+ attempt_id: Optional[str],
776
+ agent_id: Optional[str] = None,
777
+ now: Optional[datetime] = None,
778
+ ) -> Dict[str, Any]:
779
+ resolved_attempt = resolve_attempt(root, step_id, attempt_id)
780
+ _require_opened_attempt(root, step_id, resolved_attempt)
781
+ if not attempt_is_verified(root, step_id, resolved_attempt):
782
+ raise RuntimeError("verify-step must pass before complete-step")
783
+ _require_owner(root, step_id, resolved_attempt, agent_id, now, action="complete")
784
+ return _close_step(root, step_id, resolved_attempt, "completed")
785
+
786
+
787
+ def block_step(
788
+ root: Path,
789
+ step_id: str,
790
+ attempt_id: Optional[str],
791
+ reason: str,
792
+ agent_id: Optional[str] = None,
793
+ now: Optional[datetime] = None,
794
+ ) -> Dict[str, Any]:
795
+ resolved_attempt = resolve_attempt(root, step_id, attempt_id)
796
+ _require_opened_attempt(root, step_id, resolved_attempt)
797
+ _require_owner(root, step_id, resolved_attempt, agent_id, now, action="block")
798
+ return _close_step(root, step_id, resolved_attempt, "blocked", reason)
799
+
800
+
801
+ def fail_step(
802
+ root: Path,
803
+ step_id: str,
804
+ attempt_id: Optional[str],
805
+ reason: str,
806
+ agent_id: Optional[str] = None,
807
+ ) -> Dict[str, Any]:
808
+ # Break-glass path: no owner enforcement. Records who forced the failure.
809
+ extra = {"failed_by": agent_id} if agent_id else None
810
+ return _close_step(root, step_id, attempt_id, "failed", reason, extra=extra)
811
+
812
+
813
+ def mark_step_verified(
814
+ root: Path,
815
+ step_id: str,
816
+ attempt_id: Optional[str],
817
+ findings: List[Dict[str, Any]],
818
+ agent_id: Optional[str] = None,
819
+ now: Optional[datetime] = None,
820
+ ) -> Dict[str, Any]:
821
+ with file_lock(_step_runs_lock_path(root)):
822
+ resolved_attempt = resolve_attempt(root, step_id, attempt_id)
823
+ _require_opened_attempt(root, step_id, resolved_attempt)
824
+ _require_owner(root, step_id, resolved_attempt, agent_id, now, action="verify")
825
+ return _append_step_event(
826
+ root,
827
+ {
828
+ "event": "verified",
829
+ "step_id": step_id,
830
+ "attempt_id": resolved_attempt,
831
+ "findings": findings,
832
+ },
833
+ )
834
+
835
+
836
+ def amend_step(
837
+ root: Path,
838
+ plan: Dict[str, Any],
839
+ step_id: str,
840
+ agent_id: str,
841
+ reason: str,
842
+ reason_code: Optional[str] = None,
843
+ finding_refs: Optional[List[Dict[str, str]]] = None,
844
+ lease_minutes: Optional[int] = None,
845
+ now: Optional[datetime] = None,
846
+ ) -> Dict[str, Any]:
847
+ """Open an auditable amendment attempt on a completed step.
848
+
849
+ The amendment is a new attempt linked to the latest completed attempt; the
850
+ prior attempt is never mutated.
851
+ """
852
+ _find_step(plan, step_id)
853
+ with file_lock(_step_runs_lock_path(root)):
854
+ completed = latest_completed_attempt(root, step_id)
855
+ if completed is None:
856
+ raise ValueError(f"step {step_id} has no completed attempt; use claim-step")
857
+ if read_step_state(root)["steps"].get(step_id, {}).get("open_attempts"):
858
+ raise ValueError(f"step {step_id} has an open attempt; finish it first")
859
+ if not reason or not reason.strip():
860
+ raise ValueError("amend-step requires a non-empty --reason")
861
+ if not agent_id or not agent_id.strip():
862
+ raise ValueError("amend-step requires --agent")
863
+ if reason_code is not None and reason_code not in AMENDMENT_REASON_CODES:
864
+ raise ValueError(
865
+ f"invalid reason_code {reason_code}; expected one of "
866
+ f"{', '.join(sorted(AMENDMENT_REASON_CODES))}"
867
+ )
868
+ amends_attempt, completed_event = completed
869
+ attempt_id = _next_attempt_id(read_step_events(root))
870
+ event: Dict[str, Any] = {
871
+ "event": "amendment_started",
872
+ "step_id": step_id,
873
+ "attempt_id": attempt_id,
874
+ "amends_attempt": amends_attempt,
875
+ "amends_completed_at": completed_event.get("recorded_at"),
876
+ "agent_id": agent_id,
877
+ "reason": reason,
878
+ }
879
+ if reason_code is not None:
880
+ event["reason_code"] = reason_code
881
+ if finding_refs:
882
+ event["finding_refs"] = finding_refs
883
+ minutes = _lease_minutes_or_default(
884
+ lease_minutes, lease_ttl_minutes(root) if lease_policy(root) == "enforce" else None
885
+ )
886
+ if minutes is not None:
887
+ event["lease_expires_at"] = _utc_plus_minutes(minutes, now)
888
+ recorded = _append_step_event(root, event)
889
+ pointer = _attempt_pointer(root, step_id)
890
+ pointer.parent.mkdir(parents=True, exist_ok=True)
891
+ pointer.write_text(f"{attempt_id}\n", encoding="utf-8")
892
+ return recorded
893
+
894
+
895
+ def _require_owner(
896
+ root: Path,
897
+ step_id: str,
898
+ attempt_id: str,
899
+ agent_id: Optional[str],
900
+ now: Optional[datetime],
901
+ *,
902
+ action: str,
903
+ allow_expired: bool = False,
904
+ ) -> None:
905
+ """Enforce-only owner + expiry gate on an attempt.
906
+
907
+ Under ``lease_policy != "enforce"`` this is a no-op (advisory behavior is
908
+ unchanged). Under enforce the caller's ``agent_id`` must match the attempt
909
+ opener, and a finite lease must not have expired -- unless ``allow_expired``
910
+ (the ``renew-lease`` self-recovery path, where the owner may extend an
911
+ already-expired finite lease). A foreign agent is always rejected.
912
+ """
913
+ if lease_policy(root) != "enforce":
914
+ return
915
+ attempt = read_step_state(root)["attempts"].get(attempt_id)
916
+ if attempt is None:
917
+ return # existence handled by callers
918
+ if not agent_id:
919
+ raise ValueError(
920
+ f"{action} on {step_id}/{attempt_id} requires --agent or "
921
+ "AGENTFLOW_AGENT_ID under enforce"
922
+ )
923
+ owner = attempt.get("agent_id")
924
+ if owner is not None and agent_id != owner:
925
+ raise ValueError(
926
+ f"{action} rejected: {attempt_id} is owned by {owner}, not {agent_id}"
927
+ )
928
+ if owner is None:
929
+ raise ValueError(
930
+ f"{action} rejected: {attempt_id} has no owner; use fail-step "
931
+ "or repair the attempt ledger"
932
+ )
933
+ if not allow_expired and attempt_is_expired(attempt, _now(now), lease_grace_seconds(root)):
934
+ raise ValueError(
935
+ f"{action} rejected: {attempt_id} lease expired at "
936
+ f"{attempt.get('lease_expires_at')}; renew-lease or reclaim-step"
937
+ )
938
+
939
+
940
+ def require_lifecycle_owner(
941
+ root: Path,
942
+ step_id: str,
943
+ attempt_id: Optional[str],
944
+ agent_id: Optional[str],
945
+ now: Optional[datetime] = None,
946
+ *,
947
+ action: str,
948
+ ) -> str:
949
+ with file_lock(_step_runs_lock_path(root)):
950
+ resolved = resolve_attempt(root, step_id, attempt_id)
951
+ _require_opened_attempt(root, step_id, resolved)
952
+ _require_owner(root, step_id, resolved, agent_id, now, action=action)
953
+ return resolved
954
+
955
+
956
+ def renew_lease(
957
+ root: Path,
958
+ step_id: str,
959
+ attempt_id: Optional[str] = None,
960
+ agent_id: Optional[str] = None,
961
+ minutes: Optional[int] = None,
962
+ now: Optional[datetime] = None,
963
+ ) -> Dict[str, Any]:
964
+ """Extend an attempt's lease by appending a metadata-only lease_renewed row.
965
+
966
+ Owner self-recovery: the owning agent may renew even an expired finite
967
+ lease (``allow_expired=True``); foreign recovery still goes through
968
+ reclaim-step. Prior rows are never mutated -- projection surfaces the
969
+ newest deadline.
970
+ """
971
+ with file_lock(_step_runs_lock_path(root)):
972
+ resolved = resolve_attempt(root, step_id, attempt_id)
973
+ _require_opened_attempt(root, step_id, resolved)
974
+ _require_owner(root, step_id, resolved, agent_id, now, action="renew", allow_expired=True)
975
+ resolved_minutes = _lease_minutes_or_default(minutes, lease_ttl_minutes(root))
976
+ recorded = _append_step_event(
977
+ root,
978
+ {
979
+ "event": "lease_renewed",
980
+ "step_id": step_id,
981
+ "attempt_id": resolved,
982
+ "agent_id": agent_id,
983
+ "lease_expires_at": _utc_plus_minutes(resolved_minutes, now),
984
+ },
985
+ )
986
+ return recorded
987
+
988
+
989
+ def require_writable_attempt(
990
+ root: Path,
991
+ step_id: str,
992
+ attempt_id: str,
993
+ *,
994
+ new_work: bool,
995
+ agent_id: Optional[str] = None,
996
+ now: Optional[datetime] = None,
997
+ ) -> None:
998
+ """Validate a receipt's target attempt before it is written.
999
+
1000
+ All receipts must name an existing attempt for the same step. New-work
1001
+ receipts additionally require the attempt to be open (not terminal), so
1002
+ post-completion edits must go through amend-step. Under lease_policy=enforce
1003
+ a new-work write also requires the caller to be the (non-expired) owner.
1004
+ """
1005
+ state = read_step_state(root)
1006
+ attempt = state["attempts"].get(attempt_id)
1007
+ if attempt is not None and attempt.get("step_id") != step_id:
1008
+ attempt = None
1009
+ if attempt is None:
1010
+ raise ValueError(f"attempt {attempt_id} does not belong to step {step_id}")
1011
+ if not new_work:
1012
+ return
1013
+ if not attempt_has_opener(root, step_id, attempt_id):
1014
+ raise ValueError(
1015
+ f"attempt {attempt_id} for step {step_id} was never opened with "
1016
+ "claim-step or amend-step"
1017
+ )
1018
+ if not attempt.get("open"):
1019
+ guidance = (
1020
+ "open an amendment with amend-step"
1021
+ if state["steps"].get(step_id, {}).get("completed")
1022
+ else "retry with claim-step"
1023
+ )
1024
+ raise ValueError(
1025
+ f"attempt {attempt_id} for step {step_id} is not open; {guidance}"
1026
+ )
1027
+ _require_owner(root, step_id, attempt_id, agent_id, now, action="write")