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/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": []}