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/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
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")
|