From 557f864335dff1abbc36fdda4fb180227bb24d0b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 15:23:58 -0400 Subject: [PATCH 01/11] Derive goal execution from decisions, ledger and live turn --- src/agent_comms/acp_extension.py | 4 +- src/agent_comms/agent_events.py | 12 +- src/agent_comms/goal_actions.py | 12 - src/agent_comms/goal_attempts.py | 9 + src/agent_comms/goal_failure_observation.py | 117 ++----- src/agent_comms/goal_generation.py | 44 +++ src/agent_comms/goal_management.py | 50 +-- src/agent_comms/goal_presentation.py | 370 +++++++++++++------- src/agent_comms/goal_states.py | 144 ++------ src/agent_comms/goal_turn_permit.py | 4 - src/agent_comms/goal_waits.py | 41 +-- src/agent_comms/goals.py | 70 +--- src/agent_comms/history_views.py | 12 +- src/agent_comms/presentation.py | 40 ++- src/agent_comms/schedule_rules.py | 13 - src/agent_comms/tools.py | 4 +- src/agent_comms/turn_goal_account.py | 41 +-- src/agent_comms/turn_lease.py | 13 + src/agent_comms/turn_progress.py | 4 +- src/agent_comms/turn_terminal.py | 19 +- src/agent_comms/ui_model/goal.py | 12 +- 21 files changed, 458 insertions(+), 577 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index dbe75c2f39..3347da2083 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -35,7 +35,7 @@ WireName, WireOmitDefault, ) -from .goal_presentation import GoalSnapshot, ThreadGoalExecution +from .goal_presentation import GoalSnapshot, GoalExecution from .input_attempt import InputAttempt from .input_origin import HumanInputOrigin, InputOrigin, UnattributedInputOrigin from .native_runtime_input import CurrentNativeCursor @@ -138,7 +138,7 @@ def of(cls, *updates: AgentCommsUpdate) -> UpdateMetadata: class GoalSnapshotReply(DeclaredReply, GoalSnapshot): """A goal request's reply: the owner's goal snapshot and its turn state.""" - execution: Annotated[ThreadGoalExecution, WireName("goalExecution")] + execution: Annotated[GoalExecution, WireName("goalExecution")] meta: Annotated[UpdateMetadata, WireName("_meta")] diff --git a/src/agent_comms/agent_events.py b/src/agent_comms/agent_events.py index 64adcdb382..0cafba4a34 100644 --- a/src/agent_comms/agent_events.py +++ b/src/agent_comms/agent_events.py @@ -22,7 +22,7 @@ ) from .turn_terminal import FailedGoalTurn from .turn_goal_account import OriginGoalSettlement, VerifiedGoalSettlement -from .diagnostics import terminal_failure_reason +from .diagnostics import FailureReason, terminal_failure_reason from .child_process import ProcessIdentity from .declared_family import DeclaredFamily from .field_codec import NullMember @@ -272,17 +272,15 @@ def resolve( return replace(self, outcome=outcome) @property - def failure_reason(self) -> str: + def failure_reason(self) -> FailureReason: return terminal_failure_reason(asdict(self)) def failure_observation(self, account, permit, captured): return None if self.ok else super().failure_observation(account, permit, captured) - def observe_goal(self, account) -> None: - if not self.ok: - account.permit.block(account, "Backend turn failed; inspect local diagnostics before resuming.") - else: - account.require_output(self.text) + def settle_current(self, account) -> None: + if self.ok: + VerifiedGoalSettlement.settle_current(account) def settle_launched(self, account, permit, store, captured, current) -> None: if self.ok: diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index a08047d539..9ff0e8ee8f 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -20,7 +20,6 @@ from .goal_states import ( ActiveGoal, BlockedGoal, - BlockedState, CompletedGoal, GoalState, OwnerPause, @@ -241,15 +240,6 @@ def before_transition(self, goal: Goal, ctx: GoalActionContext) -> None: "The interrupted goal attempt is unresolved. Inspect it, then use " "Retry to authorize a new attempt. Your messages can still be sent." ) - blocked = replace( - goal, - state=BlockedGoal(refusal), - progress=goal.progress, - revision=goal.revision + 1, - ) - ctx.goals.registry.register( - replace(thread, goal=blocked), ctx.goals.registry.status(thread.name) - ) raise ValueError(refusal) elif not generation.lifecycle.allows_resume(thread.turn_record.executing): raise ValueError("The goal attempt is unresolved; inspect it before Retry.") @@ -661,7 +651,5 @@ def next_state(self, goal: Goal, ctx: GoalActionContext) -> GoalState: def require_transition(self, goal: Goal, state: GoalState, ctx: GoalActionContext) -> None: """A blocked goal leaves its state machine only through the attempt ledger's retry.""" - if not isinstance(goal.state, BlockedState): - raise ValueError("The blocked goal changed; refresh its state.") store = ctx.goals.open_goal_store() store.authorize_current_retry(goal.id, uuid4().hex) diff --git a/src/agent_comms/goal_attempts.py b/src/agent_comms/goal_attempts.py index d7685c6a3a..8a1469e971 100644 --- a/src/agent_comms/goal_attempts.py +++ b/src/agent_comms/goal_attempts.py @@ -42,8 +42,11 @@ SucceededAttempt, ) from .goal_failure_observation import ( + FailedTurnEvidence, FailedTurnObservation, + GoalFailureEvidence, GoalLedgerTable, + NoFailedTurnEvidence, record_observation, ) from .goal_generation import ( @@ -802,6 +805,12 @@ def read_attempt(self, attempt_id: str) -> LedgerAttempt: with closing(self._connect()) as conn: return AttemptRecord.read_in(conn, attempt_id) + def failure_evidence(self, generation: Generation) -> GoalFailureEvidence: + with closing(self._connect()) as conn: + return next(FailedTurnEvidence.stream( + conn, attempt_id=generation.attempt_identity.attempt_id, + ), NoFailedTurnEvidence()) + def stopped_permit(self, goal_id: str) -> GoalTurnPermit: generation = self.require_generation(goal_id) return generation.lifecycle.stopped_permit(self, generation) diff --git a/src/agent_comms/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 8ae5102cd7..94136d880c 100644 --- a/src/agent_comms/goal_failure_observation.py +++ b/src/agent_comms/goal_failure_observation.py @@ -8,15 +8,14 @@ import math import sqlite3 +from abc import ABC, abstractmethod from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Literal, Annotated +from typing import TYPE_CHECKING, Annotated -from .coordination_database import CoordinationStore from .diagnostics import FailureReason -from .field_codec import FieldCodec, Id128Text, WireName -from .goal_attempt_identity import FailureNotObserved, GoalAttemptIdentity +from .field_codec import Id128Text, WireName +from .goal_attempt_identity import GoalAttemptIdentity from .thread_identity import ( ClaimedIdentity, RecordedThreadIncarnation, @@ -24,7 +23,6 @@ TurnFence, TurnIdentity, ) -from .thread_status import ThreadStatus from .typed_table import Column, DurableTables, ForeignKey, TypedTable if TYPE_CHECKING: @@ -32,14 +30,27 @@ from .goals import Goal from .threads import Thread from .turn_lease import TurnLeaseFence + from .goal_presentation import GoalExecution class GoalLedgerTable(DurableTables): """Declarations in the durable goal-attempt and passive evidence ledger.""" +class GoalFailureEvidence(ABC): + @abstractmethod + def execution(self, goal_id: str) -> GoalExecution: ... + + +@dataclass(frozen=True) +class NoFailedTurnEvidence(GoalFailureEvidence): + def execution(self, goal_id: str) -> GoalExecution: + from .goal_presentation import AttemptUnresolvedExecution + return AttemptUnresolvedExecution(goal_id) + + @dataclass(frozen=True) -class FailedTurnEvidence(GoalLedgerTable, TypedTable): +class FailedTurnEvidence(GoalLedgerTable, TypedTable, GoalFailureEvidence): attempt_id: Annotated[str, Column(primary_key=True)] goal_id: str generation: Annotated[int, Column(check="generation>0")] @@ -67,6 +78,10 @@ def __post_init__(self) -> None: if not isinstance(self.reason, FailureReason): raise ValueError("Observation reason must be decoded") + def execution(self, goal_id: str) -> GoalExecution: + from .goal_presentation import AttemptFailedExecution + return AttemptFailedExecution(goal_id, self) + @property def identity(self) -> GoalAttemptIdentity: return GoalAttemptIdentity(self.goal_id, self.generation, self.attempt_id) @@ -158,91 +173,3 @@ def record_observation( conn.execute("ROLLBACK TO passive_observation") finally: conn.execute("RELEASE passive_observation") - - -ObservationState = Literal["backend_suspended", "owner_paused", "paused_uncertain", "unavailable"] - - -@dataclass(frozen=True, slots=True) -class FailedTurnProjection: - state: ObservationState - reason: str - - def to_primitive(self) -> dict[str, object]: - # Deliberately no incident IDs, token, worktree, turn ID, diagnostics, - # acknowledgement bodies, canRetry, grants, or control actions. - return {"schema": 1, **FieldCodec.encode(self)} - - @classmethod - def missing_store(cls, store, goal, owner, claim_scope): - return cls("unavailable", store.failure_reason) - - @classmethod - def invalid_store(cls, store, goal, owner, claim_scope): - return cls("unavailable", store.failure_reason) - - @classmethod - def capture(cls, conn, goal, owner, claim_scope): - from .goal_attempts import ( - AttemptRecord, - Generation, - StorageUncertainError, - assert_goal_attempt_schema, - ) - - try: - assert_goal_attempt_schema(conn) - except StorageUncertainError: - return cls("unavailable", "unsupported_schema") - generation = Generation.one(conn, goal_id=goal.id) - if generation is None: - return cls("unavailable", "missing_binding") - try: - expected = generation.failure_identity() - attempt = AttemptRecord.one(conn, attempt_id=expected.attempt_id) - if attempt is None: - return cls("unavailable", "missing_binding") - attempt.phase.require_failure() - except FailureNotObserved as error: - return cls("unavailable", str(error)) - row = FailedTurnEvidence.one(conn, attempt_id=expected.attempt_id) - if row is None: - return cls("unavailable", "missing_binding") - if row.identity != expected or attempt.reservation.identity != expected: - return cls("unavailable", "missing_binding") - if not row.matches_owner(owner, claim_scope): - return cls("unavailable", "owner_or_goal_changed") - state, explanation = goal.state.failure_projection(row.reason.value) - return cls(state, explanation) - - @classmethod - def read_for_goal(cls, path: Path, owner: Thread, owner_status: ThreadStatus, - claim_scope: int, goal: Goal) -> FailedTurnProjection: - if not owner.process_identity.allows_goal_observation(owner_status): - return cls("unavailable", "owner_or_goal_changed") - try: - # Read lifecycle eligibility before opening the passive ledger. - goal.state.failure_projection("") - except FailureNotObserved as error: - return cls("unavailable", str(error)) - if type(claim_scope) is not int or claim_scope <= 0: - return cls("unavailable", "owner_or_goal_changed") - with CoordinationStore.observed(path).observing(cls, goal, owner, claim_scope) as result: - return result - - -def read_failed_turn_projection( - path: Path, - *, - owner: Thread, - owner_status: ThreadStatus, - claim_scope: int, -) -> FailedTurnProjection: - """Read the existing private goal ledger, never initialize/migrate/repair it. - - Caller supplies trusted canonical registry snapshots, not a viewer's - claimed identity. Samples are not an atomic cross-store revision and must - never be used for dispatch or as evidence of absence of an owner pause. - """ - - return owner.goal.failed_turn_projection(path, owner, owner_status, claim_scope) diff --git a/src/agent_comms/goal_generation.py b/src/agent_comms/goal_generation.py index a3e7712b25..4b013006df 100644 --- a/src/agent_comms/goal_generation.py +++ b/src/agent_comms/goal_generation.py @@ -17,6 +17,10 @@ if TYPE_CHECKING: from .goal_attempts import Generation, GoalAttemptStore + from .goal_failure_observation import GoalFailureEvidence + from .goal_presentation import GoalExecution, GoalExecutionSource + from .goal_management import Goals + from .threads import Thread @dataclass(frozen=True) @@ -29,6 +33,24 @@ class GenerationState(DeclaredFamily, LifecycleState, affix="Generation"): @abstractmethod def successors(cls) -> tuple[type[GenerationState], ...]: ... + def execution(self, source: GoalExecutionSource) -> GoalExecution: + return source.turn.execution(source) + + def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import AttemptUnresolvedExecution + return AttemptUnresolvedExecution(source.goal.id) + + def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: + from .goal_failure_observation import NoFailedTurnEvidence + return NoFailedTurnEvidence() + + def verified_turn(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: + pass + + def turn_permit(self, store: GoalAttemptStore, goals: Goals, owner: Thread, + claim_scope: int, generation: Generation) -> GoalTurnPermit: + return NoLaunchPermit() + def validate_attempt(self, attempt_id: str | None) -> None: if not attempt_id: raise ValueError("This generation requires an attempt identity.") @@ -65,6 +87,15 @@ class RetryableGeneration: class ReadyGeneration(GenerationState, RetryableGeneration): ready = True + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import RunnableExecution + return RunnableExecution(source.goal.id) + + def turn_permit(self, store: GoalAttemptStore, goals: Goals, owner: Thread, + claim_scope: int, generation: Generation) -> GoalTurnPermit: + grant = goals.ready_goal_grant_locked(owner, claim_scope, generation) + return store.claim_launch(store.reserve(generation.goal_id, generation.number, ready_grant=grant)) + def require_ready(self) -> None: pass @@ -110,6 +141,15 @@ def allows_resume(self, active_turn: bool) -> bool: class BlockedGeneration(GenerationState, RetryableGeneration): failed = True + def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: + return store.failure_evidence(generation) + + def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: + return source.evidence.execution(source.goal.id) + + def verified_turn(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: + store.authorize_current_retry(generation.goal_id, witness) + def require_failure(self) -> None: return None @@ -136,6 +176,10 @@ def authorize_retry( class CompletedGeneration(GenerationState): terminal = True + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import CompletedExecution + return CompletedExecution(source.goal.id) + @classmethod def successors(cls) -> tuple[type[GenerationState], ...]: return () diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index b4ee37d15b..4789e3a1d2 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -15,7 +15,6 @@ from .coordination_errors import StaleFence from .errors import RelationViolationError from .goal_actions import ( - BlockedGoalAction, GoalAction, GoalActionContext, GoalPrecondition, @@ -44,7 +43,9 @@ GoalPublicationSnapshot, GoalSnapshot, GoalWaitTarget, - ThreadGoalExecution, + GoalExecution, + GoalExecutionSource, + IdleGoalExecution, ) from .goals import Goal, GoalDeclaration from .input_attempt import ( @@ -350,7 +351,7 @@ def _release_finished_wait( self.waits.clear(goal.id, wait_id=wait.wait_id) return True - def goal_execution(self, name: str) -> ThreadGoalExecution: + def goal_execution(self, name: str) -> GoalExecution: return self._goal_snapshot(name).execution def goal_changed(self, name: str, previous: GoalPublicationSnapshot) -> GoalChange: @@ -372,9 +373,20 @@ def goal_snapshot(self, name: str) -> GoalSnapshot: def _goal_snapshot(self, name: str) -> GoalSnapshot: snapshot = self.registry.snapshot() canonical = snapshot.canonical_name(name) - goal = snapshot.threads[canonical].goal - execution = GoalWaits.execution(goal, self.waits.read(), snapshot, self.agents) - return GoalSnapshot(goal, execution) + return self.snapshot_for(snapshot.threads[canonical], snapshot, self.waits.read()) + + def snapshot_for(self, owner: Thread, snapshot: RegistrySnapshot, + waits: dict[str, RecordedGoalWait]) -> GoalSnapshot: + """All goal readers build the same source from the original records.""" + for goal in owner.goal: + store = self.open_goal_store() + generation = store.require_generation(goal.id) + source = GoalExecutionSource( + goal, GoalWaits.for_goal(goal, waits), generation, + generation.lifecycle.evidence(store, generation), owner, snapshot, self.agents, + ) + return GoalSnapshot(goal, source.execution()) + return GoalSnapshot(owner.goal, IdleGoalExecution()) def consume_goal_wait(self, name: str, wait_id: str) -> bool: """Called under the send-boundary wire lock after reserving an attempt.""" @@ -432,29 +444,3 @@ def _updating_goal[Result: GoalDeclaration]( action = action.prepare(self, name, actor) with _store_lock(self._wire_lock_path): yield action.apply(GoalActionContext(self, self.registry.require(name), actor)) - - def block_failed_origin(self, name: str, goal: Goal) -> GoalDeclaration: - return self.update_goal(name, BlockedGoalAction( - expect=GoalPrecondition(goal_id=goal.id, expected_goal=goal), - progress="Goal origin turn did not finish successfully.", - )) - - def block_goal_after_failed_turn( - self, - name: str, - *, - started_goal: Goal, - expected_worktree: str, - diagnostic: str, - ) -> GoalDeclaration: - """Make an unresolved same-ID attempt visible without losing newer progress.""" - with _store_lock(self._wire_lock_path): - thread = self.registry.require(name) - current = thread.goal.matching(started_goal.id) - if thread.worktree != expected_worktree: - return thread.goal - blocked = current.after_failed_turn(diagnostic) - if blocked is current: - return thread.goal - self.registry.register(replace(thread, goal=blocked), self.registry.status(thread.name)) - return blocked diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 3cb58bd0db..871d0e507f 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -1,193 +1,309 @@ -"""Behavior for the runnable/standby refinement of a goal's domain state.""" +"""Goal execution is a read of the decisions, wait, ledger and live owner.""" from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import asdict, dataclass, fields -from enum import StrEnum -from typing import TYPE_CHECKING, Self - -from .field_codec import FieldCodec, NullMember, RecordMember, SubclassUnion, WireValue -from .goal_states import ( - ActiveGoal, - BlockedGoal, - CompletedGoal, - GoalState, - PausedGoal, - UnrecordedBlockGoal, -) -from .goals import ( - Goal, - GoalCheckpoint, - GoalDeclaration, -) +from functools import cached_property +from typing import TYPE_CHECKING, ClassVar, Self + +from .field_codec import NullMember, RecordMember, SubclassUnion, WireValue +from .goal_failure_observation import FailedTurnEvidence +from .goal_states import PauseSource +from .goals import Goal, GoalCheckpoint, GoalDeclaration from .thread_identity import RecordedThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: + from .agent_activity import AgentActivity from .bus_publication import CommittedDelivery + from .goal_actions import GoalAction + from .goal_attempts import Generation + from .goal_failure_observation import GoalFailureEvidence + from .goal_waits import GoalWait + from .registry_document import RegistrySnapshot + from .threads import Thread + from .turn_lease import TurnDeclaration -class ExecutionPresentation(ABC): - waiting = False +@dataclass(frozen=True, slots=True) +class GoalWaitTarget(WireValue): + incarnation: RecordedThreadIncarnation - @abstractmethod - def render(self, execution: GoalExecution) -> tuple[str, str]: ... + def sent(self, original: CommittedDelivery) -> bool: + return original.audience.sender_lookup == self.incarnation.lookup - def details_heading(self, execution: GoalExecution, heading: str) -> str: - return heading + def to_wire(self) -> dict[str, str | float]: + return self.incarnation.to_wire() - def represents(self, state: GoalState) -> bool: - return False + @classmethod + def from_wire(cls, data: Mapping) -> GoalWaitTarget: + return cls(RecordedThreadIncarnation.from_wire(data)) - def waiting_presentation(self, execution: GoalExecution, ordinary: ThreadPresentation) -> ThreadPresentation: - return ordinary + @classmethod + def schema(cls) -> dict: + return RecordedThreadIncarnation.schema() @dataclass(frozen=True) -class StateExecutionPresentation(ExecutionPresentation): - state: type[GoalState] - - def represents(self, state: GoalState) -> bool: - return state.__class__ is self.state +class GoalExecutionSource: + """One read's original owners; never persisted or used as launch authority.""" - def render(self, execution: GoalExecution) -> tuple[str, str]: - return FieldCodec.decode( - GoalState, self.state.wire_payload(execution.block_reason, None) - ).presentation() + goal: Goal + wait: GoalWait + generation: Generation + evidence: GoalFailureEvidence + owner: Thread + snapshot: RegistrySnapshot + agents: AgentActivity + @cached_property + def turn(self) -> TurnDeclaration: + return self.owner.turn_record.read() -class StandbyExecutionPresentation(ExecutionPresentation): - waiting = True + @cached_property + def owner_alive(self) -> bool: + return self.owner.process_alive - def details_heading(self, execution: GoalExecution, heading: str) -> str: - return f"{heading} · {self.render(execution)[1]}" + def execution(self) -> GoalExecution: + return self.goal.state.execution(self) - def waiting_presentation(self, execution: GoalExecution, ordinary: ThreadPresentation) -> ThreadPresentation: - return execution.presentation(ordinary.title) - def render(self, execution: GoalExecution) -> tuple[str, str]: - names = ", ".join(f"@{target.incarnation.name}" for target in execution.wait_for) - idle = ", ".join(f"@{target.incarnation.name}" for target in execution.inactive_wait_for) - suffix = f"; no active turn: {idle}" if idle else "" - return "◌", f"Standby · waiting for {names}{suffix}" +class GoalExecution(SubclassUnion, affix="Execution"): + """The complete execution family consumed by commands, views and the commander.""" + terminal: ClassVar[bool] = False + toggle_label: ClassVar[str] = "Pause" -class GoalExecutionState(StrEnum): - view: ExecutionPresentation + @property + def toggle(self) -> type[GoalAction] | None: + return None - def __new__(cls, value: str, view: ExecutionPresentation | None = None) -> GoalExecutionState: - assert view is not None # Enum declarations supply behavior; value lookup uses EnumMeta. - obj = str.__new__(cls, value) - obj._value_ = value - obj.view = view - return obj + def details_heading(self, heading: str) -> str: + return heading - RUNNABLE = ("runnable", StateExecutionPresentation(ActiveGoal)) - STANDBY = ("standby", StandbyExecutionPresentation()) - PAUSED = (FieldCodec.encode(PausedGoal), StateExecutionPresentation(PausedGoal)) - BLOCKED = (FieldCodec.encode(BlockedGoal), StateExecutionPresentation(BlockedGoal)) - UNRECORDED_BLOCK = ( - FieldCodec.encode(UnrecordedBlockGoal), - StateExecutionPresentation(UnrecordedBlockGoal), - ) - COMPLETED = (FieldCodec.encode(CompletedGoal), StateExecutionPresentation(CompletedGoal)) + def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: + return ordinary - @classmethod - def for_domain_state(cls, state: GoalState) -> GoalExecutionState: - return next(member for member in cls if member.view.represents(state)) + def standby(self) -> bool: + return False + def waiting_for(self, goal: Goal) -> bool: + return False -@dataclass(frozen=True, slots=True) -class GoalWaitTarget(WireValue): - incarnation: RecordedThreadIncarnation + def unit_blocks(self) -> bool: + return False - def sent(self, original: CommittedDelivery) -> bool: - """Only this declared incarnation can supply a dependency reply.""" - return original.audience.sender_lookup == self.incarnation.lookup + def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + pass - def to_wire(self) -> dict[str, str | float]: - return self.incarnation.to_wire() + @abstractmethod + def validate_goal(self, goal: GoalCheckpoint) -> None: ... - @classmethod - def from_wire(cls, data: Mapping) -> GoalWaitTarget: - return cls(RecordedThreadIncarnation.from_wire(data)) + @abstractmethod + def heading(self, revision: int) -> str: ... - @classmethod - def schema(cls) -> dict: - return RecordedThreadIncarnation.schema() +@dataclass(frozen=True) +class IdleGoalExecution(NullMember, GoalExecution): + def validate_goal(self, goal: GoalCheckpoint) -> None: + pass -class ThreadGoalExecution(SubclassUnion): - """The execution contract, including absence, consumed by thread views.""" + def heading(self, revision: int) -> str: + return "No current goal" - __slots__ = () - def details_heading(self, heading: str) -> str: - return heading +@dataclass(frozen=True) +class BoundGoalExecution(RecordMember, GoalExecution, declared_name=None): + goal_id: str + glyph: ClassVar[str] = "✓" + label: ClassVar[str] + @property @abstractmethod - def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: ... + def summary(self) -> str: ... - @abstractmethod - def standby(self) -> bool: ... + def presentation(self, title: str) -> ThreadPresentation: + return ThreadPresentation(title, self.glyph, self.summary) - @abstractmethod - def waiting_for(self, goal: Goal) -> bool: ... + def heading(self, revision: int) -> str: + return f"Goal · {self.label} · rev {revision}" + + def validate_goal(self, goal: GoalCheckpoint) -> None: + goal.require_identity(self.goal_id) - @abstractmethod - def validate_goal(self, goal: GoalCheckpoint) -> None: ... + +class WaitingExecution(BoundGoalExecution, declared_name=None): + def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: + return self.presentation(ordinary.title) @dataclass(frozen=True) -class GoalExecution(RecordMember, ThreadGoalExecution): - state: GoalExecutionState - goal_id: str - wait_for: tuple[GoalWaitTarget, ...] = () - inactive_wait_for: tuple[GoalWaitTarget, ...] = () - block_reason: str | None = None +class RunnableExecution(BoundGoalExecution): + label = "Active" - def details_heading(self, heading: str) -> str: - return self.state.view.details_heading(self, heading) + @property + def summary(self) -> str: + return "Active" - def presentation(self, title: str) -> ThreadPresentation: - glyph, summary = self.state.view.render(self) - return ThreadPresentation(title, glyph, summary) + @property + def toggle(self) -> type[GoalAction]: + from .goal_actions import PausedGoalAction + return PausedGoalAction - def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: - return self.state.view.waiting_presentation(self, ordinary) - def standby(self) -> bool: - return self.state.view.waiting +@dataclass(frozen=True) +class RunningExecution(RunnableExecution): + label = "Running" - def waiting_for(self, goal: Goal) -> bool: - return goal.state.active and self.goal_id == goal.id and self.standby() + @property + def summary(self) -> str: + return "Running" - def validate_goal(self, goal: GoalCheckpoint) -> None: - goal.require_identity(self.goal_id) + +@dataclass(frozen=True) +class CompletedExecution(BoundGoalExecution): + terminal = True + label = "Completed" + toggle_label = "Completed" + + @property + def summary(self) -> str: + return "Completed" @dataclass(frozen=True) -class IdleGoalExecution(NullMember, ThreadGoalExecution): - """No goal execution has been projected.""" +class PausedExecution(WaitingExecution): + source: PauseSource + label = "Paused" + toggle_label = "Resume" - def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: - return ordinary + @property + def summary(self) -> str: + return "Paused" + + @property + def toggle(self) -> type[GoalAction]: + from .goal_actions import ActiveGoalAction + return ActiveGoalAction + + +@dataclass(frozen=True) +class StandbyExecution(WaitingExecution): + wait_for: tuple[GoalWaitTarget, ...] + inactive_wait_for: tuple[GoalWaitTarget, ...] + label = "Standby" + glyph = "◌" + + @property + def summary(self) -> str: + names = ", ".join(f"@{target.incarnation.name}" for target in self.wait_for) + idle = ", ".join(f"@{target.incarnation.name}" for target in self.inactive_wait_for) + suffix = f"; no active turn: {idle}" if idle else "" + return f"Standby · waiting for {names}{suffix}" + + @property + def toggle(self) -> type[GoalAction]: + from .goal_actions import PausedGoalAction + return PausedGoalAction + + def details_heading(self, heading: str) -> str: + return f"{heading} · {self.summary}" def standby(self) -> bool: - return False + return True def waiting_for(self, goal: Goal) -> bool: - return False + return goal.id == self.goal_id and goal.state.active - def validate_goal(self, goal: GoalCheckpoint) -> 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 + def reason(self) -> str: ... + + @property + def summary(self) -> str: + reason = " ".join(self.reason.split()) + summary = reason[:157] + "…" if len(reason) > 160 else reason + return f"Blocked · {summary}" + + def unit_blocks(self) -> bool: + return True + + def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: + return (f"BLOCKED {unit}: orders {', '.join(orders)}; goal {self.goal_id}; " + f"{self.reason} -> Core reissues the orders") + + def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + from .commander.standing import UnownedWheelSpin + from .goal_actions import GoalPrecondition + from .thread_identity import CoreWatcherIncarnation + from dataclasses import replace + + observed = comms.registry.require(unit).goal.require_id(self.goal_id) + for problem in orders: + todo = comms.goals.todos.get(problem) + todo.require_assignee(comms.registry.require(unit).incarnation, observed.id) + action = UnownedWheelSpin.order_for(todo, commander, CoreWatcherIncarnation.watcher()) + observed = comms.goals.update_goal(unit, replace( + action, expect=GoalPrecondition( + goal_id=observed.id, expected_state=observed.state, + expected_checkpoint=observed.captured, + ), + )) + + +@dataclass(frozen=True) +class DecidedBlockExecution(BlockedExecution): + block_reason: str + + @property + def reason(self) -> str: + return self.block_reason + + +@dataclass(frozen=True) +class UnrecordedBlockExecution(BlockedExecution): + @property + def reason(self) -> str: + return "reason unavailable" + + +@dataclass(frozen=True) +class AttemptUnresolvedExecution(BlockedExecution): + @property + def reason(self) -> str: + return "inspect the unknown attempt; never replay" + + def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: + return (f"UNRESOLVED BLOCK {unit}: orders {', '.join(orders)}; " + f"goal {self.goal_id} -> {self.reason}") + + def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: pass -class GoalPublicationSnapshot(ABC): - """Last published goal projection, including the not-yet-published state.""" +@dataclass(frozen=True) +class AttemptFailedExecution(BlockedExecution): + evidence: FailedTurnEvidence + + @property + def reason(self) -> str: + return self.evidence.reason.value + +class GoalPublicationSnapshot(ABC): @abstractmethod def changed_by(self, current: GoalSnapshot) -> bool: ... @@ -205,17 +321,10 @@ def standby(self) -> bool: @dataclass(frozen=True) class GoalSnapshot(GoalPublicationSnapshot): - """A thread's current goal and its scheduling projection, read together. - - The execution, when present, schedules exactly this goal. - """ - goal: GoalDeclaration - execution: ThreadGoalExecution + execution: GoalExecution def changed_by(self, current: GoalSnapshot) -> bool: - # Domain identity ignores renames, but a published wait target includes - # its current name. Compare the declared presentation fields as well. return asdict(self) != asdict(current) def standby(self) -> bool: @@ -223,7 +332,6 @@ def standby(self) -> bool: @classmethod def from_snapshot(cls, snapshot: GoalSnapshot, *extra: object) -> Self: - """This record carrying ``snapshot``, followed by its own declared fields.""" return cls(*(getattr(snapshot, item.name) for item in fields(GoalSnapshot)), *extra) def __post_init__(self) -> None: diff --git a/src/agent_comms/goal_states.py b/src/agent_comms/goal_states.py index 15a5ec54e3..ce17f76b9d 100644 --- a/src/agent_comms/goal_states.py +++ b/src/agent_comms/goal_states.py @@ -1,17 +1,17 @@ -"""Goal lifecycle and pause provenance: the determining domain authorities.""" +"""Stored goal decisions; backend execution is read from the attempt ledger.""" from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, ClassVar, Literal +from typing import TYPE_CHECKING, ClassVar from .declared_family import DeclaredFamily -from .goal_attempt_identity import FailureNotObserved from .lifecycle import LifecycleState if TYPE_CHECKING: - from .goal_actions import GoalAction, GoalActionContext, ProblemOrderGoalAction + from .goal_actions import GoalActionContext, ProblemOrderGoalAction + from .goal_presentation import GoalExecution, GoalExecutionSource from .goals import Goal @@ -24,18 +24,10 @@ def instruction(self) -> str | None: def protects_pause(self) -> bool: return self.instruction() is not None - def failure_projection(self) -> tuple[Literal["owner_paused", "paused_uncertain"], str]: - return ( - ("owner_paused", "owner_pause") - if self.protects_pause - else ("paused_uncertain", "pause_attribution_uncertain") - ) - class OwnerPause(PauseSource): def instruction(self) -> str: from .turn_context import InstructionFile - return InstructionFile.read("owner-pause.md").content @@ -51,9 +43,10 @@ class RuntimePause(PauseSource): class GoalState(DeclaredFamily, LifecycleState, affix="Goal"): active: ClassVar[bool] = False terminal: ClassVar[bool] = False + acp_plan_status: ClassVar[str] = "in_progress" - def after_failed_turn(self, diagnostic: str) -> GoalState: - return self + @abstractmethod + def execution(self, source: GoalExecutionSource) -> GoalExecution: ... def accept_order(self, action: ProblemOrderGoalAction, goal: Goal, ctx: GoalActionContext) -> Goal: return action.continue_goal(goal, ctx) @@ -63,25 +56,12 @@ def require_model_resume(self) -> None: def require_active(self) -> None: from .errors import RelationViolationError - raise RelationViolationError(self.activity_refusal()) def activity_refusal(self) -> str: pause = self.pause_source - return ( - (pause.instruction() if pause else None) - or "This goal is no longer active; refresh its state." - ) - - def failure_projection(self, reason: str) -> tuple[str, str]: - raise FailureNotObserved("owner_or_goal_changed") - - @property - @abstractmethod - def toggle(self) -> type[GoalAction] | None: ... - - toggle_label: ClassVar[str] - acp_plan_status: ClassVar[str] = "in_progress" + return ((pause.instruction() if pause else None) + or "This goal is no longer active; refresh its state.") @classmethod def wire_payload(cls, reason: str | None, source: str | None) -> dict[str, object]: @@ -112,26 +92,12 @@ def pause_source(self) -> PauseSource | None: def protected(self) -> bool: return False - def presentation(self) -> tuple[str, str]: - from .goal_presentation import GoalExecutionState - - return "✓", GoalExecutionState.for_domain_state(self).value.title() - class FromOpenGoal: - """A state explicitly eligible as the target of an active/paused transition.""" - - -class BlockingOnFailure: - """An eligible state owns the failed-turn transition and reason validation.""" - - def after_failed_turn(self, diagnostic: str) -> GoalState: - from .goal_actions import required_block_reason - - return BlockedGoal(required_block_reason(diagnostic)) + """A state eligible as the target of an active/paused decision.""" -class OpenGoal(BlockingOnFailure, GoalState): +class OpenGoal(GoalState): @classmethod def successors(cls) -> tuple[type[GoalState], ...]: return GoalState.members_with(FromOpenGoal) @@ -144,42 +110,24 @@ class ActiveGoal(OpenGoal, FromOpenGoal): def require_active(self) -> None: pass - @property - def toggle(self) -> type[GoalAction]: - from .goal_actions import PausedGoalAction - - return PausedGoalAction + def execution(self, source: GoalExecutionSource) -> GoalExecution: + return source.wait.execution(source) - toggle_label = "Pause" @dataclass(frozen=True) class PausedGoal(OpenGoal, FromOpenGoal): source: PauseSource = field(default_factory=OwnerPause) - def after_failed_turn(self, diagnostic: str) -> GoalState: - if self.protected: - return self - return super().after_failed_turn(diagnostic) - - def failure_projection(self, reason: str) -> tuple[str, str]: - return self.source.failure_projection() - - @property - def toggle(self) -> type[GoalAction]: - from .goal_actions import ActiveGoalAction - - return ActiveGoalAction - - toggle_label = "Resume" + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import PausedExecution + return PausedExecution(source.goal.id, self.source) @classmethod def wire_payload(cls, reason: str | None, source: str | None) -> dict[str, object]: if reason is not None: raise ValueError("A blocked goal requires a bounded explicit reason.") - return { - "kind": cls.declared_name, - "source": {"kind": source if source is not None else OwnerPause.declared_name}, - } + return {"kind": cls.declared_name, + "source": {"kind": source if source is not None else OwnerPause.declared_name}} @property def pause_source(self) -> PauseSource: @@ -202,25 +150,9 @@ def check_transition(self, nxt: GoalState, *, owner: bool) -> None: class BlockedState(GoalState, FromOpenGoal): def accept_order(self, action: ProblemOrderGoalAction, goal: Goal, ctx: GoalActionContext) -> Goal: ordered = action.continue_goal(goal, ctx) - # A checked fresh order is new work authority, not a replay of the failed input. ctx.goals.open_goal_store().authorize_current_retry(goal.id, action.order.id) return replace(ordered, state=ActiveGoal()) - def failure_projection(self, reason: str) -> tuple[str, str]: - return "backend_suspended", reason - - @property - def toggle(self) -> type[GoalAction]: - from .goal_actions import RetryGoalAction - - return RetryGoalAction - - toggle_label = "Retry" - - @property - @abstractmethod - def reason(self) -> str | None: ... - @classmethod def successors(cls) -> tuple[type[GoalState], ...]: return (BlockedGoal,) @@ -236,14 +168,14 @@ def transition_refusal(self) -> str: class BlockedGoal(BlockedState): block_reason: str + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import DecidedBlockExecution + return DecidedBlockExecution(source.goal.id, self.block_reason) + def __post_init__(self) -> None: reason = self.block_reason - if ( - type(reason) is not str - or not reason.strip() - or reason != reason.strip() - or len(reason) > 1024 - ): + if (type(reason) is not str or not reason.strip() or reason != reason.strip() + or len(reason) > 1024): raise ValueError("A blocked goal requires a bounded explicit reason.") @classmethod @@ -254,38 +186,26 @@ def wire_payload(cls, reason: str | None, source: str | None) -> dict[str, objec def reason(self) -> str: return self.block_reason - def presentation(self) -> tuple[str, str]: - reason = " ".join(self.block_reason.split()) - summary = reason[:157] + "…" if len(reason) > 160 else reason - return "!", f"Blocked · {summary}" - @dataclass(frozen=True) class UnrecordedBlockGoal(BlockedState): - """A recorded blocked status whose reason was not recorded.""" - - @property - def reason(self) -> None: - return None - - def presentation(self) -> tuple[str, str]: - return "!", "Blocked · reason unavailable" + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import UnrecordedBlockExecution + return UnrecordedBlockExecution(source.goal.id) @dataclass(frozen=True) -class CompletedGoal(BlockingOnFailure, GoalState, FromOpenGoal): +class CompletedGoal(GoalState, FromOpenGoal): terminal = True + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import CompletedExecution + return CompletedExecution(source.goal.id) + def accept_order(self, action: ProblemOrderGoalAction, goal: Goal, ctx: GoalActionContext) -> Goal: goal.order.require_replacement(goal, ctx.goals, ctx.thread.incarnation) return action.create(ctx) - @property - def toggle(self) -> None: - return None - - toggle_label = "Completed" - @classmethod def successors(cls) -> tuple[type[GoalState], ...]: return (CompletedGoal,) diff --git a/src/agent_comms/goal_turn_permit.py b/src/agent_comms/goal_turn_permit.py index 77802a382b..c03ddf9bb9 100644 --- a/src/agent_comms/goal_turn_permit.py +++ b/src/agent_comms/goal_turn_permit.py @@ -44,10 +44,6 @@ def stop(self, store: GoalAttemptStore) -> None: for permit in self.claims: permit.stop(store) - def block(self, account: TurnGoalAccount, diagnostic: str) -> None: - for permit in self.claims: - account.block_active_goal(diagnostic) - def settle_goal(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, goal: GoalDeclaration, turn: TurnId) -> None: for permit in self.claims: diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 7395e0bb0b..b68f3875ed 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -11,13 +11,7 @@ from .bus_publication import CommittedDelivery from .declared_family import DeclaredFamily from .field_codec import NullMember -from .goal_presentation import ( - GoalExecution, - GoalExecutionState, - GoalWaitTarget, - IdleGoalExecution, - ThreadGoalExecution, -) +from .goal_presentation import GoalExecution, GoalExecutionSource, GoalWaitTarget, StandbyExecution from .goals import Goal, GoalDeclaration from .input_attempt import StoredInput, UnresolvedInput from .locked_store import WireWakeFile @@ -70,19 +64,15 @@ def consume_dependency(self, comms, canonical: str) -> bool: def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: return False - def idle_execution(self) -> IdleGoalExecution: - return IdleGoalExecution() - - def project(self, goal: Goal, snapshot: RegistrySnapshot, agents: AgentActivity) -> GoalExecution: - return GoalExecution( - GoalExecutionState.for_domain_state(goal.state), - goal.id, - block_reason=goal.state.reason, - ) + @abstractmethod + def execution(self, source: GoalExecutionSource) -> GoalExecution: ... @dataclass(frozen=True, slots=True) class NoGoalWait(GoalWait): + def execution(self, source: GoalExecutionSource) -> GoalExecution: + return source.generation.lifecycle.execution(source) + @property def targets(self) -> tuple[GoalWaitTarget, ...]: return () @@ -157,16 +147,16 @@ def consume_dependency(self, comms, canonical: str) -> bool: def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: return waits.clear(goal_id, wait_id=self.wait_id) - def project(self, goal: Goal, snapshot: RegistrySnapshot, agents: AgentActivity) -> GoalExecution: + def execution(self, source: GoalExecutionSource) -> GoalExecution: targets = tuple( - GoalWaitTarget(target.incarnation.renamed(snapshot.canonical_name(target.incarnation.name))) + GoalWaitTarget(target.incarnation.renamed(source.snapshot.canonical_name(target.incarnation.name))) for target in self.targets ) inactive = tuple( target for target in targets - if not agents.is_working(target.incarnation, snapshot) + if not source.agents.is_working(target.incarnation, source.snapshot) ) - return GoalExecution(GoalExecutionState.STANDBY, goal.id, targets, inactive) + return StandbyExecution(source.goal.id, targets, inactive) def current_for(self, goal: GoalDeclaration, owner: RecordedThreadIncarnation) -> bool: """A retained wait can survive edits, but not replacement of its owner. @@ -365,17 +355,6 @@ def resolve(self, goal: GoalDeclaration) -> GoalWait: def for_goal(goal: GoalDeclaration, rows: dict[str, RecordedGoalWait]) -> GoalWait: return rows.get(goal.active().reported_id(), NoGoalWait()) - @classmethod - def execution( - cls, - goal: GoalDeclaration, - rows: dict[str, RecordedGoalWait], - snapshot: RegistrySnapshot, - agents: AgentActivity, - ) -> ThreadGoalExecution: - """Resolve absence once beside the goal/wait acquisition.""" - return goal.describe_execution(cls.for_goal(goal, rows), snapshot, agents) - @staticmethod def current_waits(goal: GoalDeclaration, rows: dict[str, RecordedGoalWait], owner: RecordedThreadIncarnation) -> tuple[RecordedGoalWait, ...]: diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index b1799ef206..1d1fcb1d6b 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -27,7 +27,6 @@ ) from .goal_states import ActiveGoal, GoalState from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit -from .goal_failure_observation import FailedTurnProjection from .turn_goal_permission import ContinuationGoalPermission, GoalPermission, InactiveGoalPermission from .registration_inheritance import RegistrationInheritance from .repo import ScratchCheckout @@ -37,7 +36,7 @@ if TYPE_CHECKING: from .goal_actions import DirectiveGoalAction, GoalAction, GoalActionContext - from .goal_presentation import ThreadGoalExecution + from .goal_presentation import GoalExecution from .goal_waits import GoalWait, GoalWaits from .agent_activity import AgentActivity from .registry_document import RegistrySnapshot @@ -501,9 +500,6 @@ def goal_text(self, goal: Goal) -> str: def progress(self, goal: Goal, todos: Sequence[Todo]) -> str: return goal.progress - def failed_turn_progress(self, goal: Goal, diagnostic: str) -> str: - return f"{goal.progress}\n\n{diagnostic}" if goal.progress else diagnostic - def require_model_block(self) -> None: pass @@ -647,9 +643,6 @@ def progress(self, goal: Goal, todos: Sequence[Todo]) -> str: *(verdict.line for verdict in todo.verdicts[-1:]))) for todo in todos) - def failed_turn_progress(self, goal: Goal, diagnostic: str) -> str: - return goal.progress - def require_model_block(self) -> None: raise ValueError("A problem goal is not blocked by text: ask the commander to block the " "problem's Todo, or standby with wait_for naming the decider.") @@ -703,17 +696,9 @@ def task_scope(self, scope: TaskScope) -> TaskScope: ... @abstractmethod def add_context(self, context: TurnContext, owner: Thread, goals: Goals) -> TurnContext: ... - @abstractmethod - def describe_execution(self, wait: GoalWait, snapshot: RegistrySnapshot, - agents: AgentActivity) -> ThreadGoalExecution: ... - @abstractmethod def peer_change_values(self) -> GoalPeerChange: ... - @abstractmethod - def failed_turn_projection(self, path: Path, owner: Thread, owner_status: ThreadStatus, - claim_scope: int) -> FailedTurnProjection: ... - @abstractmethod def stopped_permit(self, store: GoalAttemptStore) -> GoalTurnPermit: ... @@ -721,16 +706,9 @@ def stopped_permit(self, store: GoalAttemptStore) -> GoalTurnPermit: ... def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, permit: LaunchPermit, turn: TurnId) -> None: ... - @abstractmethod - def block_after_failed_turn(self, goals: Goals, name: str, worktree: str, - diagnostic: str) -> GoalDeclaration: ... - @abstractmethod def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: ... - def after_failed_turn(self, diagnostic: str) -> GoalDeclaration: - return self - def check_expected(self, current: GoalDeclaration) -> None: pass @@ -819,10 +797,6 @@ def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptSto permit: LaunchPermit, turn: TurnId) -> None: pass - def block_after_failed_turn(self, goals: Goals, name: str, worktree: str, - diagnostic: str) -> GoalDeclaration: - return self - def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: return () @@ -851,26 +825,18 @@ def task_scope(self, scope: TaskScope) -> TaskScope: def add_context(self, context: TurnContext, owner: Thread, goals: Goals) -> TurnContext: return context - def describe_execution(self, wait: GoalWait, snapshot: RegistrySnapshot, - agents: AgentActivity) -> ThreadGoalExecution: - return wait.idle_execution() - def peer_change_values(self) -> GoalPeerChange: return GoalPeerChange(goal=self, state=self, progress="") - def failed_turn_projection(self, path: Path, owner: Thread, owner_status: ThreadStatus, - claim_scope: int) -> FailedTurnProjection: - return FailedTurnProjection("unavailable", "owner_or_goal_changed") - can_control: ClassVar[bool] = False visible: ClassVar[bool] = False objective: ClassVar[str] = "" progress: ClassVar[str] = "" - def standby(self, execution: ThreadGoalExecution) -> bool: + def standby(self, execution: GoalExecution) -> bool: return False - def heading(self, execution: ThreadGoalExecution) -> str: + def heading(self, execution: GoalExecution) -> str: return "No current goal" def action_failure(self, error: Exception) -> str: @@ -930,12 +896,6 @@ def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptSto permit: LaunchPermit, turn: TurnId) -> None: settlement(store, permit, self, turn).dispatch_sync(self.state) - def block_after_failed_turn(self, goals: Goals, name: str, worktree: str, - diagnostic: str) -> GoalDeclaration: - return goals.block_goal_after_failed_turn( - name, started_goal=self, expected_worktree=worktree, diagnostic=diagnostic, - ) - def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: return (self.render_instructions(self.order.instructions( self.order.todos(goals, owner, self.id))),) @@ -972,17 +932,9 @@ def task_scope(self, scope: TaskScope) -> TaskScope: def add_context(self, context: TurnContext, owner: Thread, goals: Goals) -> TurnContext: return context.prepend_goal(owner, self, goals) - def describe_execution(self, wait: GoalWait, snapshot: RegistrySnapshot, - agents: AgentActivity) -> ThreadGoalExecution: - return wait.project(self, snapshot, agents) - def peer_change_values(self) -> GoalPeerChange: return GoalPeerChange(goal=self.revision_key, state=type(self.state), progress=self.progress) - def failed_turn_projection(self, path: Path, owner: Thread, owner_status: ThreadStatus, - claim_scope: int) -> FailedTurnProjection: - return FailedTurnProjection.read_for_goal(path, owner, owner_status, claim_scope, self) - def check_expected(self, current: GoalDeclaration) -> None: if self != current: raise ValueError("Goal changed during resume; refresh its state.") @@ -994,18 +946,17 @@ def accepts_following_handoff(self) -> bool: def objective(self) -> str: return self.text - def details_heading(self, execution: ThreadGoalExecution) -> str: + def details_heading(self, execution: GoalExecution) -> str: return execution.details_heading(self.heading(execution)) can_control: ClassVar[bool] = True visible: ClassVar[bool] = True - def standby(self, execution: ThreadGoalExecution) -> bool: + def standby(self, execution: GoalExecution) -> bool: return execution.waiting_for(self) - def heading(self, execution: ThreadGoalExecution) -> str: - status = "Standby" if self.standby(execution) else self.state.declared_name - return f"Goal · {status} · rev {self.revision}" + def heading(self, execution: GoalExecution) -> str: + return execution.heading(self.revision) def action_failure(self, error: Exception) -> str: return str(error) @@ -1043,13 +994,6 @@ def captured(self) -> GoalCheckpoint: def render_instructions(self, orders_text: str) -> str: return "\n\n".join(part for part in (self.order.goal_text(self), orders_text) if part) - def after_failed_turn(self, diagnostic: str) -> Goal: - progress = self.order.failed_turn_progress(self, diagnostic) - state = self.state.after_failed_turn(diagnostic) - if state is self.state: - return self - return replace(self, state=state, progress=progress, revision=self.revision + 1) - def __post_init__(self) -> None: if not self.text.strip() or not self.id: raise ValueError("A goal requires text and an identity.") diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index ff7b45851c..0143d45291 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -192,7 +192,7 @@ def __init__( self.goals = goals self.ledger = ledger self._wire_lock_path = root / "wire" - self.presentation = BusPresentation(bus, registry, channels.catalog, agents) + self.presentation = BusPresentation(bus, registry, channels.catalog, agents, goals) self.transcript_reads = transcript_read_state(bus.reads.path) def message_notifications_for_references(self, references: Sequence[MessageReference]): @@ -319,7 +319,7 @@ def thread_views( self, visibility: RosterVisibility = DEFAULT_ROSTER, ) -> tuple[ThreadView, ...]: snapshot = self.registry.snapshot() - return ThreadView.roster(snapshot, self.agents, GoalWaits.at(self.root), visibility) + return ThreadView.roster(snapshot, self.agents, self.goals, visibility) def retired_views(self, threads: Iterable[ThreadIncarnation], channels: Iterable[str]) -> RetiredViews: """Which of these thread incarnations and channel views no longer exist. @@ -350,7 +350,7 @@ def thread_presentations( """Read the requested threads' status once, before loading their messages.""" snapshot = self.registry.snapshot() views = ThreadView.roster( - snapshot, self.agents, GoalWaits.at(self.root), + snapshot, self.agents, self.goals, RosterVisibility(archived=True), names=names, ) return ThreadPresentations( @@ -369,7 +369,7 @@ def viewer_snapshot( self.root, basis.registry, basis.catalog, self.agents, basis.channels, self.last_sent_timestamps(clocks.sent.items(), registry=basis.registry), display_activity, - unread=self.bus.pending_counts_opened( + goals=self.goals, unread=self.bus.pending_counts_opened( source, basis, conversations=basis.registry.threads, ), channel_unread=display_unread, visibility=visibility, read_marker_notice=basis.document.notice, @@ -435,13 +435,13 @@ def list_threads(self, active_only: bool = False) -> tuple[ThreadRecord, ...]: if not active_only or snapshot.status(name).active) pending = CountedRead().counts(self.bus, tuple(thread.name for thread in threads)) return ThreadRecord.read( - snapshot, self.agents, GoalWaits.at(self.root).read(), threads, pending) + snapshot, self.agents, self.goals, threads, pending) def thread_detail(self, name: str, read: InboxRead) -> ThreadRecord: thread = self.registry.require(name) pending = read.counts(self.bus, (thread.name,)) record, = ThreadRecord.read(self.registry.snapshot(), self.agents, - GoalWaits.at(self.root).read(), (thread,), pending) + self.goals, (thread,), pending) return record def poll(self, target: PollTarget) -> ThreadPoll: diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index aee5e55030..65cbe77aa9 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -40,8 +40,9 @@ WireInline, WireOmitDefault, ) -from .goal_presentation import ThreadGoalExecution +from .goal_presentation import GoalExecution from .goal_waits import GoalWaits +from .goal_attempts import GoalAttemptStore from .mentions import MentionCandidate from .message_page import ( NEWEST_PAGE, @@ -94,7 +95,7 @@ from .catalog_document import CatalogDocument from .catalog_store import ChannelCatalog from .comms import Comms - from .goal_waits import RecordedGoalWait + from .goal_management import Goals from .history_views import HistoryViews from .message_bus import MessageBus from .messages import Message @@ -430,12 +431,13 @@ def reading(self, presentation, target, viewer): class BusPresentation: def __init__( self, bus: MessageBus, registry: Registration, catalog: ChannelCatalog, - agents: AgentActivity, + agents: AgentActivity, goals: Goals, ): self.bus = bus self.registry = registry self.catalog = catalog self.agents = agents + self.goals = goals self._path = bus.log.path self._wire_lock_path = bus.log.path.parent / "wire" self._display_indexes: dict[str, BusDisplayIndex] = {} @@ -463,7 +465,7 @@ def roster( ) as basis: snapshot = CoordinationSnapshot.capture( self._path.parent, basis.registry, basis.catalog, self.agents, basis.channels, - {}, {}, unread={}, channel_unread={}, visibility=visibility, + {}, {}, goals=self.goals, unread={}, channel_unread={}, visibility=visibility, read_marker_notice=basis.document.notice, ) return replace( @@ -610,24 +612,25 @@ class ThreadRecord(CliResult): thread: Annotated[Thread, WireInline()] status: Annotated[ThreadStatus, ThreadStatusName] activity: Annotated[ActivityData, PresenceExclude()] - goal_execution: ThreadGoalExecution + goal_execution: GoalExecution pending: Annotated[InboxCount, WireOmitDefault()] = field( default=UncountedInbox(), kw_only=True ) @classmethod def read(cls, snapshot: RegistrySnapshot, agents: AgentActivity, - waits: dict[str, RecordedGoalWait], threads: Iterable[Thread], + goals: Goals, threads: Iterable[Thread], pending: Mapping[str, InboxCount]) -> tuple[ThreadRecord, ...]: """Only active threads are observed; the rest show the absent activity.""" threads = tuple(threads) + waits = goals.waits.read() observed = agents.all_activity( (thread for thread in threads if snapshot.status(thread.name).active), snapshot=snapshot) return tuple(cls( thread, snapshot.status(thread.name), observed.get(thread.name, Activity.absent(thread.name)), - GoalWaits.execution(thread.goal, waits, snapshot, agents), + goals.snapshot_for(thread, snapshot, waits).execution, pending=pending[thread.name], ) for thread in threads) @@ -658,18 +661,18 @@ class ThreadView(ThreadRecord): @classmethod def roster( cls, snapshot: RegistrySnapshot, agents: AgentActivity, - goal_waits: GoalWaits, + goals: Goals, visibility: RosterVisibility, *, names: Iterable[str] | None = None, ) -> tuple[ThreadView, ...]: threads = tuple(snapshot.visible_threads(visibility, names=names)) if not threads: return () runtime = agents.runtime_info.for_threads(thread.name for thread in threads) - waits = goal_waits.read() + waits = goals.waits.read() activities = agents.all_activity(threads, snapshot=snapshot) return tuple(cls( thread, snapshot.status(thread.name), activities[thread.name], - GoalWaits.execution(thread.goal, waits, snapshot, agents), runtime[thread.name], + goals.snapshot_for(thread, snapshot, waits).execution, runtime[thread.name], snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) @@ -727,12 +730,12 @@ def capture( cls, root: Path, registry: RegistrySnapshot, catalog: CatalogDocument, agents: AgentActivity, declarations: Channels, sent: Mapping[str, float], messages: Mapping[str, ChannelActivity], *, - unread: Mapping[str, int], channel_unread: Mapping[str, int], + goals: Goals, unread: Mapping[str, int], channel_unread: Mapping[str, int], visibility: RosterVisibility, read_marker_notice: ReadNotice = NoReadNotice(), ) -> CoordinationSnapshot: """Assemble both rosters from the caller's acquired identity and read cut.""" threads = OrderedDict((view.thread.name, view) for view in ThreadView.roster( - registry, agents, GoalWaits.at(root), visibility, + registry, agents, goals, visibility, )) activities = {name: view.activity for name, view in threads.items()} return cls( @@ -814,8 +817,9 @@ def room_ledger_paths(views: HistoryViews) -> tuple[Path, ...]: return RoomLedger.source_paths(views.registry.storage_rule.room_ledger.root) -def goal_wait_paths(views: HistoryViews) -> tuple[Path, ...]: - return GoalWaits.source_paths(views.root) +def goal_execution_paths(views: HistoryViews) -> tuple[Path, ...]: + return (*GoalWaits.source_paths(views.root), + *GoalAttemptStore.source_paths(views.root / GoalAttemptStore.root_directory)) def relationship_paths(views: HistoryViews) -> tuple[Path, ...]: @@ -889,9 +893,9 @@ class WireRevision(ReadState): lambda views: (views.transcript_reads.index_path,), StoresView, SidebarView ), ] - goal_waits: Annotated[ + goal_execution: Annotated[ tuple[FileRevision, ...], - *revision_source(goal_wait_paths, *STATUS_SOURCE), + *revision_source(goal_execution_paths, *STATUS_SOURCE), ] relationships: Annotated[ tuple[FileRevision, ...], @@ -973,8 +977,8 @@ def changed_threads(self, previous: ThreadsObserved, comms: Comms, changed.update(alias for alias, target in snapshot.aliases.items() if target in changed) activity_changed = {name for name in activity if activity[name] != previous.activity.get(name)} - status_names = names if (self.goal_waits, self.expiry_period, self.work_room) != ( - earlier.goal_waits, earlier.expiry_period, earlier.work_room) else names & activity_changed + status_names = names if (self.goal_execution, self.expiry_period, self.work_room) != ( + earlier.goal_execution, earlier.expiry_period, earlier.work_room) else names & activity_changed if status_names: status_batch = comms.views.thread_presentations(status_names) changed.update(name for name in status_names diff --git a/src/agent_comms/schedule_rules.py b/src/agent_comms/schedule_rules.py index 59c966d05f..8491bb0cd6 100644 --- a/src/agent_comms/schedule_rules.py +++ b/src/agent_comms/schedule_rules.py @@ -111,20 +111,7 @@ async def run(self) -> None: dependency_wait_id=pending[0].goal_wait_id, ) except RequestError as error: - try: - started_goal = goal.require() - except RelationViolationError as missing: - raise error from missing pending[0].require_dependency_wake(error) - await Coordination.run_worker(partial(inputs.comms.goals.block_goal_after_failed_turn, - owner.name, - started_goal=started_goal, - expected_worktree=owner.worktree, - diagnostic=( - "Standby wake launch authority unavailable; inspect the UNKNOWN " - "input before explicit Retry. No input was replayed." - ), - )) await inputs.effects.turns.goals.sync_goal_execution(session_id, owner.name) finally: inputs.effects.turns.turn_tasks.pop(session_id, None) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 08b0add04b..947bc5c90e 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -72,7 +72,7 @@ TransitionGoalAction, ) from .goal_history import RecordedGoalRevision -from .goal_presentation import GoalSnapshot, ThreadGoalExecution +from .goal_presentation import GoalSnapshot, GoalExecution from .goal_waits import NoStandbyReview, StandbyReview from .goals import Goal, GoalDeclaration, OrderCheck from .message_bus import HeldBusWrite @@ -221,7 +221,7 @@ class GoalSet(OwnGoalPublication): @dataclass(frozen=True) class GoalProgress(OwnGoalPublication, GoalSnapshot): - execution: Annotated[ThreadGoalExecution, WireName("goal_execution")] + execution: Annotated[GoalExecution, WireName("goal_execution")] @dataclass(frozen=True) diff --git a/src/agent_comms/turn_goal_account.py b/src/agent_comms/turn_goal_account.py index 97ecd83894..d0f2aeabd1 100644 --- a/src/agent_comms/turn_goal_account.py +++ b/src/agent_comms/turn_goal_account.py @@ -38,6 +38,18 @@ class VerifiedGoalSettlement(MroDispatch): goal: Goal turn: TurnId + @classmethod + def settle_current(cls, account: TurnGoalAccount) -> None: + comms, owner = account.comms, account.owner + with comms.registry.store.locked(): + current = comms.registry.require(account.thread_name) + if current.incarnation != owner.incarnation or current.worktree != owner.worktree: + return + for goal in owner.goal.continued_by(current.goal): + store = comms.goals.open_goal_store() + generation = store.require_generation(goal.id) + generation.lifecycle.verified_turn(store, generation, f"verified-turn:{account.turn.value}") + @property def progress_witness(self) -> str: return f"native-terminal:{self.turn.value}" @@ -67,17 +79,6 @@ def completion_witness(self) -> str: return f"origin-completed:{self.goal.revision}" -@dataclass(frozen=True) -class FailedGoalOrigin(MroDispatch): - comms: Comms - thread_name: str - goal: Goal - - @handles(ActiveGoal, CompletedGoal) - def block(self, state) -> None: - self.comms.goals.block_failed_origin(self.thread_name, self.goal) - - @dataclass(kw_only=True) class TurnGoalAccount: comms: Comms @@ -98,10 +99,7 @@ def reserve(*, owner: Thread, goal: Goal, comms: Comms, autonomous: bool) -> Goa claim_scope = comms.registry.snapshot().claim_generation(owner.name) with _store_lock(comms._wire_lock_path): generation = store.require_generation(goal.id) - generation.lifecycle.require_ready() - grant = comms.goals.ready_goal_grant_locked(owner, claim_scope, generation) - reservation = store.reserve(goal.id, generation.number, ready_grant=grant) - return store.claim_launch(reservation) + return generation.lifecycle.turn_permit(store, comms.goals, owner, claim_scope, generation) except GoalAttemptError as error: if autonomous: return NoLaunchPermit() @@ -159,18 +157,6 @@ def _record_origin_usage(self, permit: LaunchPermit) -> None: self.unattributed_usage.clear() self.pending_origins[self.thread_name] = permit.reservation.goal_id - def block_active_goal(self, diagnostic: str) -> None: - current = self.comms.registry.require(self.thread_name).continuation_goal(self.owner) - current.block_after_failed_turn( - self.comms.goals, self.thread_name, self.owner.worktree, diagnostic, - ) - - def require_output(self, text: str) -> None: - if not (self.has_productive_tool or text.strip()): - self.permit.block(self, - "Backend reported success without assistant output " - "or tool activity; inspect the session before resuming.") - def settle_launched(self, permit: LaunchPermit, terminal: TurnTerminal) -> None: store = self.comms.goals.open_goal_store() captured = self.owner.goal.require().require_id(permit.reservation.goal_id) @@ -179,6 +165,7 @@ def settle_launched(self, permit: LaunchPermit, terminal: TurnTerminal) -> None: def finish(self, terminal: TurnTerminal) -> None: """Settle all original grants once from the actual producer terminal event.""" + terminal.settle_current(self) self.permit.settle(self, terminal) for goal_id, permit in self.originated.items(): current = self.comms.registry.require(self.thread_name).goal.matching(goal_id) diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 7769e426ba..b7c9859e0e 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -26,6 +26,7 @@ from .child_process import ProcessState from .registry_document import RegistrySnapshot from .threads import Thread + from .goal_presentation import GoalExecution, GoalExecutionSource class TurnDeclaration(SubclassUnion, affix=""): @@ -35,6 +36,9 @@ class TurnDeclaration(SubclassUnion, affix=""): id: str | None started_at: float | None + @abstractmethod + def execution(self, source: GoalExecutionSource) -> GoalExecution: ... + def project_activity(self, activity: ObservedActivity) -> ObservedActivity: return self.phase.project_activity(activity) @@ -88,6 +92,9 @@ class NoTurn(NullMember, TurnDeclaration): executing = False report_turn = "" + def execution(self, source: GoalExecutionSource) -> GoalExecution: + return source.generation.lifecycle.idle_execution(source) + def require_active(self) -> ActiveTurn: raise RelationViolationError("registry owner has no active turn") @@ -211,6 +218,12 @@ class ActiveTurn(RecordMember, TurnDeclaration): phase: TurnPhase = field(default_factory=PreparingPhase) executing = True + def execution(self, source: GoalExecutionSource) -> GoalExecution: + from .goal_presentation import RunningExecution + if source.owner_alive and source.owner.process_identity.owns_turn(self): + return RunningExecution(source.goal.id) + return source.generation.lifecycle.idle_execution(source) + @property def report_turn(self) -> str: return self.id diff --git a/src/agent_comms/turn_progress.py b/src/agent_comms/turn_progress.py index 03688f196c..578dd5088c 100644 --- a/src/agent_comms/turn_progress.py +++ b/src/agent_comms/turn_progress.py @@ -202,12 +202,14 @@ async def done(self, event: events.Done) -> events.Done: ok=False, text=("An identified follow-up input was not started; inspect local diagnostics."), ) + if event.ok and not (event.text.strip() or self.goals.has_productive_tool): + event = replace(event, ok=False, + text="Backend reported success without assistant output or tool activity.") event = self.turn.terminal.reported(event) event = event.resolve(self.terminal_error, document, self.original) # Retain the resolved outcome before transport publication. If delivery # throws, failure handling must not classify or resolve it a second time. self.turn.terminal = event - await Coordination.run_worker(partial(event.observe_goal, self.goals)) return event @handles(events.InputStarted) diff --git a/src/agent_comms/turn_terminal.py b/src/agent_comms/turn_terminal.py index 2a683b54e6..26e290e701 100644 --- a/src/agent_comms/turn_terminal.py +++ b/src/agent_comms/turn_terminal.py @@ -10,7 +10,6 @@ from .diagnostics import FailureReason, terminal_failure_reason from .goal_failure_observation import FailedTurnObservation from .goal_attempts import StaleAttemptError -from .turn_goal_account import FailedGoalOrigin if TYPE_CHECKING: from .agent_events import Done @@ -34,9 +33,6 @@ def from_cancellation(error: asyncio.CancelledError) -> TurnTerminal: def interrupted(self, terminal: TurnTerminal) -> TurnTerminal: return self - def observe_goal(self, account: TurnGoalAccount) -> None: - pass - def reported(self, event: Done) -> Done: return event @@ -51,14 +47,15 @@ async def report_failure(self, progress: TurnProgress, error: Exception): detail = f"Native turn failed: {type(cause).__name__}: {cause}" return (await progress.record_failure(detail)).outcome + def settle_current(self, account: TurnGoalAccount) -> None: + pass + @abstractmethod def settle_launched(self, account, permit, store, captured, current) -> None: ... def settle_origin(self, account, permit, store, goal_id, current) -> None: if not permit.has_verified_progress(store): permit.fail_origin(store, goal_id) - for goal in current: - FailedGoalOrigin(account.comms, account.thread_name, goal).dispatch_sync(goal.state) class FailedGoalTurn(TurnTerminal): @@ -88,13 +85,9 @@ def settle_launched(self, account, permit, store, captured, current) -> None: observation = self.failure_observation(account, permit, captured) with suppress(StaleAttemptError): permit.reservation.fail( - store, "Goal turn ended without verified terminal progress.", + store, self.failure_reason.value, observation=observation, ) - current.block_after_failed_turn( - account.comms.goals, account.thread_name, account.owner.worktree, - "Goal turn ended without verified terminal progress.", - ) class EndedWithoutResult(FailedGoalTurn): @@ -105,10 +98,6 @@ def failure_reason(self) -> FailureReason: def interrupted(self, terminal: TurnTerminal) -> TurnTerminal: return terminal - def settle_launched(self, account, permit, store, captured, current) -> None: - account.block_active_goal("Backend turn ended without a result; inspect local diagnostics.") - super().settle_launched(account, permit, store, captured, current) - class OperatorStopped(TurnTerminal): def settle_launched(self, account, permit, store, captured, current) -> None: diff --git a/src/agent_comms/ui_model/goal.py b/src/agent_comms/ui_model/goal.py index 82d3d93b62..aea6e72475 100644 --- a/src/agent_comms/ui_model/goal.py +++ b/src/agent_comms/ui_model/goal.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: - from agent_comms.goal_presentation import ThreadGoalExecution + from agent_comms.goal_presentation import GoalExecution from agent_comms.goals import GoalDeclaration @@ -25,13 +25,13 @@ def snapshot(self) -> GoalDeclaration: ... def visible(self) -> bool: ... @abstractmethod - def heading(self, execution: ThreadGoalExecution) -> str: ... + def heading(self, execution: GoalExecution) -> str: ... - def details_heading(self, execution: ThreadGoalExecution) -> str: + def details_heading(self, execution: GoalExecution) -> str: return self.heading(execution) @abstractmethod - def standby(self, execution: ThreadGoalExecution) -> bool: ... + def standby(self, execution: GoalExecution) -> bool: ... @abstractmethod def action_failure(self, error: Exception) -> str: ... @@ -49,11 +49,11 @@ def snapshot(self) -> GoalDeclaration: visible: ClassVar[bool] = True - def standby(self, execution: ThreadGoalExecution) -> bool: + def standby(self, execution: GoalExecution) -> bool: return False def action_failure(self, error: Exception) -> str: return str(error) - def heading(self, execution: ThreadGoalExecution) -> str: + def heading(self, execution: GoalExecution) -> str: return self.last_confirmed.unavailable_heading From 61070a8ba7940acbae9c776eac5373ccf5250994 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 15:28:19 -0400 Subject: [PATCH 02/11] Make owner stop win at settlement and derive commander goal blocks --- src/agent_comms/acp.py | 4 +- src/agent_comms/commander/standing.py | 71 ++++++--------------------- src/agent_comms/goal_presentation.py | 39 ++++----------- src/agent_comms/owned_turn.py | 5 +- src/agent_comms/turn_runner.py | 4 +- src/agent_comms/turn_terminal.py | 22 ++++++--- src/agent_comms/worker.py | 3 +- 7 files changed, 48 insertions(+), 100 deletions(-) diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index 730bfdb4ea..571e07353a 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -50,7 +50,7 @@ ) from .acp_failure import NativeTurnError from .cohort_foreground import _accept_visible_deliveries -from .turn_terminal import EndedWithoutResult, TurnTerminal +from .turn_terminal import EndedWithoutResult, TurnInterruption, TurnTerminal from .comms import Comms, wire from .coordination_cohort import next_sealed_assignment from .coordination_database import CoordinationStore @@ -102,6 +102,7 @@ def __init__( use_unstable_protocol: bool = False, auto_wake: bool = True, private_selected_tool_intent: SelectedToolIntent | None = None, + owner_stop: TurnInterruption = EndedWithoutResult(), ): self._comms = comms if private_selected_tool_intent is not None: @@ -126,6 +127,7 @@ def __init__( self, agent_bin=agent_bin, agent_args=agent_args, + owner_stop=owner_stop, ) self.sessions = self.session_lifecycle_class( comms, self.turns.agent_bin, self.turns.agent_args, self._runtime, runtime_enabled, self diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 8456ed193c..8324e6b364 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -12,11 +12,9 @@ from agent_comms.build_identity import RecordedBuildIdentity from agent_comms.comms import Comms -from agent_comms.goal_actions import GoalPrecondition, ReissuedOrderGoalAction +from agent_comms.goal_actions import ReissuedOrderGoalAction from agent_comms.goals import Goal -from agent_comms.goal_states import BlockedState -from agent_comms.goal_history import GoalHistoryStore -from agent_comms.goal_generation import GenerationState, RetryableGeneration +from agent_comms.goal_presentation import BlockedExecution from agent_comms.message_page import MessageReadRequest from agent_comms.message_reference import MessageReference from agent_comms.messages import Message @@ -119,67 +117,26 @@ def line(self) -> str: @dataclass(frozen=True) -class BlockedHolder(HolderCannotRun, declared_name=None): - """An idle holder classified by its goal and original attempt ledger.""" +class GoalCannotRun(UndatedStandingRule): + """An ordered unit whose goal execution supplies the block and its action.""" - block_record: Path - goal: Goal - - @staticmethod - @abstractmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: ... - - @classmethod - def stalled(cls, comms: Comms, thread: Thread) -> bool: - store = comms.goals.open_goal_store() - return not thread.executing and any( - cls.accepts(goal, store.require_generation(goal.id).lifecycle) - for goal in thread.goal) + unit: str + orders: tuple[str, ...] + execution: BlockedExecution @classmethod - def found(cls, comms: Comms, thread: Thread, orders: tuple[str, ...]) -> Self: - return cls( - thread.name, orders, GoalHistoryStore.path_in(comms.root), thread.goal.require() + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + return tuple( + cls(owner.name, tuple(todo.id for todo in todos), block) + for owner, todos in cls.holders(comms, commander).items() + for block in comms.goals.goal_execution(owner.name).unit_blocks() ) - -@dataclass(frozen=True) -class UnresolvedBlock(BlockedHolder): - """An attempt with an unknown outcome: Core never retries it, so it is never replayed.""" - - @staticmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: - return not isinstance(generation, RetryableGeneration) - - def line(self) -> str: - return (f"UNRESOLVED BLOCK {self.unit}: orders {', '.join(self.orders)}; " - f"goal {self.goal.id}@{self.goal.revision} has an attempt with an unknown result " - "-> inspect it, then fail or finish the attempt") - - -@dataclass(frozen=True) -class Blocked(BlockedHolder): - @staticmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: - return isinstance(goal.state, BlockedState) and isinstance(generation, RetryableGeneration) - def enforce(self, comms: Comms, commander: str) -> None: - observed = self.goal - for problem in self.orders: - todo = comms.goals.todos.get(problem) - todo.require_assignee(comms.registry.require(self.unit).incarnation, observed.id) - action = UnownedWheelSpin.order_for(todo, commander, CoreWatcherIncarnation.watcher()) - observed = comms.goals.update_goal(self.unit, replace( - action, expect=GoalPrecondition( - goal_id=observed.id, expected_state=observed.state, - expected_checkpoint=observed.captured, - ), - )) + self.execution.enforce_unit_block(comms, commander, self.unit, self.orders) def line(self) -> str: - return (f"BLOCKED {self.unit}: orders {', '.join(self.orders)}; " - f"block record {self.block_record}, goal {self.goal.id}@{self.goal.revision} " - "-> Core reissues the orders") + return self.execution.unit_block_line(self.unit, self.orders) @dataclass(frozen=True) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 871d0e507f..0bffb803d9 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from dataclasses import asdict, dataclass, fields +from dataclasses import asdict, dataclass, fields, replace from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Self @@ -73,7 +73,6 @@ def execution(self) -> GoalExecution: class GoalExecution(SubclassUnion, affix="Execution"): """The complete execution family consumed by commands, views and the commander.""" - terminal: ClassVar[bool] = False toggle_label: ClassVar[str] = "Pause" @property @@ -92,11 +91,8 @@ def standby(self) -> bool: def waiting_for(self, goal: Goal) -> bool: return False - def unit_blocks(self) -> bool: - return False - - def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: - pass + def unit_blocks(self) -> tuple[BlockedExecution, ...]: + return () @abstractmethod def validate_goal(self, goal: GoalCheckpoint) -> None: ... @@ -121,8 +117,8 @@ class BoundGoalExecution(RecordMember, GoalExecution, declared_name=None): label: ClassVar[str] @property - @abstractmethod - def summary(self) -> str: ... + def summary(self) -> str: + return self.label def presentation(self, title: str) -> ThreadPresentation: return ThreadPresentation(title, self.glyph, self.summary) @@ -143,10 +139,6 @@ def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentati class RunnableExecution(BoundGoalExecution): label = "Active" - @property - def summary(self) -> str: - return "Active" - @property def toggle(self) -> type[GoalAction]: from .goal_actions import PausedGoalAction @@ -157,20 +149,10 @@ def toggle(self) -> type[GoalAction]: class RunningExecution(RunnableExecution): label = "Running" - @property - def summary(self) -> str: - return "Running" - @dataclass(frozen=True) class CompletedExecution(BoundGoalExecution): - terminal = True label = "Completed" - toggle_label = "Completed" - - @property - def summary(self) -> str: - return "Completed" @dataclass(frozen=True) @@ -179,10 +161,6 @@ class PausedExecution(WaitingExecution): label = "Paused" toggle_label = "Resume" - @property - def summary(self) -> str: - return "Paused" - @property def toggle(self) -> type[GoalAction]: from .goal_actions import ActiveGoalAction @@ -238,8 +216,8 @@ def summary(self) -> str: summary = reason[:157] + "…" if len(reason) > 160 else reason return f"Blocked · {summary}" - def unit_blocks(self) -> bool: - return True + def unit_blocks(self) -> tuple[BlockedExecution, ...]: + return (self,) def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: return (f"BLOCKED {unit}: orders {', '.join(orders)}; goal {self.goal_id}; " @@ -249,8 +227,9 @@ def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str from .commander.standing import UnownedWheelSpin from .goal_actions import GoalPrecondition from .thread_identity import CoreWatcherIncarnation - from dataclasses import replace + if comms.goals.goal_execution(unit) != self: + return observed = comms.registry.require(unit).goal.require_id(self.goal_id) for problem in orders: todo = comms.goals.todos.get(problem) diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 59d64a0a51..2ca4e3453b 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -319,7 +319,7 @@ def open_stream(self, resources: AsyncExitStack, permits: ExitStack): resources.push_async_callback(self.close_changed_project) def finish_goals(self) -> None: - self.goals.finish(self.terminal) + self.goals.finish(self.runner.owner_stop.interrupting(self.terminal)) async def close_changed_project(self) -> None: name = await self.runner.sessions.sync_identity(self.session_id) @@ -412,8 +412,7 @@ async def run(self) -> None: return await self.run_native() except asyncio.CancelledError as error: - self.terminal = self.terminal.interrupted( - TurnTerminal.from_cancellation(error)) + self.terminal = TurnTerminal.from_cancellation(error).interrupting(self.terminal) raise def settle_unbound(self): diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index d427899858..ad8469c433 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -47,7 +47,7 @@ from .turn_effects import TurnEffects from .turn_lease import TurnLeaseFence, TurnState from .turn_phase import CancellingPhase, TurnPhase -from .turn_terminal import EndedWithoutResult, TurnTerminal +from .turn_terminal import EndedWithoutResult, TurnInterruption, TurnTerminal if TYPE_CHECKING: from . import pi_events as pi @@ -88,10 +88,12 @@ def __init__( *, agent_bin: LaunchBinary, agent_args: LaunchArguments, + owner_stop: TurnInterruption = EndedWithoutResult(), ): self.comms = comms self.runtime = runtime self.effects = effects + self.owner_stop = owner_stop self.agent_bin = agent_bin.configured() self.agent_args = agent_args.configured() self.turn_tasks: dict[str, asyncio.Task[Any]] = {} diff --git a/src/agent_comms/turn_terminal.py b/src/agent_comms/turn_terminal.py index 26e290e701..22406c8b81 100644 --- a/src/agent_comms/turn_terminal.py +++ b/src/agent_comms/turn_terminal.py @@ -18,7 +18,12 @@ from .turn_progress import TurnProgress -class TurnTerminal(ABC): +class TurnInterruption(ABC): + @abstractmethod + def interrupting(self, prior: TurnTerminal) -> TurnTerminal: ... + + +class TurnTerminal(TurnInterruption): """Own interruption and settlement policy, never a nullable result flag.""" @staticmethod @@ -30,8 +35,8 @@ def from_cancellation(error: asyncio.CancelledError) -> TurnTerminal: raise TypeError("Turn cancellation requires a declared terminal") return terminal - def interrupted(self, terminal: TurnTerminal) -> TurnTerminal: - return self + def interrupting(self, prior: TurnTerminal) -> TurnTerminal: + return prior def reported(self, event: Done) -> Done: return event @@ -95,11 +100,11 @@ class EndedWithoutResult(FailedGoalTurn): def failure_reason(self) -> FailureReason: return terminal_failure_reason({}) - def interrupted(self, terminal: TurnTerminal) -> TurnTerminal: - return terminal - class OperatorStopped(TurnTerminal): + def interrupting(self, prior: TurnTerminal) -> TurnTerminal: + return self + def settle_launched(self, account, permit, store, captured, current) -> None: self.settle_origin(account, permit, store, captured.id, current) @@ -111,9 +116,12 @@ async def prepare_publication(self, progress: TurnProgress) -> Done: raise asyncio.CancelledError(self) -class OwnerStop(asyncio.Future[TurnTerminal]): +class OwnerStop(asyncio.Future[TurnTerminal], TurnInterruption): """The worker's existing stop wait carries the operator's actual reason.""" + def interrupting(self, prior: TurnTerminal) -> TurnTerminal: + return self.terminal.interrupting(prior) + def request(self) -> None: if not self.done(): self.set_result(OperatorStopped()) diff --git a/src/agent_comms/worker.py b/src/agent_comms/worker.py index a869e691c1..bae763800a 100644 --- a/src/agent_comms/worker.py +++ b/src/agent_comms/worker.py @@ -33,12 +33,13 @@ async def run_owner(comms: Comms) -> None: async with Coordination.worker(CoordinationStore.path_in(comms.root)): name = ThreadLaunch.current().name thread = await Coordination.run_worker(partial(comms.registry.require, name)) + stopped = OwnerStop() agent = CommsAgent( comms, runtime_enabled=True, agent_bin=GivenBinary(NativeArguments.owner_launcher(LaunchRuntime.running())), + owner_stop=stopped, ) - stopped = OwnerStop() loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, stopped.request) From fe773490dac25d14d9336e5c8bae17554899eaa1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:01:04 -0400 Subject: [PATCH 03/11] Prove dead-owner recovery and retire stored backend goal blocks --- src/agent_comms/attempt_recovery.py | 93 +++++----- src/agent_comms/commander/standing.py | 8 +- src/agent_comms/goal_presentation.py | 9 +- src/agent_comms/native_claim_epoch.py | 9 +- src/agent_comms/owner_lifecycle.py | 4 + tests/test_backend_failure_owner_pause.py | 61 ------ tests/test_goal_answers.py | 14 -- tests/test_goal_block_reason.py | 33 +--- tests/test_goal_execution.py | 138 ++++++++++++++ tests/test_goal_failure_observation.py | 214 +++------------------- tests/test_goal_nominal.py | 9 - tests/test_goal_resume.py | 56 +----- tests/test_goal_standby.py | 12 +- tests/test_holder_standing_rules.py | 51 ++---- tests/test_native_unknown_recovery.py | 92 +++++++--- tests/test_todos.py | 8 +- tools/cutover/carry_goal_execution.py | 59 ++++++ 17 files changed, 391 insertions(+), 479 deletions(-) delete mode 100644 tests/test_backend_failure_owner_pause.py create mode 100644 tests/test_goal_execution.py create mode 100644 tools/cutover/carry_goal_execution.py diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 33fabf8148..47a3c9de92 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -40,7 +40,6 @@ from .coordination_database import CoordinationStore from .native_input_record import FullNativeExecution -from agent_comms.native_claim_epoch import UnrecordedNativeClaim from agent_comms.typed_table import RowNotFoundError from .field_codec import MalformedRecord from .input_disposition import InputDispositions @@ -73,11 +72,11 @@ def observe_native_release( """Compare the recorded native send, released owner and live registry. Keep canonical wire/bus/registry exclusion through monitor settlement. - A later verified release also blocks earlier input generations of the same - owner. If permission to send was never recorded, require the exact - current owner to be attested stopped and dead: absence of an epoch is - not proof that the input was unsent. Lease expiry or a replaced PID alone - is insufficient. + The release records the original process identity (pid and start_time). + That process must be dead and no longer own the registry entry, and its + native subprocesses must have exited. A successor's claim counter or + active turn does not establish the predecessor's lifetime. Missing + input still settles UNKNOWN, never unsent or safe to replay. """ from agent_comms.comms import Comms from agent_comms.coordinated_runtime_schema import assert_native_runtime_schema @@ -113,6 +112,7 @@ def observe_native_release( except (ValueError, IdentityConflict, RelationViolationError) as error: raise RecoveryBlocked(f"native owner release does not prove loss of this claim_scope: {error}") from error try: + proof.require_native_exit() yield proof finally: object.__setattr__(proof, "_active", False) @@ -131,8 +131,39 @@ def owns(self, store: Coordination, attempt: AttemptRecord) -> bool: ) def require_native_exit(self) -> None: + """Both attempt kinds exclude the same native child resources.""" if not self.owns(self._store, self.attempt): raise RecoveryBlocked("native exit observation requires acquired owner loss") + session_files = self.recovery_session_files() + session_dir = ( + self._store.session.path.parent / "native-sessions" / self.attempt.owner_lookup + ) + proc = Path("/proc") + if not proc.is_dir(): + raise RecoveryBlocked("native recovery requires Linux process observation") + for entry in proc.iterdir(): + if not entry.name.isdecimal(): + continue + try: + if entry.stat().st_uid != os.getuid(): + continue + args = (entry / "cmdline").read_bytes().split(b"\0") + except (FileNotFoundError, ProcessLookupError): + continue + except PermissionError as error: + raise RecoveryBlocked("native process observation was denied") from error + for index, argument in enumerate(args[:-1]): + selected = argument == b"--session" and any( + args[index + 1] == os.fsencode(path) for path in session_files + ) + allocated = ( + argument == b"--session-dir" and args[index + 1] == os.fsencode(session_dir) + ) + if selected or allocated: + raise RecoveryBlocked("native session subprocess is still running") + + @abstractmethod + def recovery_session_files(self) -> tuple[Path, ...]: ... @abstractmethod def settle(self, recovery_cls, store: Coordination, execution_id: str, owner) -> None: ... @@ -143,16 +174,11 @@ def require_native_input(self) -> NativeRuntimeInput: ... @dataclass(frozen=True, slots=True) class UndispatchedOwnerLoss(VerifiedOwnerLoss): - """No input row was recorded; exact stopped owner loss still leaves UNKNOWN.""" + """No input row was recorded; original process loss still leaves UNKNOWN.""" - def require_native_exit(self) -> None: - super(UndispatchedOwnerLoss, self).require_native_exit() - release = self.release.require_owner_loss(self.boundary.registry, self.attempt.owner_identity) - current = self.boundary.registry.require(release.name) - try: - UnrecordedNativeClaim().require_release(release, self.boundary.registry, current) - except RelationViolationError as error: - raise RecoveryBlocked(str(error)) from error + def recovery_session_files(self) -> tuple[Path, ...]: + owner = self.boundary.registry.require(self.release.name) + return tuple(files.session for files in owner.runtime.storage_files()) def require_native_input(self) -> NativeRuntimeInput: raise RecoveryBlocked("native attempt has no original input") @@ -187,38 +213,11 @@ def settle(self, recovery_cls, store: Coordination, execution_id: str, owner) -> except RecoveryBlocked: recovery_cls.abandon_released_native_attempt(store, execution_id) - def require_native_exit(self) -> None: - """Observe the recorded input's original launch resources, never a successor's.""" - super(DispatchedOwnerLoss, self).require_native_exit() - session_files = self.native_input.sent_owner_claimed_generation.recovery_session_files( + def recovery_session_files(self) -> tuple[Path, ...]: + """A dispatched input retains its original selected session.""" + return self.native_input.sent_owner_claimed_generation.recovery_session_files( self.native_input, self.boundary.registry.require(self.release.name) ) - session_dir = ( - self._store.session.path.parent / "native-sessions" / self.native_input.owner_lookup - ) - proc = Path("/proc") - if not proc.is_dir(): - raise RecoveryBlocked("native recovery requires Linux process observation") - for entry in proc.iterdir(): - if not entry.name.isdecimal(): - continue - try: - if entry.stat().st_uid != os.getuid(): - continue - args = (entry / "cmdline").read_bytes().split(b"\0") - except (FileNotFoundError, ProcessLookupError): - continue - except PermissionError as error: - raise RecoveryBlocked("native process observation was denied") from error - for index, argument in enumerate(args[:-1]): - selected = argument == b"--session" and any( - args[index + 1] == os.fsencode(path) for path in session_files - ) - allocated = ( - argument == b"--session-dir" and args[index + 1] == os.fsencode(session_dir) - ) - if selected or allocated: - raise RecoveryBlocked("native session subprocess is still running") @dataclass(frozen=True, slots=True, kw_only=True) @@ -395,8 +394,8 @@ def abandon_released_native_attempt( UNKNOWN effects and releases its slot. Input evidences/cursors are never changed. A frozen publication still requires its acknowledgement resolver. - Without a recorded send generation, stop the current owner normally - before calling, then restart it normally for unrelated new work. + A live successor need not stop: loss belongs to the original process, + while its UNKNOWN input and all evidence remain untouched. """ with VerifiedOwnerLoss.observe_native_release(store, execution_id) as loss: snapshot = store.snapshots.get(execution_id) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 8324e6b364..7e30e288c1 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -122,18 +122,20 @@ class GoalCannotRun(UndatedStandingRule): unit: str orders: tuple[str, ...] + goal: Goal execution: BlockedExecution @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: return tuple( - cls(owner.name, tuple(todo.id for todo in todos), block) + cls(owner.name, tuple(todo.id for todo in todos), view.goal.require(), block) for owner, todos in cls.holders(comms, commander).items() - for block in comms.goals.goal_execution(owner.name).unit_blocks() + for view in (comms.goals.goal_snapshot(owner.name),) + for block in view.execution.unit_blocks() ) def enforce(self, comms: Comms, commander: str) -> None: - self.execution.enforce_unit_block(comms, commander, self.unit, self.orders) + self.execution.enforce_unit_block(comms, commander, self.unit, self.orders, self.goal) def line(self) -> str: return self.execution.unit_block_line(self.unit, self.orders) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 0bffb803d9..2d8cac6516 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -223,14 +223,15 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: return (f"BLOCKED {unit}: orders {', '.join(orders)}; goal {self.goal_id}; " f"{self.reason} -> Core reissues the orders") - def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal) -> None: from .commander.standing import UnownedWheelSpin from .goal_actions import GoalPrecondition + from .goals import GoalChangedError from .thread_identity import CoreWatcherIncarnation if comms.goals.goal_execution(unit) != self: - return - observed = comms.registry.require(unit).goal.require_id(self.goal_id) + raise GoalChangedError(comms.registry.require(unit), goal.captured) + observed = goal for problem in orders: todo = comms.goals.todos.get(problem) todo.require_assignee(comms.registry.require(unit).incarnation, observed.id) @@ -269,7 +270,7 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: return (f"UNRESOLVED BLOCK {unit}: orders {', '.join(orders)}; " f"goal {self.goal_id} -> {self.reason}") - def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal) -> None: pass diff --git a/src/agent_comms/native_claim_epoch.py b/src/agent_comms/native_claim_epoch.py index dfc008c069..1ddf2a3c2b 100644 --- a/src/agent_comms/native_claim_epoch.py +++ b/src/agent_comms/native_claim_epoch.py @@ -64,13 +64,12 @@ def record(self, row, db, claimed_generation: int, session: NativeSessionIdentit raise StaleFence("native input claim_scope was already bound") def require_release(self, acknowledgement, snapshot, current) -> None: - current.require_local_process(acknowledgement.require_process()) - current.turn_record.read().require_idle() - if not acknowledgement.current(snapshot, current, acknowledgement.before): - raise RelationViolationError("Unrecorded native send has no exact stopped release") + # No sending claim was recorded. OwnerRelease has already checked the + # original process; no successor claim or turn can prove more about it. + pass def recovery_session_files(self, row, stopped_owner) -> tuple[Path, ...]: - # The exact stopped declaration is held by VerifiedOwnerLoss's registry lock. + # VerifiedOwnerLoss holds the registry boundary while observing resources. return tuple(files.session for files in stopped_owner.runtime.storage_files()) diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index e62bc066bd..eee6015a8c 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -223,10 +223,14 @@ def require_owner_loss(self, snapshot, owner_identity): process = self.require_process() if process.alive(): raise RelationViolationError("Released native owner process is still alive") + if snapshot.status(current.name).active and self.matches(current): + raise RelationViolationError("Released native process is still the registry owner") actual = snapshot.claimed_identity(current.name) released = ClaimedIdentity(self.incarnation(), self.after) if not actual.includes(released): raise RelationViolationError("Native release claim_scope regressed") + if actual == released and not self.matches(current): + raise RelationViolationError("Native release process differs from its original declaration") return self diff --git a/tests/test_backend_failure_owner_pause.py b/tests/test_backend_failure_owner_pause.py deleted file mode 100644 index b68756e349..0000000000 --- a/tests/test_backend_failure_owner_pause.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Owner intent survives failed execution; private attempt fences still fail closed.""" - - -import pytest - -from agent_comms.goal_actions import ( - ActiveGoalAction, - GoalPrecondition, - ModelInvocable, - OwnerInvocable, - PausedGoalAction, - SetGoalAction, -) - - -@pytest.mark.parametrize("attribution", ["model", "runtime"]) -def test_nonowner_pause_does_not_bypass_failure_block(wired, tmp_path, attribution): - wired.registry.declare(wired.registry.new_thread(name='project', tags=frozenset(), worktree=str(tmp_path))) - initial = wired.goals.update_goal("project", SetGoalAction(text="Work")) - if attribution == "model": - # Current goal declarations deny a model pause before any state change. - with pytest.raises(ValueError): - wired.goals.update_goal( - "project", - PausedGoalAction(expect=GoalPrecondition(goal_id=initial.id)), - actor=ModelInvocable, - ) - assert wired.registry.require("project").goal == initial - paused = wired.goals.update_goal( - "project", PausedGoalAction(expect=GoalPrecondition(goal_id=initial.id)) - ) - blocked = wired.goals.block_goal_after_failed_turn( - "project", started_goal=initial, expected_worktree=str(tmp_path), diagnostic="Failed" - ) - assert blocked.state.declared_name == "blocked" - assert blocked.revision == paused.revision + 1 - assert blocked.state.reason == "Failed" - - -def test_stale_owner_pause_does_not_bypass_failure_block(wired, tmp_path): - wired.registry.declare(wired.registry.new_thread(name='project', tags=frozenset(), worktree=str(tmp_path))) - initial = wired.goals.update_goal("project", SetGoalAction(text="Work")) - wired.goals.update_goal( - "project", - PausedGoalAction(expect=GoalPrecondition(goal_id=initial.id)), - actor=OwnerInvocable, - ) - wired.goals.update_goal( - "project", - ActiveGoalAction(expect=GoalPrecondition(goal_id=initial.id)), - actor=OwnerInvocable, - ) - paused = wired.goals.update_goal( - "project", PausedGoalAction(expect=GoalPrecondition(goal_id=initial.id)) - ) - blocked = wired.goals.block_goal_after_failed_turn( - "project", started_goal=initial, expected_worktree=str(tmp_path), diagnostic="Failed" - ) - assert blocked.state.declared_name == "blocked" - assert blocked.revision == paused.revision + 1 - assert blocked.state.reason == "Failed" diff --git a/tests/test_goal_answers.py b/tests/test_goal_answers.py index b4bff8ed40..66455975af 100644 --- a/tests/test_goal_answers.py +++ b/tests/test_goal_answers.py @@ -13,7 +13,6 @@ from agent_comms.goal_actions import ClearGoalAction, SetGoalAction from agent_comms.goal_attempts import ProviderUsageTotal from agent_comms.goal_turn_permit import NoLaunchPermit -from agent_comms.goal_states import BlockedGoal from agent_comms.goals import NoGoal from agent_comms.pi_payloads import PiUsage from agent_comms.thread_identity import TurnId @@ -71,25 +70,12 @@ def test_clear_during_original_or_origin_turn_does_not_block_missing_goal( with goal_turn(comms, original_goal) as account: goal = comms.registry.require("worker").goal.require() comms.goals.update_goal("worker", ClearGoalAction()) - terminal.observe_goal(account) account.finish(terminal) assert comms.registry.require("worker").goal == NoGoal() assert comms.goals.open_goal_store().snapshot(goal.id).lifecycle.terminal assert not account.pending_origins -@pytest.mark.parametrize("terminal", [ - EndedWithoutResult(), events.Done("backend failed", False), events.Done("", True), -]) -def test_unsuccessful_terminal_still_blocks_goal_on_copied_bus(copied_goal_bus, terminal): - comms = copied_goal_bus - with goal_turn(comms, True) as account: - terminal.observe_goal(account) - account.finish(terminal) - state = comms.registry.require("worker").goal.require().state - assert isinstance(state, BlockedGoal) - - @pytest.mark.parametrize("original_goal", [False, True]) @pytest.mark.parametrize("clear_goal", [False, True]) def test_provider_usage_follows_the_declared_goal(copied_goal_bus, original_goal, clear_goal): diff --git a/tests/test_goal_block_reason.py b/tests/test_goal_block_reason.py index 42ff88a8da..529b9aaf07 100644 --- a/tests/test_goal_block_reason.py +++ b/tests/test_goal_block_reason.py @@ -16,6 +16,7 @@ SetGoalAction, ) from agent_comms.goal_states import UnrecordedBlockGoal +from agent_comms.goal_presentation import PausedExecution, UnrecordedBlockExecution from agent_comms.goals import Goal from agent_comms.tools import ToolRequest from agent_comms.tool_caller import ThreadCaller @@ -64,21 +65,6 @@ def test_missing_or_blank_block_reason_rejects_without_side_effects(tmp_path, mo assert (tmp_path / "registry.json").read_bytes() == before_registry -def test_automatic_blocks_record_diagnostic_not_prior_progress(tmp_path): - comms, started = _owner(tmp_path) - progressed = comms.goals.update_goal("worker", ActiveGoalAction(progress="Verified step")) - assert progressed is not None - blocked = comms.goals.block_goal_after_failed_turn( - "worker", - started_goal=started, - expected_worktree=str(tmp_path), - diagnostic="Attempt outcome uncertain; inspect before Retry.", - ) - assert blocked is not None - assert blocked.progress == "Verified step\n\nAttempt outcome uncertain; inspect before Retry." - assert blocked.state.reason == "Attempt outcome uncertain; inspect before Retry." - assert wire(tmp_path).goals.goal_execution("worker").block_reason == blocked.state.reason - def test_unrecorded_block_is_labeled_unavailable_not_inferred_from_progress(tmp_path): comms, goal = _owner(tmp_path) thread = comms.registry.require("worker") @@ -87,13 +73,12 @@ def test_unrecorded_block_is_labeled_unavailable_not_inferred_from_progress(tmp_ ) comms.registry.register(replace(thread, goal=recorded), comms.registry.status("worker")) observed = wire(tmp_path).goals.goal_execution("worker") - assert observed is not None - assert observed.block_reason is None + assert isinstance(observed, UnrecordedBlockExecution) assert observed.presentation("worker").summary == "Blocked · reason unavailable" assert "Unrelated old progress" not in observed.presentation("worker").summary -def test_owner_resume_refusal_persists_bounded_reason_and_prior_progress(tmp_path): +def test_owner_resume_refusal_preserves_pause_and_prior_progress(tmp_path): comms, original = _owner(tmp_path) comms.registry.declare( @@ -109,10 +94,6 @@ def test_owner_resume_refusal_persists_bounded_reason_and_prior_progress(tmp_pat reservation = store.reserve(original.id, 1) reservation.fail(store, "Previous goal attempt failed") - refusal = ( - "The interrupted goal attempt is unresolved. Inspect it, then use " - "Retry to authorize a new attempt. Your messages can still be sent." - ) with pytest.raises(ValueError): comms.goals.update_goal( "worker", @@ -122,11 +103,9 @@ def test_owner_resume_refusal_persists_bounded_reason_and_prior_progress(tmp_pat actor=OwnerInvocable, ) reloaded = wire(tmp_path).registry.require("worker").goal - assert reloaded is not None and reloaded.state.declared_name == "blocked" - # Prior progress is retained; the refusal is stored separately as the reason. + assert reloaded == progressed assert reloaded.progress == "Half verified by the owner" - assert reloaded.state.reason == refusal execution = comms.goals.goal_execution("worker") - assert execution is not None and execution.block_reason == refusal - assert "Blocked · The interrupted goal attempt" in execution.presentation("worker").summary + assert isinstance(execution, PausedExecution) + assert execution.source == progressed.state.source assert comms.goals.goal_history("worker", goal_id=original.id)[-1].after == reloaded diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py new file mode 100644 index 0000000000..7af5d7ef59 --- /dev/null +++ b/tests/test_goal_execution.py @@ -0,0 +1,138 @@ +"""Real turn acquisition, terminal handling and cutover share the execution owner.""" +import os +import sqlite3 +from contextlib import AsyncExitStack, asynccontextmanager, closing +from dataclasses import replace + +import pytest + +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, SetGoalAction +from agent_comms.goal_failure_observation import FailedTurnEvidence +from agent_comms.goal_presentation import AttemptFailedExecution, RunnableExecution, RunningExecution +from agent_comms.goal_states import ActiveGoal, BlockedGoal +from agent_comms.owned_turn import OwnedTurn +from agent_comms.turn_terminal import OwnerStop +from cutover.carry_goal_execution import BackendGoalBlocks, FAILED_BACKEND_REASON +from test_goal_answers import copied_goal_bus + + +@pytest.fixture +def execution_bus(copied_goal_bus): + comms = copied_goal_bus + comms.registry.declare(comms.registry.new_thread( + "worker", frozenset(), str(comms.root.parent), + process_identity=ProcessIdentity.capture(os.getpid()), + )) + comms.goals.update_goal("worker", SetGoalAction(text="Repair original work")) + return comms + + +@asynccontextmanager +async def turn_on(comms, *, original_owner_input=False): + agent = CommsAgent(comms, auto_wake=False, owner_stop=OwnerStop()) + owner = comms.registry.require("worker") + session = "goal-execution-session" + await agent.sessions.bind_owned(owner, session) + turn = OwnedTurn( + agent.turns, session, owner.name, "Continue original work", + original_owner_input=original_owner_input, original_goal_id=owner.goal.require().id, + ) + async with AsyncExitStack() as resources, AsyncExitStack() as permits: + assert await turn.acquire(resources, permits) + turn.open_stream(resources, permits) + yield turn + + +def failure_count(comms): + with closing(sqlite3.connect(comms.goals.open_goal_store().path)) as db: + return FailedTurnEvidence.count(db) + + +@pytest.mark.asyncio +async def test_failed_turn_writes_ledger_once_and_reads_failed_execution(execution_bus): + comms = execution_bus + goal = comms.registry.require("worker").goal.require() + async with turn_on(comms) as turn: + await turn.progress.done(events.Done("backend failed", False)) + turn.finish_goals() + turn.finish_goals() + assert failure_count(comms) == 1 + assert comms.registry.require("worker").goal == goal + assert isinstance(comms.goals.goal_execution("worker"), RunningExecution) + execution = comms.goals.goal_execution("worker") + assert isinstance(execution, AttemptFailedExecution) + assert execution.evidence.turn_id == turn.turn_id + assert failure_count(comms) == 1 + assert comms.registry.require("worker").goal.state == ActiveGoal() + + +@pytest.mark.asyncio +async def test_provider_retry_during_live_turn_writes_no_goal_failure(execution_bus): + comms = execution_bus + goal = comms.registry.require("worker").goal.require() + async with turn_on(comms) as turn: + store = comms.goals.open_goal_store() + before = store.snapshot(goal.id) + await turn.progress.done(events.Done("provider is retrying", False)) + await turn.progress.tool_ended(events.ToolEnd("read", "read", True, "source read")) + assert store.snapshot(goal.id) == before + assert failure_count(comms) == 0 + assert comms.registry.require("worker").goal == goal + assert isinstance(comms.goals.goal_execution("worker"), RunningExecution) + + +@pytest.mark.asyncio +async def test_owner_stop_beats_backend_failure_and_next_turn_runs_without_retry(execution_bus): + comms = execution_bus + async with turn_on(comms) as turn: + await turn.progress.done(events.Done("dying backend", False)) + turn.runner.owner_stop.request() + # A new reader observes the same retained bus, without a Retry or goal write. + relaunched = Comms(comms.root) + assert isinstance(relaunched.goals.goal_execution("worker"), RunnableExecution) + assert relaunched.registry.require("worker").goal.state == ActiveGoal() + assert failure_count(relaunched) == 0 + async with turn_on(relaunched) as next_turn: + assert isinstance(relaunched.goals.goal_execution("worker"), RunningExecution) + await next_turn.progress.tool_ended(events.ToolEnd("read", "read", True, "source read")) + await next_turn.progress.done(events.Done("Continued without Retry", True)) + assert isinstance(relaunched.goals.goal_execution("worker"), RunnableExecution) + assert failure_count(relaunched) == 0 + + +@pytest.mark.asyncio +async def test_verified_productive_turn_recovers_blocked_generation(execution_bus): + comms = execution_bus + async with turn_on(comms) as failed: + await failed.progress.done(events.Done("backend failed", False)) + assert isinstance(comms.goals.goal_execution("worker"), AttemptFailedExecution) + async with turn_on(comms, original_owner_input=True) as productive: + await productive.progress.tool_ended(events.ToolEnd("read", "read", True, "source read")) + await productive.progress.done(events.Done("Verified productive progress", True)) + 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_goal_failure_observation.py b/tests/test_goal_failure_observation.py index ee137d7b4e..030dee5af4 100644 --- a/tests/test_goal_failure_observation.py +++ b/tests/test_goal_failure_observation.py @@ -1,34 +1,19 @@ -"""Passive observations never reopen attempts, inputs, or owner execution.""" +"""Failure evidence is passive; only its fenced attempt owns terminal writes.""" import json -import multiprocessing import os import sqlite3 -from contextlib import contextmanager from dataclasses import replace import pytest from agent_comms.child_process import ProcessIdentity from agent_comms.diagnostics import FailureReason -from agent_comms.goal_attempts import ( - AttemptRecord, - GoalAttemptStore, - StaleAttemptError, - UnresolvedAttemptError, -) -from agent_comms.goal_failure_observation import FailedTurnObservation, read_failed_turn_projection -from agent_comms.goal_generation import BlockedGeneration, ReservedGeneration -from agent_comms.goal_states import BlockedGoal, ModelPause, OwnerPause, PausedGoal +from agent_comms.goal_attempts import AttemptRecord, GoalAttemptStore, StaleAttemptError, UnresolvedAttemptError +from agent_comms.goal_failure_observation import FailedTurnObservation, NoFailedTurnEvidence +from agent_comms.goal_generation import BlockedGeneration from agent_comms.goals import Goal -from agent_comms.thread_identity import TurnIdentity -from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms.thread_status import ( - ArchivedThreadStatus, - DeletingThreadStatus, - IdleThreadStatus, - StoppedThreadStatus, -) +from agent_comms.thread_identity import RecordedThreadIncarnation, TurnIdentity from agent_comms.turn_lease import StoredTurnRecord, TurnLeaseFence @@ -38,7 +23,11 @@ def bound(tmp_path, *, thread_registration): root.mkdir(mode=0o700) store = GoalAttemptStore.initialize(root) goal = Goal("private goal prose", "goal", published_seq=0, revision=2) - owner = thread_registration.new_thread('owner', frozenset(), '/private-worktree', process_identity=ProcessIdentity.capture(os.getpid()), created_at=10.0, goal=goal, turn_generation=7, last_finished_turn_id='a' * 32) + owner = thread_registration.new_thread( + 'owner', frozenset(), '/private-worktree', + process_identity=ProcessIdentity.capture(os.getpid()), created_at=10.0, + goal=goal, turn_generation=7, last_finished_turn_id='a' * 32, + ) store.create_goal(goal.id) permit = store.claim_launch(store.reserve(goal.id, 1)) identity = TurnIdentity(owner.incarnation, 7) @@ -47,14 +36,8 @@ def bound(tmp_path, *, thread_registration): record=StoredTurnRecord.for_identity(thread_registration.store.path, identity), ) observation = FailedTurnObservation.from_terminal( - permit.reservation, - owner=owner, - goal=goal, - lease=lease, - turn_id=lease.turn_id, - claim_scope=3, - current_owner=owner, - current_claim=3, + permit.reservation, owner=owner, goal=goal, lease=lease, + turn_id=lease.turn_id, claim_scope=3, current_owner=owner, current_claim=3, reason=FailureReason.FINAL_STOP_MISSING, ) assert observation is not None @@ -66,18 +49,6 @@ def rows(store, table): return conn.execute(f"SELECT * FROM {table}").fetchall() -def blocked(owner): - return replace( - owner, goal=replace(owner.goal, state=BlockedGoal("Explicit fixture refusal"), revision=3) - ) - - -def read(store, owner, **kwargs): - return read_failed_turn_projection( - store.path, owner=owner, owner_status=IdleThreadStatus(), claim_scope=3, **kwargs - ) - - def test_duplicate_callback_and_restart_preserve_failure_bytes(bound): store, owner, _, observation = bound observation.reservation.fail(store, "private diagnostic", observation=observation) @@ -87,12 +58,7 @@ def test_duplicate_callback_and_restart_preserve_failure_bytes(bound): observation.reservation.fail(current, "duplicate", observation=observation) with pytest.raises(UnresolvedAttemptError): current.resume(owner.goal.id, 1) - projection = read(current, blocked(owner)) - assert projection.to_primitive() == { - "schema": 1, - "state": "backend_suspended", - "reason": "assistant_final_stop_missing", - } + assert current.failure_evidence(current.require_generation(owner.goal.id)) == observation.evidence assert store.path.read_bytes() == before assert len(rows(store, "failed_turn_evidence")) == 1 encoded = json.dumps(rows(store, "failed_turn_evidence")) @@ -100,33 +66,22 @@ def test_duplicate_callback_and_restart_preserve_failure_bytes(bound): assert "private diagnostic" not in encoded -@pytest.mark.parametrize( - "mutation", - [ - {"identity": TurnIdentity(RecordedThreadIncarnation("other", 10.0), 7)}, - {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 11.0), 7)}, - {"turn_id": "b" * 32}, - {"claimed_generation": 4}, - {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 10.0), 8)}, - ], -) +@pytest.mark.parametrize("mutation", [ + {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 11.0), 7)}, + {"turn_id": "b" * 32}, {"claimed_generation": 4}, + {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 10.0), 8)}, +]) def test_mismatched_turn_lease_is_not_bound(bound, mutation): store, owner, lease, observation = bound rejected = FailedTurnObservation.from_terminal( - observation.reservation, - owner=owner, - goal=owner.goal, - lease=replace(lease, **mutation), - turn_id=lease.turn_id, - claim_scope=3, - current_owner=owner, - current_claim=3, - reason=FailureReason.BACKEND_FAILED, + observation.reservation, owner=owner, goal=owner.goal, + lease=replace(lease, **mutation), turn_id=lease.turn_id, claim_scope=3, + current_owner=owner, current_claim=3, reason=FailureReason.BACKEND_FAILED, ) assert rejected is None observation.reservation.fail(store, "failed anyway", observation=rejected) assert store.snapshot("goal").lifecycle == BlockedGeneration() - assert read(store, blocked(owner)).reason == "missing_binding" + assert store.failure_evidence(store.require_generation("goal")) == NoFailedTurnEvidence() @pytest.mark.parametrize("binding", ["token", "evidence_identity"]) @@ -140,16 +95,14 @@ def test_mismatched_permit_cannot_attach_observation_or_weaken_failure(bound, bi observation.reservation.fail(store, "failed", observation=forged) assert rows(store, "failed_turn_evidence") == [] assert store.snapshot("goal").lifecycle == BlockedGeneration() - assert read(store, blocked(owner)).state == "unavailable" + assert store.failure_evidence(store.require_generation("goal")) == NoFailedTurnEvidence() def test_unclaimed_attempt_failure_has_no_backend_incident(bound): store, owner, _, observation = bound store.create_goal("unclaimed") reservation = store.reserve("unclaimed", 1) - reservation.fail( - store, "prelaunch failure", observation=replace(observation, reservation=reservation) - ) + reservation.fail(store, "prelaunch failure", observation=replace(observation, reservation=reservation)) assert store.snapshot("unclaimed").lifecycle == BlockedGeneration() assert rows(store, "failed_turn_evidence") == [] @@ -161,7 +114,7 @@ def test_stale_attempt_cannot_record_an_incident(bound): with pytest.raises(StaleAttemptError): observation.reservation.fail(store, "late", observation=observation) assert store.path.read_bytes() == before - assert read(store, blocked(owner)).state == "unavailable" + assert rows(store, "failed_turn_evidence") == [] def test_observation_insert_error_does_not_rollback_failure_fence(bound): @@ -174,122 +127,7 @@ def test_observation_insert_error_does_not_rollback_failure_fence(bound): observation.reservation.fail(store, "failed", observation=observation) assert store.snapshot("goal").lifecycle == BlockedGeneration() with sqlite3.connect(store.path) as conn: - assert ( - AttemptRecord.one( - conn, attempt_id=observation.reservation.attempt_id - ).phase.declared_name - == "failed" - ) + assert AttemptRecord.one(conn, attempt_id=observation.reservation.attempt_id).phase.declared_name == "failed" assert rows(store, "failed_turn_evidence") == [] with pytest.raises(UnresolvedAttemptError): store.resume("goal", 1) - assert read(store, blocked(owner)).reason == "unsupported_schema" - - -@pytest.mark.parametrize("source", [OwnerPause(), ModelPause()]) -def test_pause_projection_never_becomes_runnable(bound, source): - store, owner, _, observation = bound - observation.reservation.fail(store, "failed", observation=observation) - owner = replace( - owner, - goal=replace( - owner.goal, - state=PausedGoal(source), - revision=3, - ), - ) - before = store.path.read_bytes() - projection = read_failed_turn_projection( - store.path, owner=owner, owner_status=IdleThreadStatus(), claim_scope=3 - ) - assert projection.state == ( - "paused_uncertain" if isinstance(source, ModelPause) else "owner_paused" - ) - assert "canRetry" not in projection.to_primitive() - assert store.path.read_bytes() == before - - -@pytest.mark.parametrize("mode", ["missing", "invalid", "invalid_schema", "wal"]) -def test_reader_never_creates_repairs_or_migrates(bound, tmp_path, mode): - store, owner, _, observation = bound - observation.reservation.fail(store, "failed", observation=observation) - if mode == "missing": - path = tmp_path / "absent" / "goal_attempts.sqlite3" - else: - path = store.path - if mode == "invalid": - path.write_bytes(b"not sqlite") - else: - with sqlite3.connect(path) as conn: - if mode == "invalid_schema": - conn.execute("DROP TABLE goal_provider_usage") - else: - conn.execute("PRAGMA journal_mode=WAL") - before = {str(p): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} - assert ( - read_failed_turn_projection( - path, owner=blocked(owner), owner_status=IdleThreadStatus(), claim_scope=3 - ).state - == "unavailable" - ) - assert {str(p): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before - - -def _crash_recording(root, observation, after_commit): - store = GoalAttemptStore(root) - - def crash(*args): - os._exit(17) - - @contextmanager - def crash_before_commit(connection): - store._begin_transaction(connection) - yield connection - crash() - - if after_commit: - store._sync = crash - else: - store.write_transaction = crash_before_commit - observation.reservation.fail(store, "crashed failure", observation=observation) - - -@pytest.mark.parametrize("after_commit", [False, True]) -def test_process_crash_has_no_partial_incident_or_replay_right(bound, after_commit): - store, owner, _, observation = bound - child = multiprocessing.get_context("spawn").Process( - target=_crash_recording, args=(store.root, observation, after_commit) - ) - child.start() - child.join(10) - try: - assert child.exitcode == 17 - finally: - if child.is_alive(): - child.kill() - child.join(5) - before = {p.name: p.read_bytes() for p in store.root.iterdir() if p.is_file()} - projection = read(store, blocked(owner)) - assert projection.state == ("backend_suspended" if after_commit else "unavailable") - assert {p.name: p.read_bytes() for p in store.root.iterdir() if p.is_file()} == before - recovered = GoalAttemptStore(store.root) - assert recovered.snapshot("goal").lifecycle == ( - BlockedGeneration() if after_commit else ReservedGeneration() - ) - assert len(rows(recovered, "failed_turn_evidence")) == int(after_commit) - with pytest.raises(UnresolvedAttemptError): - recovered.resume("goal", 1) - - -@pytest.mark.parametrize( - "status", [StoppedThreadStatus(), ArchivedThreadStatus(), DeletingThreadStatus()] -) -def test_stopped_status_is_unavailable_even_with_retained_pid(bound, status): - store, owner, _, observation = bound - observation.reservation.fail(store, "failed", observation=observation) - assert ( - read_failed_turn_projection( - store.path, owner=blocked(owner), owner_status=status, claim_scope=3 - ).state - == "unavailable" - ) diff --git a/tests/test_goal_nominal.py b/tests/test_goal_nominal.py index 0a3814bda9..91e50e1b46 100644 --- a/tests/test_goal_nominal.py +++ b/tests/test_goal_nominal.py @@ -51,15 +51,6 @@ def test_experiment_c_every_pause_preserving_action_keeps_owner(owner, action): comms.registry.register(replace(comms.registry.require("worker"), goal=rewritten)) assert wire(comms.root).registry.require("worker").goal.state == paused.state assert comms.goals.goal_history("worker")[-1].after.state == paused.state - assert ( - comms.goals.block_goal_after_failed_turn( - "worker", - started_goal=goal, - expected_worktree=str(comms.root), - diagnostic="Backend failed", - ) - == rewritten - ) @pytest.mark.parametrize( diff --git a/tests/test_goal_resume.py b/tests/test_goal_resume.py index 7cbd5e7c4b..c865c8e734 100644 --- a/tests/test_goal_resume.py +++ b/tests/test_goal_resume.py @@ -211,51 +211,6 @@ def test_same_value_goal_transition_bumps_durable_revision(comms, monkeypatch): assert Comms(comms.root).registry.require("owner").goal == second -def test_automatic_failure_block_advances_goal_revision(comms, monkeypatch): - started = _goal(comms, monkeypatch) - snapshot = comms.registry.require("owner").goal - assert snapshot is not None - newer = Comms(comms.root).goals.update_goal( - "owner", - ActiveGoalAction( - expect=GoalPrecondition(goal_id=started["id"]), progress="verified newer step" - ), - ) - assert newer is not None - blocked = comms.goals.block_goal_after_failed_turn( - "owner", - started_goal=snapshot, - expected_worktree="/wt", - diagnostic="Backend outcome uncertain.", - ) - assert blocked is not None and blocked.state.declared_name == "blocked" - assert blocked.revision == newer.revision + 1 - assert blocked.progress.startswith("verified newer step\n\n") - assert Comms(comms.root).registry.require("owner").goal == blocked - - -def test_automatic_failure_block_preserves_existing_goal_progress(comms, monkeypatch): - started = _goal(comms, monkeypatch) - goal = comms.goals.update_goal( - "owner", - ActiveGoalAction( - expect=GoalPrecondition(goal_id=started["id"]), progress="Verified first step" - ), - ) - assert goal is not None - - blocked = comms.goals.block_goal_after_failed_turn( - "owner", - started_goal=goal, - expected_worktree="/wt", - diagnostic="Backend outcome uncertain.", - ) - - assert blocked is not None - assert blocked.state.declared_name == "blocked" - assert blocked.progress == "Verified first step\n\nBackend outcome uncertain." - - def test_exhausted_goal_revision_refuses_transition_without_write(comms): comms.registry.declare( comms.registry.new_thread(name='owner', tags=frozenset(), worktree='/wt', goal=Goal('work', 'goal-id', published_seq=0, revision=(1 << 63) - 1)) @@ -276,11 +231,9 @@ def test_a_blocked_goal_is_retried_by_its_unit_or_commander_and_authorize_retry_ store = comms.goals.open_goal_store() def failed_turn(diagnostic): - started = comms.registry.require("owner").goal - blocked = comms.goals.block_goal_after_failed_turn( - "owner", started_goal=started, expected_worktree="/wt", diagnostic=diagnostic) - assert blocked.state.declared_name == "blocked" - return blocked + generation = store.require_generation(goal_id) + store.reserve(goal_id, generation.number).fail(store, diagnostic) + assert comms.registry.require("owner").goal.state.active failed_turn("Backend outcome uncertain.") retried = invoke_tool(comms, "comms_goal", @@ -294,10 +247,11 @@ def failed_turn(diagnostic): assert commander_retry.state.active and comms.registry.require("owner").goal == commander_retry failed_turn("Provider failed a third time.") + comms.goals.update_goal("owner", RetryGoalAction(expect=GoalPrecondition(goal_id=goal_id))) store.reserve(goal_id, store.require_generation(goal_id).number) with pytest.raises(ValueError): comms.goals.update_goal("owner", RetryGoalAction(expect=GoalPrecondition(goal_id=goal_id))) - assert comms.registry.require("owner").goal.state.declared_name == "blocked" + assert comms.registry.require("owner").goal.state.active replaced = comms.goals.update_goal("owner", SetGoalAction(text="a fresh objective")) assert replaced.id != goal_id and replaced.state.active diff --git a/tests/test_goal_standby.py b/tests/test_goal_standby.py index 9c57ca79e5..559908e26c 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -14,7 +14,7 @@ SetGoalAction, StandbyGoalAction, ) -from agent_comms.goal_presentation import GoalExecutionState, GoalWaitTarget +from agent_comms.goal_presentation import RunnableExecution, StandbyExecution, GoalWaitTarget from agent_comms.goal_waits import GoalWaits from goal_owner_fixture import canonical_goal_wire from agent_comms.goal_waits import RecordedGoalWait, NoGoalWait @@ -106,7 +106,7 @@ def test_idle_live_alternative_keeps_wait_cycle_open(tmp_path): ) assert isinstance(comms.goals.goal_wait("bob"), RecordedGoalWait) - assert comms.goals.goal_execution("bob").state is GoalExecutionState.STANDBY + assert isinstance(comms.goals.goal_execution("bob"), StandbyExecution) def test_dead_active_turn_does_not_make_wait_cycle_runnable(tmp_path): @@ -159,7 +159,7 @@ def test_standby_entry_accepts_idle_live_dependency(tmp_path): expect=GoalPrecondition(goal_id=alice.id), wait_for=("bob",))) assert isinstance(comms.goals.goal_wait("alice"), RecordedGoalWait) assert comms.goals.recover_closed_goal_wait("alice") == () - assert comms.goals.goal_execution("alice").state is GoalExecutionState.STANDBY + assert isinstance(comms.goals.goal_execution("alice"), StandbyExecution) def test_idle_live_dependency_keeps_wait_group_open(tmp_path): @@ -181,7 +181,7 @@ def test_idle_live_dependency_keeps_wait_group_open(tmp_path): comms.registry.require("bob").incarnation, comms.registry.snapshot()) assert comms.goals.recover_closed_goal_wait("alice") == () assert isinstance(comms.goals.goal_wait("alice"), RecordedGoalWait) - assert comms.goals.goal_execution("alice").state is GoalExecutionState.STANDBY + assert isinstance(comms.goals.goal_execution("alice"), StandbyExecution) @pytest.mark.parametrize("pending_reply", [False, True]) @@ -227,7 +227,7 @@ def test_liveness_check_releases_preexisting_closed_wait_group(tmp_path, pending assert comms.goals.recover_closed_goal_wait("alice") == ("alice", "bob") assert isinstance(comms.goals.goal_wait("alice"), NoGoalWait) - assert comms.goals.goal_execution("alice").state is GoalExecutionState.RUNNABLE + assert isinstance(comms.goals.goal_execution("alice"), RunnableExecution) assert "Standby was released" in comms.registry.require("alice").goal.progress assert comms.goals.recover_closed_goal_wait("alice") == () @@ -268,7 +268,7 @@ def test_new_live_dependency_turn_keeps_old_wait_group_open(tmp_path, bound_old_ assert wait is not None assert comms.goals.recover_closed_goal_wait("alice") == () assert comms.goals.goal_wait("alice") == wait - assert comms.goals.goal_execution("alice").state is GoalExecutionState.STANDBY + assert isinstance(comms.goals.goal_execution("alice"), StandbyExecution) comms.agents.finish_turn(comms.registry.require("bob").turn_lease) assert comms.goals.recover_closed_goal_wait("alice") == ("alice", "bob") diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index e47670b85d..889da7f9c8 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -25,14 +25,14 @@ from agent_comms.commander.order import Commander, OrderFile, SayVerb from agent_comms.commander.standing import ( HeartbeatSilent, - Blocked, Detached, HolderCannotRun, UnitAskUnanswered, UnownedWheelSpin, UnresolvedBlock, + GoalCannotRun, Detached, HolderCannotRun, UnitAskUnanswered, UnownedWheelSpin, ) from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.goal_actions import BlockedGoalAction, CreateProblemGoalAction, SetGoalAction from agent_comms.goal_attempts import AttemptRecord, GoalAttemptStore, GoalWorkDecision from agent_comms.goal_attempt_phase import ResolvedAttempt -from agent_comms.goal_generation import GenerationState, ReadyGeneration, RetryableGeneration -from agent_comms.goal_states import ActiveGoal, BlockedGoal +from agent_comms.goal_generation import ReadyGeneration +from agent_comms.goal_states import ActiveGoal from agent_comms.goals import CountCheckResult, Goal, OrderCheck, RequestGoalOrder from agent_comms.input_disposition import InputDispositions from agent_comms.messages import InfoKind, Message @@ -114,8 +114,7 @@ def test_an_active_goal_with_an_unresolved_attempt_is_reported_never_retried(hel store.recover_unreserved_ready(goal.id, generation.number) store.reserve(goal.id, generation.number, ready_grant=store.ready_grant(goal.id, generation.number)) reserved = store.require_generation(goal.id) - assert Blocked.violations(held_bus, "lead") == () - violation, = UnresolvedBlock.violations(held_bus, "lead") + violation, = GoalCannotRun.violations(held_bus, "lead") assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal.require() == goal @@ -123,28 +122,6 @@ def test_an_active_goal_with_an_unresolved_attempt_is_reported_never_retried(hel assert store.require_generation(goal.id) == reserved -@pytest.fixture(params=[member() for member in GenerationState.members_with(GenerationState)]) -def holder_generation(request): - return request.param - - -def test_unresolved_block_accepts_ledger_state_independently_of_goal_state(holder_generation): - generation = holder_generation - goal = Goal("Held work", "goal", published_seq=0) - expected = not isinstance(generation, RetryableGeneration) - assert UnresolvedBlock.accepts(goal, generation) == expected - blocked = replace(goal, state=BlockedGoal("Failed turn")) - assert UnresolvedBlock.accepts(blocked, generation) == expected - - -def test_blocked_accepts_only_real_goal_blocks_with_retryable_generations(holder_generation): - generation = holder_generation - goal = Goal("Held work", "goal", published_seq=0) - assert not Blocked.accepts(goal, generation) - blocked = replace(goal, state=BlockedGoal("Failed turn")) - assert Blocked.accepts(blocked, generation) == isinstance(generation, RetryableGeneration) - - def publish(comms, sender, target, timestamp, *, answers=()): return comms.bus.publisher.publish_ordinary( Message(sender=sender, target=target, type=InfoKind, body="Not a question", timestamp=timestamp, @@ -157,9 +134,7 @@ def test_blocked_holder_republishes_every_order_without_replaying_unknown(held_b original = comms.registry.require("worker").goal.require() inputs = InputDispositions(held_bus.root) unresolved = inputs.lookup("unresolved-input") - violation, = Blocked.violations(held_bus, "lead") - assert violation.block_record.is_file() - print(violation.line()) + violation, = GoalCannotRun.violations(held_bus, "lead") previous_orders = {todo.id: todo.order.checked().id for todo in held_bus.goals.todos.held_orders()} violation.enforce(held_bus, "lead") @@ -174,7 +149,7 @@ def test_blocked_holder_republishes_every_order_without_replaying_unknown(held_b assert AttemptRecord.read_in(connection, reservation.attempt_id).holds(reservation, ResolvedAttempt()) assert inputs.lookup("unresolved-input") == unresolved assert comms.registry.require("worker").goal.require() == original - assert Blocked.violations(held_bus, "lead") == () + assert GoalCannotRun.violations(held_bus, "lead") == () def test_blocked_holder_reissues_only_undelivered_orders(held_bus): @@ -183,7 +158,7 @@ def test_blocked_holder_reissues_only_undelivered_orders(held_bus): delivered.revision, held_bus.registry.require("lead").incarnation, OperationDelivery(at=RecordedEventTime(time.time())), delivered.order.id)) blocked_holder(held_bus) - violation, = Blocked.violations(held_bus, "lead") + violation, = GoalCannotRun.violations(held_bus, "lead") assert violation.orders == ("P1",) violation.enforce(held_bus, "lead") assert held_bus.goals.todos.get("P2") == delivered @@ -202,7 +177,7 @@ def test_blocked_holder_reissues_pending_sibling_but_never_the_claimed_order(hel claimed = held_bus.goals.todos.get("P2") pending = held_bus.goals.todos.get("P1") blocked_holder(held_bus) - violation, = Blocked.violations(held_bus, "lead") + violation, = GoalCannotRun.violations(held_bus, "lead") assert violation.orders == ("P1",) violation.enforce(held_bus, "lead") assert held_bus.goals.todos.get("P2") == claimed @@ -223,10 +198,10 @@ def test_blocked_holder_is_reissued_its_accepted_order_after_editing_its_scope(h accepted = held_bus.goals.todos.get("P3").order.checked().id owner.write_text('"""The unit moved its class while working the order."""\n\n\nclass Owner:\n pass\n') blocked_holder(held_bus) - violation, = Blocked.violations(held_bus, "lead") + violation, = GoalCannotRun.violations(held_bus, "lead") violation.enforce(held_bus, "lead") assert held_bus.goals.todos.get("P3").order.checked().id != accepted - assert Blocked.violations(held_bus, "lead") == () + assert GoalCannotRun.violations(held_bus, "lead") == () def test_an_order_to_a_blocked_goal_activates_it_and_keeps_other_orders(held_bus): @@ -263,7 +238,7 @@ def test_a_running_turn_is_not_a_stalled_holder(held_bus): lease = owner.thread.turn_lease assert lease is not None try: - assert Blocked.violations(held_bus, "lead") == () + assert GoalCannotRun.violations(held_bus, "lead") == () finally: held_bus.agents.finish_turn(lease) @@ -383,7 +358,7 @@ def test_detached_and_whole_family_scan_still_find_nonreceiving_holders(held_bus assert detached.unit == "worker" and set(detached.orders) == {"P1", "P2"} family = HolderCannotRun.violations(held_bus, "lead") assert detached in family - blocked, = Blocked.violations(held_bus, "lead") + blocked, = GoalCannotRun.violations(held_bus, "lead") assert blocked in family broken = StandingRule.broken(held_bus, "lead") assert all(rule in broken for rule in family) @@ -452,7 +427,7 @@ def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus, blocked_holder(held_bus) question = publish(held_bus, "worker", "lead", time.time() - 901) expected = tuple(rule.line() for rule in ( - *Blocked.violations(held_bus, "lead"), *UnitAskUnanswered.violations(held_bus, "lead"), + *GoalCannotRun.violations(held_bus, "lead"), *UnitAskUnanswered.violations(held_bus, "lead"), )) heartbeat = CommanderHeartbeat() commander = held_bus.registry.require("lead") diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 32fdbd4239..2bad7701c8 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -20,6 +20,7 @@ from agent_comms.launch_root import LaunchRoot from agent_comms.runtime import socket_path from agent_comms.thread_model import DeclaredModel +from agent_comms.thread_text import SavedSessionFile from functools import partial from native_backend_fixture import native_backend_fixture from agent_comms.child_process import ProcessIdentity @@ -89,7 +90,8 @@ def released_starting(tmp_path): assert process.exitcode == 0 -def test_recover_owner_without_an_input_settles_unknown_and_clears_pointer(released_starting): +def recover_starting_owner(released_starting): + """Use the owner recovery entrypoint; absence of an input never means unsent.""" comms = Comms(released_starting) owner = comms.registry.require("beta") with Coordination(released_starting / "coordination.sqlite3") as store: @@ -116,6 +118,57 @@ def test_recover_owner_without_an_input_settles_unknown_and_clears_pointer(relea assert FileRevision.capture(InputDispositions.path_in(released_starting)) == dispositions +def test_recover_owner_without_an_input_settles_unknown_and_clears_pointer(released_starting): + recover_starting_owner(released_starting) + + +def running_successor(comms): + previous = comms.registry.require("beta") + comms.registry.register( + replace(previous, process_identity=ProcessIdentity.capture(os.getpid())), + new_owner=True, + ) + successor, _ = comms.registry.lease_local_turn("beta", "productive-successor") + assert successor.process_alive and successor.turn_record.read().executing + return successor + + +def test_undispatched_original_recovers_beside_a_running_successor(released_starting): + comms = Comms(released_starting) + 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 + comms.registry.release_turn(successor.require_turn_lease()) + + +@pytest.mark.parametrize("argument", ["--session", "--session-dir"]) +def test_undispatched_owner_loss_refuses_a_live_native_child(released_starting, argument): + comms = Comms(released_starting) + owner = comms.registry.require("beta") + directory = released_starting / "native-sessions" / owner.incarnation.lookup + session = directory / "undispatched.jsonl" + comms.registry.register( + replace(owner, session_file=SavedSessionFile(str(session))), + status=comms.registry.status(owner.name), + ) + selected = session if argument == "--session" else directory + child = subprocess.Popen([ + sys.executable, "-c", "import time; time.sleep(30)", argument, str(selected), + ]) + try: + with pytest.raises(RecoveryBlocked): + RecoveryMonitorCapability.recover_owner(released_starting, comms.registry.require("beta")) + with Coordination(released_starting / "coordination.sqlite3") as store: + pointer = CurrentExecutions.require(store.session._connection, owner_lookup=owner.incarnation.lookup) + execution_id = pointer.execution_id.require("the live child must keep its attempt unresolved") + assert store.snapshots.get(execution_id).attempt.lifecycle.starting + assert NativeRuntimeInput.count(store.session._connection) == 0 + finally: + child.terminate() + child.wait(timeout=5) + + def test_observed_owner_loss_expires_with_its_boundary(released_starting): owner = Comms(released_starting).registry.require("beta") with Coordination(released_starting / "coordination.sqlite3") as store: @@ -315,29 +368,24 @@ def test_abandon_requires_real_release(released_unknown, damage): assert store.snapshots.get(execution_id).is_current -def test_missing_claim_cannot_borrow_a_live_successor_release(released_unknown): +def test_dead_original_settles_unknown_beside_a_live_successor(released_unknown): process, exit_allowed, root, _root_id, execution_id, _input_id = released_unknown leave(process, exit_allowed) - with Coordination(str(root / "coordination.sqlite3")) as store: - if ( - store.session._connection.execute( - f"SELECT {NativeRuntimeInput.column_projection()['sent_owner_claimed_generation']} " - f"FROM {NativeRuntimeInput.declared_name}" - ).fetchone()[0] - is not None - ): - return # Recorded claim_scope already binds to the earlier attested release. - comms = Comms(root) - comms.registry.register( - replace( - comms.registry.require("beta"), - process_identity=ProcessIdentity.capture(os.getpid()), - ), - new_owner=True, - ) - with pytest.raises(RecoveryBlocked): - RecoveryMonitorCapability.abandon_released_native_attempt(store, execution_id) - assert store.snapshots.get(execution_id).is_current + comms = Comms(root) + successor = running_successor(comms) + before_bus = (root / "bus.jsonl").read_bytes() + with Coordination(root / "coordination.sqlite3") as store: + before_input = input_evidence(store) + RecoveryMonitorCapability.abandon_released_native_attempt(store, execution_id) + after = store.snapshots.get(execution_id) + assert not after.is_current and after.attempt.lifecycle.failed + assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not after.replay.replay_safe + 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 + comms.registry.release_turn(successor.require_turn_lease()) def test_unknown_settlement_is_atomic_on_existing_store(tmp_path): diff --git a/tests/test_todos.py b/tests/test_todos.py index f95590fe5c..692ba5b244 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -426,7 +426,7 @@ def test_watcher_git_and_github_reads_leave_wire_free(pr_commander, monkeypatch) def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): from test_holder_standing_rules import blocked_holder - from agent_comms.commander.standing import Blocked + from agent_comms.commander.standing import GoalCannotRun from agent_comms.goal_actions import RetryGoalAction from agent_comms.goals import GoalChangedError from agent_comms.input_disposition import InputDispositions @@ -435,7 +435,7 @@ def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): caplog.set_level(logging.INFO) with LaunchRoot.from_path(held_bus.root).selected(): blocked_holder(held_bus) - observed, = Blocked.violations(held_bus, "lead") + observed, = GoalCannotRun.violations(held_bus, "lead") recovered = held_bus.goals.update_goal("worker", RetryGoalAction()) rows = tuple(held_bus.goals.todos.get(problem) for problem in observed.orders) generation = held_bus.goals.open_goal_store().require_generation(recovered.id) @@ -459,12 +459,12 @@ def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): def test_blocked_enforcement_preserves_reassigned_orders(held_bus): from test_holder_standing_rules import blocked_holder - from agent_comms.commander.standing import Blocked, UnownedWheelSpin + from agent_comms.commander.standing import GoalCannotRun, UnownedWheelSpin from agent_comms.standing_rules import StandingRule with LaunchRoot.from_path(held_bus.root).selected(): blocked_holder(held_bus) - observed, = Blocked.violations(held_bus, "lead") + observed, = GoalCannotRun.violations(held_bus, "lead") row = held_bus.goals.todos.get(observed.orders[0]) reassigned = held_bus.goals.update_goal("outsider", UnownedWheelSpin.order_for( row, "lead", held_bus.registry.require("lead").incarnation)) diff --git a/tools/cutover/carry_goal_execution.py b/tools/cutover/carry_goal_execution.py new file mode 100644 index 0000000000..8d707fa31a --- /dev/null +++ b/tools/cutover/carry_goal_execution.py @@ -0,0 +1,59 @@ +"""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 0ff0ca27b8c20915f0a9864315c2d6352664eb82 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:15:03 -0400 Subject: [PATCH 04/11] Put order continuation on generations and separate active execution states --- src/agent_comms/goal_actions.py | 3 +++ src/agent_comms/goal_generation.py | 14 +++++--------- src/agent_comms/goal_management.py | 2 +- src/agent_comms/goal_presentation.py | 16 +++++++++------ src/agent_comms/goal_scheduler.py | 3 +-- src/agent_comms/goal_states.py | 4 +--- src/agent_comms/turn_goal_account.py | 9 ++++----- tests/test_holder_standing_rules.py | 29 ++++++++++++++++------------ 8 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 9ff0e8ee8f..da4568ffdc 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -510,6 +510,9 @@ def change(self, goal: Goal, ctx: GoalActionContext) -> Goal: def continue_goal(self, goal: Goal, ctx: GoalActionContext) -> Goal: request = self.request_ref(ctx) self.reserve(ctx, goal.id, request) + store = ctx.goals.open_goal_store() + generation = store.require_generation(goal.id) + generation.lifecycle.continue_work(store, generation, self.order.id) revision = goal.revision + 1 return replace(goal, order=ProblemGoalOrder(), text=ProblemGoalOrder.text, progress="", revision=revision, mention_source=bind_goal_mentions( diff --git a/src/agent_comms/goal_generation.py b/src/agent_comms/goal_generation.py index 4b013006df..682d3ee6dd 100644 --- a/src/agent_comms/goal_generation.py +++ b/src/agent_comms/goal_generation.py @@ -44,7 +44,8 @@ def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailu from .goal_failure_observation import NoFailedTurnEvidence return NoFailedTurnEvidence() - def verified_turn(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: + def continue_work(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: + """An accepted order or verified progress does not resolve unknown work.""" pass def turn_permit(self, store: GoalAttemptStore, goals: Goals, owner: Thread, @@ -78,13 +79,8 @@ def authorize_retry( raise ValueError("The goal attempt is unresolved; inspect it before retrying.") -class RetryableGeneration: - """A generation an explicit retry or a fresh order may proceed from; every other - generation's attempt is unresolved until someone inspects it.""" - - @dataclass(frozen=True) -class ReadyGeneration(GenerationState, RetryableGeneration): +class ReadyGeneration(GenerationState): ready = True def execution(self, source: GoalExecutionSource) -> GoalExecution: @@ -138,7 +134,7 @@ def allows_resume(self, active_turn: bool) -> bool: @dataclass(frozen=True) -class BlockedGeneration(GenerationState, RetryableGeneration): +class BlockedGeneration(GenerationState): failed = True def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: @@ -147,7 +143,7 @@ def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailu def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: return source.evidence.execution(source.goal.id) - def verified_turn(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: + def continue_work(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: store.authorize_current_retry(generation.goal_id, witness) def require_failure(self) -> None: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 4789e3a1d2..966154197b 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -114,7 +114,7 @@ def open_goal_store(self) -> GoalAttemptStore: def ready_goal_grant_locked( self, owner: Thread, claim_scope: int, generation: Generation ) -> str: - """Caller holds wire; recover only this owner's original unused READY grant.""" + """Caller holds registry; recover only this owner's original unused READY grant.""" try: goal = GoalLaunchOwner(thread=owner, claimed_generation=claim_scope).require_ready( self.registry.snapshot(), generation.goal_id, diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 2d8cac6516..442a5c7e3d 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -114,7 +114,9 @@ def heading(self, revision: int) -> str: class BoundGoalExecution(RecordMember, GoalExecution, declared_name=None): goal_id: str glyph: ClassVar[str] = "✓" - label: ClassVar[str] + @property + @abstractmethod + def label(self) -> str: ... @property def summary(self) -> str: @@ -135,10 +137,7 @@ def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentati return self.presentation(ordinary.title) -@dataclass(frozen=True) -class RunnableExecution(BoundGoalExecution): - label = "Active" - +class ActiveExecution(BoundGoalExecution, declared_name=None): @property def toggle(self) -> type[GoalAction]: from .goal_actions import PausedGoalAction @@ -146,7 +145,12 @@ def toggle(self) -> type[GoalAction]: @dataclass(frozen=True) -class RunningExecution(RunnableExecution): +class RunnableExecution(ActiveExecution): + label = "Active" + + +@dataclass(frozen=True) +class RunningExecution(ActiveExecution): label = "Running" diff --git a/src/agent_comms/goal_scheduler.py b/src/agent_comms/goal_scheduler.py index 03082c5c12..ed8ccf6825 100644 --- a/src/agent_comms/goal_scheduler.py +++ b/src/agent_comms/goal_scheduler.py @@ -31,7 +31,6 @@ from .reservation_rules import ReservationViolationError from .routing import ScheduledTurn from .schedule_rules import OwnerTurnScheduleCheck, WakeScheduleCheck -from .store_files import _store_lock if TYPE_CHECKING: from .registry_document import RegistrySnapshot @@ -104,7 +103,7 @@ async def schedule_goal(self, session_id: str) -> None: def prepare_goal(self, session_id: str) -> ScheduledTurn | None: """Read/recover the original READY grant in its joined storage worker.""" - with _store_lock(self.comms._wire_lock_path): + with self.comms.registry.store.locked(): snapshot = self.comms.registry.snapshot() thread = snapshot.require(self.sessions.require(session_id)) try: diff --git a/src/agent_comms/goal_states.py b/src/agent_comms/goal_states.py index ce17f76b9d..e9a8769543 100644 --- a/src/agent_comms/goal_states.py +++ b/src/agent_comms/goal_states.py @@ -149,9 +149,7 @@ def check_transition(self, nxt: GoalState, *, owner: bool) -> None: class BlockedState(GoalState, FromOpenGoal): def accept_order(self, action: ProblemOrderGoalAction, goal: Goal, ctx: GoalActionContext) -> Goal: - ordered = action.continue_goal(goal, ctx) - ctx.goals.open_goal_store().authorize_current_retry(goal.id, action.order.id) - return replace(ordered, state=ActiveGoal()) + return replace(action.continue_goal(goal, ctx), state=ActiveGoal()) @classmethod def successors(cls) -> tuple[type[GoalState], ...]: diff --git a/src/agent_comms/turn_goal_account.py b/src/agent_comms/turn_goal_account.py index d0f2aeabd1..5b3653f8cb 100644 --- a/src/agent_comms/turn_goal_account.py +++ b/src/agent_comms/turn_goal_account.py @@ -18,7 +18,6 @@ from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit from .goal_states import ActiveGoal, CompletedGoal, PausedGoal from .mro_dispatch import MroDispatch, handles -from .store_files import _store_lock if TYPE_CHECKING: from . import agent_events as events @@ -48,7 +47,7 @@ def settle_current(cls, account: TurnGoalAccount) -> None: for goal in owner.goal.continued_by(current.goal): store = comms.goals.open_goal_store() generation = store.require_generation(goal.id) - generation.lifecycle.verified_turn(store, generation, f"verified-turn:{account.turn.value}") + generation.lifecycle.continue_work(store, generation, f"verified-turn:{account.turn.value}") @property def progress_witness(self) -> str: @@ -96,8 +95,8 @@ class TurnGoalAccount: def reserve(*, owner: Thread, goal: Goal, comms: Comms, autonomous: bool) -> GoalTurnPermit: try: store = comms.goals.open_goal_store() - claim_scope = comms.registry.snapshot().claim_generation(owner.name) - with _store_lock(comms._wire_lock_path): + with comms.registry.store.locked(): + claim_scope = comms.registry.snapshot().claim_generation(owner.name) generation = store.require_generation(goal.id) return generation.lifecycle.turn_permit(store, comms.goals, owner, claim_scope, generation) except GoalAttemptError as error: @@ -127,7 +126,7 @@ def tool_ended(self, event: events.ToolEnd) -> None: self.has_productive_tool |= bool(event.ok) if event.name != "comms_set_goal" or event.ok is not True: return - with _store_lock(self.comms._wire_lock_path): + with self.comms.registry.store.locked(): owner = self.comms.registry.snapshot().require(self.owner.name) claimed = tuple(permit for goal in owner.goal for permit in self._reserve_origin(owner, goal)) for permit in claimed: diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 889da7f9c8..56e6e75781 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -18,6 +18,9 @@ from agent_comms.bus_publication import AgentOrigin from agent_comms.comms import Comms from agent_comms.coordination_database import CoordinationStore +from agent_comms import agent_events as events +import asyncio +from test_goal_execution import turn_on from agent_comms.errors import RelationViolationError from agent_comms.installer.carry import CarryChain, CarryLedger from agent_comms.locked_store import SQLiteFile @@ -28,7 +31,7 @@ GoalCannotRun, Detached, HolderCannotRun, UnitAskUnanswered, UnownedWheelSpin, ) from agent_comms.commander_heartbeat import CommanderHeartbeat -from agent_comms.goal_actions import BlockedGoalAction, CreateProblemGoalAction, SetGoalAction +from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction from agent_comms.goal_attempts import AttemptRecord, GoalAttemptStore, GoalWorkDecision from agent_comms.goal_attempt_phase import ResolvedAttempt from agent_comms.goal_generation import ReadyGeneration @@ -96,15 +99,17 @@ def held_bus(comms, tmp_path, source_repository, configured_repository): def blocked_holder(comms): - """An actual failed ledger reservation, not merely a displayed blocked label.""" - goal = comms.registry.require("worker").goal.require() - store = comms.goals.open_goal_store() - generation = store.require_generation(goal.id) - store.recover_unreserved_ready(goal.id, generation.number) - reservation = store.reserve(goal.id, generation.number, ready_grant=store.ready_grant(goal.id, generation.number)) - reservation.fail(store, "The previous attempt has an uncertain result") - comms.goals.update_goal("worker", BlockedGoalAction(progress="A reason unrelated to matching text")) - return reservation + """Settle a real failed turn; never manufacture a stored backend block.""" + owner = comms.registry.require("worker") + comms.registry.register(replace(owner, process_identity=ProcessIdentity.capture(os.getpid()))) + + async def fail_turn(): + async with turn_on(comms) as turn: + permit, = turn.goal_permit.claims + await turn.progress.done(events.Done("The previous attempt failed", False)) + return permit.reservation + + return asyncio.run(fail_turn()) def test_an_active_goal_with_an_unresolved_attempt_is_reported_never_retried(held_bus): @@ -351,7 +356,7 @@ def cli(*arguments): print('Deletion expected 0, remote main reads 4: send and check accepted; stale dirty tree unchanged.') -def test_detached_and_whole_family_scan_still_find_nonreceiving_holders(held_bus, installed_runtime): +def test_detached_and_goal_blocks_are_both_in_standing_rule_scan(held_bus, installed_runtime): blocked_holder(held_bus) held_bus.registry.register(held_bus.registry.require("worker"), StoppedThreadStatus()) detached, = Detached.violations(held_bus, "lead") @@ -359,8 +364,8 @@ def test_detached_and_whole_family_scan_still_find_nonreceiving_holders(held_bus family = HolderCannotRun.violations(held_bus, "lead") assert detached in family blocked, = GoalCannotRun.violations(held_bus, "lead") - assert blocked in family broken = StandingRule.broken(held_bus, "lead") + assert blocked in broken assert all(rule in broken for rule in family) From 2ee4172234f483ea8ab0ca486e93dfbac28a22d3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:36:21 -0400 Subject: [PATCH 05/11] Prepare paired Toad execution controls and remove stale control test --- notes/P448-TOAD-MIGRATION.md | 197 +++++++++++++++++++++++++++ src/agent_comms/goal_presentation.py | 7 +- tests/test_goal_execution.py | 9 +- tests/test_goals.py | 16 --- 4 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 notes/P448-TOAD-MIGRATION.md diff --git a/notes/P448-TOAD-MIGRATION.md b/notes/P448-TOAD-MIGRATION.md new file mode 100644 index 0000000000..57b17727f9 --- /dev/null +++ b/notes/P448-TOAD-MIGRATION.md @@ -0,0 +1,197 @@ +# 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 dispatch, BOUND-8 recovered execution at the control +boundary, and TIME-3 imports of the deleted execution alias. Do not add aliases. +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 442a5c7e3d..ec184e311d 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -172,7 +172,7 @@ def toggle(self) -> type[GoalAction]: @dataclass(frozen=True) -class StandbyExecution(WaitingExecution): +class StandbyExecution(ActiveExecution, WaitingExecution): wait_for: tuple[GoalWaitTarget, ...] inactive_wait_for: tuple[GoalWaitTarget, ...] label = "Standby" @@ -185,11 +185,6 @@ def summary(self) -> str: suffix = f"; no active turn: {idle}" if idle else "" return f"Standby · waiting for {names}{suffix}" - @property - def toggle(self) -> type[GoalAction]: - from .goal_actions import PausedGoalAction - return PausedGoalAction - def details_heading(self, heading: str) -> str: return f"{heading} · {self.summary}" diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 7af5d7ef59..ab93bedadb 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -10,7 +10,9 @@ 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, SetGoalAction +from agent_comms.goal_actions import ( + BlockedGoalAction, PausedGoalAction, RetryGoalAction, SetGoalAction, +) from agent_comms.goal_failure_observation import FailedTurnEvidence from agent_comms.goal_presentation import AttemptFailedExecution, RunnableExecution, RunningExecution from agent_comms.goal_states import ActiveGoal, BlockedGoal @@ -65,6 +67,7 @@ async def test_failed_turn_writes_ledger_once_and_reads_failed_execution(executi assert isinstance(comms.goals.goal_execution("worker"), RunningExecution) execution = comms.goals.goal_execution("worker") assert isinstance(execution, AttemptFailedExecution) + assert execution.toggle is RetryGoalAction assert execution.evidence.turn_id == turn.turn_id assert failure_count(comms) == 1 assert comms.registry.require("worker").goal.state == ActiveGoal() @@ -82,7 +85,9 @@ async def test_provider_retry_during_live_turn_writes_no_goal_failure(execution_ assert store.snapshot(goal.id) == before assert failure_count(comms) == 0 assert comms.registry.require("worker").goal == goal - assert isinstance(comms.goals.goal_execution("worker"), RunningExecution) + execution = comms.goals.goal_execution("worker") + assert isinstance(execution, RunningExecution) + assert execution.toggle is PausedGoalAction @pytest.mark.asyncio diff --git a/tests/test_goals.py b/tests/test_goals.py index 4e69faa368..e1cb07a4e4 100644 --- a/tests/test_goals.py +++ b/tests/test_goals.py @@ -13,10 +13,8 @@ GoalAction, GoalPrecondition, PausedGoalAction, - RetryGoalAction, SetGoalAction, ) -from agent_comms.goal_states import ActiveGoal, BlockedGoal, CompletedGoal, PausedGoal from agent_comms.goals import ( Goal, NoGoal, NoReportedTurn, ReportedTurn, ReportedTurnState, ) @@ -156,20 +154,6 @@ def test_explicit_resume_tool_keeps_goal_id_and_rejects_stale_calls(tmp_path, mo resume.invoke(comms, {"goal_id": goal.id, "progress": "stale replaced goal"}, caller=ThreadCaller("worker")) -@pytest.mark.parametrize( - "state,action,label", - [ - (ActiveGoal(), PausedGoalAction, "Pause"), - (PausedGoal(), ActiveGoalAction, "Resume"), - (BlockedGoal("Explicit fixture refusal"), RetryGoalAction, "Retry"), - (CompletedGoal(), None, "Completed"), - ], -) -def test_goal_control_distinguishes_resume_retry_and_completion(state, action, label): - assert state.toggle is action - assert state.toggle_label == label - - def test_agent_can_set_its_own_persistent_goal(tmp_path, monkeypatch): comms = wire(tmp_path) comms.registry.declare(comms.registry.new_thread(name='worker', tags=frozenset(), worktree=str(tmp_path))) From 9da7227a997ec906c79d1677f1641ec268f5b7e9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 16:39:02 -0400 Subject: [PATCH 06/11] Correct pattern ownership references in paired Toad notes --- notes/P448-TOAD-MIGRATION.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/notes/P448-TOAD-MIGRATION.md b/notes/P448-TOAD-MIGRATION.md index 57b17727f9..d5c63eafd2 100644 --- a/notes/P448-TOAD-MIGRATION.md +++ b/notes/P448-TOAD-MIGRATION.md @@ -25,8 +25,9 @@ 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 dispatch, BOUND-8 recovered execution at the control -boundary, and TIME-3 imports of the deleted execution alias. Do not add aliases. +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 From 636c5311d11b0d7977173d0c05d4b598f34061a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:59:01 -0400 Subject: [PATCH 07/11] P448 review: ReissuedOrderGoalAction owns its reissue, and comms is typed UnownedWheelSpin.order_for moves to ReissuedOrderGoalAction.for_todo, so Core's goal execution family no longer imports the commander layer to build a Core action; both callers use it. enforce_unit_block types comms on the base and the override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeUaZjvChAcb6qAd6zPYmw --- src/agent_comms/commander/standing.py | 11 +---------- src/agent_comms/goal_actions.py | 13 ++++++++++++- src/agent_comms/goal_presentation.py | 15 ++++++++++----- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 0fefd72500..2a8826f305 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -264,15 +264,6 @@ def idle(cls, comms: Comms, commander: str) -> list[Thread]: and TurnDigestRead(comms.root, thread, bound).read().turns), key=lambda thread: thread.name) - @staticmethod - def order_for(todo: Todo, commander: str, assigner: ThreadIncarnation) -> ReissuedOrderGoalAction: - now = time.time() - return ReissuedOrderGoalAction( - problem=todo.id, expected_revision=todo.revision, - order=todo.order.reissued(now), request=todo.goal.request(), - review_at=todo.reissued_review(now), commander=commander, assigner=assigner, - traced=todo.order.owner) - def _identity(self) -> tuple[object, ...]: # Observation times and proposed idle units change; the row and its disposition do not. return type(self), self.todo.id @@ -306,7 +297,7 @@ def paired(cls, comms: Comms, commander: str, todos: Iterable[Todo], if not todo.live_strikes(unit.name): idle.remove(unit) found.append(ReassignedWheelSpin(todo, ReusedWorkDispatch.for_unit( - cls.order_for(todo, commander, assigner), unit))) + ReissuedOrderGoalAction.for_todo(todo, commander, assigner), unit))) break covered = {rule.todo.id for rule in (*retained, *found)} return (*retained, *found, diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index da4568ffdc..4df2e66f22 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -5,7 +5,8 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, ClassVar, Annotated +import time +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from uuid import uuid4 from agent_comms import field_codec @@ -569,6 +570,16 @@ class ReissuedOrderGoalAction(OrderGoalAction): since, so they are not checked again against the moved tree. """ + @classmethod + def for_todo(cls, todo: Todo, commander: str, assigner: ThreadIncarnation) -> Self: + """The order the Todo already accepted, handed again with a fresh review window.""" + now = time.time() + return cls( + problem=todo.id, expected_revision=todo.revision, + order=todo.order.reissued(now), request=todo.goal.request(), + review_at=todo.reissued_review(now), commander=commander, assigner=assigner, + traced=todo.order.owner) + def require_sendable(self) -> None: pass diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index ec184e311d..3ba250be0b 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from .agent_activity import AgentActivity from .bus_publication import CommittedDelivery + from .comms import Comms from .goal_actions import GoalAction from .goal_attempts import Generation from .goal_failure_observation import GoalFailureEvidence @@ -222,9 +223,10 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: return (f"BLOCKED {unit}: orders {', '.join(orders)}; goal {self.goal_id}; " f"{self.reason} -> Core reissues the orders") - def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal) -> None: - from .commander.standing import UnownedWheelSpin - from .goal_actions import GoalPrecondition + def enforce_unit_block( + self, comms: Comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal, + ) -> None: + from .goal_actions import GoalPrecondition, ReissuedOrderGoalAction from .goals import GoalChangedError from .thread_identity import CoreWatcherIncarnation @@ -234,7 +236,8 @@ def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str for problem in orders: todo = comms.goals.todos.get(problem) todo.require_assignee(comms.registry.require(unit).incarnation, observed.id) - action = UnownedWheelSpin.order_for(todo, commander, CoreWatcherIncarnation.watcher()) + action = ReissuedOrderGoalAction.for_todo( + todo, commander, CoreWatcherIncarnation.watcher()) observed = comms.goals.update_goal(unit, replace( action, expect=GoalPrecondition( goal_id=observed.id, expected_state=observed.state, @@ -269,7 +272,9 @@ def unit_block_line(self, unit: str, orders: tuple[str, ...]) -> str: return (f"UNRESOLVED BLOCK {unit}: orders {', '.join(orders)}; " f"goal {self.goal_id} -> {self.reason}") - def enforce_unit_block(self, comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal) -> None: + def enforce_unit_block( + self, comms: Comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal, + ) -> None: pass From de9c1aea16af7c75ca78d04e1c35f40ce207995b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:12:56 -0400 Subject: [PATCH 08/11] P448: one owner offers goal controls; debt growth on the branch removed GoalExecution no longer answers toggle with None: ControlledExecution is the nominal family of executions the owner can move with one control (active, paused, blocked), and Toad asks the family. TurnRunner's owner_stop default was dead (acp.py always passes it), so it is required. Long lines wrapped, imports sorted and the new carry tool and goal_generation formatted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeUaZjvChAcb6qAd6zPYmw --- src/agent_comms/goal_generation.py | 28 +++++++++++++---- src/agent_comms/goal_presentation.py | 22 ++++++++------ src/agent_comms/owned_turn.py | 3 +- src/agent_comms/owner_lifecycle.py | 4 ++- src/agent_comms/turn_goal_account.py | 8 +++-- src/agent_comms/turn_lease.py | 12 +++++--- src/agent_comms/turn_progress.py | 7 +++-- src/agent_comms/turn_runner.py | 2 +- tools/cutover/carry_goal_execution.py | 43 +++++++++++++++++++-------- 9 files changed, 91 insertions(+), 38 deletions(-) diff --git a/src/agent_comms/goal_generation.py b/src/agent_comms/goal_generation.py index 682d3ee6dd..4eac9e45fa 100644 --- a/src/agent_comms/goal_generation.py +++ b/src/agent_comms/goal_generation.py @@ -38,18 +38,26 @@ def execution(self, source: GoalExecutionSource) -> GoalExecution: def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: from .goal_presentation import AttemptUnresolvedExecution + return AttemptUnresolvedExecution(source.goal.id) def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: from .goal_failure_observation import NoFailedTurnEvidence + return NoFailedTurnEvidence() def continue_work(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: """An accepted order or verified progress does not resolve unknown work.""" pass - def turn_permit(self, store: GoalAttemptStore, goals: Goals, owner: Thread, - claim_scope: int, generation: Generation) -> GoalTurnPermit: + def turn_permit( + self, + store: GoalAttemptStore, + goals: Goals, + owner: Thread, + claim_scope: int, + generation: Generation, + ) -> GoalTurnPermit: return NoLaunchPermit() def validate_attempt(self, attempt_id: str | None) -> None: @@ -85,12 +93,21 @@ class ReadyGeneration(GenerationState): def execution(self, source: GoalExecutionSource) -> GoalExecution: from .goal_presentation import RunnableExecution + return RunnableExecution(source.goal.id) - def turn_permit(self, store: GoalAttemptStore, goals: Goals, owner: Thread, - claim_scope: int, generation: Generation) -> GoalTurnPermit: + def turn_permit( + self, + store: GoalAttemptStore, + goals: Goals, + owner: Thread, + claim_scope: int, + generation: Generation, + ) -> GoalTurnPermit: grant = goals.ready_goal_grant_locked(owner, claim_scope, generation) - return store.claim_launch(store.reserve(generation.goal_id, generation.number, ready_grant=grant)) + return store.claim_launch( + store.reserve(generation.goal_id, generation.number, ready_grant=grant) + ) def require_ready(self) -> None: pass @@ -174,6 +191,7 @@ class CompletedGeneration(GenerationState): def execution(self, source: GoalExecutionSource) -> GoalExecution: from .goal_presentation import CompletedExecution + return CompletedExecution(source.goal.id) @classmethod diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 3ba250be0b..12a6385d54 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -74,12 +74,6 @@ def execution(self) -> GoalExecution: class GoalExecution(SubclassUnion, affix="Execution"): """The complete execution family consumed by commands, views and the commander.""" - toggle_label: ClassVar[str] = "Pause" - - @property - def toggle(self) -> type[GoalAction] | None: - return None - def details_heading(self, heading: str) -> str: return heading @@ -138,7 +132,17 @@ def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentati return self.presentation(ordinary.title) -class ActiveExecution(BoundGoalExecution, declared_name=None): +class ControlledExecution(BoundGoalExecution, declared_name=None): + """An execution the owner moves on with one control; the others offer none.""" + + toggle_label: ClassVar[str] = "Pause" + + @property + @abstractmethod + def toggle(self) -> type[GoalAction]: ... + + +class ActiveExecution(ControlledExecution, declared_name=None): @property def toggle(self) -> type[GoalAction]: from .goal_actions import PausedGoalAction @@ -161,7 +165,7 @@ class CompletedExecution(BoundGoalExecution): @dataclass(frozen=True) -class PausedExecution(WaitingExecution): +class PausedExecution(WaitingExecution, ControlledExecution): source: PauseSource label = "Paused" toggle_label = "Resume" @@ -196,7 +200,7 @@ def waiting_for(self, goal: Goal) -> bool: return goal.id == self.goal_id and goal.state.active -class BlockedExecution(WaitingExecution, declared_name=None): +class BlockedExecution(WaitingExecution, ControlledExecution, declared_name=None): glyph = "!" label = "Blocked" toggle_label = "Retry" diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 2ca4e3453b..20fb13e020 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -412,7 +412,8 @@ async def run(self) -> None: return await self.run_native() except asyncio.CancelledError as error: - self.terminal = TurnTerminal.from_cancellation(error).interrupting(self.terminal) + cancelled = TurnTerminal.from_cancellation(error) + self.terminal = cancelled.interrupting(self.terminal) raise def settle_unbound(self): diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index eee6015a8c..f691536232 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -230,7 +230,9 @@ def require_owner_loss(self, snapshot, owner_identity): if not actual.includes(released): raise RelationViolationError("Native release claim_scope regressed") if actual == released and not self.matches(current): - raise RelationViolationError("Native release process differs from its original declaration") + raise RelationViolationError( + "Native release process differs from its original declaration" + ) return self diff --git a/src/agent_comms/turn_goal_account.py b/src/agent_comms/turn_goal_account.py index 5b3653f8cb..899b7d4eec 100644 --- a/src/agent_comms/turn_goal_account.py +++ b/src/agent_comms/turn_goal_account.py @@ -47,7 +47,9 @@ def settle_current(cls, account: TurnGoalAccount) -> None: for goal in owner.goal.continued_by(current.goal): store = comms.goals.open_goal_store() generation = store.require_generation(goal.id) - generation.lifecycle.continue_work(store, generation, f"verified-turn:{account.turn.value}") + generation.lifecycle.continue_work( + store, generation, f"verified-turn:{account.turn.value}" + ) @property def progress_witness(self) -> str: @@ -98,7 +100,9 @@ def reserve(*, owner: Thread, goal: Goal, comms: Comms, autonomous: bool) -> Goa with comms.registry.store.locked(): claim_scope = comms.registry.snapshot().claim_generation(owner.name) generation = store.require_generation(goal.id) - return generation.lifecycle.turn_permit(store, comms.goals, owner, claim_scope, generation) + return generation.lifecycle.turn_permit( + store, comms.goals, owner, claim_scope, generation + ) except GoalAttemptError as error: if autonomous: return NoLaunchPermit() diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index b7c9859e0e..75630ff880 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -7,13 +7,17 @@ from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Annotated - +from typing import TYPE_CHECKING, Annotated, ClassVar from .activity import ObservedActivity from .errors import RelationViolationError from .field_codec import ( - NullMember, RecordMember, SingleFieldValue, SubclassUnion, WireName, WireTransient, + NullMember, + RecordMember, + SingleFieldValue, + SubclassUnion, + WireName, + WireTransient, boundary_decoder, ) from .locked_store import DurableFile, JsonDocument, PatternFile @@ -24,9 +28,9 @@ if TYPE_CHECKING: from .child_process import ProcessState + from .goal_presentation import GoalExecution, GoalExecutionSource from .registry_document import RegistrySnapshot from .threads import Thread - from .goal_presentation import GoalExecution, GoalExecutionSource class TurnDeclaration(SubclassUnion, affix=""): diff --git a/src/agent_comms/turn_progress.py b/src/agent_comms/turn_progress.py index 578dd5088c..75af578948 100644 --- a/src/agent_comms/turn_progress.py +++ b/src/agent_comms/turn_progress.py @@ -203,8 +203,11 @@ async def done(self, event: events.Done) -> events.Done: text=("An identified follow-up input was not started; inspect local diagnostics."), ) if event.ok and not (event.text.strip() or self.goals.has_productive_tool): - event = replace(event, ok=False, - text="Backend reported success without assistant output or tool activity.") + event = replace( + event, + ok=False, + text="Backend reported success without assistant output or tool activity.", + ) event = self.turn.terminal.reported(event) event = event.resolve(self.terminal_error, document, self.original) # Retain the resolved outcome before transport publication. If delivery diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index ad8469c433..3d031b9007 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -88,7 +88,7 @@ def __init__( *, agent_bin: LaunchBinary, agent_args: LaunchArguments, - owner_stop: TurnInterruption = EndedWithoutResult(), + owner_stop: TurnInterruption, ): self.comms = comms self.runtime = runtime diff --git a/tools/cutover/carry_goal_execution.py b/tools/cutover/carry_goal_execution.py index 8d707fa31a..1484d28804 100644 --- a/tools/cutover/carry_goal_execution.py +++ b/tools/cutover/carry_goal_execution.py @@ -3,6 +3,7 @@ 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 @@ -13,18 +14,28 @@ 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.", -}) +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): @@ -55,5 +66,11 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: 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) + 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 63f1ae6af5779dde5b204f89d7238efb8801a6bb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:18:50 -0400 Subject: [PATCH 09/11] Fix typed goal reissue and the ledger observation path --- src/agent_comms/goal_actions.py | 5 +++-- src/agent_comms/presentation.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 4df2e66f22..09c8fcc1b3 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -574,11 +574,12 @@ class ReissuedOrderGoalAction(OrderGoalAction): def for_todo(cls, todo: Todo, commander: str, assigner: ThreadIncarnation) -> Self: """The order the Todo already accepted, handed again with a fresh review window.""" now = time.time() + order = todo.order.reissued(now) return cls( problem=todo.id, expected_revision=todo.revision, - order=todo.order.reissued(now), request=todo.goal.request(), + order=order, request=todo.goal.request(), review_at=todo.reissued_review(now), commander=commander, assigner=assigner, - traced=todo.order.owner) + traced=order.owner) def require_sendable(self) -> None: pass diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 65cbe77aa9..a9e82a9fb9 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -818,8 +818,10 @@ def room_ledger_paths(views: HistoryViews) -> tuple[Path, ...]: def goal_execution_paths(views: HistoryViews) -> tuple[Path, ...]: - return (*GoalWaits.source_paths(views.root), - *GoalAttemptStore.source_paths(views.root / GoalAttemptStore.root_directory)) + return ( + *GoalWaits.source_paths(views.root), + GoalAttemptStore.path_in(views.root / GoalAttemptStore.root_directory), + ) def relationship_paths(views: HistoryViews) -> tuple[Path, ...]: From cb7bff2e260db734c0268908e1d9a467d0139181 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:45:13 -0400 Subject: [PATCH 10/11] Sort the imports the merge touched Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeUaZjvChAcb6qAd6zPYmw --- src/agent_comms/acp.py | 4 ++-- src/agent_comms/goal_actions.py | 2 +- src/agent_comms/goal_failure_observation.py | 3 +-- src/agent_comms/goal_management.py | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index 571e07353a..f5e9713c90 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -50,7 +50,6 @@ ) from .acp_failure import NativeTurnError from .cohort_foreground import _accept_visible_deliveries -from .turn_terminal import EndedWithoutResult, TurnInterruption, TurnTerminal from .comms import Comms, wire from .coordination_cohort import next_sealed_assignment from .coordination_database import CoordinationStore @@ -75,13 +74,14 @@ from .threads import Thread from .turn_effects import TurnEffects from .turn_runner import TurnRunner +from .turn_terminal import EndedWithoutResult, TurnInterruption, TurnTerminal from .wire_log import WireLog if TYPE_CHECKING: from .selected_tool_broker import SelectedToolIntent -from .turn_runner import RELAY_PREFIX from .native_arguments import ConfiguredBinary, InheritedArguments, LaunchArguments, LaunchBinary +from .turn_runner import RELAY_PREFIX class CommsAgent(SessionEffects, InputEffects, TurnEffects): diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 43348f3d54..740bc837d4 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -2,10 +2,10 @@ from __future__ import annotations +import time from abc import abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace -import time from typing import TYPE_CHECKING, Annotated, ClassVar, Self from uuid import uuid4 diff --git a/src/agent_comms/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 94136d880c..035bd0bdcc 100644 --- a/src/agent_comms/goal_failure_observation.py +++ b/src/agent_comms/goal_failure_observation.py @@ -12,7 +12,6 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Annotated - from .diagnostics import FailureReason from .field_codec import Id128Text, WireName from .goal_attempt_identity import GoalAttemptIdentity @@ -27,10 +26,10 @@ if TYPE_CHECKING: from .goal_attempts import Reservation + from .goal_presentation import GoalExecution from .goals import Goal from .threads import Thread from .turn_lease import TurnLeaseFence - from .goal_presentation import GoalExecution class GoalLedgerTable(DurableTables): diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 813ca75e60..31db0cf484 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -41,11 +41,11 @@ from .native_input_owner import RegistryOwner from .registry_document import RegistrySnapshot from .goal_presentation import ( + GoalExecution, + GoalExecutionSource, GoalPublicationSnapshot, GoalSnapshot, GoalWaitTarget, - GoalExecution, - GoalExecutionSource, IdleGoalExecution, ) from .goals import Goal, GoalDeclaration From 33307e004a24d7d96199c39f16231bba727c7802 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:46:15 -0400 Subject: [PATCH 11/11] Execution members import at module level where no cycle runs back goal_generation and turn_lease reach goal_presentation and goal_failure_observation at import time; neither reaches back. Only the toggle actions stay function-local: goal_actions imports goal_presentation through goal_waits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeUaZjvChAcb6qAd6zPYmw --- src/agent_comms/goal_generation.py | 14 ++++++-------- src/agent_comms/goal_presentation.py | 6 ++---- src/agent_comms/turn_lease.py | 4 ++-- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/goal_generation.py b/src/agent_comms/goal_generation.py index 23c65bc27e..0ceb2fce1a 100644 --- a/src/agent_comms/goal_generation.py +++ b/src/agent_comms/goal_generation.py @@ -12,6 +12,12 @@ FailedAttempt, GoalAttemptPhase, ) +from .goal_failure_observation import NoFailedTurnEvidence +from .goal_presentation import ( + AttemptUnresolvedExecution, + CompletedExecution, + RunnableExecution, +) from .lifecycle import LifecycleState from .lifecycle_capability import Complete, Failed, Terminal @@ -37,13 +43,9 @@ def execution(self, source: GoalExecutionSource) -> GoalExecution: return source.turn.execution(source) def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: - from .goal_presentation import AttemptUnresolvedExecution - return AttemptUnresolvedExecution(source.goal.id) def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: - from .goal_failure_observation import NoFailedTurnEvidence - return NoFailedTurnEvidence() def continue_work(self, store: GoalAttemptStore, generation: Generation, witness: str) -> None: @@ -89,8 +91,6 @@ class ReadyGeneration(GenerationState): ready = True def execution(self, source: GoalExecutionSource) -> GoalExecution: - from .goal_presentation import RunnableExecution - return RunnableExecution(source.goal.id) def turn_permit( @@ -190,8 +190,6 @@ def authorize_retry( @dataclass(frozen=True) class CompletedGeneration(Complete, Terminal, GenerationState): def execution(self, source: GoalExecutionSource) -> GoalExecution: - from .goal_presentation import CompletedExecution - return CompletedExecution(source.goal.id) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 625ceaa8bd..b06de85097 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -11,9 +11,9 @@ from .field_codec import NullMember, RecordMember, SubclassUnion, WireValue from .goal_failure_observation import FailedTurnEvidence from .goal_states import PauseSource -from .goals import Goal, GoalCheckpoint, GoalDeclaration +from .goals import Goal, GoalChangedError, GoalCheckpoint, GoalDeclaration from .lifecycle_capability import Active -from .thread_identity import RecordedThreadIncarnation +from .thread_identity import CoreWatcherIncarnation, RecordedThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: @@ -232,8 +232,6 @@ def enforce_unit_block( self, comms: Comms, commander: str, unit: str, orders: tuple[str, ...], goal: Goal, ) -> None: from .goal_actions import GoalPrecondition, ReissuedOrderGoalAction - from .goals import GoalChangedError - from .thread_identity import CoreWatcherIncarnation if comms.goals.goal_execution(unit) != self: raise GoalChangedError(comms.registry.require(unit), goal.captured) diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 3a608819b7..bd9b8cdb4d 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -20,8 +20,9 @@ WireTransient, boundary_decoder, ) -from .locked_store import DurableFile, JsonDocument, PatternFile +from .goal_presentation import RunningExecution from .lifecycle_capability import Executing, LifecycleCapability +from .locked_store import DurableFile, JsonDocument, PatternFile from .routing import TurnRouting from .store_files import _store_lock from .thread_identity import ThreadIncarnation, TurnFence, TurnId, TurnIdentity @@ -217,7 +218,6 @@ class ActiveTurn(Executing, RecordMember, TurnDeclaration): turn_generation: TurnGeneration = UnrecordedGeneration() phase: TurnPhase = field(default_factory=PreparingPhase) def execution(self, source: GoalExecutionSource) -> GoalExecution: - from .goal_presentation import RunningExecution if source.owner_alive and source.owner.process_identity.owns_turn(self): return RunningExecution(source.goal.id) return source.generation.lifecycle.idle_execution(source)