From 409cdf6e1acd3ce767f1170cc000215c4fd25dfe Mon Sep 17 00:00:00 2001 From: pi-typed-dispatch Date: Mon, 2 Nov 2026 08:44:44 -0500 Subject: [PATCH 1/5] Preserve publication identity and recapture changed source cuts --- src/agent_comms/head_review.py | 42 ++++++++- src/agent_comms/integration.py | 115 +++++++++++++++++++----- tests/github_cli_stub.py | 13 ++- tests/github_fixture.py | 6 +- tests/integration_source_fixture.py | 5 +- tests/test_integration.py | 130 ++++++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 27 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 38c984d6d4..c8caa6edd1 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -11,6 +11,7 @@ from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path +from time import monotonic, sleep from typing import TYPE_CHECKING, Annotated, ClassVar, Self from agent_comms_hooks import ToolRefusedError @@ -134,6 +135,23 @@ def current(self) -> OpenPullRequestHead: ) return current + def published_after(self, previous: PullRequestHead) -> OpenPullRequestHead: + """Observe a known successful push; only its previous head may lag briefly.""" + if not self.same_pull_request(previous): + raise ToolRefusedError("Publication observations must name the same pull request") + deadline = monotonic() + 5 + current = self.at(self.repo, self.pr) + while not current.holds(self.head): + if not current.holds(previous.head) or monotonic() >= deadline: + raise ToolRefusedError( + f"Published #{self.pr} expected {self.head}; forge observed {current.head}; " + f"previous head {previous.head}. Preserve this attempt and resume it; " + "do not repeat creation." + ) + sleep(min(0.1, max(0, deadline - monotonic()))) + current = self.at(self.repo, self.pr) + return current + def closed_or_open(self) -> ReadPullRequestHead: """GitHub's read in whatever state the PR is in, refused unless this is still its head: a closed PR's head can only be dropped, so nothing here may arm or integrate it.""" @@ -465,8 +483,14 @@ def source_urls(core: Repo, baseline: str, candidate: str) -> dict[RepositoryNam def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> None: """Git and forge prove this frozen input; Todo and verdict owners decide permission.""" + self._validate_current(repositories, carrier, carrier.current()) + + def _validate_current( + self, repositories: tuple[Repo, ...], carrier: PullRequestHead, + current: OpenPullRequestHead, + ) -> None: + """One observed forge cut and the expected immutable capture answer different questions.""" core = Repo.selected(repositories, carrier.repo.to_wire()) - current = carrier.current() if current.base != WorkBranches.main: raise ToolRefusedError("The integration carrier no longer targets main") self.require_carrier(core, carrier) @@ -488,6 +512,10 @@ def resume_publication( """An ordinary source record cannot become a combined publication.""" raise ToolRefusedError("This input is not a resumable combined publication") + def require_recapture(self, core: Repo, carrier: PullRequestHead) -> None: + """A completed capture supplies the default recapture proof.""" + self.require_carrier(core, carrier) + def require_source_reviews(self) -> None: """Native promotion and the hosted gate require these same exact source decisions.""" for source in self.sources: @@ -628,6 +656,15 @@ def resume_publication( ) -> CombinedInput: return run.capture(context, self, carrier) + def require_recapture(self, core: Repo, carrier: PullRequestHead) -> None: + """A known published prospective cut may be replaced explicitly, never recreated.""" + self.require_repositories( + self.declared_repositories(core, self.local.baseline.commit, carrier.head) + ) + self.bind(core, carrier) + if self.from_message(WorkBranches(core.checkout).commit_message(carrier.head)) != (self,): + raise ToolRefusedError("The published prospective cut does not contain this exact input") + def bind(self, repository: Repo, carrier: PullRequestHead) -> CombinedInput: """Bind the actual publication without substituting another source tree or history.""" if carrier.repo != self.predecessor.repository: @@ -680,6 +717,9 @@ def resume_publication( run.retain_capture(self, commit) return self + def validate_published(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> None: + self._validate_current(repositories, carrier, carrier.published_after(self.carrier)) + def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: self.local.require_origin(core) self.local.require_heads(carrier) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index bfa0414334..fbcc210281 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -19,7 +19,7 @@ from .team.order import TeamContext from .nominal.declared_family import DeclaredFamily from agent_comms_hooks import ToolRefusedError -from .nominal.field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder +from .nominal.field_codec import FieldCodec, GitCommitText, GoalId, PathText, WireName, boundary_decoder from .head_review import ( BaseSide, CombinedInput, @@ -206,8 +206,12 @@ def include(self, head: PullRequestHead) -> None: fetched = Repo.git(self.path, "rev-parse", "FETCH_HEAD").strip() if fetched != head.head: raise IntegrationFailed(f"#{head.pr} moved while fetching; capture its new head") + self.merge_revision(head.head) + + def merge_revision(self, commit: str) -> None: + """The integration checkout owns merging and conflict cleanup for members and main.""" try: - Repo.git(self.path, "merge", "--no-ff", "--no-edit", head.head, timeout=300) + Repo.git(self.path, "merge", "--no-ff", "--no-edit", commit, timeout=300) except GitCommandError: if Repo.answers(self.path, "rev-parse", "-q", "--verify", "MERGE_HEAD"): Repo.git(self.path, "merge", "--abort") @@ -930,10 +934,7 @@ def publish( excluded_carriers=companions) primary = store.get(primary.id) primary.require_assignee(actor, generation) - prepared.write(self.output) - Repo.git(self.checkout.path, "commit", "--allow-empty", "-m", - f"Prepare the actual source cut\n\n{prepared.trailer}") - Repo.git(self.checkout.path, "push", "-u", "origin", branch) + self.prepare_publication(prepared, branch) primary = store.get(primary.id) primary.require_assignee(actor, generation) link = primary.order.checked().open_pull_request( @@ -942,16 +943,80 @@ def publish( store, actor) carrier = PullRequestHead.at(link.repo, link.number) inputs = prepared.resume_publication(self, context, carrier) - primary = store.get(primary.id) - primary.require_assignee(actor, generation) + return self.finish_publication(context, primary.id, generation, carrier, branch, inputs) + + def prepare_publication(self, prepared: UnpublishedInput, branch: str) -> None: + """A durable prospective cut precedes both first publication and explicit recapture.""" + prepared.write(self.output) + Repo.git(self.checkout.path, "commit", "--allow-empty", "-m", + f"Prepare the actual source cut\n\n{prepared.trailer}") + Repo.git(self.checkout.path, "push", "-u", "origin", branch) + + def finish_publication( + self, context: TeamContext, problem: str, generation: GoalId, + carrier: PullRequestHead, branch: str, inputs: CombinedInput, + ) -> CombinedInput: + """Link and validate the capture we actually pushed, not a lagging forge head.""" + repository = self.checkout.owned_repository(context) + store = context.comms.goals.todos + primary = store.get(problem) + primary.require_assignee(context.actor_identity, generation) link = PullRequestLink(repository.github, carrier.pr) - store.apply(primary.id, RecordPullRequest.for_order(primary, actor, link)) + store.apply(primary.id, RecordPullRequest.for_order(primary, context.actor_identity, link)) Repo.git(self.checkout.path, "push", "origin", branch) - current = PullRequestHead.at(repository.github, carrier.pr) - inputs.validate(context.repos, current) + captured = PullRequestHead(repository.github, carrier.pr, + Repo.git(self.checkout.path, "rev-parse", "HEAD").strip()) + inputs.validate_published(context.repos, captured) print(f"PUBLISHED {link.url}; qualification only; no merge permission", flush=True) return inputs + def recapture( + self, context: TeamContext, problems: tuple[str, ...], *, carrier: PullRequestHead, + companions: tuple[PullRequestHead, ...] = (), + ) -> CombinedInput: + """Explicitly replace a changed cut on its real carrier; preserve the old capture.""" + if not problems: + raise IntegrationFailed("Recapture requires an actual primary problem") + repository = self.checkout.owned_repository(context) + branch = self.checkout.publication_branch() + store = context.comms.goals.todos + primary = store.get(problems[0]) + generation = primary.held().generation + primary.require_assignee(context.actor_identity, generation) + current = carrier.current() + matching = tuple(row for row in PullRequests().rows(repository.github, (current.pr,)) + if row.branch == branch) + if len(matching) != 1: + raise IntegrationFailed("Recapture requires the real PR on this owned publication branch") + commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + if current.repo != repository.github or not Repo.descends( + self.checkout.path, current.head, commit + ): + raise IntegrationFailed("Recapture must preserve the same real carrier's published history") + message = WorkBranches(self.checkout.path).commit_message(current.head) + recorded = (*IntegrationInput.from_message(message), *UnpublishedInput.from_message(message)) + if len(recorded) != 1: + raise IntegrationFailed("Recapture requires one existing published source cut") + (previous,) = recorded + previous.require_recapture(repository, carrier) + baseline = IntegrationSource.main_head(repository) + repository.acquire(baseline) + primary = store.get(primary.id) + primary.require_assignee(context.actor_identity, generation) + self.checkout.merge_revision(baseline) + prepared = self.prepare(context, problems, companions=companions, + excluded_carriers=(carrier, *companions)) + primary = store.get(primary.id) + primary.require_assignee(context.actor_identity, generation) + self.prepare_publication(prepared, branch) + prospective = PullRequestHead(repository.github, carrier.pr, + Repo.git(self.checkout.path, "rev-parse", "HEAD").strip()) + observed = prospective.published_after(current) + primary = store.get(primary.id) + primary.require_assignee(context.actor_identity, generation) + inputs = prepared.resume_publication(self, context, observed) + return self.finish_publication(context, primary.id, generation, observed, branch, inputs) + def qualify( self, context: TeamContext, @@ -1162,6 +1227,10 @@ def run() -> int: "--publish", default="", metavar="PROBLEM", help="prepare the actual source cut and create its first real carrier once", ) + operation.add_argument( + "--recapture", default="", metavar="PROBLEM", + help="explicitly replace a changed source cut on the same existing captured carrier", + ) parser.add_argument( "--workflow-output", type=Path, @@ -1249,7 +1318,7 @@ def run() -> int: tuple(pytest_arguments), ) # Decode foreign operations once; source publication never enters execute/test/lease. - if arguments.qualify or arguments.publish: + if arguments.qualify or arguments.publish or arguments.recapture: if pytest_arguments: parser.error("Source publication and qualification cannot run tests") context = TeamContext.read_root(resolve_comms_route(arguments.comms_root).root) @@ -1262,7 +1331,7 @@ def run() -> int: selected = tuple(head.identity for head in companions) if arguments.publish: if arguments.carrier: - parser.error("First publication has no prior --carrier; resume with --qualify") + parser.error("First publication has no prior --carrier; resume the same --publish") if not arguments.callers or not arguments.title or not arguments.body_files: parser.error("--publish requires --from, --title and --body-file") run.publish( @@ -1274,16 +1343,22 @@ def run() -> int: if arguments.title or arguments.body_files: parser.error("--title and --body-file belong to first --publish") if arguments.carrier <= 0: - parser.error("Qualification requires --carrier naming the actual separate combined PR") + parser.error("Qualification or recapture requires the actual separate --carrier PR") carrier = PullRequestHead.at(Repo.of(run.checkout.path).github, arguments.carrier) - run.qualify( - context, (arguments.qualify, *arguments.with_problem), - carrier=carrier.identity, - companions=selected, - ) + if arguments.recapture: + if not arguments.callers: + parser.error("Explicit recapture requires --from naming the actual holder") + run.recapture(context, (arguments.recapture, *arguments.with_problem), + carrier=carrier.identity, companions=selected) + else: + run.qualify( + context, (arguments.qualify, *arguments.with_problem), + carrier=carrier.identity, + companions=selected, + ) return 0 if any(values[option.dest] for option in source_options._group_actions): - parser.error("Source selection/publication options require --qualify or --publish") + parser.error("Source selection/publication options require a publication operation") return run.execute() diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 7cec6799ac..aeb7c25c47 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -14,10 +14,13 @@ def follow_git(request): """The foreign forge's branch head and PR ref follow the actual Git push.""" for remote in request.get("remotes", ()): row = request["listed"] + previous = row["head"] row["head"] = subprocess.run( ("git", "--git-dir", remote, "rev-parse", f"refs/heads/{row['branch']}"), capture_output=True, text=True, check=True, ).stdout.strip() + if row["head"] != previous: + request["observations"] = [previous] * request.get("lag_reads", 0) subprocess.run(("git", "--git-dir", remote, "update-ref", f"refs/pull/{row['number']}/head", row["head"]), check=True) @@ -36,6 +39,7 @@ def main(): for repository in repositories.values(): for request in repository["requests"].values(): follow_git(request) + (directory / "pull_requests.json").write_text(json.dumps(repositories)) with (directory / "calls.jsonl").open("a") as calls: calls.write(json.dumps(arguments) + "\n") if arguments[:2] == ["pr", "create"]: @@ -46,6 +50,7 @@ def main(): assert "--draft" in arguments and arguments[-2:] == ["--body-file", "-"] request = {"body": sys.stdin.read(), "listed": row, "remotes": creation["remotes"]} follow_git(request) + request["lag_reads"] = creation["lag_reads"] data["requests"][str(row["number"])] = request (directory / "pull_requests.json").write_text(json.dumps(repositories)) if creation["reply_refusal"]: @@ -72,9 +77,13 @@ def main(): elif parts[:1] == ["pulls"] and len(parts) == 2: assert arguments[arguments.index("--jq") + 1] == ( '.head.sha + " " + .base.ref + " " + (.draft | tostring) + " " + .state') - listed = data["requests"][parts[1]]["listed"] + request = data["requests"][parts[1]] + listed = request["listed"] + observations = request.get("observations", []) + observed = observations.pop(0) if observations else listed["head"] + (directory / "pull_requests.json").write_text(json.dumps(repositories)) state = "open" if listed["kind"] in data["open_kinds"] else "closed" - print(listed["head"], listed["base"], str(listed["kind"] == "draft").lower(), state) + print(observed, listed["base"], str(listed["kind"] == "draft").lower(), state) elif len(parts) == 3 and parts[0] == "commits" and parts[2] == "pulls": assert arguments[arguments.index("--jq") + 1] == "map(.number)" print(json.dumps([entry["listed"]["number"] for entry in data["requests"].values() diff --git a/tests/github_fixture.py b/tests/github_fixture.py index 4b68194dfb..d4a0d2d63d 100644 --- a/tests/github_fixture.py +++ b/tests/github_fixture.py @@ -19,6 +19,7 @@ class PullRequestCreation: listed: ListedPullRequest remotes: tuple[Path, ...] = () reply_refusal: str = "" + lag_reads: int = 0 @dataclass(frozen=True) @@ -52,11 +53,12 @@ def refuse_read(self, repository: RepositoryName, number: int, refusal: str) -> self.path.write_text(json.dumps(data)) def create_next(self, repository: RepositoryName, row: ListedPullRequest, *, - remotes: tuple[Path, ...] = (), reply_refusal: str = "") -> None: + remotes: tuple[Path, ...] = (), reply_refusal: str = "", + lag_reads: int = 0) -> None: """The forge can follow a real pushed branch and lose its reply after creating it.""" data = json.loads(self.path.read_text()) data[repository.to_wire()]["next_creation"] = FieldCodec.encode( - PullRequestCreation(row, remotes, reply_refusal)) + PullRequestCreation(row, remotes, reply_refusal, lag_reads)) self.path.write_text(json.dumps(data)) def refuse_status(self, repository: RepositoryName, head: str, context: str, diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 49b3eed254..b5f1af94ef 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -55,7 +55,8 @@ def publish_carrier(self, github: GitHubFixture) -> PullRequestHead: )) return PullRequestHead(self.core.github, self.carrier_number, commit) - def starting_publication(self, reply_refusal: str, *, checkout_owner: str = "worker") -> Self: + def starting_publication(self, reply_refusal: str, *, checkout_owner: str = "worker", + lag_reads: int = 0) -> Self: """The forge follows the pushed carrier branch, including an actually lost reply.""" context = self.context.model_copy(update={"actor": PeerCaller("worker")}) checkout = self.owned_checkout(context, self.core, checkout_owner, "P3") @@ -64,7 +65,7 @@ def starting_publication(self, reply_refusal: str, *, checkout_owner: str = "wor self.github.create_next(self.core.github, DraftListedPullRequest( self.carrier_number, "publish-carrier", self.core.main_commit, stamp, "main", reviewable_times=(stamp,)), remotes=(self.core.remote_path,), - reply_refusal=reply_refusal) + reply_refusal=reply_refusal, lag_reads=lag_reads) return replace(self, context=context, checkout=checkout) def publish(self, output: Path) -> CombinedInput: diff --git a/tests/test_integration.py b/tests/test_integration.py index 41ef4d4443..552e99057a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -620,6 +620,136 @@ def test_publication_recovery_refuses_changed_companion_without_recreating( assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 +def test_first_publication_waits_only_for_known_previous_forge_head( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("", lag_reads=2) + inputs = pair.publish(pair.output) + current = PullRequestHead.at(pair.core.github, pair.carrier_number) + inputs.validate(pair.context.repos, current) + assert current.head == pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +def test_published_head_observation_refuses_an_unexpected_foreign_replacement( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("") + inputs = pair.publish(pair.output) + expected = PullRequestHead.at(pair.core.github, pair.carrier_number).identity + stamp = "2026-10-24T00:00:00Z" + replacement = "f" * 40 + pair.github.put(pair.core.github, DraftListedPullRequest( + pair.carrier_number, "publish-carrier", replacement, stamp, "main", + reviewable_times=(stamp,))) + with pytest.raises(ToolRefusedError, match=replacement): + expected.published_after(inputs.carrier) + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +@pytest.mark.parametrize("advance_core", (False, True)) +@pytest.mark.parametrize("lost_reply", (False, True)) +def test_explicit_recapture_preserves_real_carrier_and_old_history_after_changed_mains( + mutual_sources: MutualSources, advance_core: bool, lost_reply: bool, +) -> None: + pair = mutual_sources.starting_publication("Reply lost after creation" if lost_reply else "") + with pytest.raises(TodoError) if lost_reply else nullcontext(): + pair.publish(pair.output) + old = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + original_input = tuple(path.read_bytes() for path in pair.output.glob("*input.json")) + sources = (pair.client, pair.core) if advance_core else (pair.client,) + for source in sources: + source.git(source.checkout, "switch", "main") + source.commit_change("Advance actual main", *source.write_files({ + Path("main-only.py"): "advanced = True\n", + })) + source.git(source.checkout, "push", "origin", "main") + source.git(source.checkout, "switch", "feature") + with pytest.raises(ToolRefusedError): + pair.publish(pair.output.with_name("changed-cut-refusal")) + carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) + output = pair.output.with_name("explicit-recapture") + inputs = integration.IntegrationRun(pair.checkout, output, "Replace changed mains explicitly").recapture( + pair.context, ("P3",), carrier=carrier.identity) + current = PullRequestHead.at(pair.core.github, pair.carrier_number) + inputs.validate(pair.context.repos, current) + assert inputs.carrier.pr == carrier.pr == pair.carrier_number + assert inputs.source(pair.client.github).baseline.commit == pair.client.main_commit + assert inputs.local.baseline.commit == pair.core.main_commit + assert not inputs.local.has_changes(pair.core) + assert pair.core.git(pair.checkout.path, "show", "-s", "--format=%P", current.head).split() == [inputs.carrier.head] + assert Repo.descends(pair.checkout.path, old, current.head) + assert tuple(path.read_bytes() for path in pair.output.glob("*input.json")) == original_input + assert pair.publish(pair.output.with_name("recovered-recapture")) == inputs + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() == current.head + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +@pytest.mark.parametrize("borrowed", (False, True)) +def test_explicit_recapture_refuses_wrong_holder_or_borrowed_checkout_before_mutation( + mutual_sources: MutualSources, borrowed: bool, +) -> None: + pair = mutual_sources.starting_publication("") + pair.publish(pair.output) + creator_checkout = pair.owned_checkout(pair.context, pair.core, "lead", "P3") + pair.core.git(creator_checkout.path, "switch", "-c", "creator-recapture") + context = pair.context if borrowed else pair.context.model_copy(update={"actor": PeerCaller("lead")}) + before = pair.core.git(creator_checkout.path, "rev-parse", "HEAD").strip() + refs = pair.core.git(pair.core.remote_path, "show-ref") + output = pair.output.with_name("forbidden-recapture") + carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) + with pytest.raises(StorageDenied if borrowed else TodoConflict): + integration.IntegrationRun(creator_checkout, output, "Refuse foreign recapture").recapture( + context, ("P3",), carrier=carrier.identity) + assert pair.core.git(creator_checkout.path, "rev-parse", "HEAD").strip() == before + assert pair.core.git(pair.core.remote_path, "show-ref") == refs + assert not output.exists() + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +def test_explicit_recapture_refuses_a_different_actual_carrier_branch( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("Reply lost after creation") + with pytest.raises(TodoError): + pair.publish(pair.output) + before = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + stamp = "2026-10-24T00:00:00Z" + foreign = PullRequestHead(pair.core.github, pair.carrier_number + 1, before) + pair.github.put(pair.core.github, DraftListedPullRequest( + foreign.pr, "another-carrier", before, stamp, "main", reviewable_times=(stamp,))) + refs = pair.core.git(pair.core.remote_path, "show-ref") + output = pair.output.with_name("different-carrier-refusal") + with pytest.raises(integration.IntegrationFailed): + integration.IntegrationRun(pair.checkout, output, "Refuse a different real carrier").recapture( + pair.context, ("P3",), carrier=foreign) + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() == before + assert pair.core.git(pair.core.remote_path, "show-ref") == refs + assert not output.exists() + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +def test_explicit_recapture_cli_uses_same_real_carrier_without_creation( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("") + pair.publish(pair.output) + pair.client.git(pair.client.checkout, "switch", "main") + pair.client.commit_change("Move client main", *pair.client.write_files({Path("main-only.py"): "new = True\n"})) + pair.client.git(pair.client.checkout, "push", "origin", "main") + output = pair.output.with_name("cli-recapture") + result = subprocess.run(( + str(Path(sys.executable).with_name("agent-comms-integrate")), + "--recapture", "P3", "--from", "worker", "--comms-root", str(pair.context.root), + "--carrier", str(pair.carrier_number), "--checkout", str(pair.checkout.path), + "--output", str(output), "--risk", "Exercise the explicit recapture console", + ), capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + inputs = IntegrationInput.at(output) + inputs.validate(pair.context.repos, PullRequestHead.at(pair.core.github, pair.carrier_number)) + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + def test_prepared_attempt_without_discoverable_carrier_never_repeats_creation( mutual_sources: MutualSources, ) -> None: From 31285f2b10be74596f852d9064fac45b05f1e15c Mon Sep 17 00:00:00 2001 From: pi-typed-dispatch Date: Mon, 2 Nov 2026 08:50:18 -0500 Subject: [PATCH 2/5] Check current scope before publication checkout mutation --- src/agent_comms/integration.py | 2 ++ tests/test_integration.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index fbcc210281..1faa01c922 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -189,6 +189,8 @@ def owned_repository(self, context: TeamContext) -> Repo: owner = context.comms.registry.require(context.actor_name) context.comms.registry.storage_rule.require_owned_checkout( owner, repository.checkout, repository.github, RepositoryReads()) + snapshot = context.comms.registry.snapshot() + snapshot.commander_project.require_scope(snapshot, context.actor_identity, repository.github) return repository def publication_branch(self) -> str: diff --git a/tests/test_integration.py b/tests/test_integration.py index 552e99057a..82250612ff 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,6 +15,7 @@ import tomli_w from agent_comms import integration +from agent_comms.team.order import Team from agent_comms_hooks import ToolRefusedError from agent_comms.head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, MergeHeadReview, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource from agent_comms.repository_name import RepositoryName @@ -707,6 +708,28 @@ def test_explicit_recapture_refuses_wrong_holder_or_borrowed_checkout_before_mut assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 +def test_explicit_recapture_rechecks_scope_before_integrating_core_main( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("") + pair.publish(pair.output) + pair.core.git(pair.core.checkout, "switch", "main") + pair.core.commit_change("Advance Core main", *pair.core.write_files({Path("main-only.py"): "new = True\n"})) + pair.core.git(pair.core.checkout, "push", "origin", "main") + before = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + refs = pair.core.git(pair.core.remote_path, "show-ref") + carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) + Team.on_root("lead", (pair.client,), pair.context.root) + output = pair.output.with_name("revoked-scope-recapture") + with pytest.raises(ToolRefusedError): + integration.IntegrationRun(pair.checkout, output, "Refuse revoked Core scope").recapture( + pair.context, ("P3",), carrier=carrier.identity) + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() == before + assert pair.core.git(pair.core.remote_path, "show-ref") == refs + assert not output.exists() + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + def test_explicit_recapture_refuses_a_different_actual_carrier_branch( mutual_sources: MutualSources, ) -> None: From 499d4e79c63a21995966d8431747ee5a59c013a9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 09:21:22 -0500 Subject: [PATCH 3/5] Check source preparation before integrating recapture main --- src/agent_comms/integration.py | 41 +++++++++++++++------- tests/integration_source_fixture.py | 11 ++++++ tests/test_integration.py | 54 ++++++++++++++++++++++------- 3 files changed, 81 insertions(+), 25 deletions(-) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 1faa01c922..f4316e7fef 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -9,7 +9,7 @@ import sys import tomllib from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Annotated, ClassVar, Self @@ -46,7 +46,7 @@ from .repository_name import RepositoryName from .systemd_unit import AgentsSlice from .test_lease import LeaseStart, TestLease -from .todos import CarriedHeadVerdict, PullRequestLink, PullRequests, RecordPullRequest, StoredHeadReview, VerdictReason +from .todos import CarriedHeadVerdict, PullRequestLink, PullRequests, RecordPullRequest, StoredHeadReview, Todo, VerdictReason from .tool_caller import PeerCaller from .work_branches import WorkBranches @@ -219,6 +219,9 @@ def merge_revision(self, commit: str) -> None: Repo.git(self.path, "merge", "--abort") raise + def prepare_main(self, base: str) -> None: + """Ordinary preparation preserves its candidate; recapture can integrate main.""" + def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: """Checking preserves every source member, including when their merge conflicts.""" self.require_editable() @@ -256,6 +259,20 @@ def test_files(self, base: str, heads: tuple[PullRequestHead, ...]) -> tuple[str return TestSelection.read(self.path).files(self.path, changed) +@dataclass(frozen=True) +class RecaptureCheckout(IntegrationCheckout): + """Integrate current main only after the shared source preparation checks.""" + + context: TeamContext + primary: Todo + + def prepare_main(self, base: str) -> None: + self.owned_repository(self.context) + current = self.context.comms.goals.todos.get(self.primary.id) + current.require_assignee(self.context.actor_identity, self.primary.held().generation) + self.merge_revision(base) + + @dataclass(frozen=True) class TestAttempt: log: Path @@ -836,16 +853,18 @@ def prepare( for excluded in excluded_carriers)}.values(), key=lambda head: head.pr) ) - self.output.mkdir(parents=True, exist_ok=False) - IntegrationCheckout.create( - core, BaseSide.checkout(self.output, core.github), base - ) - tree = Repo.git(self.checkout.path, "rev-parse", "HEAD^{tree}").strip() resolved = {companion.repo: companion.identity for companion in companions} if len(resolved) != len(companions) or set(resolved) - companion_repositories.keys(): raise IntegrationFailed( "Select at most one actual carrier per declared companion repository" ) + self.output.mkdir(parents=True, exist_ok=False) + self.checkout.prepare_main(base) + candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + IntegrationCheckout.create( + core, BaseSide.checkout(self.output, core.github), base + ) + tree = Repo.git(self.checkout.path, "rev-parse", "HEAD^{tree}").strip() sources: list[IntegrationSource] = [ LocalIntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree) ] @@ -1001,13 +1020,11 @@ def recapture( raise IntegrationFailed("Recapture requires one existing published source cut") (previous,) = recorded previous.require_recapture(repository, carrier) - baseline = IntegrationSource.main_head(repository) - repository.acquire(baseline) primary = store.get(primary.id) primary.require_assignee(context.actor_identity, generation) - self.checkout.merge_revision(baseline) - prepared = self.prepare(context, problems, companions=companions, - excluded_carriers=(carrier, *companions)) + prepared = replace(self, checkout=RecaptureCheckout(self.checkout.path, context, primary)).prepare( + context, problems, companions=companions, + excluded_carriers=(carrier, *companions)) primary = store.get(primary.id) primary.require_assignee(context.actor_identity, generation) self.prepare_publication(prepared, branch) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index b5f1af94ef..30edbfc33f 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -73,6 +73,17 @@ def publish(self, output: Path) -> CombinedInput: self.context, ("P3",), title="Publish the actual paired source cut", body="The client migration is substantive; Core retains its actual source cut.") + @staticmethod + def advance_main(*sources: SourceRepository) -> None: + """One real main-advance procedure for the paired cut's refusal controls.""" + for source in sources: + source.git(source.checkout, "switch", "main") + source.commit_change("Advance actual main", *source.write_files({ + Path("main-only.py"): "advanced = True\n", + })) + source.git(source.checkout, "push", "origin", "main") + source.git(source.checkout, "switch", "feature") + def prepare_carrier(self, problems: tuple[str, ...]) -> PullRequestHead: """Fixture setup owns an actual published combination before production capture.""" todos = tuple(self.context.comms.goals.todos.get(problem) for problem in problems) diff --git a/tests/test_integration.py b/tests/test_integration.py index 82250612ff..980ce46d45 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -659,13 +659,7 @@ def test_explicit_recapture_preserves_real_carrier_and_old_history_after_changed old = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() original_input = tuple(path.read_bytes() for path in pair.output.glob("*input.json")) sources = (pair.client, pair.core) if advance_core else (pair.client,) - for source in sources: - source.git(source.checkout, "switch", "main") - source.commit_change("Advance actual main", *source.write_files({ - Path("main-only.py"): "advanced = True\n", - })) - source.git(source.checkout, "push", "origin", "main") - source.git(source.checkout, "switch", "feature") + pair.advance_main(*sources) with pytest.raises(ToolRefusedError): pair.publish(pair.output.with_name("changed-cut-refusal")) carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) @@ -713,9 +707,7 @@ def test_explicit_recapture_rechecks_scope_before_integrating_core_main( ) -> None: pair = mutual_sources.starting_publication("") pair.publish(pair.output) - pair.core.git(pair.core.checkout, "switch", "main") - pair.core.commit_change("Advance Core main", *pair.core.write_files({Path("main-only.py"): "new = True\n"})) - pair.core.git(pair.core.checkout, "push", "origin", "main") + pair.advance_main(pair.core) before = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() refs = pair.core.git(pair.core.remote_path, "show-ref") carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) @@ -730,6 +722,44 @@ def test_explicit_recapture_rechecks_scope_before_integrating_core_main( assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 +def test_explicit_recapture_preserves_reused_output_before_integrating_main( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources.starting_publication("") + pair.publish(pair.output) + pair.advance_main(pair.core) + before = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + inputs = IntegrationInput.at(pair.output) + carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) + with pytest.raises(FileExistsError): + integration.IntegrationRun(pair.checkout, pair.output, "Preserve existing output").recapture( + pair.context, ("P3",), carrier=carrier.identity) + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() == before + assert IntegrationInput.at(pair.output) == inputs + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + +@pytest.mark.parametrize(("problems", "failure"), ( + (("P3", "P3"), integration.StopAfterFailure()), + (("P3",), integration.ExcludeAfterFailure(7)), +)) +def test_explicit_recapture_rejects_invalid_preparation_before_integrating_main( + mutual_sources: MutualSources, problems: tuple[str, ...], failure: integration.FailureAction, +) -> None: + pair = mutual_sources.starting_publication("") + pair.publish(pair.output) + pair.advance_main(pair.core) + before = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + output = pair.output.with_name("invalid-preparation") + carrier = PullRequestHead.at(pair.core.github, pair.carrier_number) + with pytest.raises(integration.IntegrationFailed): + integration.IntegrationRun(pair.checkout, output, "Refuse invalid preparation", failure).recapture( + pair.context, problems, carrier=carrier.identity) + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() == before + assert not output.exists() + assert sum(call[:2] == ("pr", "create") for call in pair.github.calls()) == 1 + + def test_explicit_recapture_refuses_a_different_actual_carrier_branch( mutual_sources: MutualSources, ) -> None: @@ -757,9 +787,7 @@ def test_explicit_recapture_cli_uses_same_real_carrier_without_creation( ) -> None: pair = mutual_sources.starting_publication("") pair.publish(pair.output) - pair.client.git(pair.client.checkout, "switch", "main") - pair.client.commit_change("Move client main", *pair.client.write_files({Path("main-only.py"): "new = True\n"})) - pair.client.git(pair.client.checkout, "push", "origin", "main") + pair.advance_main(pair.client) output = pair.output.with_name("cli-recapture") result = subprocess.run(( str(Path(sys.executable).with_name("agent-comms-integrate")), From 4c93a9efdc39a622cb6b15605676c9d3b81b1e55 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 13:47:39 -0500 Subject: [PATCH 4/5] Use pytest declared defaults for changed-test selection --- pyproject.toml | 2 +- src/agent_comms/integration.py | 13 +++++++++---- tests/test_integration.py | 23 +++++++++++++++++++++++ uv.lock | 4 ++-- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf880bbda9..e635a97cf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,12 +30,12 @@ dependencies = [ "nominal-refactor-advisor @ git+https://github.com/OpenHCSDev/NominalRefactorAdvisor@main", "packaging>=24", "pydantic>=2.13", + "pytest>=7.0", "python-introspect @ git+https://github.com/OpenHCSDev/python-introspect@main", ] [project.optional-dependencies] dev = [ - "pytest>=7.0", "pytest-cov>=4.0", "pytest-asyncio>=0.23", "pytest-xdist>=3.6", diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index f4316e7fef..253b7f3a8c 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Annotated, ClassVar, Self +from _pytest.config import get_config + from .active_route import resolve_comms_route from .build_identity import RepositoryCommit from .child_process import STOP_GRACE_SECONDS, ResidentManager, SpawnedProcess @@ -62,16 +64,19 @@ class TestSelection: @classmethod def read(cls, checkout: Path) -> TestSelection: - # Decode only this consumer's declared fields; pytest owns the other options. + # Register pytest's built-in declarations without loading project plugins, + # conftests or collection. Missing options belong to those declarations. + defaults = get_config() with (checkout / "pyproject.toml").open("rb") as source: match tomllib.load(source): case {"tool": {"pytest": {"ini_options": {**configuration}}}}: return FieldCodec.decode( cls, { - entry.key: configuration[entry.key] + entry.key: configuration.get( + entry.key, defaults.getini(entry.key) + ) for entry in FieldCodec.argument_fields(cls) - if entry.key in configuration }, ) raise IntegrationFailed("pyproject.toml requires [tool.pytest.ini_options]") @@ -82,7 +87,7 @@ def files(self, checkout: Path, changed: tuple[Path, ...]) -> tuple[str, ...]: { str(path) for path in changed - if any(path.is_relative_to(root) for root in self.directories) + if any(path.is_relative_to(root) for root in self.directories or (".",)) and any(path.match(pattern) for pattern in self.patterns) and (checkout / path).is_file() } diff --git a/tests/test_integration.py b/tests/test_integration.py index 980ce46d45..d96b71d2d8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -458,6 +458,29 @@ def test_selection_uses_the_project_declaration_and_unions_existing_files(tmp_pa Path("checks/spec_missing.py"))) == (str(selected),) +@pytest.mark.parametrize("configuration", ( + 'testpaths = ["checks"]\n', + '', +)) +def test_selection_uses_pytest_defaults_without_loading_project_code( + tmp_path: Path, configuration: str, +): + (tmp_path / "pyproject.toml").write_text( + '[tool.pytest.ini_options]\n' + configuration + ) + (tmp_path / "conftest.py").write_text( + 'raise RuntimeError("selection must not execute project code")\n' + ) + selected = Path("checks/test_owner.py") + ignored = Path("checks/owner.py") + for path in (selected, ignored): + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_text("def test_ok(): pass\n") + assert integration.TestSelection.read(tmp_path).files( + tmp_path, (selected, ignored, selected), + ) == (str(selected),) + + def test_only_accepted_ready_members_are_selected(github_cli, tmp_path: Path): source = SourceRepository.create(tmp_path / "integration-source") github_cli.configure(source.github) diff --git a/uv.lock b/uv.lock index 413e3e5598..b7192958ed 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ dependencies = [ { name = "nominal-refactor-advisor" }, { name = "packaging" }, { name = "pydantic" }, + { name = "pytest" }, { name = "python-introspect" }, ] @@ -31,7 +32,6 @@ dependencies = [ dev = [ { name = "black" }, { name = "mypy" }, - { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, @@ -51,7 +51,7 @@ requires-dist = [ { name = "nominal-refactor-advisor", git = "https://github.com/OpenHCSDev/NominalRefactorAdvisor?rev=main" }, { name = "packaging", specifier = ">=24" }, { name = "pydantic", specifier = ">=2.13" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest", specifier = ">=7.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" }, From f6f2a196de3f287c9f9c21f1a06bb107020c9008 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 14:14:05 -0500 Subject: [PATCH 5/5] Declare both captured dependency graphs in the common checker solve --- tests/test_checker_stack.py | 50 +++++++++++++++++-------------------- tools/checker_stack.py | 23 ++++++----------- 2 files changed, 30 insertions(+), 43 deletions(-) diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index 8dd7803a19..c4a9a8aaa9 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -42,14 +42,14 @@ def test_local_package_membership_comes_from_both_source_declarations() -> None: }) -@pytest.mark.parametrize("dependencies,allowed,declared_python", [ - pytest.param((), True, "", id="dependencies0-True"), - pytest.param(("idna==3.10",), False, "", id="dependencies1-False"), - pytest.param((), True, ">=3.14", id="requires-python"), +@pytest.mark.parametrize("dependencies,declared_python", [ + pytest.param((), "", id="unchanged-dependencies"), + pytest.param(("idna==3.10",), "", id="added-dependency"), + pytest.param((), ">=3.14", id="requires-python"), ]) -def test_private_project_preserves_settings_pins_and_refuses_external_migration( +def test_private_project_preserves_settings_pins_and_captured_sources( mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, - dependencies: tuple[str, ...], allowed: bool, declared_python: str, + dependencies: tuple[str, ...], declared_python: str, monkeypatch: pytest.MonkeyPatch, test_uv: Path, ) -> None: pair = mutual_sources @@ -84,23 +84,18 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( inputs=inputs, uv_program=test_uv) original = checker.core / "stack" before = (original / "pyproject.toml").read_bytes(), (original / "uv.lock").read_bytes() - if allowed: - checker.write_project() - expected = tomllib.loads(before[0].decode()) - expected["tool"]["uv"]["sources"]["agent-comms"] = {"path": str(checker.core), "editable": True} - expected["tool"]["uv"]["sources"]["source-client"] = { - "path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True, - "marker": "python_version >= '3.10'", "fixture-option": {"preserved": True}} + checker.write_project() + expected = tomllib.loads(before[0].decode()) + expected["tool"]["uv"]["sources"]["agent-comms"] = {"path": str(checker.core), "editable": True} + expected["tool"]["uv"]["sources"]["source-client"] = { + "path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True, + "marker": "python_version >= '3.10'", "fixture-option": {"preserved": True}} - assert tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) == expected - assert checker.lock.read_bytes() == before[1] - monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) - with pytest.raises(ValueError, match="isolated runner bootstrap prefix"): - checker.prepare() - else: - with pytest.raises(ValueError, match="External runtime dependency migration"): - checker.write_project() - assert not checker.checkout.exists() + assert tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) == expected + assert checker.lock.read_bytes() == before[1] + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + with pytest.raises(ValueError, match="isolated runner bootstrap prefix"): + checker.prepare() assert ((original / "pyproject.toml").read_bytes(), (original / "uv.lock").read_bytes()) == before for side in (BaseSide, HeadSide): for source in inputs.sources: @@ -135,11 +130,12 @@ def test_bootstrap_extra_is_runtime_only_when_the_virtual_root_requests_it( with InstallCommands() as commands: checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, runtime=LaunchRuntime.running(), source_directory=pair.output, inputs=inputs, uv_program=test_uv) - if requested: - with pytest.raises(ValueError, match="External runtime dependency migration"): - checker.require_runtime_compatibility() - else: - checker.require_runtime_compatibility() + checker.write_project() + private = PrivateUvProject.read(checker.checkout / "stack/pyproject.toml") + projected = tuple(Requirement(text) for text in private.project.dependencies) + baseline_edges = tuple(edge for edge in projected if edge.name == "idna") + assert tuple(str(edge) for edge in baseline_edges) == (("idna>=3.9",) if requested else ()) + checker.resolve() # Both compatible bounds belong to one actual solve. before = checker.clients.projects(BaseSide)[pair.core.github] after = checker.clients.projects(HeadSide)[pair.core.github] assert before.requirements != after.requirements # Complete metadata was never filtered away. diff --git a/tools/checker_stack.py b/tools/checker_stack.py index 4390a194a9..565a1025be 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -87,21 +87,6 @@ def requirements(self, side: type[Side]) -> tuple[Requirement, ...]: self.clients.environment, ) - def require_runtime_compatibility(self) -> None: - # Local package presence differs legitimately; only the real external contract agrees. - before, after = ( - frozenset( - str(requirement) - for requirement in self.requirements(side) - if canonicalize_name(requirement.name) not in self.local_names - ) - for side in (BaseSide, HeadSide) - ) - if before != after: - raise ValueError( - "External runtime dependency migration requires an explicit compatible environment" - ) - @cached_property def bootstrap_lock(self) -> ResolvedLock: return ResolvedLock.read(self.core / "uv.lock") @@ -144,7 +129,6 @@ def private_requirements(self) -> tuple[Requirement, ...]: def write_project(self) -> None: """Declare both complete root graphs and retained identities before the one solve.""" - self.require_runtime_compatibility() retained = ( self.retained_packages ) # Conflicting runtime-only locks refuse before prefix mutation. @@ -154,6 +138,13 @@ def write_project(self) -> None: document = self.source_projects[HeadSide].model_copy(deep=True) projects = self.clients.projects(HeadSide) document.enrich(projects[CorePackage.repository]) + for active in self.requirements(BaseSide): + if canonicalize_name(active.name) not in self.local_names: + # Activation already used the baseline interpreter/extras. A root has + # no parent extra, so retain the selected edge without its old marker. + requirement = Requirement(str(active)) + requirement.marker = None + document.project.dependencies.append(str(requirement)) materialized = { identity: ( HeadSide.require_materialized(self.source_directory, self.inputs.source(identity)),