From cf7aa551fa2593718e337df9ac100572e93c127f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 22:47:08 -0400 Subject: [PATCH] Ratchet clients: the project owns its ClientSources; --ci-inputs reads CI's cuts #3606 moved ClientTypeErrors into AgentCommsProject with a fresh stack clone and no way to read the integration entrypoint's materialized cuts, so #3566 had to drop --ci-inputs from debt_ratchet.py (a974c0f54). AgentCommsProject now holds one ClientSources (default: the head's declared stack, cloned once); AgentCommsProject.from_arguments decodes --ci-inputs into MaterializedClients. ClientTypeErrors.parts asks it per compared side and also runs when the source requires clients (a companion can change while Core does not). The static clone in ClientTypeErrors.clients is deleted. Two tests still importing the deleted debt_ratchet.ClientTypeErrors/compare now build the project with its clients. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_integration.py | 12 +++--- tests/test_module_layers_clients.py | 7 ++-- tools/agent_comms_project.py | 60 ++++++++++++++++++----------- tools/debt_ratchet.py | 8 +++- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2c4474e77c..ee730bcbbf 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -29,7 +29,8 @@ from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS -from debt_ratchet import ClientTypeErrors, compare +from agent_comms_project import AgentCommsProject, ClientTypeErrors +from agent_comms.pinned_audit import audit from module_layers import MaterializedClients, Package pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @@ -372,8 +373,9 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() clients = materialized_clients(pair.output, inputs) - assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, - {"src/agent_comms/api.py"}, "src/", clients)) == 2 + project = AgentCommsProject(pair.core.checkout, clients=clients) + assert len(ClientTypeErrors.parts(project, source.baseline.commit, head, + {"src/agent_comms/api.py"}, "src/")) == 2 for side in (BaseSide, HeadSide): client, = clients.at(pair.core.checkout, source.baseline.commit, head, side) assert client.import_paths == tuple(str(client.checkout / path) for path in ("src", "tools")) @@ -387,8 +389,8 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( assert entry.returncode == 0, entry.stdout + entry.stderr # Both namespace files are selected under --explicit-package-bases: a duplicate # performance/tools.performance identity aborts the real checker, not a zero count. - result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, - measured=(ClientTypeErrors,), clients=clients) + result = audit.ratchet.compare(project, source.baseline.commit, head, "src", 1, + (ClientTypeErrors,)) assert not result.failed assert all(value == 0 for value in result.delta.values()) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 90aa7f89ed..57788fed81 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -13,7 +13,7 @@ import tomli_w from module_layers import MaterializedClients, OutgoingWire, Package, StackClient -from debt_ratchet import ClientTypeErrors +from agent_comms_project import AgentCommsProject, ClientTypeErrors from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.todos import DraftListedPullRequest from github_fixture import GitHubFixture @@ -102,8 +102,9 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( clients = resource.at(pair.core.checkout, core.baseline.commit, tested, side) assert len(clients) == int(present) assert all(client.checkout == side.checkout(pair.output, pair.client.github) for client in clients) - assert len(ClientTypeErrors.parts(pair.core.checkout, core.baseline.commit, tested, - {"src/agent_comms/api.py"}, "src/", resource)) == 1 + assert len(ClientTypeErrors.parts(AgentCommsProject(pair.core.checkout, clients=resource), + core.baseline.commit, tested, + {"src/agent_comms/api.py"}, "src/")) == 1 def test_materialized_client_files_come_from_each_actual_cut( diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index a65e5a0f99..f636dee82d 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -7,8 +7,8 @@ from __future__ import annotations +import argparse import ast -import atexit import dataclasses import inspect import json @@ -26,7 +26,7 @@ from subprocess import CompletedProcess import stored_formats -from module_layers import PACKAGE, StackClient +from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient import agent_comms from agent_comms import ( @@ -40,10 +40,11 @@ from agent_comms.child_process import ForkedProcessPool, SpawnedProcess from agent_comms.core_stamp import WheelStamp from agent_comms.declared_family import DeclaredFamily +from agent_comms.head_review import Side as CutSide from agent_comms.field_codec import boundary_decoder -from agent_comms.installer.carry import STEPS from agent_comms.installer.commands import InstallCommands from agent_comms.installer.runtime_resolution import StackSources +from agent_comms.installer.carry import STEPS from agent_comms.installer.probes.carry import Carry from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.pinned_audit import SKILL, audit @@ -103,6 +104,24 @@ class AgentCommsProject(audit.project.Project[PlanPhase]): """agent-comms' own facts, read from the modules that declare them.""" reader: StoredFormatReader = RevisionReader() + clients: ClientSources = ClientSources() + """Where the compared revisions' stack clients come from: the head's declared stack, + cloned once, unless CI already materialized each side's exact cut.""" + + @classmethod + def from_arguments(cls, root: Path, commands: InstallCommands) -> tuple[AgentCommsProject, list[str]]: + """This checkout measured with the clients its command line names, and the arguments + left for the audit's own command line. --ci-inputs names the integration entrypoint's + already-materialized exact source input.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + "--ci-inputs", dest="clients", default=ClientSources(), + type=lambda directory: MaterializedClients.read( + Path(directory).resolve(), uv=StackSources.frontend(), + python=Path(sys.executable), commands=commands), + help="use the integration entrypoint's already-materialized exact source input") + known, rest = parser.parse_known_args() + return cls(root, clients=known.clients), rest probe = ( "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " @@ -240,20 +259,13 @@ class ClientTypeErrors(audit.checkers.SideCounts): only the running app found it.""" @staticmethod - @cache - def clients(repo: audit.project.Project, head: str) -> tuple[StackClient, ...]: - """The head's stack clients, cloned once per run and read by both sides; a revision - declaring no stack has none.""" - stack = "stack/pyproject.toml" - if not repo.tracks(head, stack): - return () - into = Path(tempfile.mkdtemp(prefix="ratchet-clients-")) - atexit.register(shutil.rmtree, into, True) - (into / "stack").mkdir() - (into / stack).write_bytes(repo.blob(head, stack)) - with InstallCommands() as commands: - return StackClient.fetched(into / "stack", into, uv=StackSources.frontend(), - python=Path(sys.executable), commands=commands) + def cut(repo: AgentCommsProject, base: str, head: str, + side: type[audit.ratchet.Side]) -> tuple[StackClient, ...]: + """The clients of the materialized cut that selects the same revision as this compared + side. The audit and head_review each declare the base/head pair; until they are one + family the cut is chosen by the revision its own ref selects, never by its name.""" + return tuple(client for cut in CutSide.sides() if cut.ref(base, head) == side.ref(base, head) + for client in repo.clients.at(repo.path, base, head, cut)) @staticmethod def selected(client: StackClient) -> list[str]: @@ -285,15 +297,17 @@ def findings( if site.path in selected) @classmethod - def parts(cls, repo: audit.project.Project, base: str, head: str, changed: set[str], + def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], root: str) -> tuple[Callable[[], object], ...]: - # Only a change to the package the clients import can change what they see. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): + # Only a change to the package the clients import can change what they see, unless the + # clients are materialized per side: a companion can change while Core does not. + if not (repo.clients.requires_clients() + or any(path.startswith(f"src/{PACKAGE}/") for path in changed)): return () return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) - for client in cls.clients(repo, head) - if cls.selected(client) - for side in audit.ratchet.Side.sides()) + for side in audit.ratchet.Side.sides() + for client in cls.cut(repo, base, head, side) + if cls.selected(client)) @classmethod def measured( diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 292acaf2d2..238b877ec2 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 """The debt ratchet's CI entry: this checkout, measured as AgentCommsProject by the refactor-audit it pins. Every measure, the command line, the comparison, its JSON report and its exit status are -the audit's; the one fact this entry owns is which project it measures.""" +the audit's; the one fact this entry owns is which project it measures, and the project reads +where its own stack clients come from.""" from __future__ import annotations from pathlib import Path from agent_comms_project import AgentCommsProject +from agent_comms.installer.commands import InstallCommands from agent_comms.pinned_audit import audit if __name__ == "__main__": - raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd()))) + with InstallCommands() as commands: + project, arguments = AgentCommsProject.from_arguments(Path.cwd(), commands) + raise SystemExit(audit.ratchet.main(project, arguments))