From cee06a36fb17c4de58d4cc0b030fe803ffbe22a8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:08:28 -0400 Subject: [PATCH 1/4] Qualify installed goal stops and retire the completed cutover --- notes/P448-TOAD-MIGRATION.md | 198 -------------------------- src/agent_comms/goal_presentation.py | 3 +- tests/test_goal_execution.py | 27 +--- tests/test_holder_standing_rules.py | 10 +- tests/test_native_unknown_recovery.py | 6 +- tools/cutover/carry_goal_execution.py | 76 ---------- 6 files changed, 12 insertions(+), 308 deletions(-) delete mode 100644 notes/P448-TOAD-MIGRATION.md delete mode 100644 tools/cutover/carry_goal_execution.py diff --git a/notes/P448-TOAD-MIGRATION.md b/notes/P448-TOAD-MIGRATION.md deleted file mode 100644 index d5c63eafd2..0000000000 --- a/notes/P448-TOAD-MIGRATION.md +++ /dev/null @@ -1,198 +0,0 @@ -# P448 paired Toad edits — ready to apply after allocation is repaired - -Owner: unit-goalblock. This is an implementation handoff, **not applied Toad code**. -Core PR #2529 checkpoint at preparation: `4bb907101156df2b027abc9123e552354176f6f9`. -Toad source inspected: origin/main `43b0467826a6b8926011aaa42cb1eb87ec34615d`. -The relevant files are unchanged from the existing read-only base checkout at -`de66b5120c2cc9ed29d86b84aa0e5a3bc22c32d9`. - -The allocation owner is the commander's PR #2535, expected in install 42 -(message 15553). Until that fix is actually installed, do not create a checkout -by another mechanism. The only prescribed checkout is -`/home/ts/wt/unit-goalblock--P448--toad`. Core must not install alone. - -## Ownership and source evidence - -Reuse `GoalSession`, which already owns a conversation's goal controls, display, -execution and source lifetime. Pass that existing owner to control predicates -and captions instead of passing a naked stored goal and recovering execution -from its state. Core's `GoalExecution` owns the action and caption. Toad renders -and submits that action; it does not classify failure or choose Retry by reading -`goal.state`, an enum, a kind string or an isinstance chain. - -Refactor-audit Package inventory parsed all **283 Toad production modules with -zero parse omissions** at the main above. Core's existing inventory parsed 427 -modules with zero omissions. Declaration/import/call evidence is retained at -`/home/ts/.cache/agent-scratch/unit-goalblock/toad-controls-ast.txt` and -`final-owner-ast.txt`. Dynamic Python rebinding is outside that syntactic proof. -Patterns: remove IMPL-2 enum-based fixture constructors and IMPL-12 copied Pause -behavior. Keep controls dependent on the execution owner. Do not introduce TIME-3 -compatibility aliases for the deleted ThreadGoalExecution base. -The Core checkpoint also removes the stale state-toggle test and its literal -caption table; actual turn tests now check Retry versus Pause on the resulting -execution. StandbyExecution composes ActiveExecution and WaitingExecution so the -shared Pause action is inherited, not copied into the standby sibling. - -Production consumers: -- `src/toad/goal_interaction.py`: deleted type import/annotation; the three stored - state probes in ToggleInteraction; shared control predicate/caption protocol. -- `src/toad/widgets/goal_bar.py`: the single control-paint loop is the only caller - of enabled/visible/caption. It already watches execution as well as goal data. -- `src/toad/widgets/conversation.py`: deleted type import and reactive annotation. -- `src/toad/screens/goal_details.py`: already watches both values and uses the - session's execution. No replacement reader, state filter or cache is needed. -- `src/toad/session_observation.py`: already publishes both members of the same - Core GoalSnapshot and retains an unavailable view on read failure. Keep this - owner and source-binding checks; do not create another observation path. -- `src/toad/core/input_events.py`/GoalControl: declared interaction dispatch is - already correct; control discovery remains derived from the family. - -## Exact production edits - -### goal_interaction.py - -Import `GoalExecution` in place of `ThreadGoalExecution`. Remove the unused -GoalDisplay TYPE_CHECKING import after changing the signatures below. -`GoalSession.execution` returns GoalExecution directly, not a compatibility name. - -Make the base control methods consume the owner already used by apply: - -```python -@classmethod -def enabled(cls, session: GoalSession) -> bool: - return session.display.can_control - -@classmethod -def visible(cls, session: GoalSession) -> bool: - return True - -@classmethod -def caption(cls, session: GoalSession, collapsed: bool) -> str: - return cls.label -``` - -CollapseInteraction keeps its differences only: enabled(session) returns True; -caption(session, collapsed) returns `"Expand" if collapsed else cls.label`. - -Replace ToggleInteraction's predicate/caption/action with: - -```python -@classmethod -def visible(cls, session: GoalSession) -> bool: - return session.display.can_control and session.execution.toggle is not None - -@classmethod -def caption(cls, session: GoalSession, collapsed: bool) -> str: - return session.execution.toggle_label if session.display.can_control else cls.label - -@classmethod -async def apply(cls, session: GoalSession) -> None: - action = session.execution.toggle - if action is None: - session.view.flash(session.display.heading(session.execution)) - else: - await session.change(action) -``` - -The optional action is the existing Core control contract, not a test for a -missing block. Completed/idle executions supply no action. Failed/unresolved -executions supply Retry; unresolved authorization remains refused by the ledger. -Do not copy a completed/failure decision or its explanation into this widget. - -In GoalSession.activate, after the existing refresh and binding checks, call -`action.enabled(self)` rather than `action.enabled(view.goal_display)`. Keep the -source-binding guards, finally-refresh, rejected-write handling and prompt focus. - -### widgets/goal_bar.py - -In `_update_goal_text`, acquire `session = self._source().goal_controls` once and -change the existing loop (no new loop or registry): - -```python -for control in self.actions: - action = control.interaction - control.disabled = not action.enabled(session) - control.display = action.visible(session) - control.update(action.caption(session, self.collapsed)) -``` - -The execution watcher must still repaint this loop. This makes a ledger-only -change update both the heading and Retry/Pause/Resume without a stored goal -change. Leave standby animation, collapse/resize, document height and source -lifetime with their existing owners. - -### widgets/conversation.py - -Use `GoalExecution` in the import and in -`goal_execution: var[GoalExecution] = var(IdleGoalExecution())`. -No adapter, re-export, string state or second reactive status is added. - -## Test-family migration - -- `tests/support/goals.py`: use GoalExecution annotations. Require an explicit - execution in ScriptedGoalOwner.owning: a present goal must not silently receive - IdleGoalExecution. Its scripted successful update must publish the matching - active goal **and RunnableExecution**, not leave a stale failure execution. - This fixture supplies a canned external reply; do not implement another Core - state machine or dispatch by the requested action in it. -- `tests/textual/test_goal_retry.py`: pass - `DecidedBlockExecution(goal.id, goal.state.block_reason)` to the scripted owner. - Await the owner snapshot/paint before asserting the Retry caption; do not rely - on a manual goal assignment while execution is still Idle. After clicking, - assert RetryGoalAction reached the owner and the owner reply repaints Pause. - Add the actual defect case through a typed ActiveGoal + failed-attempt snapshot - (using the existing Core owner/fixture), not a fake stored backend BlockedGoal. -- `tests/textual/test_goal_owner_views.py`: import StandbyExecution, - RunnableExecution and PausedExecution. Replace the two enum constructors with - `StandbyExecution(goal.id, targets, ())` and `RunnableExecution(goal.id)`. - When the scripted source becomes paused, publish its matching PausedExecution; - do not retain an inconsistent standby execution and make the widget re-decide - precedence. Restore the matching standby snapshot on return. -- `tests/textual/test_goal_bar_collapse.py`: use StandbyExecution; shown_bar must - supply RunnableExecution alongside its present GOAL before asserting its - header and controls. Preserve narrow/collapsed geometry, draft and pulse checks. -- `tests/textual/test_goal_controls.py`: show_goal supplies RunnableExecution for - the present goal. ProbeInteraction still needs no entry in a hand-made roster; - it inherits the owner-based caption/predicate protocol. -- Replace stale None assertions for absent goals in - `tests/native/test_native_goal_objective_edit.py` and - `tests/textual/test_compaction_turn_events.py` with the existing NoGoal and - IdleGoalExecution members. The inspected main changes the compaction process - owner from ParentedProcess to SpawnedProcess, but leaves this stale goal - assertion unchanged; preserve its newer process-owner change. - Do not keep None-as-absent state as an alternate contract. -- `tests/native/test_native_goal_bar.py`: its two `execution is not None` waits - do not establish that a goal was read, because IdleGoalExecution is non-null. - Wait for the expected owner goal/checkpoint and painted behavior instead. - -Search the **whole Toad production and test roots** after applying. There must be -no ThreadGoalExecution, GoalExecutionState, old GoalExecution(enum, ...) calls, -stored-state toggle or external execution-state switch left. Keep legitimate -terminal/scrollback `.execution.state` uses outside this goal family unchanged. - -## Start immediately when the paired checkout is available - -1. Confirm install 42 includes the actual #2535 fix, use the prescribed checkout - creation, and inspect current Toad main plus open file owners/PRs. At this - snapshot the open PRs are #1115/#1121/#1125/#1129/#1131; none owns these goal - controls. Fetch again then, not a new clone or environment. -2. Apply the owner-and-consumers batch above, including every affected test - fixture/caller. Import the paired Core worktree through existing dependencies; - no uv sync, virtualenv, compatibility alias or installed-package mutation. -3. First Toad commit: push, verify ls-remote equals HEAD and open a draft PR. - Keep both working versions visible; keep Core PR #2529 current with main. -4. Run only the touched goal-family tests, in the background under timeout 300 - and faulthandler_timeout=60. Reuse existing dependencies and persistent owned - scratch. No local ratchets or full suite. Record collected/executed counts. -5. Qualify the paired app on a copied saved bus/session, with a plain st terminal - on an isolated display. Exercise the affected native transport and visible - Toad controls: failed ledger with Active goal -> productive successor -> Active, - stop and actual worker relaunch without Retry, UNKNOWN never replayed, explicit - model/owner blocks preserved, and ledger-only changes repainted while the view - stays open. Include fresh DM/reply with the tab open, tab return, details, - history, drafts, selection/reader position and controls after a rejected Retry. -6. The merger owns the fresh practice install on the pushed paired heads. Core - does not install alone. Run the one-shot carry during rollout, retain its - migrated/preserved counts, then remove it and its one-time test after it ran. - Record the full delivery claim in Core only when the order's done list holds; - a Core-only checkpoint is not P448 acceptance. diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index c1744e4e0f..ae6df7f466 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Mapping from dataclasses import asdict, dataclass, fields from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Self @@ -39,7 +38,7 @@ def to_wire(self) -> dict[str, str | float]: return self.incarnation.to_wire() @classmethod - def from_wire(cls, data: Mapping) -> GoalWaitTarget: + def from_wire(cls, data: dict[str, object]) -> GoalWaitTarget: return cls(RecordedThreadIncarnation.from_wire(data)) @classmethod diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index b7e97e0a1c..1e6105532f 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -1,18 +1,15 @@ -"""Real turn acquisition, terminal handling and cutover share the execution owner.""" +"""Real turn acquisition and terminal handling share the execution owner.""" import os import sqlite3 from contextlib import AsyncExitStack, asynccontextmanager, closing -from dataclasses import replace import pytest -from cutover.carry_goal_execution import FAILED_BACKEND_REASON, BackendGoalBlocks from agent_comms import agent_events as events from agent_comms.acp import CommsAgent from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.goal_actions import ( - BlockedGoalAction, PausedGoalAction, RetryGoalAction, SetGoalAction, @@ -23,7 +20,7 @@ RunnableExecution, RunningExecution, ) -from agent_comms.goal_states import ActiveGoal, BlockedGoal +from agent_comms.goal_states import ActiveGoal from agent_comms.owned_turn import OwnedTurn from agent_comms.turn_terminal import OwnerStop from test_goal_answers import copied_goal_bus @@ -127,23 +124,3 @@ async def test_verified_productive_turn_recovers_blocked_generation(execution_bu assert isinstance(comms.goals.goal_execution("worker"), RunnableExecution) assert comms.registry.require("worker").goal.state == ActiveGoal() assert failure_count(comms) == 1 # The old failure remains evidence, never replayed. - - -def test_cutover_retires_backend_text_and_preserves_owner_block(execution_bus): - comms = execution_bus - owner = comms.registry.require("worker") - comms.registry.register(replace(owner, goal=replace( - owner.goal.require(), - state=BlockedGoal(FAILED_BACKEND_REASON), - ))) - comms.registry.declare(comms.registry.new_thread("explicit", frozenset(), owner.worktree)) - comms.goals.update_goal("explicit", SetGoalAction(text="Needs owner decision")) - decided = comms.goals.update_goal("explicit", BlockedGoalAction(progress="Waiting for owner approval")) - dry = BackendGoalBlocks.carry(comms.root, apply=False) - assert dry.detail == {"migrated": 1, "preserved": 1} - assert comms.registry.require("worker").goal.state != ActiveGoal() - carried = BackendGoalBlocks.carry(comms.root, apply=True) - assert carried.detail == dry.detail - assert comms.registry.require("worker").goal.state == ActiveGoal() - assert comms.registry.require("explicit").goal == decided - assert BackendGoalBlocks.carry(comms.root, apply=True).changed == 0 diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 7474b7a7ed..f6e8cbba90 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -33,7 +33,9 @@ from agent_comms.coordinator import Coordination from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec -from agent_comms.goal_actions import CreateProblemGoalAction, DirectiveGoalAction, SetGoalAction +from agent_comms.goal_actions import ( + CreateProblemGoalAction, DirectiveGoalAction, ReissuedOrderGoalAction, SetGoalAction, +) from agent_comms.goal_attempts import GoalAttemptStore, GoalWorkDecision from agent_comms.goal_presentation import RunnableExecution from agent_comms.goal_states import ActiveGoal @@ -68,6 +70,7 @@ def held_bus(comms, tmp_path, source_repository, configured_repository): """One setup owner: real declarations, checked orders, UNKNOWN row, then a copy.""" source = source_repository scope = source.scope() + comms.messaging.initialize_private_initial_protocol() declare_test_bus(comms, channels=('comms',), threads=tuple( comms.registry.new_thread(name=name, tags=frozenset({'comms'}), worktree=str(source.checkout)) for name in ('lead', 'worker', 'outsider') @@ -160,7 +163,7 @@ def test_an_order_to_a_blocked_goal_activates_it_and_keeps_other_orders(held_bus blocked_holder(held_bus) before = held_bus.registry.require("worker").goal.require() todo = held_bus.goals.todos.get("P1") - held_bus.goals.update_goal("worker", UnownedWheelSpin.order_for( + held_bus.goals.update_goal("worker", ReissuedOrderGoalAction.for_todo( todo, "lead", held_bus.registry.require("lead").incarnation, )) current = held_bus.registry.require("worker").goal.require() @@ -171,9 +174,8 @@ def test_an_order_to_a_blocked_goal_activates_it_and_keeps_other_orders(held_bus async def test_sigterm_group_preserves_active_goal_and_never_reissues_orders( - held_bus, native_runtime_package, source_programs, tmp_path, + held_bus, native_runtime_package, tmp_path, ): - source_programs.bind(held_bus.root.parent) provider_root = tmp_path / "stopping-provider" provider_root.mkdir(mode=0o700) async with native_backend_fixture(provider_root, package=native_runtime_package) as fixture: diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 99dc4ee6e1..dc988ab9e6 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -132,7 +132,7 @@ def running_successor(comms): new_owner=True, ) successor, _ = comms.registry.lease_local_turn("beta", "productive-successor") - assert successor.process_alive and successor.turn_record.read().executing + assert successor.executing return successor @@ -141,7 +141,7 @@ def test_undispatched_original_recovers_beside_a_running_successor(released_star successor = running_successor(comms) recover_starting_owner(released_starting) assert comms.registry.require("beta") == successor - assert successor.process_alive and successor.turn_record.read().executing + assert successor.executing comms.registry.release_turn(successor.require_turn_lease()) @@ -387,7 +387,7 @@ def test_dead_original_settles_unknown_beside_a_live_successor(released_unknown) assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert comms.registry.require("beta") == successor - assert successor.process_alive and successor.turn_record.read().executing + assert successor.executing comms.registry.release_turn(successor.require_turn_lease()) diff --git a/tools/cutover/carry_goal_execution.py b/tools/cutover/carry_goal_execution.py deleted file mode 100644 index 1484d28804..0000000000 --- a/tools/cutover/carry_goal_execution.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Remove backend-produced registry blocks once; explicit decisions stay stored. - -The retired texts belong only to this cutover. Delete this step after rollout; -no turn, retry, timer or installation hook uses them to authorize execution. -""" - -from __future__ import annotations - -from dataclasses import replace -from pathlib import Path - -from agent_comms.goal_states import ActiveGoal, BlockedGoal -from agent_comms.installer.carry import Carried, Carry -from agent_comms.locked_store import RegistryFile -from agent_comms.registry_store import RegistryStore - -FAILED_BACKEND_REASON = "Backend turn failed; inspect local diagnostics before resuming." -BACKEND_REASONS = frozenset( - { - FAILED_BACKEND_REASON, - ( - "Backend reported success without assistant output or tool activity; inspect " - "the session before resuming." - ), - "Goal turn ended without verified terminal progress.", - "Backend turn ended without a result; inspect local diagnostics.", - "Goal origin turn did not finish successfully.", - ( - "Standby wake launch authority unavailable; inspect the UNKNOWN input before " - "explicit Retry. No input was replayed." - ), - ( - "The interrupted goal attempt is unresolved. Inspect it, then use Retry to " - "authorize a new attempt. Your messages can still be sent." - ), - "Goal attempt unresolved; inspect diagnostics before Retry.", - } -) - - -class BackendGoalBlocks(Carry): - @classmethod - def backup_files(cls, root: Path) -> tuple[Path, ...]: - store = RegistryStore(RegistryFile.path_in(root)) - paths = tuple(path for path in store.written if path.exists()) - for path in paths: - cls.require_confined(root, path) - return paths - - @classmethod - def carry(cls, root: Path, *, apply: bool) -> Carried: - store = RegistryStore(RegistryFile.path_in(root)) - cls.require_confined(root, store.path) - if not store.path.exists(): - return Carried(0) - migrated = preserved = 0 - with store.editing() as edit: - for owner in tuple(edit.document.threads.values()): - for goal in owner.goal: - if isinstance(goal.state, BlockedGoal): - if goal.state.block_reason in BACKEND_REASONS: - migrated += 1 - active = replace(goal, state=ActiveGoal(), revision=goal.revision + 1) - edit.document.install_thread(replace(owner, goal=active)) - else: - preserved += 1 - if apply and migrated: - edit.commit() - rewritten = ( - tuple(str(path.relative_to(root)) for path in store.written) - if apply and migrated - else () - ) - return Carried( - migrated, detail={"migrated": migrated, "preserved": preserved}, rewritten=rewritten - ) From 271b439d0f63f2c5b3c3046929e594ac04532a5b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:59:26 -0400 Subject: [PATCH 2/4] Reject human stop targets before lifecycle effects --- src/agent_comms/owner_lifecycle.py | 3 +++ tests/test_holder_standing_rules.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 2d4e616f55..252b528e87 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -444,6 +444,9 @@ def _stop_selection(self, name: str) -> tuple[Thread, int] | None: with _store_lock(self._wire_lock_path): snapshot = self.registry.snapshot() thread = self.registry.require(name) + thread.role.require_executable( + RelationViolationError("Only agent threads can be stopped.") + ) if not snapshot.status(thread.name).active and ( thread.process_identity.allows_inactive_stop() ): diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 469a69805b..a29e76ce36 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -105,6 +105,14 @@ def held_bus(comms, tmp_path, source_repository, configured_repository): return copied_bus +def test_stop_rejects_a_human_before_any_registry_change(comms, tmp_path): + reader = comms.messaging.user_identity(str(tmp_path)) + before = comms.registry.store.path.read_bytes() + with pytest.raises(RelationViolationError, match="Only agent threads can be stopped"): + comms.owners.stop(reader.name) + assert comms.registry.store.path.read_bytes() == before + + def blocked_holder(comms): """Settle a real failed turn; never manufacture a stored backend block.""" owner = comms.registry.require("worker") From 865529e336577093730832c15b6193024575a0e8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:19:03 -0400 Subject: [PATCH 3/4] Preserve goal execution identity through codec and publication --- src/agent_comms/goal_presentation.py | 36 +++++++++++++++------------- tests/test_goal_execution.py | 16 +++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index ae6df7f466..20584dac9e 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -3,11 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass, fields +from dataclasses import dataclass, fields from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Self -from .field_codec import NullMember, RecordMember, SubclassUnion, WireValue +from .declared_family import DeclaredFamily +from .field_codec import NullMember, WireValue from .goal_failure_observation import FailedTurnEvidence from .goal_states import PauseSource from .goals import Goal, GoalCheckpoint, GoalDeclaration @@ -70,7 +71,7 @@ def execution(self) -> GoalExecution: return self.goal.state.execution(self) -class GoalExecution(SubclassUnion, affix="Execution"): +class GoalExecution(DeclaredFamily, affix="Execution"): """The complete execution family consumed by commands, views and the commander.""" def details_heading(self, heading: str) -> str: @@ -96,7 +97,7 @@ def heading(self, revision: int) -> str: ... @dataclass(frozen=True) -class IdleGoalExecution(NullMember, GoalExecution): +class IdleGoalExecution(GoalExecution): def validate_goal(self, goal: GoalCheckpoint) -> None: pass @@ -105,7 +106,7 @@ def heading(self, revision: int) -> str: @dataclass(frozen=True) -class BoundGoalExecution(RecordMember, GoalExecution, declared_name=None): +class BoundGoalExecution(GoalExecution, declared_name=None): goal_id: str glyph: ClassVar[str] = "✓" @property @@ -148,6 +149,15 @@ def toggle(self) -> type[GoalAction]: return PausedGoalAction +class RetryableExecution(ControlledExecution, declared_name=None): + toggle_label = "Retry" + + @property + def toggle(self) -> type[GoalAction]: + from .goal_actions import RetryGoalAction + return RetryGoalAction + + @dataclass(frozen=True) class RunnableExecution(ActiveExecution): label = "Active" @@ -199,15 +209,9 @@ def waiting_for(self, goal: Goal) -> bool: return goal.id == self.goal_id and bool(goal.state.items_with(Active, goal)) -class BlockedExecution(WaitingExecution, ControlledExecution, declared_name=None): +class BlockedExecution(WaitingExecution, declared_name=None): glyph = "!" label = "Blocked" - toggle_label = "Retry" - - @property - def toggle(self) -> type[GoalAction]: - from .goal_actions import RetryGoalAction - return RetryGoalAction @property @abstractmethod @@ -228,7 +232,7 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: @dataclass(frozen=True) -class DecidedBlockExecution(BlockedExecution): +class DecidedBlockExecution(BlockedExecution, RetryableExecution): block_reason: str @property @@ -237,7 +241,7 @@ def reason(self) -> str: @dataclass(frozen=True) -class UnrecordedBlockExecution(BlockedExecution): +class UnrecordedBlockExecution(BlockedExecution, RetryableExecution): @property def reason(self) -> str: return "reason unavailable" @@ -254,7 +258,7 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: f"goal {self.goal_id} -> {self.reason}") @dataclass(frozen=True) -class AttemptFailedExecution(BlockedExecution): +class AttemptFailedExecution(BlockedExecution, RetryableExecution): evidence: FailedTurnEvidence @property @@ -284,7 +288,7 @@ class GoalSnapshot(GoalPublicationSnapshot): execution: GoalExecution def changed_by(self, current: GoalSnapshot) -> bool: - return asdict(self) != asdict(current) + return self != current def standby(self) -> bool: return self.execution.standby() diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 1e6105532f..a1dfbf96c9 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -2,6 +2,7 @@ import os import sqlite3 from contextlib import AsyncExitStack, asynccontextmanager, closing +from dataclasses import fields import pytest @@ -9,6 +10,7 @@ from agent_comms.acp import CommsAgent from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms +from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import ( PausedGoalAction, RetryGoalAction, @@ -17,6 +19,8 @@ from agent_comms.goal_failure_observation import FailedTurnEvidence from agent_comms.goal_presentation import ( AttemptFailedExecution, + GoalExecution, + GoalSnapshot, RunnableExecution, RunningExecution, ) @@ -37,6 +41,18 @@ def execution_bus(copied_goal_bus): return comms +@pytest.mark.parametrize("execution_type", tuple( + member for member in GoalExecution.members_with(GoalExecution) + if fields(member) == fields(RunnableExecution) +)) +def test_execution_identity_survives_wire_and_change_detection(execution_bus, execution_type): + goal = execution_bus.registry.require("worker").goal + execution = execution_type(goal.id) + assert FieldCodec.decode(GoalExecution, FieldCodec.encode(execution)) == execution + current = GoalSnapshot(goal, RunnableExecution(goal.id)) + assert current.changed_by(GoalSnapshot(goal, execution)) == (current.execution != execution) + + @asynccontextmanager async def turn_on(comms, *, original_owner_input=False): agent = CommsAgent(comms, auto_wake=False, owner_stop=OwnerStop()) From 757960cbaadaf75d5c6a840622a1004227402534 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:32:19 -0400 Subject: [PATCH 4/4] Put copied goal-bus setup on its shared fixture owner --- tests/conftest.py | 3 +++ tests/goal_owner_fixture.py | 12 ++++++++++++ tests/test_goal_answers.py | 11 ----------- tests/test_holder_standing_rules.py | 1 + 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 940d72178d..a788b8645a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest +import goal_owner_fixture from xdist import is_xdist_worker from source_programs import SourcePrograms from source_revision_fixture import SourceRepository @@ -27,6 +28,8 @@ from agent_comms.store_files import _store_lock from agent_comms.thread_text import ForkedParent, GivenThreadText +pytest_plugins = (goal_owner_fixture.__name__,) + class TranscriptRecords: """Fixed test time, projected through each record's declared constructor.""" diff --git a/tests/goal_owner_fixture.py b/tests/goal_owner_fixture.py index 2aa982d2d2..33e0220004 100644 --- a/tests/goal_owner_fixture.py +++ b/tests/goal_owner_fixture.py @@ -1,6 +1,10 @@ """Canonical bus setup for owner lifecycle tests with no automatic model work.""" +import shutil + +import pytest + from agent_comms.comms import Comms @@ -11,3 +15,11 @@ def canonical_goal_wire(root): return comms +@pytest.fixture +def copied_goal_bus(tmp_path): + source = canonical_goal_wire(tmp_path / "source") + target = tmp_path / "copy" + shutil.copytree(source.root, target) + return Comms(target) + + diff --git a/tests/test_goal_answers.py b/tests/test_goal_answers.py index 005712c7f5..907fea00dd 100644 --- a/tests/test_goal_answers.py +++ b/tests/test_goal_answers.py @@ -1,7 +1,6 @@ """Goal absence is dispatched before work; a clear never revives a turn's goal.""" import os -import shutil from contextlib import ExitStack, contextmanager from uuid import uuid4 @@ -9,7 +8,6 @@ from agent_comms import agent_events as events from agent_comms.child_process import ProcessIdentity -from agent_comms.comms import Comms from agent_comms.goal_actions import ClearGoalAction, SetGoalAction from agent_comms.goal_attempts import ProviderUsageTotal from agent_comms.goal_turn_permit import NoLaunchPermit @@ -20,15 +18,6 @@ from agent_comms.turn_terminal import EndedWithoutResult, OperatorStopped -@pytest.fixture -def copied_goal_bus(tmp_path): - source = Comms(tmp_path / "source") - source.messaging.initialize_private_initial_protocol() - target = tmp_path / "copy" - shutil.copytree(source.root, target) - return Comms(target) - - @contextmanager def goal_turn(comms, original_goal): comms.registry.declare(comms.registry.new_thread( diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 4a1a535dca..5884abafcd 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -26,6 +26,7 @@ from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.comms import Comms from agent_comms.coordinator import Coordination +from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import ( CreateProblemGoalAction, DirectiveGoalAction, ReissuedOrderGoalAction, SetGoalAction,