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/porcelain.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""Porcelain commands that report and sequence the Agentflow loop.
|
|
2
|
+
|
|
3
|
+
Read-only inspection (`next_action`) plus thin sequencers (`finish_step`,
|
|
4
|
+
`finish_run`) over existing plumbing. No new durable state.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shlex
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .artifacts import plan_binding_sha256, read_json, read_jsonl
|
|
16
|
+
from .contracts import CHANGE_KINDS, EXECUTION_ARTIFACT_PATHS, PROVENANCE_VALUES
|
|
17
|
+
from .execution import (
|
|
18
|
+
ATTEMPT_OPENING_EVENTS,
|
|
19
|
+
LEASE_EVENTS,
|
|
20
|
+
OPEN_EVENTS,
|
|
21
|
+
TERMINAL_EVENTS,
|
|
22
|
+
attempt_deadline,
|
|
23
|
+
attempt_is_expired,
|
|
24
|
+
attempt_is_verified,
|
|
25
|
+
complete_step,
|
|
26
|
+
lease_grace_seconds,
|
|
27
|
+
lease_policy,
|
|
28
|
+
lease_ttl_minutes,
|
|
29
|
+
load_execution_contract,
|
|
30
|
+
mark_step_verified,
|
|
31
|
+
next_step,
|
|
32
|
+
read_step_state,
|
|
33
|
+
require_lifecycle_owner,
|
|
34
|
+
validate_execution_contract,
|
|
35
|
+
)
|
|
36
|
+
from .execution_coverage import verify_run, verify_step
|
|
37
|
+
from .git import changed_file_records
|
|
38
|
+
from .proof import verify_proof
|
|
39
|
+
from .receipts import command_receipts, file_receipts, sha256_path
|
|
40
|
+
from .validation import audit_drift, path_in_effective_scope, validate_plan
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Action:
|
|
45
|
+
state: str
|
|
46
|
+
reason: str
|
|
47
|
+
command: Optional[str] = None
|
|
48
|
+
args: List[str] = field(default_factory=list)
|
|
49
|
+
step_id: Optional[str] = None
|
|
50
|
+
gate: Optional[str] = None
|
|
51
|
+
diagnostics: List[str] = field(default_factory=list)
|
|
52
|
+
blocking: bool = True
|
|
53
|
+
resumability: Optional[Dict[str, Any]] = None
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
56
|
+
data: Dict[str, Any] = {
|
|
57
|
+
"state": self.state,
|
|
58
|
+
"reason": self.reason,
|
|
59
|
+
"blocking": self.blocking,
|
|
60
|
+
"diagnostics": list(self.diagnostics),
|
|
61
|
+
}
|
|
62
|
+
if self.command is not None:
|
|
63
|
+
data["command"] = self.command
|
|
64
|
+
data["args"] = list(self.args)
|
|
65
|
+
if self.step_id:
|
|
66
|
+
data["step_id"] = self.step_id
|
|
67
|
+
if self.gate:
|
|
68
|
+
data["gate"] = self.gate
|
|
69
|
+
if self.resumability is not None:
|
|
70
|
+
data["resumability"] = self.resumability
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_IGNORED_PREFIXES = (".agent/", ".git/")
|
|
75
|
+
_STUCK_ADVISORY_RULES = {
|
|
76
|
+
"repeated_command_failure",
|
|
77
|
+
"repeated_verify_failure",
|
|
78
|
+
"alternating_no_op",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cmd(args: List[str]) -> str:
|
|
83
|
+
"""Render an argv list as a copy-paste 'agentflow ...' command."""
|
|
84
|
+
rendered = []
|
|
85
|
+
for token in args:
|
|
86
|
+
rendered.append(token if token == "$USER" else shlex.quote(token))
|
|
87
|
+
return "agentflow " + " ".join(rendered)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_plan(root: Path) -> Optional[Any]:
|
|
91
|
+
plan_path = root / ".agent/plan.lock.json"
|
|
92
|
+
if not plan_path.exists():
|
|
93
|
+
return None
|
|
94
|
+
return read_json(plan_path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_RECOVERY_ACTIONS = ("claim", "continue", "renew", "reclaim", "fail")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _recovery_action(
|
|
101
|
+
action: str,
|
|
102
|
+
allowed: bool,
|
|
103
|
+
reason: str,
|
|
104
|
+
*,
|
|
105
|
+
automatic: bool = True,
|
|
106
|
+
break_glass: bool = False,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"action": action,
|
|
110
|
+
"allowed": allowed,
|
|
111
|
+
"automatic": automatic,
|
|
112
|
+
"break_glass": break_glass,
|
|
113
|
+
"reason": reason,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _denied_recovery_actions(reason: str) -> List[Dict[str, Any]]:
|
|
118
|
+
return [
|
|
119
|
+
_recovery_action(
|
|
120
|
+
action,
|
|
121
|
+
False,
|
|
122
|
+
reason,
|
|
123
|
+
automatic=action != "fail",
|
|
124
|
+
break_glass=action == "fail",
|
|
125
|
+
)
|
|
126
|
+
for action in _RECOVERY_ACTIONS
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _receipt_string(
|
|
131
|
+
row: Dict[str, Any],
|
|
132
|
+
key: str,
|
|
133
|
+
relative: str,
|
|
134
|
+
line_number: int,
|
|
135
|
+
*,
|
|
136
|
+
non_empty: bool = True,
|
|
137
|
+
) -> str:
|
|
138
|
+
value = row.get(key)
|
|
139
|
+
if not isinstance(value, str) or (non_empty and not value):
|
|
140
|
+
requirement = "non-empty string" if non_empty else "string"
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"{relative}:{line_number}: receipt {key} must be a {requirement}"
|
|
143
|
+
)
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_command_receipt(
|
|
148
|
+
row: Dict[str, Any],
|
|
149
|
+
relative: str,
|
|
150
|
+
line_number: int,
|
|
151
|
+
) -> None:
|
|
152
|
+
for key in ("id", "step_id", "attempt_id", "provenance"):
|
|
153
|
+
_receipt_string(row, key, relative, line_number)
|
|
154
|
+
for key in ("cwd", "started_at", "finished_at"):
|
|
155
|
+
_receipt_string(row, key, relative, line_number, non_empty=False)
|
|
156
|
+
if row["provenance"] not in PROVENANCE_VALUES:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"{relative}:{line_number}: receipt provenance is invalid"
|
|
159
|
+
)
|
|
160
|
+
command = row.get("command")
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(command, list)
|
|
163
|
+
or not command
|
|
164
|
+
or not all(isinstance(item, str) for item in command)
|
|
165
|
+
):
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"{relative}:{line_number}: receipt command must be a non-empty string array"
|
|
168
|
+
)
|
|
169
|
+
exit_code = row.get("exit_code")
|
|
170
|
+
if "exit_code" not in row or (
|
|
171
|
+
exit_code is not None
|
|
172
|
+
and (not isinstance(exit_code, int) or isinstance(exit_code, bool))
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"{relative}:{line_number}: receipt exit_code must be an integer or null"
|
|
176
|
+
)
|
|
177
|
+
if not isinstance(row.get("truncated"), bool):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"{relative}:{line_number}: receipt truncated must be boolean"
|
|
180
|
+
)
|
|
181
|
+
gate = row.get("gate")
|
|
182
|
+
if gate is not None and not isinstance(gate, str):
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"{relative}:{line_number}: receipt gate must be a string or null"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_file_receipt(
|
|
189
|
+
row: Dict[str, Any],
|
|
190
|
+
relative: str,
|
|
191
|
+
line_number: int,
|
|
192
|
+
) -> None:
|
|
193
|
+
for key in ("id", "step_id", "attempt_id", "path"):
|
|
194
|
+
_receipt_string(row, key, relative, line_number)
|
|
195
|
+
_receipt_string(row, "recorded_at", relative, line_number, non_empty=False)
|
|
196
|
+
if row.get("change_kind") not in CHANGE_KINDS:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"{relative}:{line_number}: receipt change_kind is invalid"
|
|
199
|
+
)
|
|
200
|
+
for key in ("before_git_blob", "after_sha256"):
|
|
201
|
+
value = row.get(key)
|
|
202
|
+
if key not in row or (value is not None and not isinstance(value, str)):
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"{relative}:{line_number}: receipt {key} must be a string or null"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _projection_ledgers(root: Path) -> Dict[str, List[Dict[str, Any]]]:
|
|
209
|
+
ledgers: Dict[str, List[Dict[str, Any]]] = {}
|
|
210
|
+
for name, relative in EXECUTION_ARTIFACT_PATHS.items():
|
|
211
|
+
if name == "execution-contract":
|
|
212
|
+
continue
|
|
213
|
+
path = root / relative
|
|
214
|
+
if not path.is_file():
|
|
215
|
+
raise ValueError(f"{relative} is missing")
|
|
216
|
+
rows = read_jsonl(path)
|
|
217
|
+
for line_number, row in enumerate(rows, start=1):
|
|
218
|
+
if not isinstance(row, dict):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"{relative}:{line_number}: ledger entry must be a JSON object"
|
|
221
|
+
)
|
|
222
|
+
if name == "command-receipts":
|
|
223
|
+
_validate_command_receipt(row, relative, line_number)
|
|
224
|
+
elif name == "file-receipts":
|
|
225
|
+
_validate_file_receipt(row, relative, line_number)
|
|
226
|
+
ledgers[name] = rows
|
|
227
|
+
return ledgers
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _validate_step_event_identity(
|
|
231
|
+
events: List[Dict[str, Any]],
|
|
232
|
+
policy: str,
|
|
233
|
+
) -> None:
|
|
234
|
+
valid_events = OPEN_EVENTS | TERMINAL_EVENTS | LEASE_EVENTS
|
|
235
|
+
attempt_steps: Dict[str, str] = {}
|
|
236
|
+
attempt_owners: Dict[str, Optional[str]] = {}
|
|
237
|
+
opened_attempts = set()
|
|
238
|
+
terminal_attempts = set()
|
|
239
|
+
for line_number, event in enumerate(events, start=1):
|
|
240
|
+
event_kind = event.get("event")
|
|
241
|
+
step_id = event.get("step_id")
|
|
242
|
+
attempt_id = event.get("attempt_id")
|
|
243
|
+
if event_kind not in valid_events:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f".agent/step-runs.jsonl:{line_number}: invalid event {event_kind!r}"
|
|
246
|
+
)
|
|
247
|
+
if not isinstance(step_id, str) or not step_id:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f".agent/step-runs.jsonl:{line_number}: step_id must be a non-empty string"
|
|
250
|
+
)
|
|
251
|
+
if not isinstance(attempt_id, str) or not attempt_id:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f".agent/step-runs.jsonl:{line_number}: attempt_id must be a non-empty string"
|
|
254
|
+
)
|
|
255
|
+
owner = event.get("agent_id")
|
|
256
|
+
if owner is not None and not isinstance(owner, str):
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f".agent/step-runs.jsonl:{line_number}: agent_id must be a string"
|
|
259
|
+
)
|
|
260
|
+
deadline = event.get("lease_expires_at")
|
|
261
|
+
if deadline is not None and not isinstance(deadline, str):
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f".agent/step-runs.jsonl:{line_number}: lease_expires_at must be a string or null"
|
|
264
|
+
)
|
|
265
|
+
prior_step = attempt_steps.setdefault(attempt_id, step_id)
|
|
266
|
+
if prior_step != step_id:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"attempt {attempt_id} belongs to multiple steps: {prior_step}, {step_id}"
|
|
269
|
+
)
|
|
270
|
+
if attempt_id in terminal_attempts:
|
|
271
|
+
raise ValueError(f"attempt {attempt_id} has events after a terminal event")
|
|
272
|
+
if event_kind in ATTEMPT_OPENING_EVENTS:
|
|
273
|
+
if attempt_id in opened_attempts:
|
|
274
|
+
raise ValueError(f"attempt {attempt_id} has multiple opening events")
|
|
275
|
+
opened_attempts.add(attempt_id)
|
|
276
|
+
attempt_owners[attempt_id] = owner
|
|
277
|
+
elif attempt_id not in opened_attempts and event_kind != "failed":
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"attempt {attempt_id} has no opening event before {event_kind}"
|
|
280
|
+
)
|
|
281
|
+
if event_kind in LEASE_EVENTS and policy == "enforce":
|
|
282
|
+
expected_owner = attempt_owners[attempt_id]
|
|
283
|
+
if not owner or owner != expected_owner:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"attempt {attempt_id} renewed by {owner} instead of {expected_owner}"
|
|
286
|
+
)
|
|
287
|
+
if event_kind in TERMINAL_EVENTS:
|
|
288
|
+
terminal_attempts.add(attempt_id)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _base_resumability(
|
|
292
|
+
plan: Optional[Dict[str, Any]],
|
|
293
|
+
agent_id: Optional[str],
|
|
294
|
+
) -> Dict[str, Any]:
|
|
295
|
+
contract = {
|
|
296
|
+
"plan_schema_version": None,
|
|
297
|
+
"plan_sha256": None,
|
|
298
|
+
"locked": False,
|
|
299
|
+
"locked_at": None,
|
|
300
|
+
"execution_schema_version": None,
|
|
301
|
+
"execution_contract_sha256": None,
|
|
302
|
+
}
|
|
303
|
+
if isinstance(plan, dict):
|
|
304
|
+
schema_version = plan.get("schema_version")
|
|
305
|
+
locked_at = plan.get("locked_at")
|
|
306
|
+
contract.update({
|
|
307
|
+
"plan_schema_version": (
|
|
308
|
+
schema_version if isinstance(schema_version, str) else None
|
|
309
|
+
),
|
|
310
|
+
"plan_sha256": plan_binding_sha256(plan),
|
|
311
|
+
"locked": plan.get("locked") is True,
|
|
312
|
+
"locked_at": locked_at if isinstance(locked_at, str) else None,
|
|
313
|
+
})
|
|
314
|
+
return {
|
|
315
|
+
"contract": contract,
|
|
316
|
+
"agent_id": agent_id or None,
|
|
317
|
+
"step": None,
|
|
318
|
+
"attempt": None,
|
|
319
|
+
"lease": {
|
|
320
|
+
"policy": None,
|
|
321
|
+
"ttl_minutes": None,
|
|
322
|
+
"grace_seconds": None,
|
|
323
|
+
"expires_at": None,
|
|
324
|
+
"state": "unknown",
|
|
325
|
+
"exclusive": False,
|
|
326
|
+
},
|
|
327
|
+
"receipts": {"commands": [], "files": []},
|
|
328
|
+
"gates": [],
|
|
329
|
+
"recovery_actions": _denied_recovery_actions("state is not resumable"),
|
|
330
|
+
"diagnostics": [],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _diagnose(
|
|
335
|
+
projection: Dict[str, Any],
|
|
336
|
+
code: str,
|
|
337
|
+
message: str,
|
|
338
|
+
artifact: str,
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
projection["diagnostics"].append({
|
|
341
|
+
"code": code,
|
|
342
|
+
"message": message,
|
|
343
|
+
"artifact": artifact,
|
|
344
|
+
})
|
|
345
|
+
projection["recovery_actions"] = _denied_recovery_actions(message)
|
|
346
|
+
return projection
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _invalid_action(projection: Dict[str, Any]) -> Action:
|
|
350
|
+
messages = [item["message"] for item in projection["diagnostics"]]
|
|
351
|
+
return Action(
|
|
352
|
+
"state_invalid",
|
|
353
|
+
"Agentflow state is invalid or ambiguous",
|
|
354
|
+
diagnostics=messages,
|
|
355
|
+
resumability=projection,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _attach(action: Action, projection: Dict[str, Any]) -> Action:
|
|
360
|
+
action.resumability = projection
|
|
361
|
+
return action
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _project_receipts(root: Path, step_id: str, attempt_id: str) -> Dict[str, Any]:
|
|
365
|
+
commands = [
|
|
366
|
+
{
|
|
367
|
+
"id": row.get("id"),
|
|
368
|
+
"gate": row.get("gate"),
|
|
369
|
+
"command": row.get("command"),
|
|
370
|
+
"exit_code": row.get("exit_code"),
|
|
371
|
+
"decision": row.get("decision"),
|
|
372
|
+
"timed_out": row.get("timed_out") is True,
|
|
373
|
+
"provenance": row.get("provenance"),
|
|
374
|
+
"finished_at": row.get("finished_at"),
|
|
375
|
+
}
|
|
376
|
+
for row in command_receipts(root)
|
|
377
|
+
if row.get("step_id") == step_id
|
|
378
|
+
and row.get("attempt_id") == attempt_id
|
|
379
|
+
and isinstance(row.get("id"), str)
|
|
380
|
+
]
|
|
381
|
+
files = [
|
|
382
|
+
{
|
|
383
|
+
"id": row.get("id"),
|
|
384
|
+
"path": row.get("path"),
|
|
385
|
+
"change_kind": row.get("change_kind"),
|
|
386
|
+
"recorded_at": row.get("recorded_at"),
|
|
387
|
+
}
|
|
388
|
+
for row in file_receipts(root)
|
|
389
|
+
if row.get("step_id") == step_id
|
|
390
|
+
and row.get("attempt_id") == attempt_id
|
|
391
|
+
and isinstance(row.get("id"), str)
|
|
392
|
+
]
|
|
393
|
+
return {"commands": commands, "files": files}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _project_recovery_actions(
|
|
397
|
+
step_id: Optional[str],
|
|
398
|
+
attempt: Optional[Dict[str, Any]],
|
|
399
|
+
agent_id: Optional[str],
|
|
400
|
+
policy: str,
|
|
401
|
+
lease_state: str,
|
|
402
|
+
) -> List[Dict[str, Any]]:
|
|
403
|
+
if step_id is None:
|
|
404
|
+
return _denied_recovery_actions("no actionable step")
|
|
405
|
+
if attempt is None:
|
|
406
|
+
claim_allowed = bool(agent_id)
|
|
407
|
+
claim_reason = (
|
|
408
|
+
f"step {step_id} is eligible for {agent_id}"
|
|
409
|
+
if claim_allowed
|
|
410
|
+
else "claim requires an agent identity"
|
|
411
|
+
)
|
|
412
|
+
return [
|
|
413
|
+
_recovery_action("claim", claim_allowed, claim_reason),
|
|
414
|
+
_recovery_action("continue", False, "no open attempt"),
|
|
415
|
+
_recovery_action("renew", False, "no open attempt"),
|
|
416
|
+
_recovery_action("reclaim", False, "no open attempt"),
|
|
417
|
+
_recovery_action(
|
|
418
|
+
"fail", False, "no open attempt",
|
|
419
|
+
automatic=False, break_glass=True,
|
|
420
|
+
),
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
owner = attempt.get("agent_id")
|
|
424
|
+
enforce = policy == "enforce"
|
|
425
|
+
if not enforce:
|
|
426
|
+
continue_allowed = True
|
|
427
|
+
continue_reason = "advisory policy does not enforce attempt ownership"
|
|
428
|
+
renew_allowed = True
|
|
429
|
+
renew_reason = "advisory renewal is metadata and does not establish exclusivity"
|
|
430
|
+
elif not agent_id:
|
|
431
|
+
continue_allowed = renew_allowed = False
|
|
432
|
+
continue_reason = renew_reason = "owner-only action requires an agent identity"
|
|
433
|
+
elif owner is None:
|
|
434
|
+
continue_allowed = renew_allowed = False
|
|
435
|
+
continue_reason = renew_reason = "attempt has no recorded owner"
|
|
436
|
+
elif agent_id != owner:
|
|
437
|
+
continue_allowed = renew_allowed = False
|
|
438
|
+
continue_reason = renew_reason = f"attempt is owned by {owner}"
|
|
439
|
+
else:
|
|
440
|
+
renew_allowed = True
|
|
441
|
+
renew_reason = "owner may renew a finite or no-deadline lease"
|
|
442
|
+
continue_allowed = lease_state != "expired"
|
|
443
|
+
continue_reason = (
|
|
444
|
+
"owner holds the active lease"
|
|
445
|
+
if continue_allowed
|
|
446
|
+
else "expired enforced lease must be renewed or reclaimed"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
reclaim_allowed = bool(agent_id) and lease_state == "expired"
|
|
450
|
+
reclaim_reason = (
|
|
451
|
+
"finite lease is expired and an agent identity was supplied"
|
|
452
|
+
if reclaim_allowed
|
|
453
|
+
else (
|
|
454
|
+
"reclaim requires an agent identity"
|
|
455
|
+
if not agent_id and lease_state == "expired"
|
|
456
|
+
else "only an expired finite lease is reclaimable"
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return [
|
|
460
|
+
_recovery_action("claim", False, "step already has an open attempt"),
|
|
461
|
+
_recovery_action("continue", continue_allowed, continue_reason),
|
|
462
|
+
_recovery_action("renew", renew_allowed, renew_reason),
|
|
463
|
+
_recovery_action("reclaim", reclaim_allowed, reclaim_reason),
|
|
464
|
+
_recovery_action(
|
|
465
|
+
"fail",
|
|
466
|
+
True,
|
|
467
|
+
"fail-step is an explicit operator break-glass action",
|
|
468
|
+
automatic=False,
|
|
469
|
+
break_glass=True,
|
|
470
|
+
),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def resumability_projection(
|
|
475
|
+
root: Path,
|
|
476
|
+
plan: Any,
|
|
477
|
+
agent_id: Optional[str] = None,
|
|
478
|
+
strict: bool = False,
|
|
479
|
+
now: Optional[datetime] = None,
|
|
480
|
+
) -> Dict[str, Any]:
|
|
481
|
+
projection = _base_resumability(plan, agent_id)
|
|
482
|
+
if not isinstance(plan, dict):
|
|
483
|
+
return _diagnose(
|
|
484
|
+
projection,
|
|
485
|
+
"plan_invalid",
|
|
486
|
+
".agent/plan.lock.json top-level value must be a JSON object",
|
|
487
|
+
".agent/plan.lock.json",
|
|
488
|
+
)
|
|
489
|
+
try:
|
|
490
|
+
plan_errors = validate_plan(plan)
|
|
491
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
492
|
+
plan_errors = [f"plan structure is invalid: {exc}"]
|
|
493
|
+
locked_at = plan.get("locked_at")
|
|
494
|
+
if locked_at is not None and not isinstance(locked_at, str):
|
|
495
|
+
plan_errors.append("locked_at must be a string or null")
|
|
496
|
+
if plan_errors:
|
|
497
|
+
return _diagnose(
|
|
498
|
+
projection,
|
|
499
|
+
"plan_invalid",
|
|
500
|
+
"; ".join(plan_errors),
|
|
501
|
+
".agent/plan.lock.json",
|
|
502
|
+
)
|
|
503
|
+
contract_path = root / ".agent/execution.contract.json"
|
|
504
|
+
if not contract_path.exists():
|
|
505
|
+
return _diagnose(
|
|
506
|
+
projection,
|
|
507
|
+
"execution_contract_missing",
|
|
508
|
+
".agent/execution.contract.json is missing",
|
|
509
|
+
".agent/execution.contract.json",
|
|
510
|
+
)
|
|
511
|
+
try:
|
|
512
|
+
contract = load_execution_contract(root)
|
|
513
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
514
|
+
return _diagnose(
|
|
515
|
+
projection,
|
|
516
|
+
"execution_contract_invalid",
|
|
517
|
+
str(exc),
|
|
518
|
+
".agent/execution.contract.json",
|
|
519
|
+
)
|
|
520
|
+
if not isinstance(contract, dict):
|
|
521
|
+
return _diagnose(
|
|
522
|
+
projection,
|
|
523
|
+
"execution_contract_invalid",
|
|
524
|
+
"execution contract must be a JSON object",
|
|
525
|
+
".agent/execution.contract.json",
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
contract_errors = validate_execution_contract(contract)
|
|
529
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
530
|
+
return _diagnose(
|
|
531
|
+
projection,
|
|
532
|
+
"execution_contract_invalid",
|
|
533
|
+
str(exc),
|
|
534
|
+
".agent/execution.contract.json",
|
|
535
|
+
)
|
|
536
|
+
if contract_errors:
|
|
537
|
+
return _diagnose(
|
|
538
|
+
projection,
|
|
539
|
+
"execution_contract_invalid",
|
|
540
|
+
"; ".join(item["message"] for item in contract_errors),
|
|
541
|
+
".agent/execution.contract.json",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
# Inside the fail-closed handler: these re-read .agent files, and a
|
|
546
|
+
# concurrent unlink between the contract load above and here must
|
|
547
|
+
# yield a diagnostic, not a traceback.
|
|
548
|
+
policy = lease_policy(root)
|
|
549
|
+
ttl = lease_ttl_minutes(root)
|
|
550
|
+
grace = lease_grace_seconds(root)
|
|
551
|
+
projection["contract"].update({
|
|
552
|
+
"execution_schema_version": contract.get("schema_version"),
|
|
553
|
+
"execution_contract_sha256": sha256_path(contract_path),
|
|
554
|
+
})
|
|
555
|
+
projection["lease"].update({
|
|
556
|
+
"policy": policy,
|
|
557
|
+
"ttl_minutes": ttl,
|
|
558
|
+
"grace_seconds": grace,
|
|
559
|
+
"exclusive": policy == "enforce",
|
|
560
|
+
})
|
|
561
|
+
ledgers = _projection_ledgers(root)
|
|
562
|
+
_validate_step_event_identity(ledgers["step-runs"], policy)
|
|
563
|
+
state = read_step_state(root)
|
|
564
|
+
plan_steps = {
|
|
565
|
+
step["id"]: step
|
|
566
|
+
for step in plan.get("steps", [])
|
|
567
|
+
if isinstance(step, dict) and isinstance(step.get("id"), str)
|
|
568
|
+
}
|
|
569
|
+
unknown_steps = sorted(set(state["steps"]) - set(plan_steps))
|
|
570
|
+
if unknown_steps:
|
|
571
|
+
return _diagnose(
|
|
572
|
+
projection,
|
|
573
|
+
"execution_state_invalid",
|
|
574
|
+
f"execution ledger references unknown steps: {', '.join(unknown_steps)}",
|
|
575
|
+
".agent/step-runs.jsonl",
|
|
576
|
+
)
|
|
577
|
+
open_attempts = [
|
|
578
|
+
(step_id, attempt_id)
|
|
579
|
+
for step_id, step in state["steps"].items()
|
|
580
|
+
for attempt_id in step.get("open_attempts", [])
|
|
581
|
+
]
|
|
582
|
+
if len(open_attempts) > 1:
|
|
583
|
+
return _diagnose(
|
|
584
|
+
projection,
|
|
585
|
+
"ambiguous_open_attempts",
|
|
586
|
+
"multiple open attempts prevent safe resumability selection",
|
|
587
|
+
".agent/step-runs.jsonl",
|
|
588
|
+
)
|
|
589
|
+
if not open_attempts:
|
|
590
|
+
eligible = next_step(root, plan)
|
|
591
|
+
if eligible is not None:
|
|
592
|
+
step_id = eligible["id"]
|
|
593
|
+
step_state = state["steps"].get(step_id, {})
|
|
594
|
+
projection["step"] = {
|
|
595
|
+
"id": step_id,
|
|
596
|
+
"state": step_state.get("status", "pending"),
|
|
597
|
+
"completed": bool(step_state.get("completed")),
|
|
598
|
+
}
|
|
599
|
+
projection["lease"]["state"] = "not_applicable"
|
|
600
|
+
projection["recovery_actions"] = _project_recovery_actions(
|
|
601
|
+
projection["step"]["id"] if projection["step"] else None,
|
|
602
|
+
None,
|
|
603
|
+
agent_id,
|
|
604
|
+
policy,
|
|
605
|
+
"not_applicable",
|
|
606
|
+
)
|
|
607
|
+
return projection
|
|
608
|
+
|
|
609
|
+
step_id, attempt_id = open_attempts[0]
|
|
610
|
+
attempt = state["attempts"].get(attempt_id)
|
|
611
|
+
if not isinstance(attempt, dict) or attempt.get("step_id") != step_id:
|
|
612
|
+
return _diagnose(
|
|
613
|
+
projection,
|
|
614
|
+
"execution_state_invalid",
|
|
615
|
+
f"attempt {attempt_id} is inconsistent with step {step_id}",
|
|
616
|
+
".agent/step-runs.jsonl",
|
|
617
|
+
)
|
|
618
|
+
if policy == "enforce" and not attempt.get("agent_id"):
|
|
619
|
+
return _diagnose(
|
|
620
|
+
projection,
|
|
621
|
+
"execution_state_invalid",
|
|
622
|
+
f"enforced attempt {attempt_id} has no owner",
|
|
623
|
+
".agent/step-runs.jsonl",
|
|
624
|
+
)
|
|
625
|
+
expires_at = attempt.get("lease_expires_at")
|
|
626
|
+
if expires_at is not None and attempt_deadline(attempt) is None:
|
|
627
|
+
return _diagnose(
|
|
628
|
+
projection,
|
|
629
|
+
"execution_state_invalid",
|
|
630
|
+
f"attempt {attempt_id} has an invalid lease deadline",
|
|
631
|
+
".agent/step-runs.jsonl",
|
|
632
|
+
)
|
|
633
|
+
current = now or datetime.now(timezone.utc)
|
|
634
|
+
lease_state = (
|
|
635
|
+
"no_deadline"
|
|
636
|
+
if expires_at is None
|
|
637
|
+
else "expired"
|
|
638
|
+
if attempt_is_expired(attempt, current, grace)
|
|
639
|
+
else "live"
|
|
640
|
+
)
|
|
641
|
+
step_state = state["steps"][step_id]
|
|
642
|
+
projection["step"] = {
|
|
643
|
+
"id": step_id,
|
|
644
|
+
"state": step_state.get("status", "pending"),
|
|
645
|
+
"completed": bool(step_state.get("completed")),
|
|
646
|
+
}
|
|
647
|
+
projection["attempt"] = {
|
|
648
|
+
"id": attempt_id,
|
|
649
|
+
"state": attempt.get("status"),
|
|
650
|
+
"owner": attempt.get("agent_id"),
|
|
651
|
+
"open": bool(attempt.get("open")),
|
|
652
|
+
}
|
|
653
|
+
projection["lease"].update({
|
|
654
|
+
"expires_at": expires_at,
|
|
655
|
+
"state": lease_state,
|
|
656
|
+
})
|
|
657
|
+
projection["receipts"] = _project_receipts(root, step_id, attempt_id)
|
|
658
|
+
projection["gates"] = verify_step(
|
|
659
|
+
root,
|
|
660
|
+
plan,
|
|
661
|
+
step_id,
|
|
662
|
+
attempt_id,
|
|
663
|
+
strict=strict,
|
|
664
|
+
record=False,
|
|
665
|
+
include_gates=True,
|
|
666
|
+
).get("gates", [])
|
|
667
|
+
projection["recovery_actions"] = _project_recovery_actions(
|
|
668
|
+
step_id,
|
|
669
|
+
attempt,
|
|
670
|
+
agent_id,
|
|
671
|
+
policy,
|
|
672
|
+
lease_state,
|
|
673
|
+
)
|
|
674
|
+
return projection
|
|
675
|
+
except (
|
|
676
|
+
AttributeError,
|
|
677
|
+
KeyError,
|
|
678
|
+
OSError,
|
|
679
|
+
TypeError,
|
|
680
|
+
ValueError,
|
|
681
|
+
json.JSONDecodeError,
|
|
682
|
+
) as exc:
|
|
683
|
+
return _diagnose(
|
|
684
|
+
projection,
|
|
685
|
+
"execution_state_invalid",
|
|
686
|
+
str(exc),
|
|
687
|
+
"execution ledgers",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str,
|
|
692
|
+
attempt_id: str) -> List[str]:
|
|
693
|
+
"""Scoped changed files with no file receipt for this open attempt."""
|
|
694
|
+
recorded = {
|
|
695
|
+
r.get("path")
|
|
696
|
+
for r in file_receipts(root)
|
|
697
|
+
if r.get("step_id") == step_id and r.get("attempt_id") == attempt_id
|
|
698
|
+
}
|
|
699
|
+
missing: List[str] = []
|
|
700
|
+
for record in changed_file_records(root):
|
|
701
|
+
path = record.get("path")
|
|
702
|
+
if not isinstance(path, str):
|
|
703
|
+
continue
|
|
704
|
+
if path.startswith(_IGNORED_PREFIXES):
|
|
705
|
+
continue
|
|
706
|
+
if not path_in_effective_scope(plan, step_id, path):
|
|
707
|
+
continue
|
|
708
|
+
if path not in recorded:
|
|
709
|
+
missing.append(path)
|
|
710
|
+
return sorted(set(missing))
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _command_gate_commands(step: Dict[str, Any]) -> List[tuple[str, List[str], List[str]]]:
|
|
714
|
+
"""Return command gate labels, commands, and aliases in plan order."""
|
|
715
|
+
validation = [item for item in step.get("validation", []) if isinstance(item, str)]
|
|
716
|
+
gates = step.get("gates")
|
|
717
|
+
commands: List[tuple[str, List[str], List[str]]] = []
|
|
718
|
+
if isinstance(gates, list):
|
|
719
|
+
for index, gate in enumerate(gates):
|
|
720
|
+
if isinstance(gate, dict) and gate.get("kind") == "command":
|
|
721
|
+
run = gate.get("run")
|
|
722
|
+
if isinstance(run, list) and all(isinstance(item, str) for item in run):
|
|
723
|
+
label = " ".join(run)
|
|
724
|
+
aliases = [label]
|
|
725
|
+
if index < len(validation):
|
|
726
|
+
aliases.append(validation[index])
|
|
727
|
+
commands.append((label, run, aliases))
|
|
728
|
+
if commands:
|
|
729
|
+
return commands
|
|
730
|
+
if validation:
|
|
731
|
+
return [(validation[0], ["<cmd>"], [validation[0]])]
|
|
732
|
+
return [("<gate>", ["<cmd>"], ["<gate>"])]
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _finding_mentions_gate(finding: Dict[str, str], aliases: List[str]) -> bool:
|
|
736
|
+
message = finding.get("message", "")
|
|
737
|
+
return any(
|
|
738
|
+
message == f"missing command receipt for gate: {alias}"
|
|
739
|
+
or message.startswith(f"gate {alias} ")
|
|
740
|
+
for alias in aliases
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _first_gate_command(
|
|
745
|
+
step: Dict[str, Any],
|
|
746
|
+
findings: Optional[List[Dict[str, str]]] = None,
|
|
747
|
+
) -> tuple[str, List[str]]:
|
|
748
|
+
"""Return the unmet gate label and command for next-action guidance."""
|
|
749
|
+
commands = _command_gate_commands(step)
|
|
750
|
+
for label, command, aliases in commands:
|
|
751
|
+
if any(_finding_mentions_gate(finding, aliases) for finding in findings or []):
|
|
752
|
+
return label, command
|
|
753
|
+
label, command, _aliases = commands[0]
|
|
754
|
+
return label, command
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _latest_run_verification(root: Path) -> Optional[Dict[str, Any]]:
|
|
758
|
+
latest: Optional[Dict[str, Any]] = None
|
|
759
|
+
for row in read_jsonl(root / ".agent/verification-runs.jsonl"):
|
|
760
|
+
if isinstance(row, dict) and row.get("scope") == "run":
|
|
761
|
+
latest = row
|
|
762
|
+
return latest
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def next_action(
|
|
766
|
+
root: Path,
|
|
767
|
+
strict: bool = False,
|
|
768
|
+
agent_id: Optional[str] = None,
|
|
769
|
+
) -> Action:
|
|
770
|
+
try:
|
|
771
|
+
plan = _load_plan(root)
|
|
772
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
773
|
+
return _invalid_action(_diagnose(
|
|
774
|
+
_base_resumability(None, agent_id),
|
|
775
|
+
"plan_invalid",
|
|
776
|
+
str(exc),
|
|
777
|
+
".agent/plan.lock.json",
|
|
778
|
+
))
|
|
779
|
+
if plan is None:
|
|
780
|
+
projection = _diagnose(
|
|
781
|
+
_base_resumability(None, agent_id),
|
|
782
|
+
"plan_missing",
|
|
783
|
+
"no .agent/plan.lock.json",
|
|
784
|
+
".agent/plan.lock.json",
|
|
785
|
+
)
|
|
786
|
+
return _attach(
|
|
787
|
+
Action("uninitialized", "no .agent/plan.lock.json",
|
|
788
|
+
command=_cmd(["init"]), args=["init"]),
|
|
789
|
+
projection,
|
|
790
|
+
)
|
|
791
|
+
if not isinstance(plan, dict):
|
|
792
|
+
return _invalid_action(_diagnose(
|
|
793
|
+
_base_resumability(None, agent_id),
|
|
794
|
+
"plan_invalid",
|
|
795
|
+
".agent/plan.lock.json top-level value must be a JSON object",
|
|
796
|
+
".agent/plan.lock.json",
|
|
797
|
+
))
|
|
798
|
+
if plan.get("locked") is not True:
|
|
799
|
+
projection = _diagnose(
|
|
800
|
+
_base_resumability(plan, agent_id),
|
|
801
|
+
"plan_unlocked",
|
|
802
|
+
"plan exists but is not locked",
|
|
803
|
+
".agent/plan.lock.json",
|
|
804
|
+
)
|
|
805
|
+
args = ["lock-plan", ".agent/plan.lock.json"]
|
|
806
|
+
return _attach(
|
|
807
|
+
Action("plan_unlocked", "plan exists but is not locked",
|
|
808
|
+
command=_cmd(args), args=args),
|
|
809
|
+
projection,
|
|
810
|
+
)
|
|
811
|
+
if not (root / ".agent/execution.contract.json").exists():
|
|
812
|
+
projection = _diagnose(
|
|
813
|
+
_base_resumability(plan, agent_id),
|
|
814
|
+
"execution_contract_missing",
|
|
815
|
+
"execution contract missing",
|
|
816
|
+
".agent/execution.contract.json",
|
|
817
|
+
)
|
|
818
|
+
return _attach(
|
|
819
|
+
Action("execution_uninitialized", "execution contract missing",
|
|
820
|
+
command=_cmd(["init-execution"]), args=["init-execution"]),
|
|
821
|
+
projection,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
projection = resumability_projection(root, plan, agent_id, strict)
|
|
825
|
+
if projection["diagnostics"]:
|
|
826
|
+
return _invalid_action(projection)
|
|
827
|
+
attempt = projection["attempt"]
|
|
828
|
+
inflight = None
|
|
829
|
+
if attempt is not None and projection["step"] is not None:
|
|
830
|
+
step_id = projection["step"]["id"]
|
|
831
|
+
step = next(
|
|
832
|
+
item
|
|
833
|
+
for item in plan.get("steps", [])
|
|
834
|
+
if isinstance(item, dict) and item.get("id") == step_id
|
|
835
|
+
)
|
|
836
|
+
inflight = (step_id, attempt["id"], step)
|
|
837
|
+
if inflight is None:
|
|
838
|
+
eligible = next_step(root, plan)
|
|
839
|
+
if eligible is not None:
|
|
840
|
+
step_id = eligible["id"]
|
|
841
|
+
args = ["claim-step", step_id, "--agent", "$USER"]
|
|
842
|
+
return _attach(
|
|
843
|
+
Action("step_unclaimed", f"step {step_id} is eligible and unclaimed",
|
|
844
|
+
command=_cmd(args), args=args, step_id=step_id),
|
|
845
|
+
projection,
|
|
846
|
+
)
|
|
847
|
+
# All steps complete: run/drift/proof states.
|
|
848
|
+
drift = audit_drift(root, plan)
|
|
849
|
+
if drift.get("status") == "fail":
|
|
850
|
+
return _attach(
|
|
851
|
+
Action("drift_failing", "changes fall outside plan scope",
|
|
852
|
+
command=_cmd(["finish-run"]), args=["finish-run"],
|
|
853
|
+
diagnostics=[f"out of scope: {p}" for p in
|
|
854
|
+
drift.get("out_of_scope_files", [])]),
|
|
855
|
+
projection,
|
|
856
|
+
)
|
|
857
|
+
run_result = verify_run(root, plan, strict=strict, record=False)
|
|
858
|
+
run_record = _latest_run_verification(root)
|
|
859
|
+
run_warnings = [
|
|
860
|
+
finding for finding in run_result.get("warnings", [])
|
|
861
|
+
if finding.get("rule") not in _STUCK_ADVISORY_RULES
|
|
862
|
+
]
|
|
863
|
+
run_findings = [*run_result.get("errors", []), *run_warnings]
|
|
864
|
+
run_failed = run_result.get("status") == "failed" or (
|
|
865
|
+
strict and bool(run_warnings)
|
|
866
|
+
)
|
|
867
|
+
if run_record is None or run_failed:
|
|
868
|
+
diagnostics = [f["message"] for f in run_findings]
|
|
869
|
+
if run_record is None:
|
|
870
|
+
diagnostics.insert(0, "run verification has not been recorded")
|
|
871
|
+
return _attach(
|
|
872
|
+
Action("run_unverified", "run-level verification not satisfied",
|
|
873
|
+
command=_cmd(["finish-run"]), args=["finish-run"],
|
|
874
|
+
diagnostics=diagnostics),
|
|
875
|
+
projection,
|
|
876
|
+
)
|
|
877
|
+
proof_path = root / ".agent/proof-pack.json"
|
|
878
|
+
if not proof_path.exists():
|
|
879
|
+
return _attach(
|
|
880
|
+
Action("proof_missing", "proof pack not built",
|
|
881
|
+
command=_cmd(["finish-run"]), args=["finish-run"]),
|
|
882
|
+
projection,
|
|
883
|
+
)
|
|
884
|
+
findings = verify_proof(root, proof_path, strict=strict)
|
|
885
|
+
errors = [f for f in findings if f.get("severity") == "error"]
|
|
886
|
+
if errors:
|
|
887
|
+
# Stale = inputs changed since the proof was built; verify_proof
|
|
888
|
+
# signals that as a "hash mismatch", "path mismatch", or other
|
|
889
|
+
# checksum/receipt mismatch (all reported as "... mismatch ...").
|
|
890
|
+
# Everything else (malformed metadata, failing checks) is failing.
|
|
891
|
+
stale = any("hash" in f["message"] or "mismatch" in f["message"]
|
|
892
|
+
for f in errors)
|
|
893
|
+
state = "proof_stale" if stale else "proof_failing"
|
|
894
|
+
return _attach(
|
|
895
|
+
Action(state, "proof verification failed",
|
|
896
|
+
command=_cmd(["finish-run"]), args=["finish-run"],
|
|
897
|
+
diagnostics=[f["message"] for f in errors]),
|
|
898
|
+
projection,
|
|
899
|
+
)
|
|
900
|
+
return _attach(
|
|
901
|
+
Action("complete", "run complete and proof verified",
|
|
902
|
+
command=None, args=[], blocking=False),
|
|
903
|
+
projection,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
step_id, attempt_id, step = inflight
|
|
907
|
+
unrecorded = _scoped_unrecorded_paths(root, plan, step_id, attempt_id)
|
|
908
|
+
if unrecorded:
|
|
909
|
+
args = ["record-file-change", "--step", step_id, "--path", unrecorded[0]]
|
|
910
|
+
return _attach(
|
|
911
|
+
Action(
|
|
912
|
+
"file_receipts_missing",
|
|
913
|
+
f"step {step_id} has changed scoped files with no receipt",
|
|
914
|
+
command=_cmd(args), args=args, step_id=step_id,
|
|
915
|
+
diagnostics=[f"unrecorded: {p}" for p in unrecorded],
|
|
916
|
+
),
|
|
917
|
+
projection,
|
|
918
|
+
)
|
|
919
|
+
verify_result = verify_step(root, plan, step_id, attempt_id,
|
|
920
|
+
strict=strict, record=False)
|
|
921
|
+
step_failed = verify_result.get("status") == "failed" or (
|
|
922
|
+
strict and verify_result.get("status") == "warning"
|
|
923
|
+
)
|
|
924
|
+
if step_failed:
|
|
925
|
+
findings = [*verify_result["errors"], *verify_result["warnings"]]
|
|
926
|
+
gate, command = _first_gate_command(step, findings)
|
|
927
|
+
args = ["run", "--step", step_id, "--gate", gate, "--", *command]
|
|
928
|
+
return _attach(
|
|
929
|
+
Action(
|
|
930
|
+
"validation_missing",
|
|
931
|
+
f"step {step_id} has an unmet validation gate",
|
|
932
|
+
command=_cmd(args), args=args, step_id=step_id, gate=gate,
|
|
933
|
+
diagnostics=[f["message"] for f in findings],
|
|
934
|
+
),
|
|
935
|
+
projection,
|
|
936
|
+
)
|
|
937
|
+
if not attempt_is_verified(root, step_id, attempt_id):
|
|
938
|
+
args = ["finish-step", step_id]
|
|
939
|
+
return _attach(
|
|
940
|
+
Action("step_unverified",
|
|
941
|
+
f"step {step_id} validated but not verified",
|
|
942
|
+
command=_cmd(args), args=args, step_id=step_id),
|
|
943
|
+
projection,
|
|
944
|
+
)
|
|
945
|
+
args = ["finish-step", step_id]
|
|
946
|
+
return _attach(
|
|
947
|
+
Action("step_uncompleted",
|
|
948
|
+
f"step {step_id} verified but not completed",
|
|
949
|
+
command=_cmd(args), args=args, step_id=step_id),
|
|
950
|
+
projection,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def finish_step(root: Path, plan: Dict[str, Any], step_id: str,
|
|
955
|
+
attempt_id: Optional[str], strict: bool = False,
|
|
956
|
+
replay: bool = False,
|
|
957
|
+
agent_id: Optional[str] = None) -> Dict[str, Any]:
|
|
958
|
+
resolved = require_lifecycle_owner(root, step_id, attempt_id, agent_id, action="verify")
|
|
959
|
+
result = verify_step(root, plan, step_id, resolved, strict=strict, replay=replay)
|
|
960
|
+
findings = [*result["errors"], *result["warnings"]]
|
|
961
|
+
out: Dict[str, Any] = {
|
|
962
|
+
"step_id": step_id,
|
|
963
|
+
"attempt_id": resolved,
|
|
964
|
+
"verification_status": result["status"],
|
|
965
|
+
"verified": False,
|
|
966
|
+
"completed": False,
|
|
967
|
+
"diagnostics": [f["message"] for f in findings],
|
|
968
|
+
}
|
|
969
|
+
if result["status"] == "failed":
|
|
970
|
+
return out
|
|
971
|
+
mark_step_verified(root, step_id, resolved, findings, agent_id=agent_id)
|
|
972
|
+
out["verified"] = True
|
|
973
|
+
complete_step(root, step_id, resolved, agent_id=agent_id)
|
|
974
|
+
out["completed"] = True
|
|
975
|
+
return out
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def finish_run(root: Path, plan_path: Path, strict: bool = False) -> Dict[str, Any]:
|
|
979
|
+
# Import inside the function: cli.py imports porcelain, so a top-level
|
|
980
|
+
# `from . import cli` here would create a circular import.
|
|
981
|
+
import contextlib
|
|
982
|
+
import io
|
|
983
|
+
|
|
984
|
+
from . import cli as _cli
|
|
985
|
+
|
|
986
|
+
root_args = ["--root", str(root)]
|
|
987
|
+
plan_args = ["--plan", str(plan_path)]
|
|
988
|
+
strict_args = ["--strict"] if strict else []
|
|
989
|
+
gates = [
|
|
990
|
+
("audit-drift", ["audit-drift", *root_args, *plan_args]),
|
|
991
|
+
("verify-run", ["verify-run", *root_args, *plan_args, *strict_args]),
|
|
992
|
+
("build-proof", ["build-proof", *root_args, *plan_args, *strict_args]),
|
|
993
|
+
("verify-proof", ["verify-proof", *root_args, *strict_args]),
|
|
994
|
+
]
|
|
995
|
+
recorded: List[Dict[str, str]] = []
|
|
996
|
+
for name, argv in gates:
|
|
997
|
+
# Capture each gate's output so finish_run never leaks gate chatter to
|
|
998
|
+
# stdout (keeping --json parseable) while still surfacing the failing
|
|
999
|
+
# gate's real output in diagnostics instead of swallowing it.
|
|
1000
|
+
out_buffer, err_buffer = io.StringIO(), io.StringIO()
|
|
1001
|
+
with contextlib.redirect_stdout(out_buffer), contextlib.redirect_stderr(err_buffer):
|
|
1002
|
+
code = _cli.main(argv)
|
|
1003
|
+
status = "ok" if code == 0 else "fail"
|
|
1004
|
+
recorded.append({"name": name, "status": status})
|
|
1005
|
+
if code != 0:
|
|
1006
|
+
output = out_buffer.getvalue() + err_buffer.getvalue()
|
|
1007
|
+
diagnostics = [line for line in output.splitlines() if line.strip()]
|
|
1008
|
+
return {
|
|
1009
|
+
"ok": False,
|
|
1010
|
+
"stopped_at": name,
|
|
1011
|
+
"gates": recorded,
|
|
1012
|
+
"diagnostics": diagnostics or [f"gate {name} failed"],
|
|
1013
|
+
}
|
|
1014
|
+
return {"ok": True, "stopped_at": None, "gates": recorded, "diagnostics": []}
|