From 22326e59be00d601f4ce32c6b5d5ea66b73ba83b Mon Sep 17 00:00:00 2001 From: Atelier Protocol Experiment Date: Thu, 30 Jul 2026 03:33:00 -0700 Subject: [PATCH] fix(delegation): recover denied PR acknowledgements Allow a blocked result to retain an exact remotely reachable replacement candidate after PR publication acknowledgement is denied, without retroactively asserting PR mutation authority. Preserve the predecessor PR history and require fresh exact PR authority before later delivery. Add fresh-clone release, reclaim, and delivery coverage. --- contract_tests/test_claiming.py | 109 +++++++++++++++++------- skills/atelier/references/delegation.md | 12 ++- skills/atelier/scripts/delegation.py | 62 +++++++++----- skills/atelier/scripts/mailbox.py | 60 ++++++++++--- 4 files changed, 172 insertions(+), 71 deletions(-) diff --git a/contract_tests/test_claiming.py b/contract_tests/test_claiming.py index abf3c71c..d4b1d47e 100644 --- a/contract_tests/test_claiming.py +++ b/contract_tests/test_claiming.py @@ -2418,7 +2418,7 @@ def test_blocked_result_replaces_older_candidate_with_latest_verified_push(self) self.assertEqual(work["claim"]["candidate"]["head_revision"], latest_head) reconstruct_mailbox(checkout) - def test_inherited_candidate_survives_pr_block_release_reclaim_and_delivery(self) -> None: + def test_denied_pr_ack_recovery_survives_release_reclaim_and_delivery(self) -> None: def fresh_coordinator() -> ClaimCoordinator: return ClaimCoordinator( str(self.mailbox_remote), @@ -2601,9 +2601,6 @@ def fresh_coordinator() -> ClaimCoordinator: ) self.coordinator = fresh_coordinator() - advanced_claim = self.claim(token="head-advance-token-0") - advanced_delegation = self.delegation() - advanced_invocation = self.delegated_invocation(advanced_claim) git( self.project_checkout, "-c", @@ -2613,11 +2610,31 @@ def fresh_coordinator() -> ClaimCoordinator: "commit", "--allow-empty", "-m", - "Advance inherited candidate", + "Advance canonical base", ) - advanced_head = git(self.project_checkout, "rev-parse", "HEAD").stdout.strip() - git(self.project_checkout, "push", "origin", f"{advanced_head}:{candidate_ref}") - advanced_candidate = {**candidate, "head_sha": advanced_head} + git(self.project_checkout, "push", "origin", "HEAD:main") + advanced_claim = self.claim(token="head-advance-token-0") + advanced_delegation = self.delegation() + advanced_invocation = self.delegated_invocation(advanced_claim) + advanced_base = advanced_invocation["repository"]["base_sha"] + self.assertNotEqual(advanced_base, candidate["base_sha"]) + candidate_tree = git(self.project_checkout, "rev-parse", "HEAD^{tree}").stdout.strip() + advanced_head = git( + self.project_checkout, + "-c", + "user.name=Atelier Test", + "-c", + "user.email=atelier-test@invalid", + "commit-tree", + candidate_tree, + "-m", + "Rewrite inherited candidate", + ).stdout.strip() + advanced_candidate = { + **candidate, + "base_sha": advanced_base, + "head_sha": advanced_head, + } advanced_terminal_candidate = { **advanced_candidate, "publication": { @@ -2643,29 +2660,52 @@ def fresh_coordinator() -> ClaimCoordinator: token="head-advance-token-1", candidate=advanced_candidate, ) - advanced_published = self.delegated_checkpoint( - advanced_delegation, + git( + self.project_checkout, + "push", + "--force", + "origin", + f"{advanced_head}:{candidate_ref}", + ) + recoverable_candidate = { + **advanced_candidate, + "publication": {"kind": "ordinary", "pull_requests": []}, + } + denied_publication = self.delegated_request( advanced_invocation, advanced_push, action="repository.candidate.push", - token="head-advance-token-2", phase="candidate_published", - candidate=advanced_terminal_candidate, + candidate=recoverable_candidate, + ) + denial = self.assert_checkpoint_denied_without_mutation( + advanced_delegation, + advanced_invocation, + denied_publication, + ) + self.assertIn( + "candidate pull request metadata lacks exact pre-mutation authority", + denial["reason"], ) advanced_blocked_result = self.blocked_result( advanced_invocation, - advanced_published, - candidate=advanced_terminal_candidate, + advanced_push, + candidate=recoverable_candidate, authority_used=["repository.candidate.push"], ) advanced_blocked_result["blocking_reason"] = ( - "The same pull request remains blocked after its candidate head advanced." + "The candidate push succeeded, but publication acknowledgement was denied " + "because the existing pull request lacked fresh exact authority." ) + advanced_blocked_result["unresolved_obligations"] = [ + "Release and reclaim the exact pushed candidate.", + "Obtain fresh pull_request.update authority before restoring the existing PR.", + ] advanced_delegation.finalize( self.work_id, advanced_invocation, advanced_blocked_result, - self.fence(advanced_published), + self.fence(advanced_push), approved_commit=self.approved_commit, policy_target=self.policy_target(), host_target=self.delegation_host_target(), @@ -2689,41 +2729,50 @@ def fresh_coordinator() -> ClaimCoordinator: "remote": "origin", "remote_url": candidate["remote_url"], "remote_ref": candidate_ref, - "base_revision": candidate["base_sha"], + "base_revision": advanced_base, "head_revision": advanced_head, - "pull_request": pull_request_url, + "pull_request": None, "workspace_id": None, - "published_at": (self.live_at + timedelta(minutes=2)) + "published_at": (self.live_at + timedelta(minutes=3)) .isoformat() .replace("+00:00", "Z"), }, ) self.assertEqual(advanced_work["claim"]["inherited_receipt_id"], release_id) - - advanced_work["claim"]["inherited_receipt_id"] = first_release_id - fixtures.write_markdown( - advanced_checkout / f"work/{self.work_id}/work.md", - advanced_work, + blocked_receipt, _ = _read_yaml( + advanced_checkout + / f"work/{self.work_id}/receipts/{advanced_work['attempt_receipt_id']}.md", + frontmatter=True, + label="denied acknowledgement receipt", + ) + prior_receipt, _ = _read_yaml( + advanced_checkout / f"work/{self.work_id}/receipts/{release_id}.md", + frontmatter=True, + label="prior PR-bearing receipt", + ) + self.assertIsNone(blocked_receipt["candidate"]["pull_request"]) + self.assertEqual( + blocked_receipt["unresolved_obligations"], + advanced_blocked_result["unresolved_obligations"], ) - with self.assertRaisesRegex(MailboxValidationError, "checkpoint-pr-authority"): - reconstruct_mailbox(advanced_checkout) + self.assertEqual(prior_receipt["candidate"]["pull_request"], pull_request_url) advanced_release_id = new_identifier("rcp") self.coordinator.release( self.work_id, - self.fence(advanced_published), + self.fence(advanced_push), receipt_id=advanced_release_id, - reason="Transfer the same-PR advanced candidate to a final worker.", + reason="Transfer the denied-acknowledgement candidate to a final worker.", ended_at=OBSERVED_AT + timedelta(minutes=8), ) self.coordinator = fresh_coordinator() final_claim = self.claim(token="ready-pr-token-0") final_delegation = self.delegation() final_invocation = self.delegated_invocation(final_claim) - replacement_pull_request_url = "https://github.com/example/project-1/pull/795" + replacement_pull_request_url = pull_request_url replacement_terminal_candidate = json.loads(json.dumps(advanced_terminal_candidate)) replacement_pull_request = replacement_terminal_candidate["publication"]["pull_requests"][0] - replacement_pull_request["id"] = "795" + replacement_pull_request["id"] = "794" replacement_pull_request["url"] = replacement_pull_request_url replacement_authorized = self.delegated_checkpoint( final_delegation, diff --git a/skills/atelier/references/delegation.md b/skills/atelier/references/delegation.md index b71f489a..02f88798 100644 --- a/skills/atelier/references/delegation.md +++ b/skills/atelier/references/delegation.md @@ -57,10 +57,14 @@ match the still-open current native ticket before recording a receipt. - `blocked` preserves any acknowledged candidate. If the exact push succeeded but its `candidate_published` acknowledgement failed, the blocked result may recover that remotely reachable candidate only when it matches the sealed invocation and - the ledger's final authorized push head and remote ref. Atelier binds the candidate - and immutable blocked receipt atomically, records one unresolved planner decision, - and retains - mutation ownership. + the ledger's final authorized push head and remote ref. A replacement candidate + recovered this way may omit an inherited pull request that could not be + acknowledged without fresh exact PR authority; the predecessor receipt preserves + that prior candidate and PR history, while the blocked receipt preserves the + denied-acknowledgement obligations. Atelier binds the candidate and immutable + blocked receipt atomically, records one unresolved planner decision, and retains + mutation ownership. A later attempt still needs fresh exact PR authority before + it can publish or restore PR metadata. - `requires_epic` is recorded as a blocked attempt for planner action. Atelier does not invoke `implement-epic` from delegated work. diff --git a/skills/atelier/scripts/delegation.py b/skills/atelier/scripts/delegation.py index 87bd87d3..13a2099d 100644 --- a/skills/atelier/scripts/delegation.py +++ b/skills/atelier/scripts/delegation.py @@ -798,14 +798,6 @@ def _blocked_candidate( return None pull_request = _publication_pull_request(candidate) current_pull_request = current["pull_request"] if current is not None else None - if pull_request != current_pull_request and not _pull_request_mutation_authorized( - claim, - candidate_head=candidate["head_sha"], - candidate_remote_ref=candidate["remote_ref"], - ): - raise MailboxTransitionRejected( - "blocked pull request metadata lacks fresh exact pre-mutation authority" - ) candidate_identity = ( candidate["repository"], candidate["remote_url"], @@ -813,26 +805,50 @@ def _blocked_candidate( candidate["base_sha"], candidate["head_sha"], ) - if current is not None and candidate_identity == ( - current["repository"], - current["remote_url"], - current["remote_ref"], - current["base_revision"], - current["head_revision"], + current_identity = ( + ( + current["repository"], + current["remote_url"], + current["remote_ref"], + current["base_revision"], + current["head_revision"], + ) + if current is not None + else None + ) + ledger = claim["checkpoint"]["authorizations"] + recovers_exact_push = ( + result["implementation_state"] == "published" + and bool(ledger) + and ledger[-1]["phase"] == "pre_external_mutation" + and ledger[-1]["action"] == "repository.candidate.push" + and ledger[-1]["candidate_head"] == candidate["head_sha"] + and ledger[-1]["candidate_remote_ref"] == candidate["remote_ref"] + ) + drops_prior_pull_request_for_recovery = ( + current_pull_request is not None + and pull_request is None + and candidate_identity != current_identity + and recovers_exact_push + ) + if ( + pull_request != current_pull_request + and not drops_prior_pull_request_for_recovery + and not _pull_request_mutation_authorized( + claim, + candidate_head=candidate["head_sha"], + candidate_remote_ref=candidate["remote_ref"], + ) ): + raise MailboxTransitionRejected( + "blocked pull request metadata lacks fresh exact pre-mutation authority" + ) + if current is not None and candidate_identity == current_identity: value = copy.deepcopy(current) if pull_request is not None: value["pull_request"] = pull_request return value - ledger = claim["checkpoint"]["authorizations"] - if ( - result["implementation_state"] != "published" - or not ledger - or ledger[-1]["phase"] != "pre_external_mutation" - or ledger[-1]["action"] != "repository.candidate.push" - or ledger[-1]["candidate_head"] != candidate["head_sha"] - or ledger[-1]["candidate_remote_ref"] != candidate["remote_ref"] - ): + if not recovers_exact_push: raise MailboxTransitionRejected( "blocked published candidate lacks its exact push authorization" ) diff --git a/skills/atelier/scripts/mailbox.py b/skills/atelier/scripts/mailbox.py index 89d0d403..d954c59b 100644 --- a/skills/atelier/scripts/mailbox.py +++ b/skills/atelier/scripts/mailbox.py @@ -960,6 +960,36 @@ def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> b right["base_revision"], ) + def same_remote_lineage( + left: dict[str, Any] | None, + right: dict[str, Any] | None, + ) -> bool: + return left is not None and right is not None and ( + left["repository"], + left["remote"], + left["remote_url"], + left["remote_ref"], + ) == ( + right["repository"], + right["remote"], + right["remote_url"], + right["remote_ref"], + ) + + recovered_current_candidate = ( + attempt_receipt is not None + and attempt_receipt["outcome"] == "blocked" + and attempt_receipt["handoff"] == "transferable" + and attempt_receipt["claim_id"] == claim["id"] + and attempt_receipt["candidate"] == candidate + and bool(ledger) + and ledger[-1]["phase"] == "pre_external_mutation" + and ledger[-1]["action"] == "repository.candidate.push" + and candidate is not None + and ledger[-1]["candidate_head"] == candidate["head_revision"] + and ledger[-1]["candidate_remote_ref"] == candidate["remote_ref"] + ) + inherited_receipt_id = claim["inherited_receipt_id"] inherited_receipt = ( work_receipts.get(inherited_receipt_id) if inherited_receipt_id is not None else None @@ -978,7 +1008,13 @@ def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> b inherited_receipt["claim_id"] == claim["id"] or inherited_receipt["handoff"] != "transferable" or inherited_receipt["candidate"] is None - or not same_lineage(inherited_receipt["candidate"], candidate) + or ( + not same_lineage(inherited_receipt["candidate"], candidate) + and not ( + recovered_current_candidate + and same_remote_lineage(inherited_receipt["candidate"], candidate) + ) + ) ): diagnostics.append( Diagnostic( @@ -1002,19 +1038,6 @@ def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> b "claim does not bind the current transferable predecessor receipt", ) ) - recovered_current_candidate = ( - attempt_receipt is not None - and attempt_receipt["outcome"] == "blocked" - and attempt_receipt["handoff"] == "transferable" - and attempt_receipt["claim_id"] == claim["id"] - and attempt_receipt["candidate"] == candidate - and bool(ledger) - and ledger[-1]["phase"] == "pre_external_mutation" - and ledger[-1]["action"] == "repository.candidate.push" - and candidate is not None - and ledger[-1]["candidate_head"] == candidate["head_revision"] - and ledger[-1]["candidate_remote_ref"] == candidate["remote_ref"] - ) acknowledged_source = ( inherited_candidate_source if same_identity(inherited_candidate_source, candidate) @@ -1174,6 +1197,14 @@ def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> b for entry in ledger ) ) + blocked_push_drops_inherited_pull_request = ( + recovered_current_candidate + and inherited_candidate_source is not None + and candidate is not None + and not same_identity(inherited_candidate_source, candidate) + and inherited_candidate_source["pull_request"] is not None + and candidate["pull_request"] is None + ) current_unacknowledged = ( candidate is not None and not inherited_candidate @@ -1220,6 +1251,7 @@ def same_lineage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> b candidate is not None and candidate["pull_request"] != latest_published_pull_request and not terminal_pull_request_normalization + and not blocked_push_drops_inherited_pull_request ): diagnostics.append( Diagnostic(