diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 05042371b2..d1c870e1d3 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -38,7 +38,7 @@ | 5 | Board, overdue, silent, spinning | `order.py board`, `overdue`; `fleet-board.md` | `CommsTodoListTool` and the fleet tool, derived | `one_order_owner` | | 6 | Branch hygiene | `branches-*.txt` by hand | `CommsWorkBranchesTool` | `one_order_owner` | | 7 | Strikes and the strike limit | `order.py strike`; `strikes.jsonl` | `Strike` rows, author-declared `StrikeLimit`, one `comms_fire` | `record_plane` | -| 8 | Verdicts on a unit's tool calls | `judge.py ok`, `back`; `verdicts.jsonl` | Order `Verdict`; send-back revises the Goal and records its strike atomically | `record_plane` | +| 8 | Verdicts on a unit's tool calls | `judge.py ok`, `back`; both invoke `CommsVerdictTool` | Order `Verdict`; send-back revises the Goal and records its strike atomically | `record_plane` | | 9 | Plain words in verdicts and strikes | judge and order checks | `PlainText` | `record_plane` | | 10 | Refuse soft or vague orders | `order.py` | `OrderText` on every order action | `record_plane` | | 11 | What a unit did during its order | `tool_watch.py`; `edits.jsonl`; `judge.py show` | `comms_order_work`: both transcript families by `tool_call_id` | `review_queue` | @@ -61,7 +61,7 @@ | 28 | Measured load and self-report | `capacity.py` unit count | `commander.capacity.Load.of` reports orders, overdue, edits and forks; self-report comparison unverified | `control_plane` | | 29 | No handoff of finished, checked work | none; scratch transfer moved P507 after its fix was pushed and checked | Transfer refuses delivered checked work; that owner merges; check is reused | `record_plane` | | 30 | A proof is bound to the build it ran on | none; screenshots and logs are bare paths | Build run records runtime and Core/Toad revisions; close requires delivered commit | `record_plane` | -| 31 | An order states its intent as a deletion target | none; P520 renamed a decision rather than deleting it | Order owns deletion family/site/count; delivery refuses nonzero or renamed decisions | `record_plane` | +| 31 | An order states its intent as a deletion target | Current order command with a declared FamilyDeletion | CommsTodoCreateTool and CommsOrderDoneTool check the delivered family count | `record_plane` | | 32 | Where units' time goes, classified | none; activity.log lacks durations and turn boundaries | `UnitTime`: model, build/check/test/shell, dependency/commander waits and idle; classify large waits | `commander_page`, `control_plane` | | 33 | Order progress beside its deadline; the unit never sees a due time | `order.py board`, `overdue`, `due`, `send`; `rotate.py` | Derived `OrderProgress`; merged work draws no strike; first commit 10 min, later progress 15 min STALL; units see no due time | `one_order_owner`, `control_plane` | | 34 | Several commanders on one machine and bus, one per project | none; a second commander.json install overwrites the first | Registry commander/project ownership; caller from --from; scoped orders, strikes, load, board, alarms and forks; raw-send guard; duplicate project commander refused | `one_order_owner`, `cutover` | diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 19dcab0564..e8dedf7be6 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -742,8 +742,8 @@ def due(self, pid: str, at: str, why: str) -> None: moved = RecordedEventTime(time.time()).at_clock(at) self.call(CommsChangeProblemTool(problem=pid, operation=AssignTodo( todo.revision, todo.assignment, moved, todo.order, todo.goal))) - self.on_record(f'{time.strftime("%Y-%m-%d %H:%M")} {todo.holder} ({pid} order {todo.order.id} due ' - f'{todo.deadline.clock()} moved to {moved.clock()}): {why}', 'Deadline', + self.on_record(f'{time.strftime("%Y-%m-%d %H:%M")} {todo.holder} ({pid} order {todo.order.id} ' + f'deadline changed): {why}', 'Deadline', VerdictAudience.of(todo.held().owner, todo.goal)) print(f'{pid}: due {moved.clock()}') diff --git a/src/agent_comms/commander_parity/deletion.py b/src/agent_comms/commander_parity/deletion.py new file mode 100644 index 0000000000..d0a07f7471 --- /dev/null +++ b/src/agent_comms/commander_parity/deletion.py @@ -0,0 +1,295 @@ +"""Deletion intent parity through real orders, pushed commits and native delivery checks.""" + +from __future__ import annotations + +import contextlib +import io +import json +import shlex +import sys +import tempfile +from abc import ABC, abstractmethod +from dataclasses import dataclass, replace +from pathlib import Path + +from ..change_report import FileLine +from ..child_process import CountPrinted, ExitedOutcome +from ..commander.order import OrderFile +from ..field_codec import FieldCodec +from ..goal_actions import CreateProblemGoalAction +from ..goals import CountCheckResult, OrderCheck +from ..plan_phases import RecordPlanePhase +from ..progress_claims import MergedClaim +from ..repo import Repo +from ..todos import ( + CheckedCommitDelivery, + CommitDelivery, + DeliveredProblemOrder, + FailedCheckStrike, + FamilyDeletion, + MechanicalOrderRun, + Todo, +) +from ..tool_caller import ThreadCaller +from ..tools import CommsOrderDoneTool, CommsTodoCreateTool, ProblemOrdered +from . import ParityComparison, ParityRow +from .lifecycle import LifecycleObservation, LifecycleRun, MeasuredCommander + + +@dataclass(frozen=True) +class DeletionComparison(ParityComparison): + current: LifecycleObservation + native: LifecycleObservation + + @property + def passed(self) -> bool: + return ( + self.current.passed and self.native.passed and self.current.checks == self.native.checks + ) + + def describe(self) -> str: + return f"current: {self.current.describe()}; native: {self.native.describe()}" + + @classmethod + def read(cls) -> DeletionComparison: + return cls(CurrentDeletionPath.read(), NativeDeletionPath.read()) + + +class DeletionPath(ABC): + """Both adapters exercise the same scenario on independent LifecycleRun copies.""" + + @classmethod + @abstractmethod + def send(cls, run: LifecycleRun, document: OrderFile) -> Todo: ... + + @classmethod + @abstractmethod + def delivery_command(cls, run: LifecycleRun, todo: Todo, commit: str) -> tuple[str, ...]: ... + + @staticmethod + def commit(repository: Path, text: str, message: str) -> str: + (repository / "family.py").write_text(text) + Repo.git(repository, "add", "family.py") + Repo.git(repository, "commit", "-qm", message) + Repo.git(repository, "push", "-q", "origin", "main") + return Repo.git(repository, "rev-parse", "HEAD").strip() + + @classmethod + def read(cls) -> LifecycleObservation: + with LifecycleRun.open() as original: + # Keep source checkouts out of the copied worker's storage accounting. + # Limits remain unchanged; all detached checks use this persistent owned base. + rule = original.commander.comms.registry.storage_rule + with tempfile.TemporaryDirectory( + prefix="parity-deletion-", dir=rule.checkout_base + ) as checkouts: + root = original.commander.root + (root / "storage.json").write_text( + json.dumps(FieldCodec.encode(replace(rule, checkout_base=Path(checkouts)))) + ) + (root / "commander.json").write_text(original.commander.model_dump_json()) + run = replace(original, commander=MeasuredCommander.read_root(root)) + return cls.exercise(run) + + @classmethod + def exercise(cls, run: LifecycleRun) -> LifecycleObservation: + repository = Path(run.commander.comms.registry.require(run.worker).worktree) + remote = repository.parent / "deletion-origin.git" + Repo.git(repository, "init", "-q", "-b", "main") + Repo.git(repository, "config", "user.name", "Deletion parity") + Repo.git(repository, "config", "user.email", "deletion-parity@localhost") + Repo.git(repository, "init", "--bare", "-q", "--initial-branch=main", str(remote)) + Repo.git(repository, "remote", "add", "origin", str(remote)) + family = "class RetiredDecision:\n pass\n\n" + cls.commit( + repository, + family + "def decisions():\n return [RetiredDecision()]\n", + "Declare the obsolete decision family and its caller", + ) + count = OrderCheck( + str(repository), + shlex.join( + ( + sys.executable, + "-c", + "import ast;from pathlib import Path;" + "print(sum(isinstance(node,ast.ClassDef) for node in " + "ast.walk(ast.parse(Path('family.py').read_text()))))", + ) + ), + CountCheckResult(0), + ) + deletion = FamilyDeletion( + "obsolete decision family", FileLine(str(repository / "family.py"), 1), count + ) + initial = OrderFile.read(run.document.read_text()) + order = replace( + initial.order, + check=OrderCheck( + str(repository), + shlex.join((sys.executable, "-c", "import family;assert not family.decisions()")), + ), + run=MechanicalOrderRun(), + deletes=deletion, + ) + document = replace(initial, order=order) + run.document.write_text(json.dumps(FieldCodec.encode(document))) + output = io.StringIO() + with contextlib.redirect_stdout(output): + before = cls.send(run, document) + goal_threads = (run.commander.commander, run.worker) + before_goals = tuple( + run.commander.comms.registry.require(name).goal for name in goal_threads + ) + partial = cls.commit( + repository, + family + "def decisions():\n return []\n", + "Repair the caller but leave one obsolete family site", + ) + refused = run.invoke_command( + cls.delivery_command(run, before, partial), cwd=repository, env=run.python_environment() + ) + after_refusal = run.commander.comms.goals.todos.get(before.id) + remaining = count.recorded(partial, str(repository)) + after_refusal_goals = tuple( + run.commander.comms.registry.require(name).goal for name in goal_threads + ) + failed_check = False + strike_only = False + match after_refusal.strikes[len(before.strikes) :]: + case (FailedCheckStrike() as strike,): + failed_check = strike == FailedCheckStrike.make( + before, strike.at.recorded_at("The failed delivery"), strike.reason + ) and strike.reason.startswith( + f"reported done at {partial} but its order check failed: " + f"The order check is 1 away from {count.result}." + ) + strike_only = after_refusal == replace( + before, strikes=(*before.strikes, strike), revision=before.revision + 1 + ) + complete = cls.commit( + repository, + "def decisions():\n return []\n", + "Delete the last obsolete family declaration", + ) + accepted = run.invoke_command( + cls.delivery_command(run, before, complete), + cwd=repository, + env=run.python_environment(), + ) + after_acceptance = run.commander.comms.goals.todos.get(before.id) + zero = count.recorded(complete, str(repository)) + primary = order.check.recorded(partial, str(repository)) + delivered = after_acceptance.order + checks = ( + ("native order retained deletion intent", before.order.checked().deletes == deletion), + ( + "partial commit passed primary check", + bool(primary) and order.check.result.distance(primary[0]) == CountPrinted(0), + ), + ( + "partial commit retained one family site", + bool(remaining) and count.result.distance(remaining[0]) == CountPrinted(1), + ), + ("nonzero delivery refused", refused.outcome == ExitedOutcome(1)), + ( + "refusal preserved pending order and assignment", + after_refusal.order == before.order + and after_refusal.assignment == before.assignment + and after_refusal.pending, + ), + ( + "refusal preserved order and thread goals", + after_refusal.goal == before.goal and after_refusal_goals == before_goals, + ), + ("partial delivery recorded its precise failed-check strike", failed_check), + ("refusal changed only the failed-check strike and revision", strike_only), + ( + "zero count checked on delivered commit", + bool(zero) and count.result.distance(zero[0]) == CountPrinted(0), + ), + ( + "zero delivery accepted", + accepted.outcome == ExitedOutcome(0) and not after_acceptance.pending, + ), + ( + "accepted delivery names the zero-site commit", + isinstance(delivered, DeliveredProblemOrder) + and isinstance(delivered.delivery, CheckedCommitDelivery) + and delivered.delivery.delivered_commit == complete, + ), + ("accepted delivery retained deletion intent", delivered.checked().deletes == deletion), + ) + return LifecycleObservation( + CommsOrderDoneTool.__qualname__, + len((refused, accepted)), + checks, + output.getvalue() + f"partial={partial}; zero={complete}; " + f"refused={refused.outcome.summary}: " + f"{refused.stdout.decode(errors='replace')}{refused.stderr.decode(errors='replace')}; " + f"accepted={accepted.outcome.summary}: " + f"{accepted.stdout.decode(errors='replace')}{accepted.stderr.decode(errors='replace')}", + ) + + +class CurrentDeletionPath(DeletionPath): + @classmethod + def send(cls, run: LifecycleRun, document: OrderFile) -> Todo: + return run.create() + + @classmethod + def delivery_command(cls, run: LifecycleRun, todo: Todo, commit: str) -> tuple[str, ...]: + return ( + sys.executable, + "-c", + "import sys;from agent_comms.commander.order import Commander,DoneVerb;" + "DoneVerb(unit=sys.argv[2],message=sys.argv[3],sha=sys.argv[4]).run(" + "Commander.read_root(sys.argv[1]))", + str(run.commander.root), + run.worker, + todo.order.checked().id, + commit, + ) + + +class NativeDeletionPath(DeletionPath): + @classmethod + def send(cls, run: LifecycleRun, document: OrderFile) -> Todo: + result = run.commander.call( + CommsTodoCreateTool( + owner=run.worker, + order=CreateProblemGoalAction( + problem="P1", + repo=Repo.of(Path(document.order.check.directory)).github, + text=document.defect, + order=document.order, + request=document.order.request( + run.commander.comms.registry.require(run.commander.commander), ("P1",) + ), + deadline=run.deadline, + commander=run.commander.commander, + ), + ) + ) + if not isinstance(result, ProblemOrdered): + raise TypeError("Native creation returned no ordered problem") + return result.todo + + @classmethod + def delivery_command(cls, run: LifecycleRun, todo: Todo, commit: str) -> tuple[str, ...]: + request = CommsOrderDoneTool( + problem=todo.id, + order_id=todo.order.checked().id, + delivery=CommitDelivery(commit=commit, delivered_commit=commit), + claims=(MergedClaim(commit),), + ) + return request.cli_command(ThreadCaller(run.commander.commander)).argv(run.commander.root) + + +class DeletionTargetParityRow(ParityRow): + number = 31 + capability = "An order states its intent as a deletion target" + current = "Current order command with a declared FamilyDeletion" + native = "CommsTodoCreateTool and CommsOrderDoneTool check the delivered family count" + phases = (RecordPlanePhase,) + comparison = DeletionComparison.read diff --git a/src/agent_comms/commander_parity/delivery.py b/src/agent_comms/commander_parity/delivery.py new file mode 100644 index 0000000000..fa0e5bf6c3 --- /dev/null +++ b/src/agent_comms/commander_parity/delivery.py @@ -0,0 +1,198 @@ +"""Compare commit delivery through the current command and the native tool.""" + +from __future__ import annotations + +import contextlib +import io +import json +from dataclasses import dataclass, replace +from pathlib import Path + +from ..child_process import ChildOutcome, ChildResult, ExitedOutcome +from ..commander.order import OrderFile +from ..field_codec import FieldCodec +from ..progress_claims import MergedClaim +from ..repo import Repo +from ..todos import CommitDelivery, FileDeliveryEvidence, Todo +from ..tools import CommsOrderDoneTool +from . import ParityComparison +from .lifecycle import LifecycleRun + + +@dataclass(frozen=True) +class DeliveryOutcome: + outcome: ChildOutcome + pending: bool + order_line: str + refusal: str + + @classmethod + def saved(cls, todo: Todo, process: ChildResult) -> DeliveryOutcome: + return cls( + process.outcome, todo.pending, todo.order.line, process.stderr.decode(errors="replace") + ) + + @property + def delivered(self) -> bool: + return self.outcome.successful and not self.pending + + +@dataclass(frozen=True) +class DeliveryComparison(ParityComparison): + checked_commit: str + cases: tuple[tuple[str, str, DeliveryOutcome, DeliveryOutcome], ...] + + @property + def passed(self) -> bool: + return ( + len(self.cases) == 3 + and self.cases[0][2].delivered + and all( + (current.outcome, current.pending) == (native.outcome, native.pending) + and current.order_line == native.order_line + and ( + all( + outcome.outcome == ExitedOutcome(1) + and commit in outcome.refusal + and "not in" in outcome.refusal + for outcome in (current, native) + ) + if current.pending + else commit in current.order_line + ) + for _, commit, current, native in self.cases + ) + ) + + def describe(self) -> str: + return f"checked main={self.checked_commit}; outcomes={self.cases!r}" + + +@dataclass(frozen=True) +class DeliveryRepository: + path: Path + + @classmethod + def create(cls, root: Path) -> DeliveryRepository: + path = root / "delivery-repository" + path.mkdir() + remote = root / "delivery-origin.git" + Repo.git(root, "init", "--bare", "-q", str(remote)) + Repo.git(path, "init", "-q", "-b", "main") + Repo.git(path, "config", "user.name", "parity") + Repo.git(path, "config", "user.email", "parity@invalid") + url = "https://github.com/parity-local/delivery.git" + Repo.git(path, "config", f"url.{remote}.insteadOf", url) + Repo.git(path, "remote", "add", "origin", url) + Repo.git(path, "commit", "-q", "--allow-empty", "-m", "copied delivery base") + Repo.git(path, "push", "-q", "origin", "main") + Repo.git(path, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main") + return cls(path) + + def cases(self) -> tuple[tuple[str, str], ...]: + base = Repo.git(self.path, "rev-parse", "HEAD").strip() + Repo.git(self.path, "switch", "-q", "-c", "main-answer") + Repo.git(self.path, "commit", "-q", "--allow-empty", "-m", "answer integrated on main") + main = Repo.git(self.path, "rev-parse", "HEAD").strip() + Repo.git(self.path, "switch", "-q", "main") + Repo.git(self.path, "merge", "-q", "--no-ff", "main-answer", "-m", "integrate main answer") + Repo.git(self.path, "push", "-q", "origin", "main") + Repo.git(self.path, "switch", "-q", "-c", "branch-answer", base) + Repo.git(self.path, "commit", "-q", "--allow-empty", "-m", "answer integrated elsewhere") + branch = Repo.git(self.path, "rev-parse", "HEAD").strip() + Repo.git(self.path, "switch", "-q", "-c", "integration", base) + Repo.git( + self.path, "merge", "-q", "--no-ff", "branch-answer", "-m", "integrate branch answer" + ) + Repo.git(self.path, "push", "-q", "origin", "integration") + Repo.git(self.path, "switch", "-q", "-c", "unmerged-answer", base) + Repo.git(self.path, "commit", "-q", "--allow-empty", "-m", "published but not integrated") + unmerged = Repo.git(self.path, "rev-parse", "HEAD").strip() + Repo.git(self.path, "push", "-q", "origin", "unmerged-answer") + Repo.git(self.path, "switch", "-q", "main") + return ( + ("merged to main", main), + ("merged to another branch", branch), + ("not merged", unmerged), + ) + + +class DeliveredCommitCase: + @classmethod + def create_order(cls, run: LifecycleRun, repository: DeliveryRepository) -> Todo: + document = OrderFile.read(run.document.read_text()) + document = replace( + document, + order=replace( + document.order, check=replace(document.order.check, directory=str(repository.path)) + ), + ) + run.document.write_text(json.dumps(FieldCodec.encode(document))) + run.decisions.write_text(json.dumps([run.worker])) + before = { + todo.id + for todo in run.commander.comms.goals.todos.for_commander(run.commander.incarnation) + } + run.commander.problem( + str(run.document), run.worker, run.due, f"{Path(__file__).resolve()}:1" + ) + (created,) = ( + todo + for todo in run.commander.comms.goals.todos.for_commander(run.commander.incarnation) + if todo.id not in before + ) + run.decisions.write_text("[]") + return created + + @classmethod + def read(cls) -> ParityComparison: + cases = [] + with LifecycleRun.open() as run, contextlib.redirect_stdout(io.StringIO()): + repository = DeliveryRepository.create(run.commander.root) + run.decisions.write_text("[]") + run.artifact.write_text( + "Copied-bus delivered-commit comparison; no source input executed." + ) + for name, commit in repository.cases(): + current_order = cls.create_order(run, repository) + process = run.invoke( + "from agent_comms.commander.order import Commander; import sys; " + "Commander.model_validate_json(sys.argv[1]).done(" + "sys.argv[2],sys.argv[3],seen=sys.argv[4])", + run.commander.model_dump_json(), + current_order.id, + commit, + str(run.artifact), + ) + current = DeliveryOutcome.saved( + run.commander.comms.goals.todos.get(current_order.id), process + ) + native_order = cls.create_order(run, repository) + tool = CommsOrderDoneTool( + problem=native_order.id, + order_id=native_order.order.checked().id, + delivery=CommitDelivery( + commit=Repo.git(repository.path, "rev-parse", "HEAD").strip(), + delivered_commit=commit, + evidence=FileDeliveryEvidence(str(run.artifact)), + ), + claims=(MergedClaim(commit),), + ) + process = run.invoke( + "from pathlib import Path; import sys; " + "from agent_comms.comms import Comms; " + "from agent_comms.field_codec import FieldCodec; " + "from agent_comms.tool_caller import ThreadCaller; " + "from agent_comms.tools import CommsOrderDoneTool; " + "ThreadCaller(sys.argv[2]).invoke(FieldCodec.decode(CommsOrderDoneTool, " + "FieldCodec.parse(sys.argv[3])),Comms(Path(sys.argv[1])))", + str(run.commander.root), + run.commander.commander, + json.dumps(FieldCodec.encode(tool)), + ) + native = DeliveryOutcome.saved( + run.commander.comms.goals.todos.get(native_order.id), process + ) + cases.append((name, commit, current, native)) + checked_commit = Repo.git(repository.path, "rev-parse", "HEAD").strip() + return DeliveryComparison(checked_commit, tuple(cases)) diff --git a/src/agent_comms/commander_parity/handoff.py b/src/agent_comms/commander_parity/handoff.py new file mode 100644 index 0000000000..4657912be7 --- /dev/null +++ b/src/agent_comms/commander_parity/handoff.py @@ -0,0 +1,152 @@ +"""Actual current/native refusals to transfer delivered checked work.""" + +from __future__ import annotations + +import contextlib +import io +import json +from dataclasses import dataclass, replace +from pathlib import Path +from uuid import uuid4 + +from ..change_report import FileLine +from ..child_process import ChildOutcome, ChildResult, ExitedOutcome +from ..commander.config import CommanderConfig +from ..commander.order import OrderFile +from ..field_codec import FieldCodec +from ..goal_actions import OrderGoalAction +from ..tools import CommsOrderTool +from . import ParityComparison, UnverifiedParity +from .lifecycle import LifecycleRun, MeasuredCommander + + +@dataclass(frozen=True) +class HandoffComparison(ParityComparison): + cases: tuple[ + tuple[str, ChildOutcome, ChildOutcome, tuple[str, ...], tuple[str, ...], bool], ... + ] + + @property + def passed(self) -> bool: + return len(self.cases) == 2 and all( + all( + ( + expected == ExitedOutcome(1), + actual == ExitedOutcome(1), + bool(reference), + reference == refusal, + unchanged, + ) + ) + for _, expected, actual, reference, refusal, unchanged in self.cases + ) + + def describe(self) -> str: + return f"actual finished-order transfer refusals (current/native): {self.cases!r}" + + +class FinishedHandoffCase: + @staticmethod + def state(run: LifecycleRun, pid: str, receiver: str) -> str: + return json.dumps( + FieldCodec.encode( + ( + run.commander.comms.goals.todos.get(pid), + run.commander.comms.registry.require(run.worker).goal, + run.commander.comms.registry.require(receiver).goal, + ) + ), + sort_keys=True, + ) + + @staticmethod + def current(run: LifecycleRun, receiver: str, document: OrderFile, pid: str) -> ChildResult: + return run.invoke( + "import sys; from agent_comms.commander.order import Commander, OrderFile; " + "Commander.model_validate_json(sys.argv[1]).send(sys.argv[2], sys.argv[3], " + "OrderFile.read(sys.argv[4]), reassign=sys.argv[5], traced=sys.argv[6])", + run.commander.model_dump_json(), + receiver, + run.due, + json.dumps(FieldCodec.encode(document)), + "Compare the actual refusal to hand completed checked work to another real unit.", + f"{Path(__file__).resolve()}:1", + ) + + @staticmethod + def native(run: LifecycleRun, receiver: str, document: OrderFile, pid: str) -> ChildResult: + context = run.commander + todo = context.comms.goals.todos.get(pid) + tool = CommsOrderTool( + owner=receiver, + order=OrderGoalAction( + problem=pid, + expected_revision=todo.revision, + assigner=context.incarnation, + order=document.order, + deadline=run.deadline, + commander=context.commander, + request=document.order.request( + context.comms.registry.require(context.commander), (pid,) + ), + traced=FileLine.from_wire(f"{Path(__file__).resolve()}:1"), + ), + ) + return run.invoke( + "from pathlib import Path; import sys; from agent_comms.comms import Comms; " + "from agent_comms.field_codec import FieldCodec; " + "from agent_comms.tool_caller import ThreadCaller; " + "from agent_comms.tools import CommsOrderTool; " + "ThreadCaller(sys.argv[2]).invoke(FieldCodec.decode(CommsOrderTool, " + "FieldCodec.parse(sys.argv[3])), Comms(Path(sys.argv[1])))", + str(context.root), + context.commander, + json.dumps(FieldCodec.encode(tool)), + ) + + @classmethod + def read(cls) -> ParityComparison: + authority = MeasuredCommander.read(CommanderConfig.path()) + owners = tuple( + dict.fromkeys( + todo.held().owner + for todo in authority.comms.goals.todos.held_for_commander(authority.incarnation) + ) + )[-2:] + if len(owners) < 2: + return UnverifiedParity("Finished-work transfer needs two real registered unit holders") + cases = [] + for label, execute in (("current", cls.current), ("native", cls.native)): + with ( + LifecycleRun.open(extra_threads=owners) as run, + contextlib.redirect_stdout(io.StringIO()), + ): + receiver = next(owner for owner in owners if owner != run.worker) + delivered = run.complete(run.create()) + before = cls.state(run, delivered.id, receiver) + document = OrderFile.read(run.document.read_text()) + document = replace( + document, + problems=(delivered.id,), + order=replace(document.order, id=uuid4().hex), + ) + run.decisions.write_text(json.dumps([receiver])) + reference = run.invoke( + "from pathlib import Path; import sys; from agent_comms.comms import Comms; " + "todo=Comms(Path(sys.argv[1])).goals.todos.get(sys.argv[2]); " + "todo.order.require_transfer(todo)", + str(run.commander.root), + delivered.id, + ) + attempt = execute(run, receiver, document, delivered.id) + cases.append( + ( + label, + reference.outcome, + attempt.outcome, + tuple(reference.stderr.decode(errors="replace").splitlines()[-1:]), + tuple(attempt.stderr.decode(errors="replace").splitlines()[-1:]), + before == cls.state(run, delivered.id, receiver), + ) + ) + return HandoffComparison(tuple(cases)) diff --git a/src/agent_comms/commander_parity/lifecycle.py b/src/agent_comms/commander_parity/lifecycle.py index 2c0e441cb0..27ace41dec 100644 --- a/src/agent_comms/commander_parity/lifecycle.py +++ b/src/agent_comms/commander_parity/lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib import io import json @@ -20,11 +21,13 @@ from ..change_report import FileLine from ..check_root import CheckRoot +from ..child_process import BoundedRun, ChildResult from ..commander.config import CommanderConfig -from ..commander.order import Commander, OrderFile, hhmm +from ..commander.order import Commander, OrderFile from ..commander.request import RequestAddVerb from ..field_codec import FieldCodec from ..goals import CountCheckResult, OrderCheck, ProblemGoalOrder +from ..recorded_time import RecordedEventTime from ..todos import ( ApplicationOrderRun, MergedProgress, @@ -123,7 +126,6 @@ def open(cls, *, extra_threads: tuple[str, ...] = ()) -> Iterator[LifecycleRun]: commander = MeasuredCommander( commander=config.commander, root=root, - cli=config.cli, state=root / "commander-state", board=root / "board.md", narrative=root / "narrative.md", @@ -174,16 +176,45 @@ def open(cls, *, extra_threads: tuple[str, ...] = ()) -> Iterator[LifecycleRun]: "from reaching zero.", "This is stupid because an unfinished copied decision must not count " "as delivered work.", + unplanned_reason=( + "This disposable comparison scenario measures the requested parity " + "workflow without implementing a phase task." + ), ) ) ) ) yield cls(commander, worker.name, decisions, document, root / "application.json") + @staticmethod + def python_environment() -> dict[str, str]: + return {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])} + + @classmethod + def invoke(cls, program: str, *arguments: str) -> ChildResult: + return cls.invoke_command( + (sys.executable, "-c", program, *arguments), + cwd=Path.cwd(), + env=cls.python_environment(), + ) + + @staticmethod + def invoke_command( + command: tuple[str, ...], + *, + cwd: str | Path, + env: dict[str, str], + ) -> ChildResult: + return asyncio.run(BoundedRun.run(command, timeout=120, cwd=cwd, env=env)) + @property def due(self) -> str: return time.strftime("%H:%M", time.localtime(time.time() + 3600)) + @property + def deadline(self) -> RecordedEventTime: + return RecordedEventTime(time.time()).at_clock(self.due) + def create(self) -> Todo: self.commander.problem( str(self.document), self.worker, self.due, f"{Path(__file__).resolve()}:1" @@ -414,16 +445,16 @@ def checks( ( "unit messages hide clocks", all( - hhmm(clock) not in message.body + clock.clock() not in message.body for clock in (before.deadline, after.deadline) for message in messages ), ), ( "unit goal hides deadline", - "Deadline:" not in instructions and hhmm(after.deadline) not in instructions, + "Deadline:" not in instructions and after.deadline.clock() not in instructions, ), - ("commander still sees deadline", f"due {hhmm(after.deadline)}" in output), + ("commander still sees deadline", f"due {after.deadline.clock()}" in output), ) diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py index 3772660101..1f1ca4d605 100644 --- a/src/agent_comms/commander_parity/rows.py +++ b/src/agent_comms/commander_parity/rows.py @@ -11,6 +11,8 @@ StoryPhase, ) from . import ParityComparison, ParityRow, UnverifiedParity +from .delivery import DeliveredCommitCase +from .handoff import FinishedHandoffCase from .lifecycle import ( AssignOrderCase, BuildBoundProofCase, @@ -22,6 +24,7 @@ from .load import LoadComparison from .measurements import RuleComparison, StorageComparison, measured_load from .story import StoryCase +from .verdicts import VerdictComparison class CreateProblemParityRow(ParityRow): @@ -50,6 +53,7 @@ class DeliveredCommitParityRow(ParityRow): current = "`order.py done`" native = "`CommsOrderDoneTool`: merged to main, another branch, or not merged" phases = (OneOrderOwnerPhase, RecordPlanePhase) + comparison = DeliveredCommitCase.read class InstalledEvidenceParityRow(ParityRow): @@ -91,9 +95,10 @@ class StrikeLimitParityRow(ParityRow): class VerdictParityRow(ParityRow): number = 8 capability = "Verdicts on a unit's tool calls" - current = "`judge.py ok`, `back`; `verdicts.jsonl`" + current = "`judge.py ok`, `back`; both invoke `CommsVerdictTool`" native = "Order `Verdict`; send-back revises the Goal and records its strike atomically" phases = (RecordPlanePhase,) + comparison = VerdictComparison.read class PlainWordsParityRow(ParityRow): @@ -288,6 +293,8 @@ class FinishedWorkTransferParityRow(ParityRow): native = "Transfer refuses delivered checked work; that owner merges; check is reused" phases = (RecordPlanePhase,) + comparison = FinishedHandoffCase.read + class BuildBoundProofParityRow(ParityRow): number = 30 @@ -299,14 +306,6 @@ class BuildBoundProofParityRow(ParityRow): comparison = BuildBoundProofCase.read -class DeletionTargetParityRow(ParityRow): - number = 31 - capability = "An order states its intent as a deletion target" - current = "none; P520 renamed a decision rather than deleting it" - native = "Order owns deletion family/site/count; delivery refuses nonzero or renamed decisions" - phases = (RecordPlanePhase,) - - class UnitTimeParityRow(ParityRow): number = 32 capability = "Where units' time goes, classified" diff --git a/src/agent_comms/commander_parity/verdicts.py b/src/agent_comms/commander_parity/verdicts.py new file mode 100644 index 0000000000..e194d7fbf7 --- /dev/null +++ b/src/agent_comms/commander_parity/verdicts.py @@ -0,0 +1,297 @@ +"""Judge/native verdicts on actual native edits in LifecycleRun's disposable bus.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar +from uuid import uuid4 + +from ..active_route import resolve_comms_route +from ..commander.judge import BackVerb, Edit, OkVerb, VerdictVerb +from ..commander.rules import AnswerVerb, ReviewVerb +from ..rule_reviews import DrawnRule, RuleReviews +from ..todos import AcceptedVerdict, SendBackVerdict, Todo, ToolCallVerdict +from ..tools import CommsVerdictTool, TodoChanged +from ..turn_context import GoalSegment +from . import ParityComparison +from .lifecycle import LifecycleObservation, LifecycleRun + +# Comparison-side controlled input, like load.OBSERVE_CAPACITY, not a new native +# runtime bootstrap/resource. The unchanged pinned SDK owns execution and storage. +NATIVE_EDIT = """ +import { pathToFileURL } from 'node:url'; +import { readFile } from 'node:fs/promises'; +const [packagePath, sessionPath, path, callId, replacement] = process.argv.slice(1); +const sdk = pathToFileURL(`${packagePath}/dist/index.js`).href; +const { createEditTool, SessionManager } = await import(sdk); +const args = {path, edits: [{oldText: await readFile(path, 'utf8'), newText: replacement}]}; +const session = SessionManager.open(sessionPath); +session.appendMessage({role: 'user', content: 'Execute one isolated verdict-check edit.', + timestamp: Date.now()}); +session.appendMessage({ + role: 'assistant', content: [{type: 'toolCall', id: callId, name: 'edit', arguments: args}], + api: 'controlled-verification', provider: 'controlled-verification', model: 'local-native-edit', + usage: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0}}, + stopReason: 'toolUse', timestamp: Date.now(), +}); +const result = await createEditTool(process.cwd()).execute(callId, args); +if ((await readFile(path, 'utf8')) !== replacement) { + throw new Error('Native edit did not apply its request'); +} +session.appendMessage({role: 'toolResult', toolCallId: callId, toolName: 'edit', + content: result.content, details: result.details, isError: false, timestamp: Date.now()}); +""" + + +@dataclass(frozen=True) +class VerdictMutation: + call_id: str + verdicts: int + todo_revisions: int + strikes: int + goal_revisions: int + goal_history_entries: int + progress: str + + def checks(self, changes: int) -> tuple[tuple[str, bool], ...]: + return ( + ("one verdict", self.verdicts == 1), + ("one Todo revision", self.todo_revisions == 1), + ("strike delta", self.strikes == changes), + ("Goal revision delta", self.goal_revisions == changes), + ("committed Goal history delta", self.goal_history_entries == changes), + ) + + +@dataclass(frozen=True) +class VerdictObservation(LifecycleObservation): + mutation: VerdictMutation + + +class VerdictCase(ABC): + changes: ClassVar[int] + native_owner: ClassVar[type[ToolCallVerdict]] + + @classmethod + @abstractmethod + def replacement(cls, run: LifecycleRun) -> str: ... + + @classmethod + @abstractmethod + def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> VerdictVerb: ... + + @staticmethod + def judge(run: LifecycleRun, command: VerdictVerb, todo: Todo, edit: Edit) -> None: + command.apply(run.commander) + + @staticmethod + def native(run: LifecycleRun, command: VerdictVerb, todo: Todo, edit: Edit) -> object: + return run.commander.call( + CommsVerdictTool( + problem=todo.id, operation=command.operation(run.commander, todo, edit.id) + ) + ) + + @classmethod + def capture( + cls, call_id: str, invoke: Callable[[LifecycleRun, VerdictVerb, Todo, Edit], object] + ) -> VerdictObservation: + with LifecycleRun.open() as run, contextlib.redirect_stdout(io.StringIO()): + # Satisfy the actual gate in this copy, without transferring original authority. + ReviewVerb().apply(run.commander) + with RuleReviews(run.commander.root).snapshot() as db: + drawn = DrawnRule.select(db, reviewer=run.commander.commander, order_by=("n",)) + for rule in drawn: + AnswerVerb( + n=rule.n, + text=( + f'"{rule.rule}" This isolated comparison keeps verdict mutation in ' + "its existing native owner, preserves original sessions, and checks " + "the actual stored Goal and strike rather than a second state store: " + f"{Path(__file__).resolve()}:1" + ), + ).apply(run.commander) + before = run.create() + worker = run.commander.comms.registry.require(run.worker) + goal = worker.goal.require() + history = len(run.commander.comms.goals.goal_history(run.worker, goal_id=goal.id)) + session_file = worker.session_file + if not session_file: + raise ValueError("The copied unit must have its own recorded native session") + for path in (Path(session_file), run.decisions, Path(worker.worktree)): + path.resolve().relative_to(run.commander.root.resolve()) + package = resolve_comms_route().launch().require_private().validate() + child = run.invoke_command( + ( + "node", + "--no-global-search-paths", + "--input-type=module", + "-e", + NATIVE_EDIT, + str(package.path), + session_file, + str(run.decisions), + call_id, + cls.replacement(run), + ), + cwd=worker.worktree, + env=package.node_environment(dict(os.environ)), + ) + if not child.outcome.successful: + raise RuntimeError( + f"Native edit {child.outcome.summary}: {child.stderr.decode('utf-8')}" + ) + edits = Edit.unjudged(run.commander.comms, run.commander.commander) + if len(edits) != 1 or edits[0].id != call_id: + raise ValueError("The copied unit must expose exactly its one real native edit") + edit = edits[0] + invoke(run, cls.command(run, before, edit), before, edit) + saved = run.commander.comms.goals.todos.get(before.id) + worker = run.commander.comms.registry.require(run.worker) + after = worker.goal.require() + native = tuple( + (item.request, item.result) + for item in run.commander.invocations + if isinstance(item.request, CommsVerdictTool) + ) + if len(native) != 1 or native[0][0].operation.tool_call_id != call_id: + raise ValueError("The native verdict must judge this one original copied tool call") + request, result = native[0] + # goal_history returns committed revisions; its internal tags stay there. + history_entries = run.commander.comms.goals.goal_history(run.worker, goal_id=goal.id)[ + history: + ] + progress = GoalSegment.capture(worker, after, run.commander.comms.goals).progress + mutation = VerdictMutation( + request.operation.tool_call_id, + len(saved.verdicts) - len(before.verdicts), + saved.revision - before.revision, + saved.strike_count - before.strike_count, + after.revision - goal.revision, + len(history_entries), + progress, + ) + checks = ( + *mutation.checks(cls.changes), + ("same Goal identity", after.id == goal.id), + ("same Goal state", after.state == goal.state), + ("native result retained", result == TodoChanged(saved, saved.strike_count)), + ("native verdict retained", request.operation in saved.verdicts), + ( + "strike is the verdict event", + all( + strike.at == request.operation.at + and strike.previous == request.operation.previous + for strike in saved.strikes[len(before.strikes) :] + ), + ), + ("declared verdict kind", isinstance(request.operation, cls.native_owner)), + ( + "Goal publication retained", + all(entry.before == goal and entry.after == after for entry in history_entries), + ), + ("native verdict visible in Goal", request.operation.line in progress), + ) + return VerdictObservation( + f"{cls.native_owner.__module__}.{cls.native_owner.__qualname__}", + len(native), + checks, + "", + mutation, + ) + + +class AcceptedCase(VerdictCase): + changes = 0 + native_owner = AcceptedVerdict + + @classmethod + def replacement(cls, run: LifecycleRun) -> str: + return "[]" + + @classmethod + def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> OkVerb: + return OkVerb( + ids=(f"{edit.id}:{edit.code()}",), + why="The native edit removes the copied workflow decision.", + serves="Remove the copied workflow decision", + lines=(edit.hunks[0].new_text,), + ) + + +class SendBackCase(VerdictCase): + changes = 1 + native_owner = SendBackVerdict + why: ClassVar[str] = "The edit duplicates the copied workflow decision instead of removing it." + + @classmethod + def replacement(cls, run: LifecycleRun) -> str: + return json.dumps([run.worker, run.worker]) + + @classmethod + def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> BackVerb: + return BackVerb( + ids=(f"{edit.id}:{edit.code()}",), + msg=todo.messages[-1].message_id, + lines=(edit.hunks[0].new_text,), + why=cls.why, + roast=( + "This is stupid because the edit keeps two copies of the same decision " + "instead of deleting it." + ), + fix="Remove the copied decision and let the existing check measure its absence.", + pattern="AGENT-2", + fact="the copied workflow decision collection", + at=f"{run.decisions}:1", + derive="delete-copy", + by=f"{Path(__file__).resolve()}:1", + ) + + +@dataclass(frozen=True) +class VerdictComparison(ParityComparison): + accepted_current: VerdictObservation + accepted_native: VerdictObservation + back_current: VerdictObservation + back_native: VerdictObservation + + @property + def passed(self) -> bool: + observations = ( + self.accepted_current, + self.accepted_native, + self.back_current, + self.back_native, + ) + return ( + all(observation.passed for observation in observations) + and self.accepted_current.mutation == self.accepted_native.mutation + and self.back_current.mutation == self.back_native.mutation + ) + + def describe(self) -> str: + return ( + "accepted current/native equal=" + f"{self.accepted_current.mutation == self.accepted_native.mutation}; " + "send-back current/native equal=" + f"{self.back_current.mutation == self.back_native.mutation}; " + f"accepted={self.accepted_native.describe()}; send-back={self.back_native.describe()}" + ) + + @classmethod + def read(cls) -> VerdictComparison: + call_id = uuid4().hex + return cls( + AcceptedCase.capture(call_id, AcceptedCase.judge), + AcceptedCase.capture(call_id, AcceptedCase.native), + SendBackCase.capture(call_id, SendBackCase.judge), + SendBackCase.capture(call_id, SendBackCase.native), + ) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index 5cd5f236cf..be2daa6035 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -331,12 +331,13 @@ def recorded(self, sha: str, repository: str) -> tuple[ChildResult, ...]: return tuple(checked.result for checked in CheckedCommit.stored(repository, sha) if checked.command == self.command)[:1] - def at(self, revision: str, repository: str) -> ChildResult: + def at(self, revision: str, repository: str, checkout_base: Path) -> ChildResult: """Read the result on ``revision``, or explicitly run the check in a detached checkout.""" sha = CheckedCommit.git(repository, "rev-parse", f"{revision}^{{commit}}").strip() for recorded in self.recorded(sha, repository): return recorded - with tempfile.TemporaryDirectory(prefix="order-check-") as scratch: + checkout_base.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="order-check-", dir=checkout_base) as scratch: tree = str(Path(scratch) / "tree") CheckedCommit.git(repository, "worktree", "add", "--detach", tree, sha) try: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index eb0b8ea756..35d7d2311e 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -108,14 +108,14 @@ async def verified(self, todo: Todo, registry: Registration) -> OrderDelivery: check = order.check for each in (check, *order.deletes.checks): await self.require_source(each) - each.result.require(await self.checked(each)) + each.result.require(await self.checked(each, registry.storage_rule.checkout_base)) return replace(self, at=await self.delivery_time(check)) @abstractmethod async def require_source(self, check): ... @abstractmethod - async def checked(self, check: OrderCheck) -> ChildResult: ... + async def checked(self, check: OrderCheck, checkout_base: Path) -> ChildResult: ... @abstractmethod async def delivery_time(self, check): ... @@ -162,8 +162,8 @@ def check_failed(self, goals: Goals, todo: Todo, error: OrderCheckFailedError) - async def require_source(self, check: OrderCheck) -> None: raise ValueError("A commit delivery names the unit's delivered commit and the checked one.") - async def checked(self, check: OrderCheck) -> ChildResult: - return await asyncio.to_thread(check.at, self.commit, check.directory) + async def checked(self, check: OrderCheck, checkout_base: Path) -> ChildResult: + return await asyncio.to_thread(check.at, self.commit, check.directory, checkout_base) async def delivery_time(self, check: OrderCheck) -> EventTime: return EventTime.capture(float( @@ -294,7 +294,7 @@ def line(self) -> str: async def require_source(self, check): pass - async def checked(self, check: OrderCheck) -> ChildResult: + async def checked(self, check: OrderCheck, checkout_base: Path) -> ChildResult: return await check.run() async def delivery_time(self, check): diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 80f1ee08b8..f391302df0 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import sys import threading from abc import abstractmethod from collections.abc import Mapping @@ -12,6 +13,7 @@ from pydantic import JsonValue +from . import cli as comms_cli from .commander.capacity import Load from .progress_claims import ProgressClaim from .pi_vocabulary import ServiceTier, ThinkingLevel, ThinkingLevelField @@ -68,7 +70,7 @@ TodoOperation, VerdictAudience) from .change_report import ChangeReport from .messages import InfoKind, MessageKind, NoticeRole, SendableMessageKind -from .native_tools import NativeToolDeclaration, NativeRequestTools +from .native_tools import ArgumentTool, NativeToolDeclaration, NativeRequestTools from .tool_caller import ToolCaller, ThreadCaller, NoThreadCaller, ThreadListing from .thread_identity import ThreadIncarnation from .cli_result import ToolCatalogResult @@ -293,7 +295,7 @@ def tool_field( @dataclass(frozen=True, kw_only=True) class ToolRequest[Caller: ToolCaller = ToolCaller]( - ThreadCommandAvailability, NativeRequestTools, NativeToolDeclaration + ArgumentTool, ThreadCommandAvailability, NativeRequestTools, NativeToolDeclaration ): description: ClassVar[str] bus_effect: ClassVar[str] = ("Changes the bus. Tristan has authorized claude-ai " @@ -329,6 +331,11 @@ def schema(cls) -> dict[str, object]: ) return result + def cli_command(self, caller: ToolCaller) -> InvokeCliCommand: + """Project this declaration into Core's existing external tool command.""" + return InvokeCliCommand(caller=caller.acting_name(''), + tool=self.declared_name, arguments=self.arguments) + @classmethod def invoke( cls, comms: Comms, arguments: Mapping[str, object], *, caller: ToolCaller @@ -1388,6 +1395,13 @@ class InvokeCliCommand(CliCommand, declared_name="tool"): "--arguments", help="Tool arguments as a JSON object", default_factory=dict, parser_default="{}") + def argv(self, root: Path) -> tuple[str, ...]: + """The CLI boundary owns its module, grammar and argument serialization.""" + caller = ToolCaller.from_actor(self.caller) + return (sys.executable, '-m', comms_cli.__name__, '--root', str(root), + self.declared_name, '--from', caller.acting_name(''), '--tool', self.tool, + '--arguments', json.dumps(self.arguments)) + def apply(self, ctx: Comms) -> dict[str, JsonValue]: return invoke_tool(ctx, self.tool, self.arguments, caller=ToolCaller.from_actor(self.caller)) diff --git a/tests/test_commander_parity_delivery.py b/tests/test_commander_parity_delivery.py new file mode 100644 index 0000000000..c3f58bc664 --- /dev/null +++ b/tests/test_commander_parity_delivery.py @@ -0,0 +1,48 @@ +from agent_comms.commander_parity.delivery import DeliveredCommitCase, DeliveryRepository +from agent_comms.commander_parity.rows import DeliveredCommitParityRow +from agent_comms.goals import CountCheckResult, OrderCheck +from agent_comms.repo import Repo + + +def test_delivery_graph_is_pushed_and_distinguishes_integration(tmp_path): + repository = DeliveryRepository.create(tmp_path) + assert "git" not in vars(DeliveryRepository) + assert Repo.git(repository.path, "config", "user.name").strip() == "parity" + assert Repo.git(repository.path, "config", "user.email").strip() == "parity@invalid" + main, branch, unmerged = repository.cases() + for _, commit in (main, branch, unmerged): + assert Repo.git( + repository.path, "branch", "--remotes", "--contains", commit, "--list", "origin/*" + ).strip() + assert Repo.git(repository.path, "merge-base", main[1], "origin/main").strip() == main[1] + assert ( + Repo.git(repository.path, "merge-base", branch[1], "origin/integration").strip() + == branch[1] + ) + assert Repo.git(repository.path, "merge-base", branch[1], "origin/main").strip() != branch[1] + assert ( + Repo.git(repository.path, "merge-base", unmerged[1], "origin/main").strip() != unmerged[1] + ) + assert ( + Repo.git(repository.path, "merge-base", unmerged[1], "origin/integration").strip() + != unmerged[1] + ) + + +def test_check_uses_configured_persistent_checkout_and_retains_real_result(tmp_path): + repository = DeliveryRepository.create(tmp_path) + checkout_base = tmp_path / "persistent-checkouts" + marker = tmp_path / "observed-checkouts" + check = OrderCheck(str(repository.path), f'pwd >> "{marker}"; echo 0', CountCheckResult(0)) + commit = Repo.git(repository.path, "rev-parse", "HEAD").strip() + first = check.at(commit, str(repository.path), checkout_base) + assert check.at(commit, str(repository.path), checkout_base) == first + (actual,) = marker.read_text().splitlines() + assert actual.startswith(str(checkout_base) + "/order-check-") + assert not tuple(checkout_base.iterdir()) + assert len(check.recorded(commit, str(repository.path))) == 1 + + +def test_delivery_row_declares_comparison_once(): + assert "verify" not in vars(DeliveredCommitParityRow) + assert DeliveredCommitParityRow.comparison == DeliveredCommitCase.read diff --git a/tests/test_commander_parity_lifecycle.py b/tests/test_commander_parity_lifecycle.py new file mode 100644 index 0000000000..0f2a827c05 --- /dev/null +++ b/tests/test_commander_parity_lifecycle.py @@ -0,0 +1,30 @@ +"""The common comparison process owner preserves its callers' process contract.""" + +import json +import sys +from pathlib import Path + +from agent_comms.child_process import ExitedOutcome +from agent_comms.commander_parity import lifecycle +from agent_comms.commander_parity.lifecycle import LifecycleRun + + +def test_command_preserves_explicit_project_and_sanitized_environment(tmp_path: Path): + result = LifecycleRun.invoke_command( + ( + sys.executable, + "-c", + "import json,os; print(json.dumps([os.getcwd(), " + "os.environ.get('PARITY_PROCESS'), 'NODE_OPTIONS' in os.environ]))", + ), + cwd=tmp_path, + env={"PARITY_PROCESS": "actual child"}, + ) + assert result.outcome == ExitedOutcome(0) + assert json.loads(result.stdout) == [str(tmp_path), "actual child", False] + + +def test_python_adapter_uses_the_candidate_source_environment(): + result = LifecycleRun.invoke("import os; print(os.environ['PYTHONPATH'])") + assert result.outcome == ExitedOutcome(0) + assert result.stdout.decode().strip() == str(Path(lifecycle.__file__).resolve().parents[2]) diff --git a/tests/test_commander_parity_verdicts.py b/tests/test_commander_parity_verdicts.py new file mode 100644 index 0000000000..c5264b93cb --- /dev/null +++ b/tests/test_commander_parity_verdicts.py @@ -0,0 +1,47 @@ +"""Observation invariants; the configured LifecycleRun journey supplies native proof.""" + +from dataclasses import replace + +from agent_comms.commander_parity.rows import VerdictParityRow +from agent_comms.commander_parity.verdicts import ( + VerdictComparison, + VerdictMutation, + VerdictObservation, +) + + +def observation(mutation: VerdictMutation, changes: int) -> VerdictObservation: + return VerdictObservation("native verdict owner", 1, mutation.checks(changes), "", mutation) + + +def test_verdict_requires_the_actual_call_event_goal_and_strike_together(): + accepted = VerdictMutation("one-call", 1, 1, 0, 0, 0, "P1: Open · Accepted") + back = replace( + accepted, + strikes=1, + goal_revisions=1, + goal_history_entries=1, + progress="P1: Open · Sent back", + ) + ok, sent_back = observation(accepted, 0), observation(back, 1) + compared = VerdictComparison(ok, ok, sent_back, sent_back) + assert compared.passed + for changed in ( + replace(back, verdicts=0), + replace(back, todo_revisions=2), + replace(back, strikes=0), + replace(back, goal_revisions=0), + replace(back, goal_history_entries=2), + ): + observed = observation(changed, 1) + assert not observed.passed + assert not replace(compared, back_current=observed, back_native=observed).passed + for changed in ( + replace(sent_back, invocations=0), + replace(sent_back, checks=(*sent_back.checks, ("retained native identity", False))), + ): + assert not replace(compared, back_current=changed, back_native=changed).passed + for changed in (replace(back, call_id="other-call"), replace(back, progress="")): + assert not replace(compared, back_native=observation(changed, 1)).passed + assert "verify" not in VerdictParityRow.__dict__ + assert VerdictParityRow.comparison == VerdictComparison.read diff --git a/tests/test_order_progress.py b/tests/test_order_progress.py index da1e20fce6..25a51e2ab8 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -115,7 +115,7 @@ def test_uncounted_check_keeps_other_overdue_orders_visible(commander, commander replace(todo, order=broken).upsert(db) create(commander, 'P2', deadline=1) commit(unit) - broken.check.at(git(unit, 'rev-parse', 'HEAD'), str(unit)) + broken.check.at(git(unit, 'rev-parse', 'HEAD'), str(unit), unit.parent) progress = read((comms.goals.todos.get('P1'), unit)) assert progress.line.endswith("; check prints no integer: printf 'no count here'") @@ -213,7 +213,7 @@ def test_progress_reads_only_explicit_check_receipts(checkout, tmp_path): source = ProgressSource.of(todo, StorageRule(checkout_base=unit.parent), Listed()) assert not source.readings().readings assert not marker.exists() - check.at(git(unit, 'rev-parse', 'HEAD').strip(), str(unit)) + check.at(git(unit, 'rev-parse', 'HEAD').strip(), str(unit), unit.parent) assert marker.read_text() == 'x' assert len(source.readings().readings) == 1 assert source.readings().readings[0].distance == CountPrinted(todo.order.checked().untouched(())) diff --git a/tests/test_standing_rules.py b/tests/test_standing_rules.py index 14858627b5..71b080c8b0 100644 --- a/tests/test_standing_rules.py +++ b/tests/test_standing_rules.py @@ -206,17 +206,17 @@ def commit(left): check = f"echo run >> {runs}; cat left" todo = create(commander, "P1", command=check, expected=0, deadline=time.time() + 3600) ordered_check = todo.order.checked().check - ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit)) + ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit), unit.parent) def progress(): return OrderProgress.read(comms.goals.todos.get("P1"), storage, PullRequests()) commit(2) - ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit)) + ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit), unit.parent) assert "; distance 2, projected done " in progress().line assert isinstance(progress().distance_readings().trend, MovingTrend) commit(2) - ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit)) + ordered_check.at(git("rev-parse", "HEAD").strip(), str(unit), unit.parent) assert progress().line.endswith("; distance 2, not falling") assert runs.read_text().count("run") == 3 # once per commit: base, then each branch commit diff --git a/tools/proofs/cn_phase3_record.py b/tools/proofs/cn_phase3_record.py index d925660ffd..bbadb40bd5 100644 --- a/tools/proofs/cn_phase3_record.py +++ b/tools/proofs/cn_phase3_record.py @@ -7,19 +7,15 @@ from __future__ import annotations import argparse -import json import os import shutil import subprocess -import sys import time from dataclasses import replace from pathlib import Path -from agent_comms import cli as comms_cli from agent_comms.change_report import FileLine from agent_comms.comms import Comms -from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder from agent_comms.progress_claims import MergedClaim @@ -50,22 +46,7 @@ def cli[Caller: ToolCaller](caller: str, tool: ToolRequest[Caller]) -> str: - arguments = FieldCodec.encode(tool) - del arguments[tool.family_discriminator] - command = [ - sys.executable, - "-m", - comms_cli.__name__, - "--root", - str(ROOT), - "tool", - "--from", - caller, - "--tool", - tool.declared_name, - "--arguments", - json.dumps(arguments), - ] + command = tool.cli_command(ToolCaller.from_actor(caller)).argv(ROOT) result = subprocess.run( command, capture_output=True, diff --git a/tools/proofs/cn_progress_read_only.py b/tools/proofs/cn_progress_read_only.py index 5065895634..05d2364053 100644 --- a/tools/proofs/cn_progress_read_only.py +++ b/tools/proofs/cn_progress_read_only.py @@ -63,7 +63,7 @@ def verify() -> None: assert not MARKER.exists() sha = CheckedCommit.git(str(checkout), "rev-parse", "HEAD").strip() assert not check.recorded(sha, str(checkout)) - check.at(sha, str(checkout)) # Deliberately explicit, never a board read. + check.at(sha, str(checkout), comms.registry.storage_rule.checkout_base) assert MARKER.read_bytes() == MARKER_CONTENT assert len(board(todo).readings) == 1 assert MARKER.read_bytes() == MARKER_CONTENT