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(