agentflow-proof 1.2.0__tar.gz → 1.2.1__tar.gz
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_proof-1.2.0 → agentflow_proof-1.2.1}/CHANGELOG.md +57 -1
- {agentflow_proof-1.2.0/src/agentflow_proof.egg-info → agentflow_proof-1.2.1}/PKG-INFO +1 -1
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/pyproject.toml +1 -1
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/__init__.py +1 -1
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/cli.py +2 -5
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/execution.py +3 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/execution_coverage.py +114 -36
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/hunks.py +9 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/porcelain.py +117 -20
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1/src/agentflow_proof.egg-info}/PKG-INFO +1 -1
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_cli.py +37 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_state.py +15 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_verification.py +66 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_porcelain.py +495 -1
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/LICENSE +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/MANIFEST.in +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/README.md +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/setup.cfg +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/__main__.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/aggregate.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/artifacts.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/capabilities.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/cli_contract.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/contracts.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/coverage.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/draft_plan.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/events.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/git.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/handoff.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/locks.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/mcp_server.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/packs.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/proof.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/receipts.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/recommend.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/review.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/review_runner.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/risk.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/runtime.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/stuck.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/validation.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/versioning.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/viewer.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/workflow_contract.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/SOURCES.txt +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/dependency_links.txt +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/entry_points.txt +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/top_level.txt +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_aggregate.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_ai_kit.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_artifact_versioning.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_build_zipapp.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_capabilities.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_ci_proof_bundle.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_config_contract.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_distribution.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_draft_plan.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_events.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_aggregation.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_mcp.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_workflow_pack.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_examples.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_contract.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_handoff.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_hunks.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_lease_enforcement.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_lease_locking.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_mcp_server.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_packs.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_proof.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_proof_compatibility.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_public_project_contract.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_receipt_concurrency.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_receipts.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_recommend.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_release.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_review.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_review_runner.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_risk.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_runtime.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_schema_contracts.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_schema_soak.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stability_policy.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stop_gate.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stuck.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_versioning.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_view_proof.py +0 -0
- {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_workflow_contract.py +0 -0
|
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.1] - 2026-11-02
|
|
11
|
+
|
|
12
|
+
Agentflow 1.2.1 fixes three cases where `next-action` sent an agent the wrong
|
|
13
|
+
way. It no longer asks a step to record a file again when the file already has
|
|
14
|
+
a matching receipt from another step or attempt (#97). It guides an edit made
|
|
15
|
+
after a step completed to `amend-step` instead of a `finish-run` that always
|
|
16
|
+
fails (#73). It reports a ledger change only when a fresh read of the ledgers
|
|
17
|
+
shows one, so a deterministic bug no longer looks like a race (#94). No state,
|
|
18
|
+
recovery action, artifact schema, schema-version constant,
|
|
19
|
+
`docs/cli-contract.json` value or exit-code change.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `next-action` no longer asks a step to record a changed file that already has
|
|
24
|
+
a matching receipt. A file in the step's scope counts as recorded when its
|
|
25
|
+
latest file receipt, from any step or attempt, matches the file as it is now
|
|
26
|
+
(a deleted file needs a deleted receipt), the check `verify-run` makes. It
|
|
27
|
+
used to require a receipt from the open attempt itself. When two steps
|
|
28
|
+
listed the same file, following it copied the first step's hunks into a new
|
|
29
|
+
receipt and made the second step the file's latest owner in the proof, and
|
|
30
|
+
an amendment had to record again files its step had already recorded. A
|
|
31
|
+
file the open attempt recorded and then edited again is now
|
|
32
|
+
`file_receipts_missing`; `next-action` used to send it to `finish-step`, and
|
|
33
|
+
`finish-run` then failed. Enforced hunk attribution also requires the
|
|
34
|
+
current hunks to be recorded, even when the content hash still matches
|
|
35
|
+
after a staged rename or file type change. Working-tree inspection errors
|
|
36
|
+
return blocking `state_invalid` diagnostics instead of a traceback. No
|
|
37
|
+
state, recovery action, schema or exit-code change. (#97)
|
|
38
|
+
- `next-action` guides an edit made after a step completed to `amend-step`
|
|
39
|
+
instead of a `finish-run` that always fails. When every changed file with no
|
|
40
|
+
matching receipt is in exactly one step's effective scope and drift reports
|
|
41
|
+
no out-of-scope or blocked file, it reports `step_amendment_needed` with
|
|
42
|
+
`amend-step <step> --reason 'record post-completion edit to <path>'`. The
|
|
43
|
+
`amend` action is `automatic: false` while any changed file lacks a matching
|
|
44
|
+
receipt, for timeout, failed and strict amendments too, because receipts
|
|
45
|
+
cannot show who made the edit. Otherwise `drift_failing` and `run_unverified`
|
|
46
|
+
name each path and the step that could record it. `drift_failing` now lists
|
|
47
|
+
blocked files and unmapped hunks (path, header and fingerprint) as well as
|
|
48
|
+
out-of-scope files, and its reason names the categories present; it used to
|
|
49
|
+
say "changes fall outside plan scope" with no diagnostics for an unmapped
|
|
50
|
+
hunk. `record-file-change`, `run` and `verify-step` on a completed step with
|
|
51
|
+
no open attempt say to open one with `amend-step` instead of `claim-step`. A
|
|
52
|
+
git or file error while inspecting changed files reports `state_invalid`. No
|
|
53
|
+
state, recovery action, schema or exit-code change. (#73)
|
|
54
|
+
- `next-action` reports "execution ledgers changed during next-action; run it
|
|
55
|
+
again" only when the ledgers really changed. When a check after the
|
|
56
|
+
validated snapshot raises, `next-action` reads the step, command, file
|
|
57
|
+
receipt and verification ledgers again. It reports `state_invalid` only if
|
|
58
|
+
they differ from the snapshot or cannot be read; otherwise the original
|
|
59
|
+
error propagates with its traceback. It used to report any `AttributeError`,
|
|
60
|
+
`KeyError`, `TypeError`, `OSError` or `ValueError` raised while choosing the
|
|
61
|
+
action as a ledger change, on the claim path too, so a deterministic bug
|
|
62
|
+
looked like a race and the rerun it asked for gave the same answer every
|
|
63
|
+
time. (#94)
|
|
64
|
+
|
|
10
65
|
## [1.2.0] - 2026-10-29
|
|
11
66
|
|
|
12
67
|
Agentflow 1.2.0 shows why a run is stuck. `next-step --explain` reports why
|
|
@@ -347,7 +402,8 @@ repository, so this heading is intentionally unlinked._
|
|
|
347
402
|
|
|
348
403
|
- Existing v0.2 proof artifacts remain valid when no execution contract exists.
|
|
349
404
|
|
|
350
|
-
[Unreleased]: https://github.com/kstruzzieri/agentflow/compare/v1.2.
|
|
405
|
+
[Unreleased]: https://github.com/kstruzzieri/agentflow/compare/v1.2.1...HEAD
|
|
406
|
+
[1.2.1]: https://github.com/kstruzzieri/agentflow/compare/v1.2.0...v1.2.1
|
|
351
407
|
[1.2.0]: https://github.com/kstruzzieri/agentflow/compare/v1.1.1...v1.2.0
|
|
352
408
|
[1.1.1]: https://github.com/kstruzzieri/agentflow/compare/v1.1.0...v1.1.1
|
|
353
409
|
[1.1.0]: https://github.com/kstruzzieri/agentflow/compare/v1.0.0...v1.1.0
|
|
@@ -47,6 +47,7 @@ from .execution_coverage import verify_run, verify_step
|
|
|
47
47
|
from .events import filter_events_since, project_events, valid_since
|
|
48
48
|
from .capabilities import append_capability_receipt, build_capability_receipt
|
|
49
49
|
from .handoff import export_handoff, lint_handoff_text
|
|
50
|
+
from .hunks import format_unmapped_hunk
|
|
50
51
|
from . import porcelain
|
|
51
52
|
from .locks import file_lock
|
|
52
53
|
from .packs import (
|
|
@@ -639,11 +640,7 @@ def command_audit_drift(args: argparse.Namespace) -> int:
|
|
|
639
640
|
hunk_lines = []
|
|
640
641
|
for entry in report["unmapped_hunks"]:
|
|
641
642
|
if isinstance(entry, dict):
|
|
642
|
-
hunk_lines.append(
|
|
643
|
-
f"- {entry['path']} @@ -{entry['old_start']},{entry['old_count']} "
|
|
644
|
-
f"+{entry['new_start']},{entry['new_count']} @@ "
|
|
645
|
-
f"{entry['hash'][:12]} ({entry['reason']})"
|
|
646
|
-
)
|
|
643
|
+
hunk_lines.append(f"- {format_unmapped_hunk(entry)}")
|
|
647
644
|
else:
|
|
648
645
|
hunk_lines.append(f"- {entry}")
|
|
649
646
|
print_lines([_terminal_safe(line) for line in hunk_lines])
|
|
@@ -604,6 +604,9 @@ def resolve_attempt(root: Path, step_id: str, attempt_id: Optional[str]) -> str:
|
|
|
604
604
|
return open_attempts[0]
|
|
605
605
|
if len(open_attempts) > 1:
|
|
606
606
|
raise ValueError(f"multiple open attempts for {step_id}; pass --attempt")
|
|
607
|
+
if state["steps"].get(step_id, {}).get("completed"):
|
|
608
|
+
# #73: claim-step refuses a completed step, so point at the command that works.
|
|
609
|
+
raise ValueError(f"step {step_id} has completed work and no open attempt; open one with amend-step")
|
|
607
610
|
raise ValueError(f"{step_id} has no open attempt; run claim-step first")
|
|
608
611
|
|
|
609
612
|
|
|
@@ -21,7 +21,7 @@ from .execution import (
|
|
|
21
21
|
receipt_timed_out,
|
|
22
22
|
resolve_attempt,
|
|
23
23
|
)
|
|
24
|
-
from .git import changed_file_records
|
|
24
|
+
from .git import changed_file_records, is_git_repo
|
|
25
25
|
from .hunks import effective_hunk_policy, unmapped_hunks
|
|
26
26
|
from .receipts import (
|
|
27
27
|
command_receipts,
|
|
@@ -381,7 +381,7 @@ def _ignored(path: str, prefixes: List[str]) -> bool:
|
|
|
381
381
|
return any(path.startswith(prefix) for prefix in prefixes)
|
|
382
382
|
|
|
383
383
|
|
|
384
|
-
def
|
|
384
|
+
def latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
|
|
385
385
|
latest: Dict[str, Dict[str, Any]] = {}
|
|
386
386
|
for receipt in file_receipts(root):
|
|
387
387
|
path = receipt.get("path")
|
|
@@ -390,11 +390,30 @@ def _latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
|
|
|
390
390
|
return latest
|
|
391
391
|
|
|
392
392
|
|
|
393
|
+
def receipt_matches_worktree(root: Path, record: Dict[str, str], receipt: Dict[str, Any]) -> bool:
|
|
394
|
+
"""True when receipt records record's changed path as it is now.
|
|
395
|
+
|
|
396
|
+
verify-run and next-action (#97) share this check.
|
|
397
|
+
"""
|
|
398
|
+
if record["change_kind"] == "deleted":
|
|
399
|
+
return receipt.get("change_kind") == "deleted"
|
|
400
|
+
return receipt.get("after_sha256") == sha256_path(root / record["path"])
|
|
401
|
+
|
|
402
|
+
|
|
393
403
|
def _reconcile_changed_files(
|
|
394
404
|
root: Path,
|
|
395
405
|
plan: Dict[str, Any],
|
|
396
406
|
require_file_receipts: bool = True,
|
|
397
407
|
) -> List[Dict[str, str]]:
|
|
408
|
+
return [finding for _path, finding in _changed_file_pairs(root, plan, require_file_receipts)]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _changed_file_pairs(
|
|
412
|
+
root: Path,
|
|
413
|
+
plan: Dict[str, Any],
|
|
414
|
+
require_file_receipts: bool = True,
|
|
415
|
+
) -> List[Tuple[str, Dict[str, str]]]:
|
|
416
|
+
"""(path, finding) for each changed file verify-run flags (#73 needs the path)."""
|
|
398
417
|
contract: Dict[str, Any] = {}
|
|
399
418
|
contract_path = root / ".agent/execution.contract.json"
|
|
400
419
|
if contract_path.exists():
|
|
@@ -405,8 +424,8 @@ def _reconcile_changed_files(
|
|
|
405
424
|
if contract is None:
|
|
406
425
|
contract = {}
|
|
407
426
|
ignore = contract.get("concurrency", {}).get("reconcile_ignore", [".agent/", ".git/"])
|
|
408
|
-
latest =
|
|
409
|
-
findings: List[Dict[str, str]] = []
|
|
427
|
+
latest = latest_file_receipts_by_path(root)
|
|
428
|
+
findings: List[Tuple[str, Dict[str, str]]] = []
|
|
410
429
|
for record in changed_file_records(root):
|
|
411
430
|
path = record["path"]
|
|
412
431
|
if _ignored(path, ignore):
|
|
@@ -415,25 +434,19 @@ def _reconcile_changed_files(
|
|
|
415
434
|
if receipt is None:
|
|
416
435
|
if not require_file_receipts:
|
|
417
436
|
continue
|
|
418
|
-
findings.append(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
)
|
|
437
|
+
findings.append((path, {
|
|
438
|
+
"severity": "error",
|
|
439
|
+
"message": f"changed file is not mapped to a step receipt: {path}",
|
|
440
|
+
}))
|
|
424
441
|
continue
|
|
425
|
-
if record
|
|
426
|
-
if receipt.get("change_kind") != "deleted":
|
|
427
|
-
findings.append(
|
|
428
|
-
{"severity": "error", "message": f"deleted file lacks deleted receipt: {path}"}
|
|
429
|
-
)
|
|
442
|
+
if receipt_matches_worktree(root, record, receipt):
|
|
430
443
|
continue
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
444
|
+
message = (
|
|
445
|
+
f"deleted file lacks deleted receipt: {path}"
|
|
446
|
+
if record["change_kind"] == "deleted"
|
|
447
|
+
else f"out-of-band edit after latest file receipt: {path}"
|
|
448
|
+
)
|
|
449
|
+
findings.append((path, {"severity": "error", "message": message}))
|
|
437
450
|
if effective_hunk_policy(root) == "enforce":
|
|
438
451
|
# Scope-filter candidates the same way audit_drift does, so verify-run
|
|
439
452
|
# and audit-drift cannot diverge on which paths are hunk-checked.
|
|
@@ -447,16 +460,14 @@ def _reconcile_changed_files(
|
|
|
447
460
|
and not matches_path(record["path"], blocked)
|
|
448
461
|
]
|
|
449
462
|
for entry in unmapped_hunks(root, candidates):
|
|
450
|
-
findings.append(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
)
|
|
463
|
+
findings.append((entry["path"], {
|
|
464
|
+
"severity": "error",
|
|
465
|
+
"message": (
|
|
466
|
+
f"unmapped hunk in {entry['path']} "
|
|
467
|
+
f"(@@ -{entry['old_start']},{entry['old_count']} "
|
|
468
|
+
f"+{entry['new_start']},{entry['new_count']} @@): no matching file receipt"
|
|
469
|
+
),
|
|
470
|
+
}))
|
|
460
471
|
return findings
|
|
461
472
|
|
|
462
473
|
|
|
@@ -672,15 +683,25 @@ def _timeout_receipt_findings(
|
|
|
672
683
|
|
|
673
684
|
|
|
674
685
|
def replacement_needs(
|
|
675
|
-
root: Path,
|
|
686
|
+
root: Path,
|
|
687
|
+
plan: Dict[str, Any],
|
|
688
|
+
strict: bool = False,
|
|
689
|
+
edits: Optional[Dict[str, Any]] = None,
|
|
676
690
|
) -> List[Dict[str, Any]]:
|
|
677
691
|
"""Plan-order completed steps that need a replacement attempt, with causes.
|
|
678
692
|
|
|
679
693
|
Mirrors the verify_run errors a replacement attempt would clear: a
|
|
680
694
|
recoverable timed-out receipt, a failed latest attempt, and (effective
|
|
681
695
|
strict) a latest attempt that is not completed. attempt_id/status are the
|
|
682
|
-
step's latest non-lease ledger event.
|
|
696
|
+
step's latest non-lease ledger event. #73: when post_completion_edits
|
|
697
|
+
offers them, each unreceipted change is a post_completion_edit cause on
|
|
698
|
+
its one candidate step, after that step's other causes.
|
|
683
699
|
"""
|
|
700
|
+
edit_causes: Dict[str, List[Dict[str, Any]]] = {}
|
|
701
|
+
if edits and edits.get("offered"):
|
|
702
|
+
for pair in edits["pairs"]:
|
|
703
|
+
[owner] = edits["candidates"][pair["path"]]
|
|
704
|
+
edit_causes.setdefault(owner, []).append({"cause": "post_completion_edit", **pair})
|
|
684
705
|
effective_strict = strict or bool(_proof_policy(root).get("strict_by_default"))
|
|
685
706
|
state = read_step_state(root)
|
|
686
707
|
latest: Dict[str, Dict[str, Any]] = {}
|
|
@@ -716,6 +737,7 @@ def replacement_needs(
|
|
|
716
737
|
causes.append({"cause": "failed_attempt"})
|
|
717
738
|
if effective_strict and status != "completed":
|
|
718
739
|
causes.append({"cause": "strict_incomplete"})
|
|
740
|
+
causes.extend(edit_causes.get(step_id, []))
|
|
719
741
|
if causes:
|
|
720
742
|
needs.append({
|
|
721
743
|
"step_id": step_id,
|
|
@@ -726,6 +748,64 @@ def replacement_needs(
|
|
|
726
748
|
return needs
|
|
727
749
|
|
|
728
750
|
|
|
751
|
+
def _requires_file_receipts(root: Path, strict: bool) -> bool:
|
|
752
|
+
policy = _proof_policy(root)
|
|
753
|
+
return (
|
|
754
|
+
strict
|
|
755
|
+
or bool(policy.get("strict_by_default"))
|
|
756
|
+
or bool(policy.get("require_file_receipts_for_changed_files", True))
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def post_completion_edits(
|
|
761
|
+
root: Path, plan: Dict[str, Any], strict: bool = False
|
|
762
|
+
) -> Dict[str, Any]:
|
|
763
|
+
"""#73: changed files verify-run flags, and whether one step can absorb them.
|
|
764
|
+
|
|
765
|
+
For the selector's terminal branch only (no open attempt, nothing
|
|
766
|
+
eligible). Read-only. A path's candidates are the plan steps whose
|
|
767
|
+
effective scope holds it, the check record-file-change applies; that
|
|
768
|
+
permits recording, it does not prove who edited. offered is True only
|
|
769
|
+
when every path has exactly one candidate, drift has no out-of-scope or
|
|
770
|
+
blocked file, and nothing makes the run permanently unprovable. Git and
|
|
771
|
+
file errors propagate: the caller fails closed.
|
|
772
|
+
"""
|
|
773
|
+
edits: Dict[str, Any] = {
|
|
774
|
+
"pairs": [], "candidates": {}, "drift": None,
|
|
775
|
+
"permanent": False, "scope_violation": False, "offered": False,
|
|
776
|
+
}
|
|
777
|
+
if not is_git_repo(root):
|
|
778
|
+
return edits
|
|
779
|
+
pairs = _changed_file_pairs(root, plan, _requires_file_receipts(root, strict))
|
|
780
|
+
if not pairs:
|
|
781
|
+
return edits
|
|
782
|
+
step_ids = [
|
|
783
|
+
step["id"] for step in plan.get("steps", [])
|
|
784
|
+
if isinstance(step, dict) and isinstance(step.get("id"), str)
|
|
785
|
+
]
|
|
786
|
+
edits["pairs"] = [{"path": path, "message": finding["message"]} for path, finding in pairs]
|
|
787
|
+
for path, _finding in pairs:
|
|
788
|
+
if path not in edits["candidates"]:
|
|
789
|
+
edits["candidates"][path] = [
|
|
790
|
+
step_id for step_id in step_ids if path_in_effective_scope(plan, step_id, path)
|
|
791
|
+
]
|
|
792
|
+
edits["permanent"] = bool(_terminal_attempt_violations(root)) or any(
|
|
793
|
+
receipt_state == "unrecoverable"
|
|
794
|
+
for _receipt, receipt_state, _by in timeout_receipt_states(root, plan)
|
|
795
|
+
)
|
|
796
|
+
if edits["permanent"]:
|
|
797
|
+
return edits
|
|
798
|
+
drift = audit_drift(root, plan)
|
|
799
|
+
edits["drift"] = drift
|
|
800
|
+
edits["scope_violation"] = bool(
|
|
801
|
+
drift.get("out_of_scope_files") or drift.get("blocked_files_changed")
|
|
802
|
+
)
|
|
803
|
+
edits["offered"] = not edits["scope_violation"] and all(
|
|
804
|
+
len(candidates) == 1 for candidates in edits["candidates"].values()
|
|
805
|
+
)
|
|
806
|
+
return edits
|
|
807
|
+
|
|
808
|
+
|
|
729
809
|
def _unresolved_finding_refs(root: Path) -> List[Dict[str, str]]:
|
|
730
810
|
"""Flag amendment finding refs whose review run is not recorded.
|
|
731
811
|
|
|
@@ -792,9 +872,7 @@ def verify_run(
|
|
|
792
872
|
result: Dict[str, Any] = {"status": "passed", "errors": [], "warnings": []}
|
|
793
873
|
policy = _proof_policy(root)
|
|
794
874
|
effective_strict = strict or bool(policy.get("strict_by_default"))
|
|
795
|
-
require_file_receipts =
|
|
796
|
-
policy.get("require_file_receipts_for_changed_files", True)
|
|
797
|
-
)
|
|
875
|
+
require_file_receipts = _requires_file_receipts(root, strict)
|
|
798
876
|
for error in validate_plan(plan):
|
|
799
877
|
result["errors"].append({"severity": "error", "message": error})
|
|
800
878
|
drift = audit_drift(root, plan)
|
|
@@ -243,3 +243,12 @@ def unmapped_hunks(root: Path, records: List[Dict[str, Any]]) -> List[Dict[str,
|
|
|
243
243
|
}
|
|
244
244
|
)
|
|
245
245
|
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def format_unmapped_hunk(entry: Dict[str, Any]) -> str:
|
|
249
|
+
"""One unmapped_hunks entry as audit-drift prints it (#73: shared with next-action)."""
|
|
250
|
+
return (
|
|
251
|
+
f"{entry['path']} @@ -{entry['old_start']},{entry['old_count']} "
|
|
252
|
+
f"+{entry['new_start']},{entry['new_count']} @@ "
|
|
253
|
+
f"{entry['hash'][:12]} ({entry['reason']})"
|
|
254
|
+
)
|
|
@@ -38,12 +38,16 @@ from .execution import (
|
|
|
38
38
|
validate_execution_contract,
|
|
39
39
|
)
|
|
40
40
|
from .execution_coverage import (
|
|
41
|
+
latest_file_receipts_by_path,
|
|
42
|
+
post_completion_edits,
|
|
43
|
+
receipt_matches_worktree,
|
|
41
44
|
replacement_needs,
|
|
42
45
|
timeout_receipt_states,
|
|
43
46
|
verify_run,
|
|
44
47
|
verify_step,
|
|
45
48
|
)
|
|
46
49
|
from .git import changed_file_records
|
|
50
|
+
from .hunks import effective_hunk_policy, format_unmapped_hunk, unmapped_hunks
|
|
47
51
|
from .proof import verify_proof
|
|
48
52
|
from .receipts import command_receipts, file_receipts, sha256_path
|
|
49
53
|
from .validation import audit_drift, path_in_effective_scope, validate_plan
|
|
@@ -165,7 +169,8 @@ class _Selection:
|
|
|
165
169
|
recorded_blockers is set (possibly []) only on the clean returns after the
|
|
166
170
|
step-runs ledger has been validated; None keeps the recorded_blockers key
|
|
167
171
|
absent from next-action. ledgers is the validated snapshot they came from,
|
|
168
|
-
set on the same returns.
|
|
172
|
+
set on the same returns. edits is the #73 post_completion_edits result,
|
|
173
|
+
filled with needs, so projection and rendering share one inspection.
|
|
169
174
|
"""
|
|
170
175
|
|
|
171
176
|
kind: Optional[str]
|
|
@@ -177,6 +182,7 @@ class _Selection:
|
|
|
177
182
|
unrecoverable: List[Dict[str, Any]] = field(default_factory=list)
|
|
178
183
|
recorded_blockers: Optional[List[Dict[str, Any]]] = None
|
|
179
184
|
ledgers: Optional[Dict[str, List[Dict[str, Any]]]] = None
|
|
185
|
+
edits: Dict[str, Any] = field(default_factory=dict)
|
|
180
186
|
|
|
181
187
|
|
|
182
188
|
def _timeout_close(
|
|
@@ -661,8 +667,14 @@ def _project_recovery_actions(
|
|
|
661
667
|
else "claim requires an agent identity"
|
|
662
668
|
)
|
|
663
669
|
amend_allowed = amend and bool(agent_id)
|
|
670
|
+
# #73: an amendment records the working tree as the step's work, so
|
|
671
|
+
# with any unreceipted change someone must say whose it is first.
|
|
672
|
+
manual = amend and bool(selection.edits.get("pairs"))
|
|
664
673
|
amend_reason = (
|
|
665
|
-
|
|
674
|
+
"changed files have no matching file receipt; an operator must attribute "
|
|
675
|
+
"them with amend-step"
|
|
676
|
+
if amend_allowed and manual
|
|
677
|
+
else f"step {step_id} has completed work and needs a replacement attempt"
|
|
666
678
|
if amend_allowed
|
|
667
679
|
else "amend requires an agent identity"
|
|
668
680
|
if amend
|
|
@@ -680,7 +692,7 @@ def _project_recovery_actions(
|
|
|
680
692
|
automatic=False, break_glass=True,
|
|
681
693
|
),
|
|
682
694
|
_recovery_action("block", False, "no open attempt"),
|
|
683
|
-
_recovery_action("amend", amend_allowed, amend_reason),
|
|
695
|
+
_recovery_action("amend", amend_allowed, amend_reason, automatic=not manual),
|
|
684
696
|
]
|
|
685
697
|
|
|
686
698
|
owner = attempt.get("agent_id")
|
|
@@ -806,14 +818,26 @@ def _resumability(
|
|
|
806
818
|
else:
|
|
807
819
|
# Nothing eligible: replace the first completed step that needs
|
|
808
820
|
# it, unless an unrecoverable timeout makes the run unprovable.
|
|
821
|
+
# #73: an unreceipted change is one more cause. Its inspection
|
|
822
|
+
# reads the working tree, so a git or file error fails closed.
|
|
823
|
+
try:
|
|
824
|
+
edits = post_completion_edits(root, plan, strict=strict)
|
|
825
|
+
except (RuntimeError, OSError) as exc:
|
|
826
|
+
return _diagnose(
|
|
827
|
+
projection,
|
|
828
|
+
"execution_state_invalid",
|
|
829
|
+
f"cannot inspect changed files: {exc}",
|
|
830
|
+
"working tree",
|
|
831
|
+
), _Selection(None)
|
|
809
832
|
selection = _Selection(
|
|
810
833
|
None,
|
|
811
|
-
needs=replacement_needs(root, plan, strict=strict),
|
|
834
|
+
needs=replacement_needs(root, plan, strict=strict, edits=edits),
|
|
812
835
|
unrecoverable=[
|
|
813
836
|
receipt
|
|
814
837
|
for receipt, receipt_state, _ in timeout_receipt_states(root, plan)
|
|
815
838
|
if receipt_state == "unrecoverable"
|
|
816
839
|
],
|
|
840
|
+
edits=edits,
|
|
817
841
|
)
|
|
818
842
|
if selection.needs and not selection.unrecoverable:
|
|
819
843
|
need = selection.needs[0]
|
|
@@ -1029,15 +1053,11 @@ def explain_next_step(root: Path) -> Dict[str, Any]:
|
|
|
1029
1053
|
return {"step": None, "reason_code": code, "diagnostics": diagnostics}
|
|
1030
1054
|
|
|
1031
1055
|
|
|
1032
|
-
def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
recorded = {
|
|
1036
|
-
r.get("path")
|
|
1037
|
-
for r in file_receipts(root)
|
|
1038
|
-
if r.get("step_id") == step_id and r.get("attempt_id") == attempt_id
|
|
1039
|
-
}
|
|
1056
|
+
def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str) -> List[str]:
|
|
1057
|
+
"""Scoped files without matching receipt content or enforced hunk coverage."""
|
|
1058
|
+
latest = latest_file_receipts_by_path(root)
|
|
1040
1059
|
missing: List[str] = []
|
|
1060
|
+
scoped_records: List[Dict[str, str]] = []
|
|
1041
1061
|
for record in changed_file_records(root):
|
|
1042
1062
|
path = record.get("path")
|
|
1043
1063
|
if not isinstance(path, str):
|
|
@@ -1046,8 +1066,12 @@ def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str,
|
|
|
1046
1066
|
continue
|
|
1047
1067
|
if not path_in_effective_scope(plan, step_id, path):
|
|
1048
1068
|
continue
|
|
1049
|
-
|
|
1069
|
+
scoped_records.append(record)
|
|
1070
|
+
receipt = latest.get(path)
|
|
1071
|
+
if receipt is None or not receipt_matches_worktree(root, record, receipt):
|
|
1050
1072
|
missing.append(path)
|
|
1073
|
+
if effective_hunk_policy(root) == "enforce":
|
|
1074
|
+
missing.extend(entry["path"] for entry in unmapped_hunks(root, scoped_records))
|
|
1051
1075
|
return sorted(set(missing))
|
|
1052
1076
|
|
|
1053
1077
|
|
|
@@ -1103,6 +1127,53 @@ def _latest_run_verification(root: Path) -> Optional[Dict[str, Any]]:
|
|
|
1103
1127
|
return latest
|
|
1104
1128
|
|
|
1105
1129
|
|
|
1130
|
+
def _unreceipted_hints(edits: Dict[str, Any]) -> List[str]:
|
|
1131
|
+
"""#73: which step could record each unreceipted change no step was offered.
|
|
1132
|
+
|
|
1133
|
+
Nothing when the edits were offered (the cause lines say it) or the run is
|
|
1134
|
+
permanently unprovable. A blocked or out-of-scope change is never presented
|
|
1135
|
+
as something an amendment can accept.
|
|
1136
|
+
"""
|
|
1137
|
+
if not edits.get("pairs") or edits.get("offered") or edits.get("permanent"):
|
|
1138
|
+
return []
|
|
1139
|
+
prefix = (
|
|
1140
|
+
"after the out-of-scope and blocked changes are resolved, "
|
|
1141
|
+
if edits.get("scope_violation") else ""
|
|
1142
|
+
)
|
|
1143
|
+
hints = []
|
|
1144
|
+
for path, candidates in edits["candidates"].items():
|
|
1145
|
+
if len(candidates) == 1:
|
|
1146
|
+
hints.append(f"{prefix}{path} is in step {candidates[0]}'s scope; "
|
|
1147
|
+
f"amend-step {candidates[0]} can record it")
|
|
1148
|
+
elif candidates:
|
|
1149
|
+
hints.append(f"{path} is in the scope of steps {', '.join(candidates)}; "
|
|
1150
|
+
"amend the step that made the edit and record it there")
|
|
1151
|
+
else:
|
|
1152
|
+
hints.append(f"{path} is in no step's scope; no amendment can record it")
|
|
1153
|
+
return hints
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _drift_reason(root: Path, drift: Dict[str, Any]) -> str:
|
|
1157
|
+
"""#73: name every failing drift category, not only out-of-scope files."""
|
|
1158
|
+
parts = []
|
|
1159
|
+
if drift.get("out_of_scope_files"):
|
|
1160
|
+
parts.append("changes fall outside plan scope")
|
|
1161
|
+
if drift.get("blocked_files_changed"):
|
|
1162
|
+
parts.append("blocked files changed")
|
|
1163
|
+
# Under observe, unmapped hunks are listed but do not fail drift.
|
|
1164
|
+
if drift.get("unmapped_hunks") and effective_hunk_policy(root) == "enforce":
|
|
1165
|
+
parts.append("changed hunks are not covered by file receipts")
|
|
1166
|
+
return "; ".join(parts) or "changes fall outside plan scope"
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _unmapped_hunk_lines(drift: Dict[str, Any]) -> List[str]:
|
|
1170
|
+
return [
|
|
1171
|
+
f"unmapped hunk: {format_unmapped_hunk(entry)}"
|
|
1172
|
+
for entry in drift.get("unmapped_hunks", [])
|
|
1173
|
+
if isinstance(entry, dict)
|
|
1174
|
+
]
|
|
1175
|
+
|
|
1176
|
+
|
|
1106
1177
|
def _replacement_cause(
|
|
1107
1178
|
step_id: str,
|
|
1108
1179
|
need: Dict[str, Any],
|
|
@@ -1118,6 +1189,8 @@ def _replacement_cause(
|
|
|
1118
1189
|
)
|
|
1119
1190
|
if cause["cause"] == "failed_attempt":
|
|
1120
1191
|
return f"replace failed attempt {need['attempt_id']}", f"{step_id} has failed attempt"
|
|
1192
|
+
if cause["cause"] == "post_completion_edit":
|
|
1193
|
+
return f"record post-completion edit to {cause['path']}", cause["message"]
|
|
1121
1194
|
return (
|
|
1122
1195
|
f"replace {need['status']} attempt {need['attempt_id']} (strict)",
|
|
1123
1196
|
f"{step_id} is not completed",
|
|
@@ -1196,7 +1269,14 @@ def next_action(
|
|
|
1196
1269
|
changed = selection.kind != "claim" and _projection_ledgers(root) != selection.ledgers
|
|
1197
1270
|
except (AttributeError, KeyError, OSError, TypeError, ValueError):
|
|
1198
1271
|
# A downstream reader can fail before the final comparison is reached.
|
|
1199
|
-
|
|
1272
|
+
# #94: blame the ledgers only if a fresh read differs or fails;
|
|
1273
|
+
# otherwise the error is Agentflow's own and a rerun cannot clear it.
|
|
1274
|
+
try:
|
|
1275
|
+
changed = _projection_ledgers(root) != selection.ledgers
|
|
1276
|
+
except (OSError, ValueError):
|
|
1277
|
+
changed = True
|
|
1278
|
+
if not changed:
|
|
1279
|
+
raise
|
|
1200
1280
|
if changed:
|
|
1201
1281
|
return _invalid_action(_diagnose(
|
|
1202
1282
|
projection,
|
|
@@ -1237,7 +1317,9 @@ def _selected_action(
|
|
|
1237
1317
|
command=_cmd(args, user_placeholder=not agent_id), args=args, step_id=step_id,
|
|
1238
1318
|
diagnostics=[
|
|
1239
1319
|
*(line for _text, line in texts),
|
|
1320
|
+
*_unmapped_hunk_lines(selection.edits.get("drift") or {}),
|
|
1240
1321
|
*(f"also needs a replacement attempt: {other['step_id']}" for other in others),
|
|
1322
|
+
*_unreceipted_hints(selection.edits),
|
|
1241
1323
|
],
|
|
1242
1324
|
),
|
|
1243
1325
|
projection,
|
|
@@ -1251,13 +1333,19 @@ def _selected_action(
|
|
|
1251
1333
|
for receipt in selection.unrecoverable
|
|
1252
1334
|
]
|
|
1253
1335
|
finish_args = ["finish-run", *strict_args]
|
|
1254
|
-
|
|
1336
|
+
hints = _unreceipted_hints(selection.edits)
|
|
1337
|
+
drift = selection.edits.get("drift") or audit_drift(root, plan)
|
|
1255
1338
|
if drift.get("status") == "fail":
|
|
1256
1339
|
return _attach(
|
|
1257
|
-
Action("drift_failing",
|
|
1340
|
+
Action("drift_failing", _drift_reason(root, drift),
|
|
1258
1341
|
command=_cmd(finish_args), args=finish_args,
|
|
1259
|
-
diagnostics=[
|
|
1260
|
-
|
|
1342
|
+
diagnostics=[
|
|
1343
|
+
*(f"out of scope: {p}" for p in drift.get("out_of_scope_files", [])),
|
|
1344
|
+
*(f"blocked: {p}" for p in drift.get("blocked_files_changed", [])),
|
|
1345
|
+
*_unmapped_hunk_lines(drift),
|
|
1346
|
+
*hints,
|
|
1347
|
+
*stranded,
|
|
1348
|
+
]),
|
|
1261
1349
|
projection,
|
|
1262
1350
|
)
|
|
1263
1351
|
run_result = verify_run(root, plan, strict=strict, record=False)
|
|
@@ -1271,7 +1359,7 @@ def _selected_action(
|
|
|
1271
1359
|
strict and bool(run_warnings)
|
|
1272
1360
|
)
|
|
1273
1361
|
if run_record is None or run_failed:
|
|
1274
|
-
diagnostics = [*(f["message"] for f in run_findings), *stranded]
|
|
1362
|
+
diagnostics = [*(f["message"] for f in run_findings), *hints, *stranded]
|
|
1275
1363
|
if run_record is None:
|
|
1276
1364
|
diagnostics.insert(0, "run verification has not been recorded")
|
|
1277
1365
|
return _attach(
|
|
@@ -1346,7 +1434,16 @@ def _selected_action(
|
|
|
1346
1434
|
),
|
|
1347
1435
|
projection,
|
|
1348
1436
|
)
|
|
1349
|
-
|
|
1437
|
+
try:
|
|
1438
|
+
unrecorded = _scoped_unrecorded_paths(root, plan, step_id)
|
|
1439
|
+
except (RuntimeError, OSError) as exc:
|
|
1440
|
+
# A concurrent ledger change must supersede this working-tree diagnostic.
|
|
1441
|
+
return _invalid_action(_diagnose(
|
|
1442
|
+
{**projection, "diagnostics": []},
|
|
1443
|
+
"execution_state_invalid",
|
|
1444
|
+
f"cannot inspect changed files: {exc}",
|
|
1445
|
+
"working tree",
|
|
1446
|
+
))
|
|
1350
1447
|
if unrecorded:
|
|
1351
1448
|
args = ["record-file-change", "--step", step_id, "--path", unrecorded[0], *agent_args]
|
|
1352
1449
|
return _attach_inflight(
|