From 8b0cdee8b3d2cef4bd77ffcf6e5ec6694b297b55 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:56:15 -0400 Subject: [PATCH 01/10] Record owner requests independently of commander goals --- src/agent_comms/commander/request.py | 58 ++++++++------------ src/agent_comms/goal_actions.py | 2 +- src/agent_comms/goals.py | 30 +---------- src/agent_comms/owner_requests.py | 80 ++++++++++++++++++++++++++++ src/agent_comms/standing_rules.py | 2 +- src/agent_comms/todos.py | 64 +++++++++++++++++++++- 6 files changed, 169 insertions(+), 67 deletions(-) create mode 100644 src/agent_comms/owner_requests.py diff --git a/src/agent_comms/commander/request.py b/src/agent_comms/commander/request.py index 6acfd135d8..d74a4fc6f4 100644 --- a/src/agent_comms/commander/request.py +++ b/src/agent_comms/commander/request.py @@ -1,31 +1,17 @@ -"""What the owner asked for, recorded as the commander's own Goal so no request is forgotten. +"""Record, list and complete independent owner requests. -`request add "TEXT"` records one and prints its id; `request complete ID --why "..."` closes -it, refused while an order serving it is open; `request list` shows the open request with its -open orders. A thread holds one Goal, so one request is open at a time. Every order names the -request it serves (`order send|problem --request ID`); rotation prints REQUEST STALLED for an -open request that no open order serves. +Every request keeps its own id and serving orders. Completing one request never +replaces the commander's current goal or another request. """ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass +from typing import Annotated from agent_comms import field_codec - -from ..goal_actions import CompletedGoalAction, GoalPrecondition, SetGoalAction -from ..goal_states import ActiveGoal -from ..goals import Goal, RequestGoalOrder -from ..todos import Todo -from .order import Commander, CommanderVerb - - -def open_requests(ctx: Commander) -> tuple[tuple[Goal, tuple[Todo, ...]], ...]: - """The commander's open owner requests and their undelivered orders.""" - thread = ctx.comms.registry.require(ctx.commander) - return tuple(request for goal in thread.goal.active() - for request in goal.order.open_requests(goal, ctx.comms.goals, thread.incarnation)) +from agent_comms.commander.order import Commander, CommanderVerb +from agent_comms.owner_requests import OwnerRequestId class RequestVerb(CommanderVerb): @@ -34,38 +20,40 @@ class RequestVerb(CommanderVerb): @dataclass(frozen=True, kw_only=True) class RequestAddVerb(RequestVerb, declared_name="add"): - help = "record an owner request as the commander's Goal; prints its id" + help = "record an owner request; prints its id" text: Annotated[str, field_codec.Argument(flags=("text",))] def run(self, ctx: Commander) -> None: - goal = ctx.comms.goals.update_goal(ctx.commander, - SetGoalAction(text=self.text, order=RequestGoalOrder())) - print(goal.require().id) + commander = ctx.comms.registry.require(self.who(ctx)).incarnation + request = ctx.comms.goals.todos.add_request(commander, self.text) + print(request.id.value) @dataclass(frozen=True, kw_only=True) class RequestCompleteVerb(RequestVerb, declared_name="complete"): - help = "close a request; refused while an order serving it is open" - id: Annotated[str, field_codec.Argument(flags=("id",))] + help = "close a request; refused while an order serving it is pending" + id: Annotated[OwnerRequestId, field_codec.Argument(flags=("id",))] why: Annotated[str, field_codec.Argument(flags=("--why",))] def run(self, ctx: Commander) -> None: - expect = GoalPrecondition(goal_id=self.id, expected_state=ActiveGoal()) - ctx.comms.goals.update_goal(ctx.commander, - CompletedGoalAction(progress=self.why, expect=expect)) - print(f"{self.id} done") + commander = ctx.comms.registry.require(self.who(ctx)).incarnation + ctx.comms.goals.todos.complete_request(commander, self.id, self.why) + print(f"{self.id.value} done") class RequestListVerb(RequestVerb, declared_name="list"): - help = "the open request and the open orders serving it" + help = "every open request and its pending orders" def run(self, ctx: Commander) -> None: - requests = open_requests(ctx) + commander = ctx.comms.registry.require(self.who(ctx)).incarnation + store = ctx.comms.goals.todos + requests = store.open_requests(commander) if not requests: print("No open request.") - for goal, served in requests: - print(f"{goal.id} {goal.text}") - for todo in served: + for request in requests: + print(f"{request.id.value} {request.text}") + for todo in (todo for todo in store.for_request(commander, request.id.value) + if todo.pending): print(f" {todo.id} review {todo.review_at.clock()}: {todo.text}") diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index bbf2b5749a..6fc5726879 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -36,10 +36,10 @@ NoGoal, ProblemGoalOrder, ReportedTurn, - RequestDirective, RequestGoalOrder, UnorderedGoalOrder, ) +from agent_comms.owner_requests import RequestDirective from .progress_claims import ProgressClaim from .recorded_time import EventTime from .repository_name import RepositoryName diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index dd0b706913..f2c56074fa 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -9,7 +9,6 @@ import signal import subprocess import tempfile -import time from abc import abstractmethod from collections.abc import Iterator, Sequence from dataclasses import dataclass, field, replace @@ -33,6 +32,7 @@ from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .text_digest import TextDigest from .ui_model.goal import GoalDisplay +from agent_comms.owner_requests import RequestDirective if TYPE_CHECKING: from .goal_actions import DirectiveGoalAction, GoalAction, GoalActionContext @@ -52,7 +52,6 @@ from .thread_identity import TurnId from .todos import Todo from .messages import Message - from .private_bus_checkpoint import CheckedRead @dataclass(frozen=True, kw_only=True) @@ -468,33 +467,6 @@ def require_replacement(self, goal: Goal, goals: Goals, owner: ThreadIncarnation pass -@dataclass(frozen=True) -class RequestDirective: - """One original directive; only a reply linked to that message answers it.""" - - window: ClassVar[int] = 10 * 60 # seconds the commander has to act on a directive - seq: int - deadline: float - - @property - def due_time(self) -> str: - return time.strftime("%H:%M", time.localtime(self.deadline)) - - def original(self, goals: Goals) -> Message: - source: CheckedRead - with goals.bus.log.checked_read() as source: - return source.delivery(self.seq).message - - def overdue(self, goals: Goals, owner: ThreadIncarnation, now: float) -> bool: - if self.deadline >= now: - return False - original = self.original(goals).reference - with goals.bus.log.checked_read() as source: - replies = source.replies((original,)) - return not any(reply.message.sender == owner.name and original in reply.message.answers - for reply in replies) - - @dataclass(frozen=True) class RequestGoalOrder(GoalOrder): """An owner request on the commander's own Goal: each order names the one it serves, and the diff --git a/src/agent_comms/owner_requests.py b/src/agent_comms/owner_requests.py new file mode 100644 index 0000000000..2f1ad0680e --- /dev/null +++ b/src/agent_comms/owner_requests.py @@ -0,0 +1,80 @@ +"""Owner request identities, lifecycle, and original directive references.""" + +from __future__ import annotations + +import time +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated, ClassVar, Self +from uuid import uuid4 + +from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import BoundedText, SingleFieldValue + +if TYPE_CHECKING: + from agent_comms.goal_management import Goals + from agent_comms.messages import Message + from agent_comms.thread_identity import ThreadIncarnation + from agent_comms.todos import Todo + + +@dataclass(frozen=True, slots=True) +class OwnerRequestId(SingleFieldValue): + value: Annotated[str, BoundedText] + + @classmethod + def new(cls) -> Self: + return cls(uuid4().hex) + + +class RequestState(DeclaredFamily, affix="RequestState"): + def require_open(self, request: OwnerRequestId) -> OpenRequestState: + raise ValueError(f"Request {request.value} is already completed.") + + +@dataclass(frozen=True) +class OpenRequestState(RequestState): + def require_open(self, request: OwnerRequestId) -> Self: + return self + + def complete( + self, request: OwnerRequestId, orders: Sequence[Todo], reason: str, + ) -> CompletedRequestState: + if any(order.pending for order in orders): + raise ValueError(f"Request {request.value} still has pending orders.") + return CompletedRequestState(reason) + + +@dataclass(frozen=True) +class CompletedRequestState(RequestState): + reason: str + + def __post_init__(self) -> None: + if not self.reason.strip(): + raise ValueError("Completing an owner request requires a reason.") + + +@dataclass(frozen=True) +class RequestDirective: + """One original directive; only a reply linked to that message answers it.""" + + window: ClassVar[int] = 10 * 60 + seq: int + deadline: float + + @property + def due_time(self) -> str: + return time.strftime("%H:%M", time.localtime(self.deadline)) + + def original(self, goals: Goals) -> Message: + with goals.bus.log.checked_read() as source: + return source.delivery(self.seq).message + + def overdue(self, goals: Goals, owner: ThreadIncarnation, now: float) -> bool: + if self.deadline >= now: + return False + original = self.original(goals).reference + with goals.bus.log.checked_read() as source: + replies = source.replies((original,)) + return not any(reply.message.sender == owner.name and original in reply.message.answers + for reply in replies) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 8727f533dd..01531624e2 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from agent_comms.comms import Comms - from agent_comms.goals import RequestDirective + from agent_comms.owner_requests import RequestDirective from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.todos import Todo diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 1739ef7305..5adbc253b5 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -64,6 +64,12 @@ from agent_comms.maintenance_barrier import MaintenanceBarrier from agent_comms.messages import InfoKind, Message from agent_comms.order_story import ProblemStory, StoryEvent, StoryLine, StorySource +from agent_comms.owner_requests import ( + OpenRequestState, + OwnerRequestId, + RequestDirective, + RequestState, +) from agent_comms.plan_phases import OrderPhase, UnplannedPhase from agent_comms.private_bus_checkpoint import PublicMessages from agent_comms.progress_claims import MergedClaim, ProgressClaim @@ -3117,6 +3123,32 @@ def _participant(thread: Thread) -> ThreadIncarnation: return thread.incarnation +@dataclass(frozen=True, slots=True) +class OwnerRequest(TypedTable): + """One owner's request, independent of any current turn goal.""" + + indexes = (Index(("commander", "state", "created_at")),) + + id: Annotated[OwnerRequestId, Column(primary_key=True, storage=TextStorage)] + commander: RecordedThreadIncarnation + text: str + state: RequestState = OpenRequestState() + directives: tuple[RequestDirective, ...] = () + created_at: float = field(default_factory=time.time) + + def __post_init__(self) -> None: + if not self.text.strip(): + raise TodoError("An owner request needs its text.") + + @classmethod + def new(cls, commander: ThreadIncarnation, text: str) -> Self: + return cls(OwnerRequestId.new(), commander.require_recorded(), text) + + def require_commander(self, commander: ThreadIncarnation) -> None: + if self.commander != commander.require_recorded(): + raise TodoError(f"Request {self.id.value} belongs to another commander.") + + @dataclass(frozen=True, slots=True) class TodoStore(DurableStore): """One SQLite row owns both todo state and its exclusive assignment. @@ -3128,7 +3160,7 @@ class TodoStore(DurableStore): root: Path filename: ClassVar[str] = "todos.sqlite3" - rows: ClassVar[tuple[type[TypedTable], ...]] = (Todo, HuntRecord) + rows: ClassVar[tuple[type[TypedTable], ...]] = (Todo, HuntRecord, OwnerRequest) @contextmanager def writing(self) -> Iterator[sqlite3.Connection]: @@ -3150,6 +3182,36 @@ def writer_connection( ): yield connection + def add_request(self, commander: ThreadIncarnation, text: str) -> OwnerRequest: + request = OwnerRequest.new(commander, text) + with self.writing() as db: + request.insert(db) + return request + + def owner_request(self, request: OwnerRequestId) -> OwnerRequest: + with self.snapshot() as db: + return OwnerRequest.require(db, id=request) + + def open_requests(self, commander: ThreadIncarnation) -> tuple[OwnerRequest, ...]: + with self.snapshot() as db: + return tuple(OwnerRequest.select( + db, commander=commander.require_recorded(), state=OpenRequestState(), + order_by=("created_at", "id"), descending=True, + )) + + def complete_request( + self, commander: ThreadIncarnation, request: OwnerRequestId, reason: str, + ) -> OwnerRequest: + with self.writing() as db: + current = OwnerRequest.require(db, id=request) + current.require_commander(commander) + state = current.state.require_open(request) + completed = replace(current, state=state.complete( + request, self.for_request(commander, request.value), reason, + )) + completed.upsert(db) + return completed + def record_hunt(self, commander: ThreadIncarnation, hunt: Hunt) -> HuntRecord: hunt.require_commander(self, commander) recorded = HuntRecord(uuid4().hex, commander, hunt) From 45e753dfbc0fb4b736e104417e0d4c9c9ceeebf4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:51:33 -0400 Subject: [PATCH 02/10] Link orders and directives to independent owner request records --- src/agent_comms/commander/judge.py | 3 +- src/agent_comms/commander/order.py | 48 +++--- src/agent_comms/commander/request.py | 16 +- src/agent_comms/commander/standing.py | 15 +- src/agent_comms/commander_heartbeat.py | 4 +- src/agent_comms/connector.py | 39 +++-- src/agent_comms/goal_actions.py | 56 ++----- src/agent_comms/goals.py | 69 +------- src/agent_comms/owner_requests.py | 67 +++++++- src/agent_comms/standing_rules.py | 14 +- src/agent_comms/todos.py | 211 +++++++++++-------------- src/agent_comms/tool_caller.py | 12 -- src/agent_comms/tools.py | 17 +- src/agent_comms/work_dispatch.py | 5 +- 14 files changed, 274 insertions(+), 302 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 8f9490dcbe..fae9e458a4 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -334,7 +334,8 @@ def check_edits(self, edits: list[Edit]) -> None: def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: for todo in todos: - audience = todo.verdicts[-1].audience.of(todo.held().owner, todo.goal) + audience = todo.verdicts[-1].audience.of( + todo.held().owner, ctx.comms.goals.todos.owner_request(todo.request.require())) roast(ctx, audience, todo.holder, self.lines, self.roast, self.fix, self.slop()) line = (f'{time.strftime("%Y-%m-%d %H:%M")} {todo.holder} ' f'(strike {todo.strike_count} on {todo.id}; caught by {ctx.commander} at review): ' diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index ea099ca454..0aec8de7e9 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -59,6 +59,7 @@ from agent_comms.standing_rules import HuntDue from agent_comms.storage_rules import UnitStorage from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.owner_requests import NewestOpenRequest, RequestSelection from agent_comms.todos import ( BluntText, CleanHunt, @@ -394,7 +395,7 @@ def send(self, unit, document: OrderFile, clean=False, reassign: Reassignment = NoReassignment(), new=None, traced: FileLine | NoSourceReference = NoSourceReference(), disk=False, finding=UnlocatedProblemFinding(), - answers: tuple[MessageReference, ...] = ()): + answers: tuple[MessageReference, ...] = (), *, request: RequestSelection): """Record the decoded order, or report every failed prerequisite before any write.""" problems = document.problems_found(clean) check = replace(document.order.check, @@ -500,16 +501,16 @@ def send(self, unit, document: OrderFile, clean=False, struck_already = replacing and listed[group[0]].struck_on_order(unit) measured = MeasuredReview.read(self.comms.goals.todos) review_at = measured.review_at(order) - request = order.request(self.comms.registry.require(self.commander), tuple(group)) + request_id = request.for_order(self.comms.goals.todos, self.incarnation) if new: self.call(CommsTodoCreateTool(owner=unit, order=CreateProblemGoalAction( problem=new[0], repo=Repo.of(Path(check.directory)).github, text=new[1], - order=order, request=request, review_at=review_at, commander=self.commander))) + order=order, request=request_id, review_at=review_at, commander=self.commander))) for pid in [] if new else group: self.call(CommsOrderTool(owner=unit, order=OrderGoalAction( problem=pid, expected_revision=self.comms.goals.todos.get(pid).revision, assigner=self.incarnation, traced=traced, - order=order, request=request, review_at=review_at, commander=self.commander))) + order=order, request=request_id, review_at=review_at, commander=self.commander))) message += "\n" + "\n".join(line for pid in group for line in order.scope.instructions(pid)) sent = self.post(VerdictAudience((unit,)), message, answers) # The message is each order's recorded link; a verdict citing it lands on that order. @@ -561,7 +562,7 @@ def say(self, units: str, body_file: str, print(self.post(VerdictAudience(tuple(units.split(','))), body.rstrip(), answers)) def problem(self, order_file, unit, where, disk=False, - answers: tuple[MessageReference, ...] = ()): + answers: tuple[MessageReference, ...] = (), *, request: RequestSelection): """A problem is born owned: Core records it with its order in one step, or not at all.""" document = OrderFile.read(Path(order_file).read_text()) location = FileLine.from_wire(where) @@ -572,7 +573,8 @@ def problem(self, order_file, unit, where, disk=False, history = self.comms.goals.todos.history_for_commander(self.incarnation) pid = f'P{max([int(t.id[1:]) for t in history if t.id[1:].isdigit()] + [0]) + 1}' self.send(unit, document, new=(pid, document.defect), - disk=disk, finding=LocatedProblemFinding(location), answers=answers) + disk=disk, finding=LocatedProblemFinding(location), answers=answers, + request=request) print(pid) def close(self, pid, works, artifact=''): @@ -645,7 +647,8 @@ def posted(self, unit: str, todo: Todo, strike: StrikeTodo, n = struck(todo, unit) line = (f'{time.strftime("%Y-%m-%d")} {strike.at.clock()} {unit} (strike {n} on {todo.id}' f'{f", {todo.id} taken away" if n >= 2 else ""}): {strike.reason}') - self.on_record(line, 'Strike', VerdictAudience.of(unit, todo.goal), answers) + self.on_record(line, 'Strike', VerdictAudience.of( + unit, self.comms.goals.todos.owner_request(todo.request.require())), answers) print(f'{unit}: {n} strike(s) on {todo.id}' + (f'. Reassign {todo.id} now and freeze its branch.' if n >= 2 else '')) def done(self, pid, sha, screenshot='', seen=''): @@ -716,7 +719,8 @@ def unstrike(self, task: str, why: str, at: StrikeSelector = NewestStrike()) -> n = struck(self.comms.goals.todos.get(task), unit) line = (f'{time.strftime("%Y-%m-%d %H:%M")} {unit} (strike voided on {task}, ' f'recorded {voided.at.clock()}; {n} strike(s) remain): {why}') - self.on_record(line, 'Void', VerdictAudience.of(unit, todo.goal)) + self.on_record(line, 'Void', VerdictAudience.of( + unit, self.comms.goals.todos.owner_request(todo.request.require()))) print(f'{unit}: {n} strike(s) on {task}') def withdraw(self, pid: str, why: str, superseded_by: Successor = NoSuccessor()) -> None: @@ -733,7 +737,8 @@ def withdraw(self, pid: str, why: str, superseded_by: Successor = NoSuccessor()) unit = previous.owner withdrawal, = self.comms.goals.todos.get(pid).order.withdrawals(todo) self.on_record(f'{time.strftime("%Y-%m-%d %H:%M")} {withdrawal}', 'Withdrawal', - VerdictAudience.of(unit, todo.goal)) + VerdictAudience.of( + unit, self.comms.goals.todos.owner_request(todo.request.require()))) self.post(VerdictAudience((unit,)), f'Withdrawn: {withdrawal}. Stop that work; it counts against no one.') print(withdrawal) @@ -808,8 +813,8 @@ class DirectiveVerb(CommanderVerb): def answer_references(self, ctx: Commander) -> tuple[MessageReference, ...]: thread = ctx.comms.registry.require(self.who(ctx)) - answerable = (*(message for goal in thread.goal.active() - for message in goal.order.directive_messages(ctx.comms.goals)), + answerable = (*(message for request in ctx.comms.goals.todos.open_requests(thread.incarnation) + for message in request.directive_messages(ctx.comms.goals)), *UnitAskUnanswered.asks(ctx.comms, thread.name, time.time())) originals = tuple(message for message in answerable if message.message_id in self.answers) if set(self.answers) != {message.message_id for message in originals}: @@ -821,9 +826,8 @@ def gate(self, ctx: Commander) -> None: answers = self.answer_references(ctx) thread = ctx.comms.registry.require(self.who(ctx)) overdue = tuple(directive.original(ctx.comms.goals) - for goal in thread.goal.active() - for directive in goal.order.overdue_directives( - goal, ctx.comms.goals, thread.incarnation, time.time())) + for request in ctx.comms.goals.todos.open_requests(thread.incarnation) + for directive in request.overdue_directives(ctx.comms.goals, time.time())) if overdue and not answers: lines = '\n'.join(f'{message.message_id}: {message.body[:80]}' for message in overdue) sys.exit('REFUSED, nothing recorded: overdue owner directives:\n' + lines @@ -836,7 +840,15 @@ class OrderVerb(CommanderVerb): @dataclass(frozen=True, kw_only=True) -class SendVerb(OrderVerb, DirectiveVerb): +class RequestedOrderVerb(OrderVerb, DirectiveVerb, declared_name=None): + request: Annotated[ + RequestSelection, Argument(flags=("--request",), + options={"help": "owner request ID; defaults to newest open"}), + ] = NewestOpenRequest() + + +@dataclass(frozen=True, kw_only=True) +class SendVerb(RequestedOrderVerb): help = "record the order in Core and send it" unit: Annotated[str, Argument(flags=("unit",))] body_file: Annotated[ @@ -875,7 +887,7 @@ class SendVerb(OrderVerb, DirectiveVerb): def run(self, ctx: Commander) -> None: ctx.send(self.unit, OrderFile.read(Path(self.body_file).read_text()), clean=self.clean, reassign=self.reassign, traced=self.traced, - disk=self.disk, answers=self.answer_references(ctx)) + disk=self.disk, answers=self.answer_references(ctx), request=self.request) @dataclass(frozen=True, kw_only=True) @@ -1119,7 +1131,7 @@ def run(self, ctx: Commander) -> None: @dataclass(frozen=True, kw_only=True) -class ProblemVerb(OrderVerb, DirectiveVerb): +class ProblemVerb(RequestedOrderVerb): help = "Core records the problem with its order, or nothing" order: Annotated[ str, Argument(flags=("order",), options={"metavar": "ORDER.json"}) @@ -1135,7 +1147,7 @@ class ProblemVerb(OrderVerb, DirectiveVerb): def run(self, ctx: Commander) -> None: ctx.problem(self.order, self.to, self.where, self.disk, - self.answer_references(ctx)) + self.answer_references(ctx), request=self.request) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/commander/request.py b/src/agent_comms/commander/request.py index d74a4fc6f4..5b067053c6 100644 --- a/src/agent_comms/commander/request.py +++ b/src/agent_comms/commander/request.py @@ -7,10 +7,12 @@ from __future__ import annotations from dataclasses import dataclass +from functools import partial from typing import Annotated from agent_comms import field_codec from agent_comms.commander.order import Commander, CommanderVerb +from agent_comms.message_bus import HeldBusWrite from agent_comms.owner_requests import OwnerRequestId @@ -18,8 +20,15 @@ class RequestVerb(CommanderVerb): """A verb of `request`.""" +class RequestMutation(HeldBusWrite, RequestVerb, declared_name=None): + """Request mutations share the same admission as order and directive publication.""" + + def apply(self, ctx: Commander) -> None: + self.write_when_open(ctx.comms, partial(super().apply, ctx)) + + @dataclass(frozen=True, kw_only=True) -class RequestAddVerb(RequestVerb, declared_name="add"): +class RequestAddVerb(RequestMutation, declared_name="add"): help = "record an owner request; prints its id" text: Annotated[str, field_codec.Argument(flags=("text",))] @@ -30,7 +39,7 @@ def run(self, ctx: Commander) -> None: @dataclass(frozen=True, kw_only=True) -class RequestCompleteVerb(RequestVerb, declared_name="complete"): +class RequestCompleteVerb(RequestMutation, declared_name="complete"): help = "close a request; refused while an order serving it is pending" id: Annotated[OwnerRequestId, field_codec.Argument(flags=("id",))] why: Annotated[str, field_codec.Argument(flags=("--why",))] @@ -52,8 +61,7 @@ def run(self, ctx: Commander) -> None: print("No open request.") for request in requests: print(f"{request.id.value} {request.text}") - for todo in (todo for todo in store.for_request(commander, request.id.value) - if todo.pending): + for todo in store.serving_orders(request.id): print(f" {todo.id} review {todo.review_at.clock()}: {todo.text}") diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index bd8e827a01..6813f3ef6b 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -27,7 +27,7 @@ from agent_comms.storage_rules import OrderCheckoutWork from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation from agent_comms.threads import Thread -from agent_comms.todos import Todo +from agent_comms.todos import OwnerRequest, Todo from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch from agent_comms.commander.config import CommanderConfig @@ -155,18 +155,17 @@ def line(self) -> str: @dataclass(frozen=True) class RequestStalled(UndatedStandingRule): - goal: Goal + request: OwnerRequest @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[RequestStalled, ...]: thread = comms.registry.require(commander) - return tuple(cls(request) for goal in thread.goal.active() - for request, served in goal.order.open_requests( - goal, comms.goals, thread.incarnation) - if not served) + store = comms.goals.todos + return tuple(cls(request) for request in store.open_requests(thread.incarnation) + if not store.serving_orders(request.id)) def line(self) -> str: - return (f"REQUEST STALLED: {self.goal.id} {self.goal.text}" + return (f"REQUEST STALLED: {self.request.id.value} {self.request.text}" " -> order its next step or mark it done") @@ -250,7 +249,7 @@ def order_for(todo: Todo, commander: str, assigner: ThreadIncarnation) -> Reissu now = time.time() return ReissuedOrderGoalAction( problem=todo.id, expected_revision=todo.revision, - order=todo.order.reissued(now), request=todo.goal.request(), + order=todo.order.reissued(now), request=todo.request.require(), review_at=todo.reissued_review(now), commander=commander, assigner=assigner, traced=todo.order.owner) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 9d659e4d52..34841050f5 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -74,9 +74,7 @@ def commanders(cls, registry: Registration, goals: Goals) -> tuple[Thread, ...]: thread for thread in registry.all_threads().values() if thread.process_alive - and any( - goal.order.open_requests(goal, goals, thread.incarnation) for goal in thread.goal - ) + and goals.todos.open_requests(thread.incarnation, limit=1) ) def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index a45e9c4683..6e8c555dac 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -50,21 +50,23 @@ from .field_codec import ( Argument, FieldCodec, MalformedRecord, PathText, WireName, WireOmitDefault, boundary_decoder, ) -from .goal_actions import DirectiveGoalAction +from agent_comms.channel_targets import CommandTarget +from agent_comms.owner_requests import NewestOpenRequest, RequestDirective, RequestSelection from .host_services import HostService, SystemdUserService from .connector_store import ( CODE_SECONDS, ApprovalQueueFull, ApprovalRefused, AuthorizationQuery, AuthorizationRequest, ClaudeCallback, ConnectorGrants, ConnectorStore, IssuedTokens, RegisteredClient, TrustedBrowser, digest, ) -from .messages import Message, QuestionKind +from agent_comms.messages import QuestionKind from .commander.standing import UnownedOrderWatch from .commander_heartbeat import CommanderHeartbeat from .coordinator import Coordination from .todos import DirectiveText, ReviewedHeadVerdict -from .tool_caller import ThreadCaller +from agent_comms.tool_caller import ThreadCaller, ToolCaller from .tools import ( - CommsVerdictTool, ReadTool, TodoChanged, ToolRequest, tool_field, + CommsSendTool, CommsVerdictTool, MessagePublished, ReadTool, SendTool, + TodoChanged, ToolRequest, tool_field, ) LOOPBACK = "127.0.0.1" @@ -73,26 +75,35 @@ @dataclass(frozen=True) class DirectorCaller(ThreadCaller): - """claude-ai speaks for the owner: each message it sends a thread is a directive, recorded as a - request when that thread holds owner requests.""" + """claude-ai speaks for the owner; its sends must pass the directive wording check.""" def require_directive(self, comms: Comms, body: str) -> None: """A directive passes DirectiveText. claude-ai directs the commanders, so the commander rules review does not gate it.""" DirectiveText.require(body) - def sent(self, comms: Comms, message: Message) -> None: - if message.target in comms.registry: # a channel holds no requests - comms.goals.update_goal( - message.target, - DirectiveGoalAction(text=message.body, seq=message.seq, at=message.timestamp), - ) - - class ConnectorTool(ToolRequest[DirectorCaller], affix="ConnectorTool"): """Connector-only declarations are absent from the native/CLI tool registry.""" +@dataclass(frozen=True, kw_only=True) +class ConnectorSendTool(SendTool, ConnectorTool, declared_name=CommsSendTool.declared_name): + request: Annotated[ + RequestSelection, + *tool_field("Owner request ID; omitted selects the recipient's newest open request"), + ] = NewestOpenRequest() + + def publish(self, comms: Comms, caller: ToolCaller) -> MessagePublished: + store = comms.goals.todos + requests = self.request.for_message(store, CommandTarget.named(self.target), comms.registry) + result = super().publish(comms, caller) + directive = RequestDirective(result.message.source.seq, + result.message.timestamp + RequestDirective.window) + for request in requests: + store.record_directive(request, directive) + return result + + @dataclass(frozen=True, kw_only=True) class ConnectorVerdictTool(ConnectorTool, declared_name=CommsVerdictTool.declared_name): label = "Review PR head" diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 9700894cb4..2d0d53cc3a 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -37,17 +37,16 @@ NoGoal, ProblemGoalOrder, ReportedTurn, - RequestGoalOrder, UnorderedGoalOrder, UnobservedGoalCheckpoint, ) -from agent_comms.owner_requests import RequestDirective +from agent_comms.owner_requests import OwnerRequestId from .progress_claims import ProgressClaim from .recorded_time import EventTime from .repository_name import RepositoryName from .thread_identity import ThreadIncarnation from .threads import Thread -from .todos import GoalRef, PendingProblemOrder, SendBackVerdict, Todo, TodoOperation +from agent_comms.todos import PendingProblemOrder, SendBackVerdict, Todo, TodoOperation if TYPE_CHECKING: from .goal_management import Goals @@ -477,34 +476,11 @@ def create(self, ctx: GoalActionContext) -> Goal: return goal -@dataclass(frozen=True, kw_only=True) -class DirectiveGoalAction(GoalAction[GoalDeclaration], RuntimeInvocable): - """A claude-ai message to a thread that holds owner requests becomes one of its requests.""" - - text: str - seq: int - at: float - - def change(self, goal: Goal, ctx: GoalActionContext) -> Goal: - return goal.order.directed(goal, self, ctx) - - def create(self, ctx: GoalActionContext) -> GoalDeclaration: - return ctx.thread.goal - - def directive(self) -> RequestDirective: - return RequestDirective(self.seq, self.at + RequestDirective.window) - - def request(self, goal: Goal, ctx: GoalActionContext) -> Goal: - ReplacementGoalAction.retire(goal, ctx) - order = RequestGoalOrder((self.directive(),)) - return SetGoalAction(text=self.text, order=order).change(goal, ctx) - - @dataclass(frozen=True, kw_only=True) class ProblemOrderGoalAction(GoalAction[Goal], RuntimeInvocable): problem: str order: PendingProblemOrder - request: str + request: OwnerRequestId review_at: EventTime commander: str @@ -512,28 +488,26 @@ class ProblemOrderGoalAction(GoalAction[Goal], RuntimeInvocable): def bound_to(self, actor: ThreadIncarnation) -> ProblemOrderGoalAction: ... @abstractmethod - def reserve(self, ctx: GoalActionContext, generation: str, goal: GoalRef) -> Todo: ... + def reserve(self, ctx: GoalActionContext, generation: str) -> Todo: ... def change(self, goal: Goal, ctx: GoalActionContext) -> Goal: return goal.state.accept_order(self, goal, ctx) def continue_goal(self, goal: Goal, ctx: GoalActionContext) -> Goal: - request = self.request_ref(ctx) - self.reserve(ctx, goal.id, request) + self.require_ready(ctx) + self.reserve(ctx, goal.id) revision = goal.revision + 1 return replace(goal, order=ProblemGoalOrder(), text=ProblemGoalOrder.text, progress="", revision=revision, mention_source=bind_goal_mentions( ProblemGoalOrder.text, goal.id, revision, ctx.thread, ctx.goals.registry.snapshot())) - def request_ref(self, ctx: GoalActionContext) -> GoalRef: - """Refuse invalid orders before reserving their Todo.""" + def require_ready(self, ctx: GoalActionContext) -> None: + """Refuse an invalid request before goal reservation or allocating a child.""" self.require_sendable() commander = ctx.goals.registry.require(self.commander) - commander.goal.require().require_id(self.request).require_active().order.require_request() + ctx.goals.todos.owner_request(self.request).require_open(commander.incarnation) self.review_at.require_recorded("A review time") - request = GoalRef(commander.name, commander.created_at, self.request) - return request def require_sendable(self) -> None: """A new order's wording and source references hold at its declared revision.""" @@ -542,9 +516,9 @@ def require_sendable(self) -> None: self.order.check.require_directory() def create(self, ctx: GoalActionContext) -> Goal: - request = self.request_ref(ctx) + self.require_ready(ctx) fresh = SetGoalAction(text=ProblemGoalOrder.text, order=ProblemGoalOrder()) - self.reserve(ctx, fresh.goal_id, request) + self.reserve(ctx, fresh.goal_id) return fresh.create(ctx) @@ -561,10 +535,10 @@ class OrderGoalAction(ProblemOrderGoalAction): def bound_to(self, actor: ThreadIncarnation) -> OrderGoalAction: return replace(self, commander=actor.name, assigner=actor) - def reserve(self, ctx: GoalActionContext, generation: str, goal: GoalRef) -> Todo: + def reserve(self, ctx: GoalActionContext, generation: str) -> Todo: return ctx.goals.todos.assign(self.problem, expected_revision=self.expected_revision, owner=ctx.thread, assigner=self.assigner, - generation=generation, goal=goal, review_at=self.review_at, order=self.order, + generation=generation, request=self.request, review_at=self.review_at, order=self.order, traced=self.traced) @@ -588,10 +562,10 @@ class CreateProblemGoalAction(ProblemOrderGoalAction): def bound_to(self, actor: ThreadIncarnation) -> CreateProblemGoalAction: return replace(self, commander=actor.name) - def reserve(self, ctx: GoalActionContext, generation: str, goal: GoalRef) -> Todo: + def reserve(self, ctx: GoalActionContext, generation: str) -> Todo: return ctx.goals.todos.create(self.problem, self.repo, self.text, creator=ctx.goals.registry.require(self.commander), owner=ctx.thread, - generation=generation, goal=goal, review_at=self.review_at, order=self.order) + generation=generation, request=self.request, review_at=self.review_at, order=self.order) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index baae52f869..ef66d550c0 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -33,10 +33,9 @@ from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .text_digest import TextDigest from .ui_model.goal import GoalDisplay -from agent_comms.owner_requests import RequestDirective if TYPE_CHECKING: - from .goal_actions import DirectiveGoalAction, GoalAction, GoalActionContext + from agent_comms.goal_actions import GoalAction, GoalActionContext from .goal_presentation import ThreadGoalExecution from .goal_waits import GoalWait, GoalWaits from .agent_activity import AgentActivity @@ -52,7 +51,6 @@ from .turn_goal_account import VerifiedGoalSettlement from .thread_identity import TurnId from .todos import Todo - from .messages import Message @dataclass(frozen=True, kw_only=True) @@ -462,14 +460,6 @@ class GoalOrder(DeclaredFamily, affix="GoalOrder"): def require_completion(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> None: pass - def require_request(self) -> None: - raise ValueError("An order serves an open owner request: request add records one.") - - def open_requests( - self, goal: Goal, goals: Goals, owner: ThreadIncarnation - ) -> tuple[tuple[Goal, tuple[Todo, ...]], ...]: - return () - def instructions(self, todos: Sequence[Todo]) -> str: return "" @@ -488,17 +478,6 @@ def failed_turn_progress(self, goal: Goal, diagnostic: str) -> str: def require_model_block(self) -> None: pass - def directed(self, goal: Goal, directive: DirectiveGoalAction, ctx: GoalActionContext) -> Goal: - """Only a thread holding owner requests records a claude-ai directive.""" - return goal - - def directive_messages(self, goals: Goals) -> tuple[Message, ...]: - return () - - def overdue_directives(self, goal: Goal, goals: Goals, owner: ThreadIncarnation, - now: float) -> tuple[RequestDirective, ...]: - return () - @abstractmethod def require_order(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> None: ... @@ -515,52 +494,6 @@ def require_replacement(self, goal: Goal, goals: Goals, owner: ThreadIncarnation pass -@dataclass(frozen=True) -class RequestGoalOrder(GoalOrder): - """An owner request on the commander's own Goal: each order names the one it serves, and the - request stays open until no order serving it is open. Each claude-ai directive the commander - receives is recorded on it when it arrives.""" - - directives: tuple[RequestDirective, ...] = () - - def require_order(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> None: - pass - - def require_replacement(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> None: - if not goal.state.terminal: - raise ValueError(f"Request {goal.id} is still open; mark it done first.") - - def require_completion(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> None: - if self.serving(goal, goals, owner): - raise ValueError(f"Request {goal.id} still has open orders.") - - def require_request(self) -> None: - pass - - def serving(self, goal: Goal, goals: Goals, owner: ThreadIncarnation) -> tuple[Todo, ...]: - return tuple(todo for todo in goals.todos.for_request(owner, goal.id) if todo.pending) - - def directed(self, goal: Goal, directive: DirectiveGoalAction, ctx: GoalActionContext) -> Goal: - """A closed request is replaced by one the directive opens; an open one records it.""" - if goal.state.terminal: - return directive.request(goal, ctx) - return replace(goal, revision=goal.revision + 1, order=replace( - self, directives=(*self.directives, directive.directive()))) - - def directive_messages(self, goals: Goals) -> tuple[Message, ...]: - return tuple(directive.original(goals) for directive in self.directives) - - def overdue_directives(self, goal: Goal, goals: Goals, owner: ThreadIncarnation, - now: float) -> tuple[RequestDirective, ...]: - return tuple(directive for directive in self.directives - if directive.overdue(goals, owner, now)) - - def open_requests( - self, goal: Goal, goals: Goals, owner: ThreadIncarnation - ) -> tuple[tuple[Goal, tuple[Todo, ...]], ...]: - return ((goal, self.serving(goal, goals, owner)),) - - @dataclass(frozen=True) class ProblemGoalOrder(GoalOrder): """A unit's one Goal groups its canonical Todo assignments, not copied orders.""" diff --git a/src/agent_comms/owner_requests.py b/src/agent_comms/owner_requests.py index 2f1ad0680e..2e274fc605 100644 --- a/src/agent_comms/owner_requests.py +++ b/src/agent_comms/owner_requests.py @@ -3,29 +3,90 @@ from __future__ import annotations import time +from abc import abstractmethod from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, ClassVar, Self from uuid import uuid4 from agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import BoundedText, SingleFieldValue +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import BoundedText, NullMember, SingleFieldValue, SubclassUnion if TYPE_CHECKING: + from agent_comms.channel_targets import NamedTarget from agent_comms.goal_management import Goals from agent_comms.messages import Message + from agent_comms.registration import Registration from agent_comms.thread_identity import ThreadIncarnation - from agent_comms.todos import Todo + from agent_comms.todos import OwnerRequest, Todo, TodoStore + + +class RequestValue(SubclassUnion, affix=""): + """One id can be a stored link and an explicitly selected request.""" + + +class RequestLink(RequestValue, declared_name=None): + @abstractmethod + def require(self) -> OwnerRequestId: ... + + +class RequestSelection(RequestValue, declared_name=None): + def for_message( + self, store: TodoStore, target: NamedTarget, registry: Registration, + ) -> tuple[OwnerRequest, ...]: + return tuple(request for name in target.direct_names + for request in self.select(store, registry.require(name).incarnation)) + + def for_order(self, store: TodoStore, commander: ThreadIncarnation) -> OwnerRequestId: + selected = self.select(store, commander) + if len(selected) != 1: + raise RelationViolationError("An order needs exactly one open owner request.") + return selected[0].id + + @abstractmethod + def select( + self, store: TodoStore, commander: ThreadIncarnation, + ) -> tuple[OwnerRequest, ...]: ... @dataclass(frozen=True, slots=True) -class OwnerRequestId(SingleFieldValue): +class OwnerRequestId(SingleFieldValue, RequestLink, RequestSelection): value: Annotated[str, BoundedText] @classmethod def new(cls) -> Self: return cls(uuid4().hex) + def require(self) -> Self: + return self + + def for_message( + self, store: TodoStore, target: NamedTarget, registry: Registration, + ) -> tuple[OwnerRequest, ...]: + return self.select(store, registry.require(target.require_dm_peer()).incarnation) + + def select( + self, store: TodoStore, commander: ThreadIncarnation, + ) -> tuple[OwnerRequest, ...]: + request = store.owner_request(self) + request.require_open(commander) + return (request,) + + +@dataclass(frozen=True) +class NoRequestLink(NullMember, RequestLink): + def require(self) -> OwnerRequestId: + raise RelationViolationError("An unordered problem has no owner request.") + + +@dataclass(frozen=True) +class NewestOpenRequest(NullMember, RequestSelection): + def select( + self, store: TodoStore, commander: ThreadIncarnation, + ) -> tuple[OwnerRequest, ...]: + return store.open_requests(commander, limit=1) + class RequestState(DeclaredFamily, affix="RequestState"): def require_open(self, request: OwnerRequestId) -> OpenRequestState: diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index af2a7a2cf3..49fdc9b6e8 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from agent_comms.comms import Comms - from agent_comms.owner_requests import RequestDirective + from agent_comms.owner_requests import OwnerRequestId, RequestDirective from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.todos import Todo @@ -293,18 +293,16 @@ def line(self) -> str: class DirectiveUnacted(DatedStandingRule): """A claude-ai directive recorded on an open request, past its deadline with no order since.""" - request: str + request: OwnerRequestId directive: RequestDirective @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: thread, now = comms.registry.require(commander), time.time() return tuple( - cls(goal.id, directive) - for goal in thread.goal.active() - for directive in goal.order.overdue_directives( - goal, comms.goals, thread.incarnation, now - ) + cls(request.id, directive) + for request in comms.goals.todos.open_requests(thread.incarnation) + for directive in request.overdue_directives(comms.goals, now) ) def began(self) -> float: @@ -312,7 +310,7 @@ def began(self) -> float: def line(self) -> str: return ( - f"DIRECTIVE UNACTED: claude-ai {self.directive.seq} on request {self.request}," + f"DIRECTIVE UNACTED: claude-ai {self.directive.seq} on request {self.request.value}," f" due {self.directive.due_time} -> order its next step or mark the request done" ) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 54b8d3bcb8..f1cf368e27 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -53,7 +53,6 @@ GitCommitText, MalformedRecord, NullMember, - RecordMember, SingleFieldValue, SubclassUnion, WireOmitDefault, @@ -71,6 +70,7 @@ OpenRequestState, OwnerRequestId, RequestDirective, + RequestLink, RequestState, ) from agent_comms.plan_phases import OrderPhase, UnplannedPhase @@ -96,7 +96,6 @@ Column, DurableStore, Index, - JsonStorage, TextStorage, TypedTable, ) @@ -1536,15 +1535,6 @@ def folded(self, into: str, why: str) -> ProblemOrder: raise ValueError("The problem has open orders; link or drop them first.") return FoldedProblemOrder(original=self, into=into, why=why) - @staticmethod - def request(sender: Thread, problems: tuple[str, ...]) -> str: - if not problems: - raise ValueError('An order needs its Problems line.') - request = sender.goal.require().require_active() - request.order.require_request() - return request.id - - @dataclass(frozen=True, kw_only=True) class UnorderedProblemOrder(ProblemOrder): @@ -2132,53 +2122,6 @@ def __init__(self, current: Todo): super().__init__(f"Todo {current.id} changed (revision {current.revision}, owner {owner}).") -class GoalReference(SubclassUnion): - @abstractmethod - def request(self) -> str: - """The commander's request goal this problem serves.""" - - def require_reference(self) -> None: - pass - - def threads(self) -> tuple[str, ...]: - return () - - -@dataclass(frozen=True) -class NoGoalReference(NullMember, GoalReference): - def request(self) -> str: - raise TodoError("A problem with no request goal cannot be ordered again.") - - -@dataclass(frozen=True) -class GoalRef(RecordMember, GoalReference): - - owner: str - owner_created: float - goal_id: str - - def __post_init__(self) -> None: - self.require_reference() - - def require_reference(self) -> None: - _text(self.owner, "Thread name", 128) - try: - self.incarnation.require_recorded() - except ValueError as error: - raise TodoError("Goal reference requires its recorded owner incarnation") from error - _text(self.goal_id, "Goal ID", 128) - - @property - def incarnation(self) -> ThreadIncarnation: - return RecordedThreadIncarnation(self.owner, self.owner_created) - - def threads(self) -> tuple[str, ...]: - return (self.owner,) - - def request(self) -> str: - return self.goal_id - - class TodoHolder(ABC): """Current, released and absent holders own their assignment behaviour.""" @@ -2369,6 +2312,9 @@ def record(self, goals: Goals, problem: str) -> Todo: def after_record(self, goals: Goals, changed: Todo) -> None: """Most operations have no automatic public effect.""" + def validate(self, db: sqlite3.Connection) -> None: + """Validate external rows inside the operation's write transaction.""" + @abstractmethod def apply(self, current: Todo) -> Todo: ... @@ -2384,14 +2330,17 @@ class AssignTodo(TodoOperation): proposed: Assignment review_at: EventTime order: PendingProblemOrder | MergedProblemOrder - goal: GoalReference + request: OwnerRequestId traced: Annotated[FileLine | NoSourceReference, WireOmitDefault()] = NoSourceReference() + def validate(self, db: sqlite3.Connection) -> None: + OwnerRequest.require(db, id=self.request).require_open(self.actor) + def apply(self, current: Todo) -> Todo: self.review_at.require_recorded("A review time") proposed = current.proposed_assignment(self.proposed) - if (current.assignment, current.review_at, current.order, current.goal) == ( - proposed, self.review_at, self.order, self.goal): + if (current.assignment, current.review_at, current.order, current.request) == ( + proposed, self.review_at, self.order, self.request): return current current.require_revision(self.expected_revision) self.actor.require_may_change(current) @@ -2402,7 +2351,7 @@ def apply(self, current: Todo) -> Todo: else current.state.assign(current, proposed, self.traced)) return replace(assigned, review_at=self.review_at, order=self.order, prior_orders=current.orders_before(self.order), - goal=self.goal, + request=self.request, revision=current.revision + 1) @property @@ -2504,9 +2453,9 @@ class VerdictAudience: threads: tuple[str, ...] @classmethod - def of(cls, unit: str, goal: GoalReference) -> VerdictAudience: - """An order's unit and its goal's thread; no verdict kind today concerns every unit.""" - return cls(tuple(dict.fromkeys((unit, *goal.threads())))) + def of(cls, unit: str, request: OwnerRequest) -> VerdictAudience: + """An order concerns its unit and the commander on its owner request.""" + return cls(tuple(dict.fromkeys((unit, request.commander.name)))) def addressed(self, target: str, body: str) -> str: return CommandTarget.named(target).addressed(self.threads, body) @@ -2903,9 +2852,51 @@ def apply(self, current): return current.state.change(current, self) +@dataclass(frozen=True, slots=True) +class OwnerRequest(TypedTable): + """One owner's request, independent of any current turn goal.""" + + indexes = (Index(("commander", "state", "created_at")),) + + id: Annotated[OwnerRequestId, Column(primary_key=True, storage=TextStorage)] + commander: RecordedThreadIncarnation + text: str + state: RequestState = OpenRequestState() + directives: tuple[RequestDirective, ...] = () + created_at: float = field(default_factory=time.time) + + def __post_init__(self) -> None: + if not self.text.strip(): + raise TodoError("An owner request needs its text.") + + @classmethod + def new(cls, commander: ThreadIncarnation, text: str) -> Self: + return cls(OwnerRequestId.new(), commander.require_recorded(), text) + + def require_commander(self, commander: ThreadIncarnation) -> None: + if self.commander != commander.require_recorded(): + raise TodoError(f"Request {self.id.value} belongs to another commander.") + + def require_open(self, commander: ThreadIncarnation) -> OpenRequestState: + self.require_commander(commander) + return self.state.require_open(self.id) + + def directive_messages(self, goals: Goals) -> tuple[Message, ...]: + return tuple(directive.original(goals) for directive in self.directives) + + def overdue_directives(self, goals: Goals, now: float) -> tuple[RequestDirective, ...]: + return tuple(directive for directive in self.directives + if directive.overdue(goals, self.commander, now)) + + @dataclass(frozen=True, slots=True) class Todo(TypedTable): - indexes = (Index(("creator", "creator_created", "state")), Index(("state",))) + pending_orders: ClassVar[str] = ( + f'json_extract("order",\'$.{ProblemOrder.family_discriminator}\') ' + f'IN {PendingProblemOrder.sql_choices()}' + ) + indexes = (Index(("creator", "creator_created", "state")), Index(("state",)), + Index(("request",)), Index(("request",), where=pending_orders)) id: Annotated[str, Column(primary_key=True)] repo: Annotated[RepositoryName, Column(storage=TextStorage)] @@ -2914,7 +2905,9 @@ class Todo(TypedTable): creator_created: float state: type[TodoState] revision: Annotated[int, Column(check="revision>0")] - goal: Annotated[GoalReference, Column(storage=JsonStorage, nullable=True)] + request: Annotated[ + RequestLink, Column(storage=TextStorage, nullable=True, references=(OwnerRequest, "id")), + ] assignment: Assignment | None order: ProblemOrder review_at: EventTime = field(default_factory=UnrecordedEventTime) @@ -2960,19 +2953,16 @@ def held_order_ids(self) -> tuple[str, ...]: def reference_pattern(self) -> str: return rf"(^|[^A-Za-z0-9_]){re.escape(self.id)}([^A-Za-z0-9_]|$)" - unrequested_goal: ClassVar[GoalReference] = NoGoalReference() - @classmethod def ordered(cls, todo_id: str, repo: RepositoryName, text: str, creator: ThreadIncarnation, assignment: Assignment, review_at: EventTime, order: PendingProblemOrder | MergedProblemOrder, - goal: GoalReference = unrequested_goal) -> Todo: + request: OwnerRequestId) -> Todo: _text(todo_id, "Todo ID", 128) _text(text, "Todo text", 2000) review_at.require_recorded("A review time") - goal.require_reference() return cls(todo_id, repo, text, creator.name, creator.wire_birth, OpenTodoState, - 1, goal, assignment, order, review_at) + 1, request, assignment, order, review_at) @property def pending(self): @@ -3117,7 +3107,7 @@ def require_same_creation(self, proposed: Todo) -> None: text=proposed.text, creator=proposed.creator, creator_created=proposed.creator_created, - goal=proposed.goal, + request=proposed.request, review_at=proposed.review_at, order=proposed.order, ) @@ -3146,32 +3136,6 @@ def _participant(thread: Thread) -> ThreadIncarnation: return thread.incarnation -@dataclass(frozen=True, slots=True) -class OwnerRequest(TypedTable): - """One owner's request, independent of any current turn goal.""" - - indexes = (Index(("commander", "state", "created_at")),) - - id: Annotated[OwnerRequestId, Column(primary_key=True, storage=TextStorage)] - commander: RecordedThreadIncarnation - text: str - state: RequestState = OpenRequestState() - directives: tuple[RequestDirective, ...] = () - created_at: float = field(default_factory=time.time) - - def __post_init__(self) -> None: - if not self.text.strip(): - raise TodoError("An owner request needs its text.") - - @classmethod - def new(cls, commander: ThreadIncarnation, text: str) -> Self: - return cls(OwnerRequestId.new(), commander.require_recorded(), text) - - def require_commander(self, commander: ThreadIncarnation) -> None: - if self.commander != commander.require_recorded(): - raise TodoError(f"Request {self.id.value} belongs to another commander.") - - @dataclass(frozen=True, slots=True) class TodoStore(DurableStore): """One SQLite row owns both todo state and its exclusive assignment. @@ -3183,7 +3147,7 @@ class TodoStore(DurableStore): root: Path filename: ClassVar[str] = "todos.sqlite3" - rows: ClassVar[tuple[type[TypedTable], ...]] = (Todo, HuntRecord, OwnerRequest) + rows: ClassVar[tuple[type[TypedTable], ...]] = (OwnerRequest, Todo, HuntRecord) @contextmanager def writing(self) -> Iterator[sqlite3.Connection]: @@ -3215,22 +3179,29 @@ def owner_request(self, request: OwnerRequestId) -> OwnerRequest: with self.snapshot() as db: return OwnerRequest.require(db, id=request) - def open_requests(self, commander: ThreadIncarnation) -> tuple[OwnerRequest, ...]: + def open_requests( + self, commander: ThreadIncarnation, *, limit: int | None = None, + ) -> tuple[OwnerRequest, ...]: with self.snapshot() as db: return tuple(OwnerRequest.select( db, commander=commander.require_recorded(), state=OpenRequestState(), - order_by=("created_at", "id"), descending=True, + order_by=("created_at", "id"), descending=True, limit=limit, )) + def record_directive(self, request: OwnerRequest, directive: RequestDirective) -> None: + with self.writing() as db: + current = OwnerRequest.require(db, id=request.id) + current.require_open(request.commander) + replace(current, directives=(*current.directives, directive)).upsert(db) + def complete_request( self, commander: ThreadIncarnation, request: OwnerRequestId, reason: str, ) -> OwnerRequest: with self.writing() as db: current = OwnerRequest.require(db, id=request) - current.require_commander(commander) - state = current.state.require_open(request) + state = current.require_open(commander) completed = replace(current, state=state.complete( - request, self.for_request(commander, request.value), reason, + request, self._serving(db, request), reason, )) completed.upsert(db) return completed @@ -3305,7 +3276,7 @@ def link( linked = target.order.linked(source.order) replace(target, order=linked, prior_orders=target.orders_before(linked, source.prior_orders), - assignment=source.held(), goal=source.goal, review_at=source.review_at, + assignment=source.held(), request=source.request, review_at=source.review_at, messages=source.messages, revision=target.revision + 1).upsert(db) def _select(self, where: str, parameters: tuple) -> tuple[Todo, ...]: @@ -3405,11 +3376,19 @@ def holds_pending(self, owner: ThreadIncarnation) -> bool: "json_extract(assignment,'$.owner')=? AND json_extract(assignment,'$.owner_created')=?", (owner.name, owner.wire_birth))) - def for_request(self, commander: ThreadIncarnation, goal_id: str) -> tuple[Todo, ...]: - """Every order that served one request, a Goal on the commander's thread, open or done.""" - return self._select( - "json_extract(goal,'$.owner')=? AND json_extract(goal,'$.owner_created')=? " - "AND json_extract(goal,'$.goal_id')=?", (commander.name, commander.wire_birth, goal_id)) + @staticmethod + def _serving(db: sqlite3.Connection, request: OwnerRequestId) -> tuple[Todo, ...]: + return tuple(Todo.select(db, request=request, where=Todo.pending_orders)) + + def serving_orders(self, request: OwnerRequestId) -> tuple[Todo, ...]: + """Only pending orders, read through the request's partial index.""" + with self.snapshot() as db: + return self._serving(db, request) + + def for_request(self, request: OwnerRequestId) -> tuple[Todo, ...]: + """Every order that served this independent request, open or done.""" + with self.snapshot() as db: + return tuple(Todo.select(db, request=request)) def for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: return self._open("creator=? AND creator_created=?", (commander.name, commander.wire_birth)) @@ -3473,7 +3452,7 @@ def create( generation: str, review_at: EventTime, order: PendingProblemOrder, - goal: GoalRef, + request: OwnerRequestId, ) -> Todo: identity, assignee = _participant(creator), _thread(owner) DirectiveText.require(text) @@ -3481,8 +3460,9 @@ def create( order.require_writable() proposed = Todo.ordered( todo_id, repo, text, identity, Assignment.of(assignee, identity, generation), - review_at, order, goal) + review_at, order, request) with self.writing() as db: + OwnerRequest.require(db, id=request).require_open(identity) proposed.insert_if_absent(db) current = self._current(db, todo_id) current.require_same_creation(proposed) @@ -3495,6 +3475,7 @@ def apply(self, todo_id: str, command: TodoCommand) -> Todo: result = command.apply(current) if result == current: return current + command.validate(db) result.upsert(db) return self._current(db, todo_id) @@ -3535,11 +3516,11 @@ def assign( owner: Thread, assigner: ThreadIncarnation, generation: str, - goal: GoalRef, + request: OwnerRequestId, review_at: EventTime, order: PendingProblemOrder, traced: FileLine | NoSourceReference = NoSourceReference(), ) -> Todo: proposed = Assignment.of(_thread(owner), assigner, generation) return self.apply( - todo_id, AssignTodo(expected_revision, proposed, review_at, order, goal, traced)) + todo_id, AssignTodo(expected_revision, proposed, review_at, order, request, traced)) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 7bdd858495..5aaa4c2163 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from .comms import Comms - from .messages import Message from .tools import ToolRequest @@ -60,11 +59,6 @@ def may_reconfigure(self, comms: Comms, name: str) -> bool: ... def require_directive(self, comms: Comms, body: str) -> None: """A caller whose sends direct units refuses a body its directive checks refuse.""" - @abstractmethod - def sent(self, comms: Comms, message: Message) -> None: - """What a message this caller sent records beyond the bus.""" - - @dataclass(frozen=True) class NoThreadCaller(ToolCaller): def invoke(self, request: ToolRequest, comms: Comms) -> ToolResult: @@ -94,10 +88,6 @@ def may_reconfigure(self, comms: Comms, name: str) -> bool: def require_directive(self, comms: Comms, body: str) -> None: """A caller with no thread gives no directive.""" - def sent(self, comms: Comms, message: Message) -> None: - """A message sent without a calling thread records nothing more.""" - - @dataclass(frozen=True) class ThreadCaller(ToolCaller): name: str @@ -132,5 +122,3 @@ def may_reconfigure(self, comms: Comms, name: str) -> bool: def require_directive(self, comms: Comms, body: str) -> None: """A unit's or a script's send is a message, not a directive.""" - def sent(self, comms: Comms, message: Message) -> None: - """An agent's message is only the bus record.""" diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index e3229c4f4a..9d44460dd8 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -462,9 +462,7 @@ def apply_without_thread( return self.apply(comms, caller) -class PublishingToolRequest[Caller: ToolCaller = ToolCaller]( - HeldBusWrite, ToolRequest[Caller], declared_name=None -): +class PublishingTool[Caller: ToolCaller = ToolCaller](HeldBusWrite): """A publication waits at the bus owner before any caller or message mutation.""" def apply(self, comms: Comms, caller: Caller) -> ToolResult: @@ -474,6 +472,12 @@ def apply(self, comms: Comms, caller: Caller) -> ToolResult: def publish(self, comms: Comms, caller: Caller) -> ToolResult: ... +class PublishingToolRequest[Caller: ToolCaller = ToolCaller]( + PublishingTool[Caller], ToolRequest[Caller], declared_name=None, +): + """Native declarations using the shared publication capability.""" + + class LiveOwnerTool: """A tool whose reply needs a launched native owner or a running restart watcher.""" @@ -1280,7 +1284,7 @@ def apply(self, comms: Comms, caller: ToolCaller) -> SendRefusalsRead: @dataclass(frozen=True, kw_only=True) -class CommsSendTool(PublishingToolRequest): +class SendTool(PublishingTool): label = "Comms Send" description = ( "Send a message to another thread or channel. Address specific threads with " @@ -1311,10 +1315,13 @@ def publish(self, comms: Comms, caller: ToolCaller) -> MessagePublished: threading.Thread(target=SendRefusal.record, args=(comms.root, self.sender, self.target, error)).start() raise - caller.sent(comms, result.message) return MessagePublished.from_send(result) +class CommsSendTool(SendTool, ToolRequest): + """The native send declaration; its fields and publication belong to SendTool.""" + + @dataclass(frozen=True, kw_only=True) class CommsAuthoredTaskTool(ThreadToolRequest, PublishingToolRequest[ThreadCaller]): """One original authorized publication path for authored task declarations.""" diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index a758e61abc..98332b8290 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Self +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.goal_actions import ( CreateProblemGoalAction, GoalActionContext, @@ -66,7 +67,7 @@ def start(self, comms: Comms) -> Thread: model=defaults.model, thinking_level=defaults.thinking_level, ) - self.action.request_ref(GoalActionContext(comms.goals, candidate)) + self.action.require_ready(GoalActionContext(comms.goals, candidate)) comms.registry.require_declaration_available(candidate, StoppedThreadStatus()) CheckoutStorageEffect(checkout).check(StorageProjection(storage, candidate)) subprocess.run( @@ -143,7 +144,7 @@ def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None repo=self.repo, text=self.text, order=order, - request=order.request(commander, (self.problem,)), + request=NewestOpenRequest().for_order(comms.goals.todos, commander.incarnation), commander=commander.name, review_at=RecordedEventTime(now + self.review_seconds), ) From 4b21b207e5eded8adc4a13b493a9d0ff8221810b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 13:00:27 -0400 Subject: [PATCH 03/10] Share parsed send targets and cover independent request publication --- src/agent_comms/commander/order.py | 6 +- src/agent_comms/connector.py | 10 +-- src/agent_comms/todos.py | 8 ++- src/agent_comms/tools.py | 12 ++-- tests/test_owner_requests.py | 99 ++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 tests/test_owner_requests.py diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index e5b2f29f06..d3c4f598c6 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -260,9 +260,9 @@ def incarnation(self) -> ThreadIncarnation: def post(self, audience: VerdictAudience, body: str, answers: tuple[MessageReference, ...] = ()) -> str: - target = audience.threads[0] if len(audience.threads) == 1 else '#comms' - published = self.comms.messaging.send(self.commander, target, - audience.addressed(target, body), answers=answers) + target = audience.target + published = self.comms.messaging.send(self.commander, target.name, + target.addressed(audience.threads, body), answers=answers) return published.message.message_id def call(self, tool): diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 8c83122cef..8c5265fce1 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -49,7 +49,7 @@ from .field_codec import ( Argument, FieldCodec, MalformedRecord, PathText, WireName, WireOmitDefault, boundary_decoder, ) -from agent_comms.channel_targets import CommandTarget +from agent_comms.channel_targets import NamedTarget from agent_comms.owner_requests import NewestOpenRequest, RequestDirective, RequestSelection from .host_services import HostService, SystemdUserService from .connector_store import ( @@ -92,10 +92,12 @@ class ConnectorSendTool(SendTool, ConnectorTool, declared_name=CommsSendTool.dec *tool_field("Owner request ID; omitted selects the recipient's newest open request"), ] = NewestOpenRequest() - def publish(self, comms: Comms, caller: ToolCaller) -> MessagePublished: + def send_message( + self, comms: Comms, caller: ToolCaller, target: NamedTarget, + ) -> MessagePublished: store = comms.goals.todos - requests = self.request.for_message(store, CommandTarget.named(self.target), comms.registry) - result = super().publish(comms, caller) + requests = self.request.for_message(store, target, comms.registry) + result = super().send_message(comms, caller, target) directive = RequestDirective(result.message.source.seq, result.message.timestamp + RequestDirective.window) for request in requests: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index f4bd5af222..343d12b181 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -36,7 +36,7 @@ FileLine, MainRevision, NoSourceReference, PullRequestHeadRevision, SourceReference, SourceRevision, ) -from agent_comms.channel_targets import CommandTarget +from agent_comms.channel_targets import CommandTarget, NamedTarget from agent_comms.child_process import ( BoundedRun, CheckPrinted, @@ -2394,8 +2394,10 @@ def of(cls, unit: str, request: OwnerRequest) -> VerdictAudience: """An order concerns its unit and the commander on its owner request.""" return cls(tuple(dict.fromkeys((unit, request.commander.name)))) - def addressed(self, target: str, body: str) -> str: - return CommandTarget.named(target).addressed(self.threads, body) + @property + def target(self) -> NamedTarget: + name = self.threads[0] if len(self.threads) == 1 else '#comms' + return CommandTarget.named(name) @dataclass(frozen=True) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 9d44460dd8..2a3b2040a7 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -19,7 +19,7 @@ from . import cli as comms_cli from .change_report import NoChangeReport, ReportEvidence from .channel_management import KeepThreadsTagDisposition, TagAction, TagDisposition -from .channel_targets import ChannelTarget, CommandTarget +from agent_comms.channel_targets import ChannelTarget, CommandTarget, NamedTarget from .channels import AnyChannel, Channel, SavedView, ViewKind, ViewMatch, ViewPredicate from .cli_commands import ( ArchiveCliCommand, @@ -112,7 +112,6 @@ StrikeReason, Todo, TodoOperation, - VerdictAudience, ) from .tool_caller import ToolCaller, ThreadCaller, NoThreadCaller, ThreadListing from .thread_identity import ThreadIncarnation @@ -1306,10 +1305,15 @@ class SendTool(PublishingTool): ] = NoChangeReport() def publish(self, comms: Comms, caller: ToolCaller) -> MessagePublished: + return self.send_message(comms, caller, CommandTarget.named(self.target)) + + def send_message( + self, comms: Comms, caller: ToolCaller, target: NamedTarget, + ) -> MessagePublished: try: caller.require_directive(comms, self.body) - body = VerdictAudience(self.audience).addressed(self.target, self.body) - result = comms.messaging.send(self.sender, self.target, body, self.type, report=self.report) + body = target.addressed(self.audience, self.body) + result = comms.messaging.send(self.sender, target.name, body, self.type, report=self.report) except ToolRefusedError as error: # The caller hears the refusal now; the record waits for the wire lock on its own. threading.Thread(target=SendRefusal.record, diff --git a/tests/test_owner_requests.py b/tests/test_owner_requests.py new file mode 100644 index 0000000000..bb15b991f7 --- /dev/null +++ b/tests/test_owner_requests.py @@ -0,0 +1,99 @@ +"""One request family journey through the real store and publication tools.""" + +from pathlib import Path + +import pytest + +from agent_comms.change_report import FileLine, MainRevision +from agent_comms.comms import Comms +from agent_comms.connector import CALLER, ConnectorSendTool +from agent_comms.errors import ToolRefusedError +from agent_comms.goals import OrderCheck +from agent_comms.order_runs import MechanicalOrderRun +from agent_comms.owner_requests import NewestOpenRequest +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.repo import Repo +from agent_comms.repository_name import RepositoryName +from agent_comms.thread_status import StoppedThreadStatus +from agent_comms.tool_caller import ThreadCaller +from agent_comms.todos import PendingProblemOrder, TodoStore, TriageScope, WithdrawTodo +from agent_comms.tools import CommsSendTool + + +def test_independent_requests_orders_and_directives(comms: Comms) -> None: + repository = Repo(checkout=Path.cwd(), github=RepositoryName("OpenHCSDev", "agent-comms")) + for name in ("claude-ai", "commander", "worker"): + thread = comms.registry.new_thread(name, frozenset(), str(repository.checkout)) + comms.registry.declare(thread, StoppedThreadStatus()) + commander = comms.registry.require("commander") + worker = comms.registry.require("worker") + store = comms.goals.todos + first = store.add_request(commander.incarnation, "commander native in Core") + second = store.add_request(commander.incarnation, "virtual bus") + assert NewestOpenRequest().for_order(store, commander.incarnation) == second.id + assert first.id.for_order(store, commander.incarnation) == first.id + + order = PendingProblemOrder( + owner=FileLine.class_declaration(TodoStore, repository, MainRevision()), + scope=TriageScope(), + check=OrderCheck(directory=str(repository.checkout), command="true"), + edits=("Read the request owner and its consumers.",), + banned=("Leave the saved bus and its sessions unchanged.",), + report="Report the result of the source investigation.", run=MechanicalOrderRun(), + ) + todo = store.create( + "P1", repository.github, "Inspect the request owner", + creator=commander, owner=worker, generation="request-family", + review_at=RecordedEventTime(order.at.recorded_at("Order") + 60), + order=order, request=first.id, + ) + assert store.serving_orders(first.id) == (todo,) + assert not store.serving_orders(second.id) + with pytest.raises(ValueError): + store.complete_request(commander.incarnation, first.id, "The work is finished.") + assert len(store.open_requests(commander.incarnation)) == 2 + assert store.serving_orders(first.id) == (todo,) + + ConnectorSendTool( + sender="claude-ai", target="commander", request=first.id, + body="Read the request records and report the result.", + ).apply(comms, CALLER) + named = comms.bus.log.latest_sequence() + ConnectorSendTool( + sender="claude-ai", target="commander", + body="Read the stored orders and report the result.", + ).apply(comms, CALLER) + newest = comms.bus.log.latest_sequence() + first, second = store.owner_request(first.id), store.owner_request(second.id) + assert len(first.directives) == len(second.directives) == 1 + assert first.directives[0].original(comms.goals).seq == named + assert second.directives[0].original(comms.goals).seq == newest + + CommsSendTool( + sender="worker", target="commander", body="The source records are available.", + ).apply(comms, ThreadCaller("worker")) + assert store.owner_request(first.id).directives == first.directives + assert store.owner_request(second.id).directives == second.directives + before = comms.bus.log.latest_sequence() + with pytest.raises(ValueError): + ConnectorSendTool( + sender="claude-ai", target="#any", request=first.id, + body="Read the request records and report the result.", + ).apply(comms, CALLER) + with pytest.raises(ToolRefusedError): + ConnectorSendTool( + sender="claude-ai", target="worker", request=first.id, + body="Read the request records and report the result.", + ).apply(comms, CALLER) + assert comms.bus.log.latest_sequence() == before + + store.complete_request(commander.incarnation, second.id, "The independent request is finished.") + assert [request.id for request in store.open_requests(commander.incarnation)] == [first.id] + store.apply(todo.id, WithdrawTodo( + todo.revision, commander.incarnation, todo.held(), "The isolated request check is complete.", + )) + store.complete_request(commander.incarnation, first.id, "No pending order remains.") + assert not store.open_requests(commander.incarnation) + assert store.for_request(first.id)[0].request == first.id + assert comms.registry.require("commander").goal == commander.goal + assert not any(thread.process_alive for thread in comms.registry.all_threads().values()) From c74c18ad9bba954b29ccf31d7f2b0fa95c69a39c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 14:10:13 -0400 Subject: [PATCH 04/10] Carry owner requests without losing directive history --- src/agent_comms/goal_history.py | 18 +- src/agent_comms/owner_requests.py | 20 +- src/agent_comms/todos.py | 20 +- src/agent_comms/tools.py | 2 +- tests/test_owner_requests.py | 15 +- tools/cutover/carry_owner_requests.py | 264 ++++++++++++++++++++++++++ 6 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 tools/cutover/carry_owner_requests.py diff --git a/src/agent_comms/goal_history.py b/src/agent_comms/goal_history.py index b393153eff..5ed48dd71e 100644 --- a/src/agent_comms/goal_history.py +++ b/src/agent_comms/goal_history.py @@ -23,6 +23,8 @@ from .field_codec import FieldCodec, MalformedRecord, HistoryExclude from .goals import GoalDeclaration, NoGoal from .locked_store import DurableFile, SQLiteFile +from agent_comms.owner_requests import RequestAtRevision +from agent_comms.field_codec import WireOmitDefault from .typed_table import Column, DurableTables, Index, JsonStorage, TypedTable @@ -30,8 +32,16 @@ class GoalHistoryError(RuntimeError): """Goal history could not be durably written or reconciled.""" +@dataclass(frozen=True, kw_only=True) +class RequestHistory: + """Moved request data stays linked to its original history observation.""" + + request_before: Annotated[RequestAtRevision | None, Column(storage=JsonStorage), WireOmitDefault()] = None + request_after: Annotated[RequestAtRevision | None, Column(storage=JsonStorage), WireOmitDefault()] = None + + @dataclass(frozen=True) -class RecordedGoalRevision: +class RecordedGoalRevision(RequestHistory): """A history entry's public fields; its owner key and journal state stay in the store.""" sequence: int kind: Literal["transition", "baseline", "observed_gap"] @@ -41,7 +51,7 @@ class RecordedGoalRevision: @dataclass(frozen=True) -class GoalHistoryEntry(TypedTable, DurableTables): +class GoalHistoryEntry(RequestHistory, TypedTable, DurableTables): # The durable kind field is a journal fact, not the TypedTable family tag. schema_error = GoalHistoryError @@ -64,7 +74,9 @@ def reports_turn(self, turn_id: str) -> bool: def recorded(self) -> RecordedGoalRevision: return RecordedGoalRevision( - self.sequence, self.kind, self.observed_at, self.before, self.after) + self.sequence, self.kind, self.observed_at, self.before, self.after, + request_before=self.request_before, request_after=self.request_after, + ) def to_wire(self) -> dict[str, object]: return FieldCodec.encode_record(self.recorded()) diff --git a/src/agent_comms/owner_requests.py b/src/agent_comms/owner_requests.py index 2e274fc605..ee4b88cbab 100644 --- a/src/agent_comms/owner_requests.py +++ b/src/agent_comms/owner_requests.py @@ -101,7 +101,7 @@ def require_open(self, request: OwnerRequestId) -> Self: def complete( self, request: OwnerRequestId, orders: Sequence[Todo], reason: str, ) -> CompletedRequestState: - if any(order.pending for order in orders): + if orders: raise ValueError(f"Request {request.value} still has pending orders.") return CompletedRequestState(reason) @@ -115,6 +115,24 @@ def __post_init__(self) -> None: raise ValueError("Completing an owner request requires a reason.") +@dataclass(frozen=True) +class RequestAtRevision: + """A historical directive prefix, not another writable request state.""" + + id: OwnerRequestId + directive_count: int + + def __post_init__(self) -> None: + if self.directive_count < 0: + raise ValueError("A directive prefix cannot have negative length.") + + def directives(self, store: TodoStore) -> tuple[RequestDirective, ...]: + request = store.owner_request(self.id) + if self.directive_count > len(request.directives): + raise RelationViolationError("Request history exceeds its recorded directives.") + return request.directives[:self.directive_count] + + @dataclass(frozen=True) class RequestDirective: """One original directive; only a reply linked to that message answers it.""" diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index eb2470f2c3..82384d6ca3 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -75,6 +75,7 @@ from agent_comms.owner_requests import ( OpenRequestState, OwnerRequestId, + RequestAtRevision, RequestDirective, RequestLink, RequestState, @@ -99,6 +100,7 @@ from agent_comms.typed_table import ( Column, DurableStore, + Generated, Index, TextStorage, TypedTable, @@ -1913,11 +1915,12 @@ def __post_init__(self) -> None: @classmethod def for_order( cls, todo: Todo, actor: ThreadIncarnation, number: int, head: str, - report: str, answer: MessageReference, + report: str, answer: MessageReference, *, store: TodoStore, ) -> OrderClaim: + request = store.owner_request(todo.request.require()) return cls( PullRequestLink(todo.repo, number), head, - Message(actor.name, todo.creator, report, InfoKind, answers=(answer,)), + Message(actor.name, request.commander.name, report, InfoKind, answers=(answer,)), ) @@ -2903,10 +2906,13 @@ def apply(self, current): class OwnerRequest(TypedTable): """One owner's request, independent of any current turn goal.""" - indexes = (Index(("commander", "state", "created_at")),) + indexes = (Index(("commander_created_at", "state", "created_at")),) id: Annotated[OwnerRequestId, Column(primary_key=True, storage=TextStorage)] commander: RecordedThreadIncarnation + commander_created_at: ClassVar[Generated] = Generated( + float, Column(generated="json_extract({commander}, '$.created_at')"), + ) text: str state: RequestState = OpenRequestState() directives: tuple[RequestDirective, ...] = () @@ -2928,6 +2934,11 @@ def require_open(self, commander: ThreadIncarnation) -> OpenRequestState: self.require_commander(commander) return self.state.require_open(self.id) + def at_revision(self, directives: tuple[RequestDirective, ...]) -> RequestAtRevision: + if self.directives[:len(directives)] != directives: + raise TodoError(f"Request {self.id.value} history is not a recorded directive prefix.") + return RequestAtRevision(self.id, len(directives)) + def directive_messages(self, goals: Goals) -> tuple[Message, ...]: return tuple(directive.original(goals) for directive in self.directives) @@ -3233,7 +3244,8 @@ def open_requests( ) -> tuple[OwnerRequest, ...]: with self.snapshot() as db: return tuple(OwnerRequest.select( - db, commander=commander.require_recorded(), state=OpenRequestState(), + db, where="commander_created_at=?", + parameters=(commander.require_recorded().created_at,), state=OpenRequestState(), order_by=("created_at", "id"), descending=True, limit=limit, )) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 4ee4c1013b..9f9a0f6055 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -1065,7 +1065,7 @@ def publish(self, comms: Comms, caller: ThreadCaller) -> TodoChanged: ) claim = OrderClaim.for_order( todo, actor, self.pull_request, self.head, self.report, - MessageReference(original.seq, original.message_id), + MessageReference(original.seq, original.message_id), store=comms.goals.todos, ) changed = ClaimOrder( todo.revision, actor, generation, claim diff --git a/tests/test_owner_requests.py b/tests/test_owner_requests.py index bb15b991f7..97843e97c0 100644 --- a/tests/test_owner_requests.py +++ b/tests/test_owner_requests.py @@ -10,13 +10,14 @@ from agent_comms.errors import ToolRefusedError from agent_comms.goals import OrderCheck from agent_comms.order_runs import MechanicalOrderRun +from agent_comms.messages import MessageReference from agent_comms.owner_requests import NewestOpenRequest from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName from agent_comms.thread_status import StoppedThreadStatus from agent_comms.tool_caller import ThreadCaller -from agent_comms.todos import PendingProblemOrder, TodoStore, TriageScope, WithdrawTodo +from agent_comms.todos import OrderClaim, PendingProblemOrder, TodoStore, TriageScope, WithdrawTodo from agent_comms.tools import CommsSendTool @@ -32,6 +33,9 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: second = store.add_request(commander.incarnation, "virtual bus") assert NewestOpenRequest().for_order(store, commander.incarnation) == second.id assert first.id.for_order(store, commander.incarnation) == first.id + assert [request.id for request in store.open_requests( + commander.incarnation.renamed("commander-renamed"), + )] == [second.id, first.id] order = PendingProblemOrder( owner=FileLine.class_declaration(TodoStore, repository, MainRevision()), @@ -68,6 +72,15 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: assert len(first.directives) == len(second.directives) == 1 assert first.directives[0].original(comms.goals).seq == named assert second.directives[0].original(comms.goals).seq == newest + prefix = first.at_revision(first.directives) + assert prefix.directives(store) == first.directives + original = first.directives[0].original(comms.goals) + claim = OrderClaim.for_order( + todo, worker.incarnation, 1, "a" * 40, "The request owner was inspected.", + MessageReference(original.seq, original.message_id), store=store, + ) + assert claim.message.target == first.commander.name + assert claim.message.answers == (original.reference,) CommsSendTool( sender="worker", target="commander", body="The source records are available.", diff --git a/tools/cutover/carry_owner_requests.py b/tools/cutover/carry_owner_requests.py new file mode 100644 index 0000000000..6bcdcff2ae --- /dev/null +++ b/tools/cutover/carry_owner_requests.py @@ -0,0 +1,264 @@ +"""Move retained request goals into Core rows after the old owners stop. + +Delete after every bus has run this step. The retired wire tag is understood +only here; no request-goal member or compatibility decoder is registered. +""" +from __future__ import annotations + +import json +import sqlite3 +import time +from contextlib import ExitStack, closing +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import FieldCodec +from agent_comms.goal_history import GoalHistoryEntry, GoalHistoryStore, RequestHistory +from agent_comms.goals import Goal, UnorderedGoalOrder +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import SQLiteFile +from agent_comms.owner_requests import CompletedRequestState, OwnerRequestId, OpenRequestState, RequestDirective +from agent_comms.registry_store import RegistryStore +from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.todos import OwnerRequest, Todo, TodoStore +from agent_comms.typed_table import Column, JsonStorage, SQLiteSchemaObject, TextStorage, TypedRow, TypedTable + + +@dataclass(frozen=True) +class StoredRequestGoal(TypedRow): + """Decode the old directive payload separately from the normalized Goal.""" + + goal: Annotated[Goal, Column(storage=JsonStorage)] + directives: Annotated[tuple[RequestDirective, ...], Column(storage=JsonStorage)] + owner_created_at: float + + def request(self, commander: RecordedThreadIncarnation, created_at: float) -> OwnerRequest: + if commander.created_at != self.owner_created_at: + raise RelationViolationError("Retained request belongs to another commander incarnation.") + state = (CompletedRequestState(self.goal.progress) if self.goal.state.terminal + else OpenRequestState()) + return OwnerRequest(OwnerRequestId(self.goal.id), commander, self.goal.text, + state, self.directives, created_at) + + +@dataclass(frozen=True) +class CurrentRequestGoal(StoredRequestGoal): + name: str + path: str + + +@dataclass(frozen=True) +class HistoricalRequestGoal(StoredRequestGoal): + sequence: int + observed_at: float + + +@dataclass(frozen=True) +class StoredRequestLink(TypedRow): + request: Annotated[OwnerRequestId, Column(storage=TextStorage)] + owner: str + owner_created: float + + +# Both the query and the replacement use the new declaration's codec. The old +# optional directives field had the empty-tuple default. +UNORDERED = json.dumps(FieldCodec.encode(UnorderedGoalOrder())) + + +def rebuild(db: sqlite3.Connection, row: type[TypedTable], changed: dict[str, str]) -> None: + """Copy every declared writable field; spell only the actual format changes.""" + fields = row._writable_fields() + selected = ", ".join(f'{changed.get(item.name, row._column_list((item.name,)))} ' + f'AS {row._column_list((item.name,))}' for item in fields) + table = row.declared_name + temporary = f"request_carry_{table}" + db.execute(f'CREATE TEMP TABLE "{temporary}" AS SELECT {selected} FROM "{table}"') + row.drop(db) + row.create(db) + columns = row._column_list(tuple(item.name for item in fields)) + db.execute(f'INSERT INTO "{table}" ({columns}) SELECT {columns} FROM "{temporary}"') + db.execute(f'DROP TABLE "{temporary}"') + + +def memory_copy(lifetime: ExitStack, path: Path) -> sqlite3.Connection: + db = lifetime.enter_context(closing(sqlite3.connect(":memory:"))) + CoordinationStore.identifiers_only(db) + if path.exists(): + with closing(CoordinationStore.read_snapshot(path)) as original: + original.backup(db) + return db + + +def historical(db: sqlite3.Connection, column: str, *, where: str = "1") -> list[HistoricalRequestGoal]: + # column is resolved from GoalHistoryEntry, never supplied by a caller. + selected = GoalHistoryEntry._column_list((column,)) + return HistoricalRequestGoal.read(db.execute( + f'SELECT sequence, observed_at, owner_created_at, ' + f"json_set({selected}, '$.order', json(?)) AS goal, " + f"coalesce(json_extract({selected}, '$.order.directives'), '[]') AS directives " + f'FROM "{GoalHistoryEntry.declared_name}" ' + f"WHERE json_extract({selected}, '$.order.kind')='request' AND ({where}) " + 'ORDER BY sequence', (UNORDERED,))) + + +class OwnerRequests(Carry): + @classmethod + def backup_files(cls, root: Path) -> tuple[Path, ...]: + registry = RegistryStore.at(root) + paths = (*registry.written, *SQLiteFile.files(TodoStore.path_in(root)), + *SQLiteFile.files(GoalHistoryStore.path_in(root))) + for path in paths: + cls.require_confined(root, path) + return tuple(path for path in paths if path.exists()) + + @classmethod + def carry(cls, root: Path, *, apply: bool) -> Carried: + cls.backup_files(root) + registry = RegistryStore.at(root) + todo_path, history_path = TodoStore.path_in(root), GoalHistoryStore.path_in(root) + if not registry.path.exists(): + if todo_path.exists() or history_path.exists(): + raise RelationViolationError("Request carry needs the retained registry.") + return Carried(0) + # The installer holds the wire lock and stops old owners. Use the existing + # registry lock/guard for its publication, not a second migration lock. + with registry.locked(), ExitStack() as lifetime: + registry.private_guard_unlocked() + raw = registry.path.read_text() + sql = lifetime.enter_context(closing(sqlite3.connect(":memory:"))) + current = CurrentRequestGoal.read(sql.execute( + "SELECT key AS name, fullkey || '.goal.order' AS path, " + "json_extract(value, '$.created_at') AS owner_created_at, " + "json_set(json_extract(value, '$.goal'), '$.order', json(?)) AS goal, " + "coalesce(json_extract(value, '$.goal.order.directives'), '[]') AS directives " + "FROM json_each(?, '$.threads') WHERE json_extract(value, '$.goal.order.kind')='request'", + (UNORDERED, raw))) + for source in current: + (raw,) = sql.execute('SELECT json_set(?, ?, json(?))', (raw, source.path, UNORDERED)).fetchone() + document = registry._decode(FieldCodec.parse(raw)) + owners = {thread.incarnation.require_recorded().created_at: thread.incarnation.require_recorded() + for thread in document.threads.values()} + todos = memory_copy(lifetime, todo_path) + history = memory_copy(lifetime, history_path) + todo_columns = Todo.stored_columns(todos) + old_links = "goal" in todo_columns + if old_links: + for link in StoredRequestLink.read(todos.execute( + "SELECT json_extract(goal,'$.goal_id') AS request, " + "json_extract(goal,'$.owner') AS owner, json_extract(goal,'$.owner_created') AS owner_created " + f'FROM "{Todo.declared_name}" WHERE goal IS NOT NULL GROUP BY goal')): + # Deleted commanders still have their original incarnation + # on the orders they issued. Do not manufacture a new actor. + owners.setdefault(link.owner_created, RecordedThreadIncarnation(link.owner, link.owner_created)) + if OwnerRequest.stored_columns(todos): + differences = SQLiteSchemaObject.differences( + TypedTable.objects_of(todos, (OwnerRequest,)), OwnerRequest.schema_objects(), + ) + if differences: + raise RelationViolationError(f"Request table differs from its declaration: {differences}") + requests = {request.id: request for request in OwnerRequest.stream(todos)} + else: + requests = {} + existing = dict(requests) + sources: dict[str, list[HistoricalRequestGoal]] = {} + first_seen: dict[OwnerRequestId, float] = {} + if GoalHistoryEntry.stored_columns(history): + for column in ("before", "after"): + sources[column] = historical(history, column) + committed = sorted((source for column in sources for source in historical( + history, column, where="state='committed'")), key=lambda source: source.sequence) + for source in committed: + identity = OwnerRequestId(source.goal.id) + first_seen.setdefault(identity, source.observed_at) + try: + commander = owners[source.owner_created_at] + except KeyError: + raise RelationViolationError( + f"Request {identity.value} has no retained commander incarnation.") from None + requests[identity] = source.request(commander, first_seen[identity]) + for source in current: + identity = OwnerRequestId(source.goal.id) + first_seen.setdefault(identity, time.time()) + requests[identity] = source.request(document.threads[source.name].incarnation.require_recorded(), + first_seen[identity]) + for identity, previous in existing.items(): + if identity in requests: + proposed = requests[identity] + if (previous.commander, previous.text, previous.state, previous.directives) != ( + proposed.commander, proposed.text, proposed.state, proposed.directives): + raise RelationViolationError(f"Request {identity.value} differs while finishing its carry.") + requests[identity] = previous + # Validate every historical endpoint, including pending/aborted/ + # uncertain intents. They cannot become new accepted directives. + prefixes = {} + for column, snapshots in sources.items(): + for source in snapshots: + identity = OwnerRequestId(source.goal.id) + if identity not in requests: + raise RelationViolationError( + f"Request {identity.value} at history {source.sequence} exists only in an unresolved intent.") + prefixes[column, source.sequence] = requests[identity].at_revision(source.directives) + new_rows = len(requests.keys() - existing.keys()) + todo_changed = old_links or new_rows > 0 + if todo_columns: + if not old_links and "request" not in todo_columns: + raise RelationViolationError("Todo has neither the retired goal nor a request link.") + if not OwnerRequest.stored_columns(todos): + OwnerRequest.create(todos) + elif requests: + TodoStore.create_schema(todos) + todo_changed = True + for request in requests.values(): + request.upsert(todos) + linked = 0 + if old_links: + (linked,) = todos.execute(f'SELECT count(*) FROM "{Todo.declared_name}" WHERE goal IS NOT NULL').fetchone() + rebuild(todos, Todo, {"request": "json_extract(goal,'$.goal_id')"}) + if todo_columns or requests: + TodoStore.require_schema(todos) + for _ in Todo.stream(todos): + pass + if list(todos.execute("PRAGMA foreign_key_check")): + raise RelationViolationError("Carried Todo request links violate their foreign keys.") + history_columns = GoalHistoryEntry.stored_columns(history) + missing = set(RequestHistory.__dataclass_fields__) - set(history_columns) + history_changed = bool(history_columns and (missing or prefixes)) + if history_changed: + rebuild(history, GoalHistoryEntry, {name: "NULL" for name in missing}) + for column, snapshots in sources.items(): + for source in snapshots: + goal_column = GoalHistoryEntry._column_list((column,)) + prefix_column = GoalHistoryEntry._column_list(("request_" + column,)) + history.execute( + f'UPDATE "{GoalHistoryEntry.declared_name}" ' + f"SET {goal_column}=json_set({goal_column}, '$.order', json(?)), {prefix_column}=? " + "WHERE sequence=?", + (UNORDERED, json.dumps(FieldCodec.encode(prefixes[column, source.sequence])), source.sequence), + ) + if history_columns: + GoalHistoryEntry.require_schema(history) + for entry in GoalHistoryEntry.stream(history): + for prefix in (entry.request_before, entry.request_after): + if prefix is not None: + request = OwnerRequest.require(todos, id=prefix.id) + if prefix.directive_count > len(request.directives): + raise RelationViolationError("Carried history exceeds its request directives.") + rewritten: list[str] = [] + if apply: + for changed, path, prepared in ((todo_changed, todo_path, todos), (history_changed, history_path, history)): + if changed: + prepared.commit() + with closing(CoordinationStore.writer(path)) as destination: + prepared.backup(destination) + path.chmod(0o600) + rewritten.extend(str(file.relative_to(root)) for file in SQLiteFile.files(path)) + if current: + registry._write_unlocked(raw) + rewritten.extend(str(path.relative_to(root)) for path in registry.written) + return Carried(new_rows + int(old_links) + int(history_changed) + len(current), + detail={"requests": new_rows, "linked_todos": linked, + "directives": sum(len(request.directives) for request in requests.values()), + "history_endpoints": len(prefixes)}, rewritten=tuple(rewritten)) From e5363c402c8fea39e97549555ba2f8ed44fb7593 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:07:44 -0400 Subject: [PATCH 05/10] Own retained table rebuilds and migrate request fixtures --- src/agent_comms/typed_table.py | 22 ++++++++++ tests/round2/test_typed_table.py | 23 +++++++++- tests/test_bus_files.py | 5 ++- tests/test_commander_forks.py | 16 ++++--- tests/test_commander_run_rules.py | 16 ++++--- tests/test_declared_schema_carry.py | 14 +++--- tests/test_goal_standby.py | 13 ------ tests/test_held_problem_orders.py | 8 ++-- tests/test_holder_standing_rules.py | 20 +++++---- tests/test_order_progress.py | 9 ++-- tests/test_order_scopes.py | 12 +++--- tests/test_order_story.py | 11 +++-- tests/test_owned_turn_goal_replacement.py | 32 ++++++-------- tests/test_owner_requests.py | 4 +- tests/test_proof_build.py | 20 +++------ tests/test_source_revision.py | 3 +- tests/test_storage_violations.py | 4 +- tests/test_todos.py | 52 +++++++++++++---------- tests/test_unowned_checkout_work.py | 15 +++---- tests/verify_connector_tools.py | 22 ++++------ tools/carry_rts_orders.py | 6 ++- tools/cutover/carry_owner_requests.py | 24 +++-------- tools/proofs/cn_phase3_record.py | 18 ++++---- tools/prove_commander_dispatch.py | 13 ++---- 24 files changed, 195 insertions(+), 187 deletions(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index a99efa4182..8c6f65ece4 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -914,6 +914,28 @@ def drop(cls, db: sqlite3.Connection) -> None: """Drop the declared table; SQLite drops its indexes with it.""" db.execute(f"DROP TABLE IF EXISTS {_identifier(cls.declared_name)}") + @classmethod + def rebuild(cls, db: sqlite3.Connection, changed: dict[str, str]) -> None: + """Copy writable fields into the declared schema, in the caller's transaction. + + Data conversions supply expressions only for the fields they change. + The table owns every other column, its indexes, constraints and triggers. + """ + cls._writable_fields(tuple(changed)) + fields = cls._writable_fields() + selected = ", ".join( + f'{changed.get(item.name, cls._column_list((item.name,)))} ' + f'AS {cls._column_list((item.name,))}' for item in fields + ) + table = _identifier(cls.declared_name) + temporary = _identifier(f"rebuild_{cls.declared_name}") + db.execute(f"CREATE TEMP TABLE {temporary} AS SELECT {selected} FROM {table}") + cls.drop(db) + cls.create(db) + columns = cls._column_list(tuple(item.name for item in fields)) + db.execute(f"INSERT INTO {table} ({columns}) SELECT {columns} FROM {temporary}") + db.execute(f"DROP TABLE {temporary}") + @classmethod def stored_columns(cls, db: sqlite3.Connection) -> tuple[str, ...]: """The columns the stored table has now, which a carry compares with the declaration.""" diff --git a/tests/round2/test_typed_table.py b/tests/round2/test_typed_table.py index b5f80f5fb8..f53e4130dd 100644 --- a/tests/round2/test_typed_table.py +++ b/tests/round2/test_typed_table.py @@ -17,6 +17,7 @@ IntegerStorage, SqlStorage, SqlValue, + SQLiteSchemaObject, TypedRow, TypedTable, RowNotFoundError, @@ -108,9 +109,29 @@ def test_declared_table_family(tmp_path): assert TableChildRow.select(db)[0].weight is None +def test_rebuild_converts_a_field_and_preserves_the_declared_rows_and_constraints(): + with sqlite3.connect(':memory:') as db: + db.execute('PRAGMA foreign_keys=ON') + TableParentRow.create(db) + TableChildRow.create(db) + TableParentRow('p', 3).insert(db) + rows = (TableChildRow('first', 'p', True, ('one',), 125.0), + TableChildRow('second', 'p', False, ('two',), 250.0)) + for row in rows: + row.insert(db) + db.execute(f'ALTER TABLE "{TableChildRow.declared_name}" RENAME COLUMN weight TO retired_weight') + TableChildRow.rebuild(db, {'weight': 'retired_weight / 100.0'}) + assert TableChildRow.select(db, order_by=('key',)) == [ + replace(row, weight=row.weight / 100) for row in rows] + assert not SQLiteSchemaObject.differences( + TableChildRow.stored_objects(db), TableChildRow.schema_objects()) + assert db.execute('PRAGMA foreign_key_check').fetchall() == [] + with pytest.raises(sqlite3.IntegrityError): + replace(rows[0], key='invalid', parent='missing').insert(db) + + def test_atomic_creation_preserves_declared_indexes_triggers_and_strict_creation(): from agent_comms.native_pi import NativeContextJournal - from agent_comms.typed_table import SQLiteSchemaObject with sqlite3.connect(':memory:') as db: NativeContextJournal.create(db, if_not_exists=True) diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index 0ee6967f9d..0a2956d78b 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -18,7 +18,7 @@ from agent_comms.private_registry_guard import PrivateOwnerRenameIntent from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName -from agent_comms.todos import GoalRef, TodoStore +from agent_comms.todos import TodoStore from test_todos import order @@ -45,7 +45,8 @@ def test_every_bus_file_is_a_declared_root_file(tmp_path: Path, source_repositor todo = TodoStore(comms.root).create( "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Keep declared bus files", creator=owner, assigner=owner.incarnation, owner=owner, - generation="declared-files", goal=GoalRef(owner.name, owner.created_at, "goal-1"), + generation="declared-files", + request=comms.goals.todos.add_request(owner.incarnation, "Keep declared bus files").id, review_at=RecordedEventTime(1000.0), order=order(source_repository), ) assert todo.id == "P1" diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index c5e7cabf34..e9d221b556 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -25,7 +25,7 @@ from agent_comms.transcript_events import ToolStartTranscript from agent_comms.goal_actions import SetGoalAction from agent_comms.installer.backup import StoreBackup -from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder +from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.order_runs import MechanicalOrderRun from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo @@ -35,7 +35,7 @@ from agent_comms.tools import CommsTodoListTool from agent_comms.thread_model import DeclaredModel from agent_comms.todos import ( - DeclarationDerivation, GoalRef, PendingProblemOrder, ProblemAllocation, ReleaseTodo, + DeclarationDerivation, PendingProblemOrder, ProblemAllocation, ReleaseTodo, Slop, StrikeReason, SupersededVerdict, Todo, TodoError, ) @@ -55,7 +55,9 @@ def fork_bus(tmp_path): name, frozenset({'comms'}), str(source.checkout), model=DeclaredModel('anthropic/claude-opus-4-6'), execution=ExternalThreadExecution)) comms.goals.update_goal('lead', SetGoalAction( - text='Repair the commander workflow', goal_id='request', order=RequestGoalOrder())) + text='Repair the commander workflow', goal_id='request')) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the commander workflow') core = Repo.of(Path(__file__).parents[1]) config = Commander.on_machine('lead', (source, core), comms.root, tmp_path) config.root_path(comms.root).write_text(config.model_dump_json()) @@ -70,7 +72,7 @@ def fork_bus(tmp_path): comms.goals.todos.create('P40', source.github, 'Existing parent problem', creator=lead, owner=comms.registry.require('retained'), assigner=lead.incarnation, generation='retained', review_at=RecordedEventTime(now + 600), order=replace(order, id='retained-order'), - goal=GoalRef('lead', lead.created_at, 'request')) + request=request.id) copied = tmp_path / 'copied-bus' StoreBackup.copy_tree(comms.root, copied) return Comms(copied), source, order @@ -277,13 +279,13 @@ def test_created_ids_live_only_on_todos_and_allocation_uses_numeric_index(fork_b issued = store.allocate_problem() assert issued == 'P41' created = store.create(issued, source.github, 'Create a real problem', creator=lead, - assigner=lead.incarnation, owner=comms.registry.require('other'), generation='created', goal=retained.goal, + assigner=lead.incarnation, owner=comms.registry.require('other'), generation='created', request=retained.request.require(), review_at=retained.review_at, order=order) with store.snapshot() as db: assert ProblemAllocation.select(db) == [] # Direct creation must affect allocation without copying its key into the allocator. store.create('P120', source.github, 'Exercise numeric ordering', creator=lead, - assigner=lead.incarnation, owner=comms.registry.require('worker'), generation='direct', goal=retained.goal, + assigner=lead.incarnation, owner=comms.registry.require('worker'), generation='direct', request=retained.request.require(), review_at=retained.review_at, order=replace(order, id='direct-order')) assert store.allocate_problem() == 'P121' with store.snapshot() as db: @@ -315,7 +317,7 @@ def test_fork_superseded_verdict_reads_its_commanders_orders(fork_bus): store.create('P41', source.github, 'Replacing parent problem', creator=lead, owner=comms.registry.require('worker'), assigner=lead.incarnation, generation='worker', review_at=RecordedEventTime(time.time() + 600), order=replace(order, id='replacing-order'), - goal=GoalRef('lead', lead.created_at, 'request')) + request=store.get('P40').request.require()) comms.registry.register_commander_child('fork-one', 'lead', comms.goals) actor = CommanderForkCaller('fork-one').identity(comms) current = store.get('P40') diff --git a/tests/test_commander_run_rules.py b/tests/test_commander_run_rules.py index 6d231ebbc7..0a6846d4a4 100644 --- a/tests/test_commander_run_rules.py +++ b/tests/test_commander_run_rules.py @@ -8,7 +8,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName -from agent_comms.todos import GoalRef, Todo, TodoStore +from agent_comms.todos import Todo, TodoStore from agent_comms.order_runs import ApplicationOrderRun from test_todos import order, thread @@ -18,10 +18,11 @@ def test_declared_run_round_trips_and_short_runs_are_refused(tmp_path, source_re checked = replace(order(source_repository), run=ApplicationOrderRun(planned)) store = TodoStore(tmp_path) lead = thread('lead', tmp_path, 1) + request = store.add_request(lead.incarnation, 'Repair the thread list').id todo = store.create('P1', RepositoryName.from_wire('OpenHCSDev/agent-comms'), 'Repair the thread list', - creator=lead, assigner=lead.incarnation, owner=lead, generation='one', goal=GoalRef('lead', 1, 'one'), review_at=RecordedEventTime(1000), - order=checked) + creator=lead, assigner=lead.incarnation, owner=lead, generation='one', + request=request, review_at=RecordedEventTime(1000), order=checked) assert FieldCodec.decode(Todo, FieldCodec.encode(todo)) == todo assert planned in todo.order.instructions(todo) with pytest.raises(ToolRefusedError): @@ -36,16 +37,17 @@ def test_declared_run_round_trips_and_short_runs_are_refused(tmp_path, source_re def test_clean_transfers_keep_history_and_third_owner_requires_a_trace(tmp_path, source_repository): store = TodoStore(tmp_path) lead = thread('lead', tmp_path, 1) + request = store.add_request(lead.incarnation, 'Repair the thread list').id first = store.create('P1', RepositoryName.from_wire('OpenHCSDev/agent-comms'), 'Repair the thread list', - creator=lead, assigner=lead.incarnation, owner=lead, generation='one', goal=GoalRef('lead', 1, 'one'), review_at=RecordedEventTime(1000), - order=order(source_repository)) + creator=lead, assigner=lead.incarnation, owner=lead, generation='one', + request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) second = store.assign('P1', expected_revision=first.revision, - owner=thread('second', tmp_path, 2), assigner=lead.incarnation, generation='two', goal=GoalRef('second', 2, 'two'), + owner=thread('second', tmp_path, 2), assigner=lead.incarnation, generation='two', request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) assert not second.strikes kwargs = dict(expected_revision=second.revision, owner=thread('third', tmp_path, 3), - assigner=lead.incarnation, generation='three', goal=GoalRef('third', 3, 'three'), review_at=RecordedEventTime(1000), order=order(source_repository)) + assigner=lead.incarnation, generation='three', request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) with pytest.raises(ValueError): store.assign('P1', **kwargs) # Source existence belongs to the commander boundary, not a Core record. diff --git a/tests/test_declared_schema_carry.py b/tests/test_declared_schema_carry.py index 9bc0219c62..3ec682a825 100644 --- a/tests/test_declared_schema_carry.py +++ b/tests/test_declared_schema_carry.py @@ -11,15 +11,15 @@ from order_scope_fixture import FIX_SCOPE from agent_comms.change_report import FileLine +from agent_comms.comms import Comms from agent_comms.errors import RelationViolationError from agent_comms.goals import OrderCheck from agent_comms.installer.carry import CarryChain from agent_comms.native_pi import NativeContextJournal from agent_comms.native_session_files import NativeSessionFiles, PrivateSession from agent_comms.recorded_time import RecordedEventTime -from agent_comms.registration import Registration from agent_comms.repository_name import RepositoryName -from agent_comms.todos import GoalRef, PendingProblemOrder, Todo, TodoStore +from agent_comms.todos import PendingProblemOrder, Todo, TodoStore from agent_comms.order_runs import MechanicalOrderRun from agent_comms.typed_table import DurableTables, SharedFileTables from cutover.carry_declared_schema import carry @@ -162,8 +162,10 @@ def test_the_schema_carry_runs_after_the_review_timer_carry(): def test_a_bus_with_a_todo_deadline_and_old_proofs_carries_through_the_whole_chain(tmp_path): - lead = Registration(tmp_path / "registry.json").new_thread( - "lead", frozenset(), str(tmp_path), created_at=1.0) + comms = Comms(tmp_path) + comms.messaging.initialize_private_initial_protocol() + lead = comms.registry.new_thread("lead", frozenset(), str(tmp_path), created_at=1.0) + comms.registry.register(lead) order = PendingProblemOrder( id="checked-order", owner=FileLine(__file__, 1), scope=FIX_SCOPE, run=MechanicalOrderRun(), check=OrderCheck(str(tmp_path), "true"), edits=("Fix the problem",), @@ -171,7 +173,9 @@ def test_a_bus_with_a_todo_deadline_and_old_proofs_carries_through_the_whole_cha at=RecordedEventTime(10.0), first_call_deadline=RecordedEventTime(130.0)) created = TodoStore(tmp_path).create( "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", - creator=lead, assigner=lead.incarnation, goal=GoalRef("lead", 1.0, "goal-1"), owner=lead, generation="initial", + creator=lead, assigner=lead.incarnation, + request=TodoStore(tmp_path).add_request(lead.incarnation, "Repair the sidebar").id, + owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order) with closing(sqlite3.connect(TodoStore.path_in(tmp_path))) as db: db.execute('ALTER TABLE "todo" RENAME COLUMN "review_at" TO "deadline"') diff --git a/tests/test_goal_standby.py b/tests/test_goal_standby.py index 59e124ad5f..34c10bb3eb 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -291,16 +291,3 @@ def test_standby_refuses_canonical_reply_that_arrived_before_wait(tmp_path, ackn assert isinstance(comms.goals.goal_wait("parent"), NoGoalWait) assert comms.registry.require("parent").goal == goal assert not (tmp_path / "input_dispositions.sqlite3").exists() - - -def test_an_open_owner_request_takes_a_new_wording_and_keeps_its_identity(tmp_path): - from agent_comms.goals import RequestGoalOrder - - comms = canonical_goal_wire(tmp_path) - comms.registry.declare(comms.registry.new_thread('lead', frozenset(), str(tmp_path))) - opened = comms.goals.update_goal('lead', SetGoalAction( - text='Commander native in Core', goal_id='request', order=RequestGoalOrder())) - edited = comms.goals.update_goal('lead', EditGoalAction(text="Tristan's plans, in order")) - assert (edited.id, edited.order, edited.text) == (opened.id, opened.order, "Tristan's plans, in order") - with pytest.raises(ValueError, match="still open"): - comms.goals.update_goal('lead', SetGoalAction(text='Another request', order=RequestGoalOrder())) diff --git a/tests/test_held_problem_orders.py b/tests/test_held_problem_orders.py index f071025c39..1a75f9c3d8 100644 --- a/tests/test_held_problem_orders.py +++ b/tests/test_held_problem_orders.py @@ -17,7 +17,8 @@ from agent_comms.goals import OrderCheck from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo -from agent_comms.todos import DeliveredProblemOrder, GoalRef, OrderMessage +from agent_comms.todos import DeliveredProblemOrder, OrderMessage +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import CommsChannelsTool @@ -34,7 +35,8 @@ def held_commander(pr_commander): lead = source.comms.registry.require(source.commander) todo = source.comms.goals.todos.create( "P1", source.repos[0].github, "Repair the held workflow", creator=lead, assigner=lead.incarnation, - owner=worker, generation="initial-order", goal=GoalRef(lead.name, lead.created_at, lead.goal.require().id), + owner=worker, generation="initial-order", + request=NewestOpenRequest().for_order(source.comms.goals.todos, lead.incarnation), review_at=RecordedEventTime(time.time() + 1000), order=order(source.repos[0]), ) published = source.comms.messaging.send(source.commander, worker.name, "Repair the held workflow through its existing declaration.") @@ -94,7 +96,7 @@ def test_second_unplanned_order_is_refused_without_recording(held_commander): lead = ctx.comms.registry.require(ctx.commander) ctx.comms.goals.todos.create( "P2", ctx.repos[0].github, "Repair the second workflow", creator=lead, assigner=lead.incarnation, owner=lead, - generation="second-workflow", goal=GoalRef(lead.name, lead.created_at, lead.goal.require().id), + generation="second-workflow", request=original.request.require(), review_at=RecordedEventTime(time.time() + 1000), order=document.order, ) path = ctx.root / "second-order.json" diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index a27a59d0d8..91496c944c 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -33,12 +33,13 @@ from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import ( - CreateProblemGoalAction, DirectiveGoalAction, ReissuedOrderGoalAction, SetGoalAction, + CreateProblemGoalAction, ReissuedOrderGoalAction, ) from agent_comms.goal_attempts import GoalAttemptStore, GoalWorkDecision from agent_comms.goal_presentation import RunnableExecution from agent_comms.goal_states import ActiveGoal -from agent_comms.goals import CountCheckResult, Goal, OrderCheck, RequestGoalOrder +from agent_comms.goals import CountCheckResult, OrderCheck +from agent_comms.owner_requests import NewestOpenRequest, RequestDirective from agent_comms.input_disposition import InputDispositions from agent_comms.installer.backup import StoreBackup from agent_comms.installer.carry import CarryChain @@ -73,13 +74,12 @@ def held_bus(comms, tmp_path, source_repository, configured_repository): comms.registry.new_thread(name=name, tags=frozenset({'comms'}), worktree=str(source.checkout)) for name in ('lead', 'worker', 'outsider') )) - comms.goals.update_goal("lead", SetGoalAction( - text="Repair the held work", goal_id="request", order=RequestGoalOrder(), - )) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the held work').id now = time.time() for problem in ("P1", "P2"): comms.goals.update_goal("worker", CreateProblemGoalAction( - problem=problem, commander="lead", request="request", + problem=problem, commander="lead", request=request, assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire("OpenHCSDev/agent-comms"), text=f"Repair {problem}", review_at=RecordedEventTime(now + 3600), @@ -208,7 +208,7 @@ def test_goal_generation_cannot_transfer_to_a_different_owner(held_bus): held_bus.goals.todos.assign( todo.id, expected_revision=todo.revision, owner=held_bus.registry.require("outsider"), assigner=CoreWatcherIncarnation.watcher(), - generation=todo.held().generation, goal=todo.goal, review_at=todo.review_at, + generation=todo.held().generation, request=todo.request.require(), review_at=todo.review_at, order=todo.order.checked().reissued(time.time()), ) assert held_bus.goals.todos.get(todo.id) == todo @@ -305,8 +305,10 @@ def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): Message(sender="outsider", target="lead", type=InfoKind, body="Ship the install now", timestamp=time.time() - 3600), origin=AgentOrigin(), ) - held_bus.goals.update_goal("lead", DirectiveGoalAction( - text=directive.body, seq=directive.seq, at=directive.timestamp)) + store = held_bus.goals.todos + selected = NewestOpenRequest().select(store, held_bus.registry.require('lead').incarnation) + store.record_directive(selected[0], RequestDirective( + directive.seq, directive.timestamp + RequestDirective.window)) ctx = Commander.read_root(held_bus.root) reply = tmp_path / "reply.txt" reply.write_text("Installing now") diff --git a/tests/test_order_progress.py b/tests/test_order_progress.py index ade713ef8e..a8880e2784 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -11,8 +11,7 @@ from source_revision_fixture import SourceRepository from agent_comms.comms import Comms -from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction -from agent_comms.goals import RequestGoalOrder +from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.native_tools import NativeRequestTools from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName @@ -120,10 +119,10 @@ def held(tmp_path): comms.messaging.initialize_private_initial_protocol() for name in ('lead', 'worker'): comms.registry.declare(comms.registry.new_thread(name, frozenset(), str(tmp_path))) - comms.goals.update_goal('lead', SetGoalAction( - text='Repair the worker', goal_id='request', order=RequestGoalOrder())) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the worker').id ThreadCaller('lead').invoke(CommsTodoCreateTool(owner='worker', order=CreateProblemGoalAction( - problem='P1', order=order(source), request='request', review_at=RecordedEventTime(1000), + problem='P1', order=order(source), request=request, review_at=RecordedEventTime(1000), commander='lead', assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire('org/repo'), text='Repair the worker')), comms) return comms, unit_checkout(tmp_path / 'wt' / 'worker--P1') diff --git a/tests/test_order_scopes.py b/tests/test_order_scopes.py index 00054bdf18..0d5ec3b5bd 100644 --- a/tests/test_order_scopes.py +++ b/tests/test_order_scopes.py @@ -17,8 +17,7 @@ from agent_comms.commander.order import OrderFile from agent_comms.coordination_database import CoordinationStore from agent_comms.field_codec import FieldCodec, MalformedRecord -from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction -from agent_comms.goals import RequestGoalOrder +from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.installer.backup import StoreBackup from agent_comms.installer.carry import Carried, CarryChain from agent_comms.locked_store import SQLiteFile @@ -68,12 +67,11 @@ def assigned(comms, scope, source, problem='P1'): def requested(comms, scope, source, problem='P1'): - comms.goals.update_goal('lead', SetGoalAction( - text='Classify the failing source or fix its declared parent', goal_id='request', - order=RequestGoalOrder())) + request = comms.goals.todos.add_request(comms.registry.require('lead').incarnation, + 'Classify the failing source or fix its declared parent').id caller = ThreadCaller('lead') CommsTodoCreateTool(owner='worker', order=CreateProblemGoalAction( - problem=problem, order=replace(order(source), scope=scope), request='request', + problem=problem, order=replace(order(source), scope=scope), request=request, review_at=RecordedEventTime(1000), commander='lead', assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire('OpenHCSDev/agent-comms'), @@ -139,7 +137,7 @@ def test_carry_scopes_current_nested_and_prior_orders_on_a_copy(comms, tmp_path, store.create(problem, original.repo, 'Classify the source', creator=lead, owner=worker, assigner=lead.incarnation, generation=original.held().generation, review_at=original.review_at, - goal=original.goal, order=replace(order(source_repository), scope=TriageScope())) + request=original.request.require(), order=replace(order(source_repository), scope=TriageScope())) copied = tmp_path / 'copy' StoreBackup.copy_tree(comms.root, copied) path = TodoStore.path_in(copied) diff --git a/tests/test_order_story.py b/tests/test_order_story.py index 2611cca151..a0836531eb 100644 --- a/tests/test_order_story.py +++ b/tests/test_order_story.py @@ -7,8 +7,7 @@ from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec -from agent_comms.goal_actions import CreateProblemGoalAction, OrderGoalAction, SetGoalAction -from agent_comms.goals import RequestGoalOrder +from agent_comms.goal_actions import CreateProblemGoalAction, OrderGoalAction from agent_comms.order_story import FleetStory, OrderStory from agent_comms.recorded_time import RecordedEventTime from agent_comms.todos import ( @@ -28,12 +27,12 @@ def test_order_story_copied_bus_strike_and_send_back_once(comms, tmp_path): comms.messaging.initialize_private_initial_protocol() for name in ("lead", "worker"): comms.registry.declare(comms.registry.new_thread(name, frozenset(), str(tmp_path))) - comms.goals.update_goal("lead", SetGoalAction( - text="Repair the worker", goal_id="request", order=RequestGoalOrder())) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the worker').id caller = ThreadCaller("lead") caller.invoke(CommsTodoCreateTool(owner="worker", order=CreateProblemGoalAction( problem="P1", order=replace(order(source), edits=("Repair the existing record owner",)), - request="request", review_at=RecordedEventTime(1000), + request=request, review_at=RecordedEventTime(1000), commander="lead", assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire("org/repo"), text="Repair the worker")), comms) todo = comms.goals.todos.get("P1") @@ -52,7 +51,7 @@ def test_order_story_copied_bus_strike_and_send_back_once(comms, tmp_path): todo = comms.goals.todos.get("P1") caller.invoke(CommsOrderTool(owner="worker", order=OrderGoalAction( problem="P1", expected_revision=todo.revision, assigner=actor, commander="lead", - request="request", review_at=RecordedEventTime(2000), + request=request, review_at=RecordedEventTime(2000), order=replace(order(source), id="resent-order"))), comms) current = comms.goals.todos.get("P1") with pytest.raises(ValueError): diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index 66e36c25d8..38b7cd4b37 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -15,10 +15,9 @@ from agent_comms.comms import Comms from agent_comms.field_codec import Id128Text from agent_comms.goal_actions import ( - BlockedGoalAction, CreateProblemGoalAction, OrderGoalAction, SetGoalAction, + BlockedGoalAction, CreateProblemGoalAction, OrderGoalAction, ) from agent_comms.goal_states import ActiveGoal -from agent_comms.goals import RequestGoalOrder from agent_comms.owned_send import OwnedSend from agent_comms.owned_turn import OwnedTurn from agent_comms.queued_input import InitialInput, QueuedInput @@ -55,11 +54,10 @@ async def test_turn_after_order_replacement_is_allowed(tmp_path, native_runtime_ 'lead', frozenset(), str(saved.project), process_identity=ProcessIdentity.capture(os.getpid()), )) - comms.goals.update_goal('lead', SetGoalAction( - text='Repair following input', goal_id='request', order=RequestGoalOrder(), - )) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair following input').id comms.goals.update_goal(worker, CreateProblemGoalAction( - problem='P1', order=order(source), request='request', + problem='P1', order=order(source), request=request, review_at=RecordedEventTime(1000), commander='lead', assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire('org/repo'), text='Repair following input', @@ -79,7 +77,7 @@ async def test_turn_after_order_replacement_is_allowed(tmp_path, native_runtime_ replaced = comms.goals.update_goal(worker, OrderGoalAction( problem='P1', expected_revision=todo.revision, assigner=comms.registry.require('lead').incarnation, - commander='lead', request='request', review_at=RecordedEventTime(2000), + commander='lead', request=request, review_at=RecordedEventTime(2000), order=replace(order(source), id='replacement'), )) # A replaced order keeps the running turn's authority; only following input waits. @@ -122,11 +120,10 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), )) - comms.goals.update_goal('lead', SetGoalAction( - text='Repair the turn owner', goal_id='request', order=RequestGoalOrder(), - )) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the turn owner').id initial = comms.goals.update_goal('worker', CreateProblemGoalAction( - problem='P1', order=order(source), request='request', + problem='P1', order=order(source), request=request, review_at=RecordedEventTime(1000), commander='lead', assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire('org/repo'), text='Repair the turn owner', @@ -141,7 +138,7 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p todo = comms.goals.todos.get('P1') replaced = comms.goals.update_goal('worker', OrderGoalAction( problem='P1', expected_revision=todo.revision, assigner=actor, - commander='lead', request='request', review_at=RecordedEventTime(2000), + commander='lead', request=request, review_at=RecordedEventTime(2000), order=replace(order(source), id='replacement'), )) old.original.goal_permission.require_current(replaced) @@ -162,7 +159,7 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p todo = comms.goals.todos.get('P1') latest = comms.goals.update_goal('worker', OrderGoalAction( problem='P1', expected_revision=todo.revision, assigner=actor, - commander='lead', request='request', review_at=RecordedEventTime(3000), + commander='lead', request=request, review_at=RecordedEventTime(3000), order=replace(order(source), id='replacement-during-preparation'), )) assert current.thread.goal == replaced != latest @@ -187,11 +184,10 @@ async def test_order_reactivating_a_blocked_goal_keeps_the_running_turn(tmp_path name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), )) - comms.goals.update_goal('lead', SetGoalAction( - text='Repair the blocked unit', goal_id='request', order=RequestGoalOrder(), - )) + request = comms.goals.todos.add_request( + comms.registry.require('lead').incarnation, 'Repair the blocked unit').id comms.goals.update_goal('worker', CreateProblemGoalAction( - problem='P1', order=order(source), request='request', + problem='P1', order=order(source), request=request, review_at=RecordedEventTime(1000), commander='lead', assigner=comms.registry.require('lead').incarnation, repo=RepositoryName.from_wire('org/repo'), text='Repair the blocked unit', @@ -210,7 +206,7 @@ async def test_order_reactivating_a_blocked_goal_keeps_the_running_turn(tmp_path todo = comms.goals.todos.get('P1') retried = comms.goals.update_goal('worker', OrderGoalAction( problem='P1', expected_revision=todo.revision, assigner=actor, - commander='lead', request='request', review_at=RecordedEventTime(2000), + commander='lead', request=request, review_at=RecordedEventTime(2000), order=replace(order(source), id='retry'), )) assert retried.state == ActiveGoal() diff --git a/tests/test_owner_requests.py b/tests/test_owner_requests.py index c8bb001aff..3439826045 100644 --- a/tests/test_owner_requests.py +++ b/tests/test_owner_requests.py @@ -47,7 +47,7 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: ) todo = store.create( "P1", repository.github, "Inspect the request owner", - creator=commander, owner=worker, generation="request-family", + creator=commander, assigner=commander.incarnation, owner=worker, generation="request-family", review_at=RecordedEventTime(order.at.recorded_at("Order") + 60), order=order, request=first.id, ) @@ -119,7 +119,7 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: awaiting_order = order.reissued(order.at.recorded_at("Order") + 1) awaiting_todo = store.create( "P2", repository.github, "Review the holder's delivery claim", creator=commander, - owner=worker, generation="claim-review", request=awaiting.id, + assigner=commander.incarnation, owner=worker, generation="claim-review", request=awaiting.id, review_at=RecordedEventTime(awaiting_order.at.recorded_at("Order") + 60), order=awaiting_order, ) awaiting_claim = OrderClaim.for_order( diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index 385e17810e..720a39a894 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -19,8 +19,8 @@ from agent_comms.commander.standing import InstallDue from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec -from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction -from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder +from agent_comms.goal_actions import CreateProblemGoalAction +from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.installer.carry import CarryChain from agent_comms.installer.runtime_resolution import ( ArchiveRecord, @@ -41,7 +41,6 @@ from agent_comms.todos import ( CommitDelivery, DoneTodoState, - GoalRef, OpenTodoState, FileDeliveryEvidence, OperationDelivery, @@ -149,7 +148,7 @@ def test_merge_in_head_cannot_close_until_the_recorded_build_contains_it( todo = comms.goals.todos.create( "P1", graph.repository.github, "Prove installation instead of inferring it from merge", creator=lead, assigner=lead.incarnation, owner=lead, generation="fixture-goal", - goal=GoalRef(lead.name, lead.created_at, "fixture-goal"), + request=comms.goals.todos.add_request(lead.incarnation, "Prove the installed build").id, review_at=RecordedEventTime(1000), order=merged, ) with pytest.raises(DeliveredCommitNotInstalledError): @@ -257,14 +256,9 @@ def test_installed_proof_closes_and_older_build_is_refused_on_copied_bus( artifact.write_text(works) source.registry.storage_rule = replace(source.registry.storage_rule, scratch_base=tmp_path) with CheckRoot(source, ("lead", "worker"), tmp_path / "proof" / "copied").open() as copied: - copied.goals.update_goal( - "lead", - SetGoalAction( - text="Record the copied application proof", - goal_id="request", - order=RequestGoalOrder(), - ), - ) + request = copied.goals.todos.add_request( + copied.registry.require("lead").incarnation, "Record the copied application proof", + ).id cases = ( ("P1", OperationDelivery(proof_build=installed), None), ("P2", OperationDelivery(proof_build=older), ProofBuildMismatchError), @@ -296,7 +290,7 @@ def test_installed_proof_closes_and_older_build_is_refused_on_copied_bus( order=CreateProblemGoalAction( problem=problem, order=order, - request="request", + request=request, review_at=RecordedEventTime(4102444800), commander="lead", assigner=caller.identity(copied), repo=graph.repository.github, diff --git a/tests/test_source_revision.py b/tests/test_source_revision.py index 075fac6359..0575f08740 100644 --- a/tests/test_source_revision.py +++ b/tests/test_source_revision.py @@ -10,6 +10,7 @@ from agent_comms.field_codec import MalformedRecord from agent_comms.goal_actions import OrderGoalAction from agent_comms.goals import GitCommandFailed +from agent_comms.owner_requests import OwnerRequestId from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.todos import FixScope, FixShape, LocatedProblemFinding @@ -60,7 +61,7 @@ def test_order_derives_its_only_source_revision_from_its_pull_request(tmp_path): location = FileLine('branch.py', 2) declaration = replace(declaration, owner=location, finding=LocatedProblemFinding(location), scope=FixScope(FixShape(parents=(location,), members=())), pull_requests=(source.link,)) - action = OrderGoalAction(problem='P1', order=declaration, request='request', + action = OrderGoalAction(problem='P1', order=declaration, request=OwnerRequestId('request'), review_at=RecordedEventTime(1000), commander='lead', expected_revision=1, assigner=RecordedThreadIncarnation('lead', 1.0)) action.require_sendable() diff --git a/tests/test_storage_violations.py b/tests/test_storage_violations.py index 1dd225f9bb..7981adbe5c 100644 --- a/tests/test_storage_violations.py +++ b/tests/test_storage_violations.py @@ -27,7 +27,7 @@ StorageViolation, UnitStorage, ) -from agent_comms.todos import GoalRef, PendingProblemOrder, PresentCheckout +from agent_comms.todos import PendingProblemOrder, PresentCheckout from agent_comms.order_runs import MechanicalOrderRun @@ -103,7 +103,7 @@ def test_storage_family_reads_once_and_renders_each_broken_fact( owner=worker, generation="storage-case", review_at=RecordedEventTime(1000), - goal=GoalRef("lead", lead.created_at, "storage-case"), + request=comms.goals.todos.add_request(lead.incarnation, "Fix the filesystem paths").id, order=PendingProblemOrder( id="storage-case-order", scope=FIX_SCOPE, owner=FileLine(__file__, 1), diff --git a/tests/test_todos.py b/tests/test_todos.py index 11a60931fa..31b7f5ad0f 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -1,4 +1,4 @@ -"""Todo creation and assignment retain exact owners, revisions and goal references.""" +"""Todo creation and assignment retain exact owners, revisions and request links.""" from __future__ import annotations @@ -29,7 +29,8 @@ from agent_comms.installer.runtime_resolution import CorePackage from agent_comms.installer.practice import practice_copy from agent_comms.goal_actions import SetGoalAction -from agent_comms.goals import OrderCheck, RequestGoalOrder +from agent_comms.goals import OrderCheck +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.launch_root import LaunchRoot from agent_comms.child_process import ProcessIdentity from agent_comms.recorded_time import RecordedEventTime @@ -43,7 +44,6 @@ Assignment, ChangeTodoState, DoneTodoState, - GoalRef, ClosedListedPullRequest, MergedListedPullRequest, PullRequestCommit, @@ -96,7 +96,7 @@ def _assign_process(root: str, checkout: str, name: str, created: float, gate) - row = store.assign( "P1", expected_revision=1, owner=thread(name, Path(root), created), assigner=thread("lead", Path(root), 1.0).incarnation, - generation=f"generation-{name}", goal=GoalRef("lead", 1.0, "goal-1"), + generation=f"generation-{name}", request=store.get("P1").request.require(), review_at=RecordedEventTime(1000.0), order=order(SourceRepository.of(Path(checkout))), ) return "won", row.assignment.owner @@ -104,12 +104,12 @@ def _assign_process(root: str, checkout: str, name: str, created: float, gate) - return "conflict", error.current.assignment.owner -def test_create_is_idempotent_and_goal_reference_is_historical(tmp_path: Path, source_repository) -> None: +def test_create_is_idempotent_and_request_link_is_historical(tmp_path: Path, source_repository) -> None: store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) - goal = GoalRef("lead", 1.0, "goal-1") + request = store.add_request(lead.incarnation, "Repair the sidebar").id original = store.create( - "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, goal=goal, + "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) assert original.repo == RepositoryName.from_wire("OPENHCSDEV/agent-comms") @@ -117,19 +117,19 @@ def test_create_is_idempotent_and_goal_reference_is_historical(tmp_path: Path, s assert original.assignment == Assignment("lead", 1.0, "lead", 1.0, "initial") assert ( store.create( - "P1", RepositoryName.from_wire("openhcsdev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, goal=goal, + "P1", RepositoryName.from_wire("openhcsdev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) == original ) with pytest.raises(TodoConflict): store.create( - "P1", RepositoryName.from_wire("openhcsdev/agent-comms"), "A different task", creator=lead, assigner=lead.incarnation, goal=goal, + "P1", RepositoryName.from_wire("openhcsdev/agent-comms"), "A different task", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) assert TodoStore(tmp_path).list(RepositoryName.from_wire("openhcsdev/agent-comms")) == (original,) newer = thread("lead", tmp_path, 2.0) - assert store.get(original.id).goal == goal + assert store.get(original.id).request == request with pytest.raises(TodoConflict): store.apply("P1", ChangeTodoState(1, DoneTodoState, newer.incarnation, None)) assert store.get("P1") == original @@ -139,8 +139,9 @@ def test_withdraw_retires_a_released_problem(tmp_path, source_repository): store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) worker = thread("worker", tmp_path, 2.0) + request = store.add_request(lead.incarnation, "Withdraw obsolete work").id todo = store.create("P1", RepositoryName.from_wire("org/repo"), "Obsolete problem", - creator=lead, assigner=lead.incarnation, owner=worker, generation="first", goal=GoalRef("lead", 1.0, "request"), + creator=lead, assigner=lead.incarnation, owner=worker, generation="first", request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) released = store.apply(todo.id, ReleaseTodo(todo.revision, lead.incarnation, todo.held(), StrikeReason("The previous worker stopped before completing this assigned repair on main."))) @@ -164,8 +165,9 @@ def test_withdraw_retires_a_released_problem(tmp_path, source_repository): def test_withdraw_refuses_a_delivered_problem(tmp_path, released, source_repository): store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) + request = store.add_request(lead.incarnation, "Retain delivered work").id todo = store.create("P1", RepositoryName.from_wire("org/repo"), "Delivered problem", - creator=lead, assigner=lead.incarnation, owner=lead, generation="first", goal=GoalRef("lead", 1.0, "request"), + creator=lead, assigner=lead.incarnation, owner=lead, generation="first", request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) delivered = store.apply(todo.id, DeliverTodo(todo.revision, lead.incarnation, OperationDelivery(at=RecordedEventTime(20)), todo.order.id)) @@ -181,8 +183,9 @@ def test_withdraw_refuses_a_delivered_problem(tmp_path, released, source_reposit def test_two_processes_assign_one_todo_before_any_worker_is_started(tmp_path: Path, source_repository) -> None: store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) + request = store.add_request(lead.incarnation, "Assign one implementation").id store.create( - "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "One implementation", creator=lead, assigner=lead.incarnation, goal=GoalRef("lead", 1.0, "goal-1"), + "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "One implementation", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) with Manager() as manager, ProcessPoolExecutor(max_workers=2) as pool: @@ -204,16 +207,15 @@ def test_two_processes_assign_one_todo_before_any_worker_is_started(tmp_path: Pa def test_validation_rejects_ambiguous_identity_and_bad_state(tmp_path: Path, source_repository) -> None: store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) + request = store.add_request(lead.incarnation, "Validate the repository and state").id for index, repo in enumerate(("org/repo/branch", "../repo", "org/", "org/repo name")): with pytest.raises(ValueError): store.create( - f"invalid-{index}", RepositoryName.from_wire(repo), "task", creator=lead, assigner=lead.incarnation, goal=GoalRef("lead", 1.0, "goal-1"), + f"invalid-{index}", RepositoryName.from_wire(repo), "task", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) - with pytest.raises(TodoError): - GoalRef("lead", float("nan"), "goal-1") store.create( - "P1", RepositoryName.from_wire("org/repo"), "task", creator=lead, assigner=lead.incarnation, goal=GoalRef("lead", 1.0, "goal-1"), + "P1", RepositoryName.from_wire("org/repo"), "task", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository), ) with pytest.raises(ValueError): @@ -223,8 +225,9 @@ def test_validation_rejects_ambiguous_identity_and_bad_state(tmp_path: Path, sou def test_merged_order_keeps_its_checked_fields(tmp_path: Path, source_repository) -> None: store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) + request = store.add_request(lead.incarnation, "Repair the sidebar").id todo = store.create( - "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, goal=GoalRef("lead", 1.0, "goal-1"), + "P1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", creator=lead, assigner=lead.incarnation, request=request, owner=lead, generation="initial", review_at=RecordedEventTime(1000.0), order=order(source_repository)) merged = replace(todo, order=todo.order.checked().merged("2b9c1f1ab")) cells = merged.order.board_cells(merged) @@ -236,7 +239,7 @@ def test_merged_order_keeps_its_checked_fields(tmp_path: Path, source_repository @pytest.fixture -def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): +def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, configured_repository): """One cited source, its real bare remote and installed history; only GitHub is synthetic.""" graph = next(graph for graph in proof_build_checkouts if graph.repository.github == CorePackage.repository) @@ -267,8 +270,11 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): rule = StorageRule(checkout_base=source.checkout.parent) (tmp_path / "storage.json").write_text(json.dumps(FieldCodec.encode(rule))) Registration(tmp_path / "registry.json").register(lead) + bus = Comms(tmp_path) + bus.goals.todos.add_request(lead.incarnation, "Repair the copied order workflow") + configured = configured_repository(bus, source) return Commander(commander=lead.name, root=tmp_path, state=tmp_path / "state", - repos=(source,), worktrees=source.checkout.parent, + repos=configured.repos, worktrees=source.checkout.parent, scratch=tmp_path, disk=tmp_path) @@ -277,7 +283,7 @@ def pr_order(ctx, pid, run: OrderRun = MechanicalOrderRun()): lead = ctx.comms.registry.require(ctx.commander) return ctx.comms.goals.todos.create( pid, source.github, "Fix the sidebar", creator=lead, assigner=lead.incarnation, owner=lead, - generation="pr-work", goal=GoalRef(lead.name, 1.0, "pr-work"), + generation="pr-work", request=NewestOpenRequest().for_order(ctx.comms.goals.todos, lead.incarnation), review_at=RecordedEventTime(1000), order=replace(order(source), id=f"order-{pid}", run=run)) @@ -363,7 +369,7 @@ def test_watcher_git_and_github_reads_leave_wire_free(pr_commander, monkeypatch) ctx.write(ctx.root / "config-home") c.goals.update_goal(ctx.commander, SetGoalAction( text="Keep the commander request active for both watcher passes", - order=RequestGoalOrder(), goal_id="pr-work")) + goal_id="pr-work")) lead = c.registry.require(ctx.commander) c.registry.register(replace(lead, process_identity=ProcessIdentity.capture(os.getpid()))) original = adopt_pr(ctx, pr_order(ctx, "P1", ApplicationOrderRun( @@ -465,7 +471,7 @@ def test_installed_merge_closes_only_the_run_that_has_been_proven(pr_commander): ctx.comms.goals.todos.create( f"P{index}", source.github, "Keep application work held until its run is recorded", creator=lead, assigner=lead.incarnation, owner=worker, generation="merge-run", - goal=GoalRef(lead.name, lead.created_at, "merge-run"), + request=NewestOpenRequest().for_order(ctx.comms.goals.todos, lead.incarnation), review_at=RecordedEventTime(1000), order=replace(order(source), id=f"merge-order-{index}", run=run, pull_requests=(link,))) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index e84d4583eb..820f7bffe6 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -26,8 +26,7 @@ from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderReview from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec -from agent_comms.goal_actions import SetGoalAction -from agent_comms.goals import OrderCheck, RequestGoalOrder +from agent_comms.goals import OrderCheck from agent_comms.installer.practice import practice_copy from agent_comms.launch_root import LaunchRoot from agent_comms.order_runs import MechanicalOrderRun @@ -37,7 +36,7 @@ from agent_comms.standing_rules import StandingRule from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_identity import CoreWatcherIncarnation -from agent_comms.todos import GoalRef, PendingProblemOrder, ReleaseTodo, StrikeReason +from agent_comms.todos import PendingProblemOrder, ReleaseTodo, StrikeReason from agent_comms.transcript_events import AssistantTranscript from agent_comms.work_branches import UnreadableCheckoutRead, WorkBranches from order_scope_fixture import FIX_SCOPE @@ -129,8 +128,8 @@ def checkout_bus(tmp_path: Path) -> Comms: for name in ('lead', 'worker', 'idle'): c.registry.declare(c.registry.new_thread( name, frozenset(), str(home), execution=ExternalThreadExecution)) - c.goals.update_goal('lead', SetGoalAction( - text='Keep changes with their author', order=RequestGoalOrder(), goal_id='request')) + request = c.goals.todos.add_request( + c.registry.require('lead').incarnation, 'Keep changes with their author').id lead, worker = c.registry.require('lead'), c.registry.require('worker') now = time.time() order = PendingProblemOrder( @@ -144,7 +143,7 @@ def checkout_bus(tmp_path: Path) -> Comms: problem, RepositoryName.from_wire('OpenHCSDev/agent-comms'), 'Keep changes with their author', creator=lead, assigner=lead.incarnation, owner=worker, generation='shared-work', review_at=RecordedEventTime(now + 3600), - order=order, goal=GoalRef('lead', lead.created_at, 'request')) + order=order, request=request) # Both current IDs change, as they do when Core hands work out again. for problem in ('P1', 'P2'): row = c.goals.todos.get(problem) @@ -152,12 +151,12 @@ def checkout_bus(tmp_path: Path) -> Comms: problem, expected_revision=row.revision, owner=worker, assigner=lead.incarnation, generation='reissued-work', review_at=RecordedEventTime(now + 3600), order=order.reissued(now + 1), - goal=GoalRef('lead', lead.created_at, 'request')) + request=request) c.goals.todos.create( 'P3', RepositoryName.from_wire('OpenHCSDev/agent-comms'), 'Keep unrelated work separate', creator=lead, assigner=lead.incarnation, owner=worker, generation='other-work', review_at=RecordedEventTime(now + 3600), - order=order.reissued(now + 2), goal=GoalRef('lead', lead.created_at, 'request')) + order=order.reissued(now + 2), request=request) row = c.goals.todos.get('P2') assert {related.id for related in c.goals.todos.related_problems(row)} == {'P1', 'P2'} c.goals.todos.apply('P2', ReleaseTodo( diff --git a/tests/verify_connector_tools.py b/tests/verify_connector_tools.py index e441ce9915..d40ae6900b 100644 --- a/tests/verify_connector_tools.py +++ b/tests/verify_connector_tools.py @@ -29,7 +29,8 @@ from agent_comms.connector_store import ConnectorStore from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import CreateProblemGoalAction, PausedGoalAction, SetGoalAction -from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder +from agent_comms.goals import CountCheckResult, OrderCheck +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.progress_claims import WorkingClaim from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_status import StoppedThreadStatus @@ -58,6 +59,8 @@ def seed(root: Path, project: Path) -> None: comms.goals.update_goal( "claude-ai", SetGoalAction(text="Measure the connector calls.", goal_id="probe-goal") ) + comms.goals.todos.add_request(comms.registry.require('claude-ai').incarnation, + 'Measure the connector calls.') original = comms.messaging.send("worker", "claude-ai", "Original scratch history.").message assert original.sender == "worker" for worker in threading.enumerate(): @@ -82,7 +85,8 @@ def arguments(comms: Comms, name: str, project: Path, checkout: Path) -> dict: order = CreateProblemGoalAction( problem="P1", order=pending, - request="request", + request=NewestOpenRequest().for_order(comms.goals.todos, + comms.registry.require('claude-ai').incarnation), review_at=RecordedEventTime(time.time() + 600), commander="claude-ai", assigner=comms.registry.require('claude-ai').incarnation, repo=RepositoryName.from_wire("OpenHCSDev/agent-comms"), @@ -103,18 +107,8 @@ def arguments(comms: Comms, name: str, project: Path, checkout: Path) -> dict: "comms_order_work", "comms_unjudged", } - if name in order_tools: - from agent_comms.goal_actions import ClearGoalAction - - comms.goals.update_goal("claude-ai", ClearGoalAction()) - comms.goals.update_goal( - "claude-ai", - SetGoalAction( - text="Measure the connector calls.", goal_id="request", order=RequestGoalOrder() - ), - ) - if name != "comms_todo_create": - CommsTodoCreateTool(owner="worker", order=order).apply(comms, ThreadCaller("claude-ai")) + if name in order_tools - {'comms_todo_create'}: + CommsTodoCreateTool(owner="worker", order=order).apply(comms, ThreadCaller("claude-ai")) if name == "comms_resume_goal": comms.goals.update_goal( "claude-ai", PausedGoalAction(progress="Paused only on this scratch bus.") diff --git a/tools/carry_rts_orders.py b/tools/carry_rts_orders.py index 093a383c61..92bee1d61c 100644 --- a/tools/carry_rts_orders.py +++ b/tools/carry_rts_orders.py @@ -25,6 +25,7 @@ boundary_decoder, ) from agent_comms.goals import OrderCheck +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_identity import ThreadIncarnation @@ -191,6 +192,7 @@ def carry(rts: Path, root: Path, apply: bool, retire: bool) -> None: if apply: comms = Comms(root) actor = comms.registry.require("claude-code").incarnation + request = NewestOpenRequest().for_order(comms.goals.todos, actor) with todos.TodoStore(root).writing() as db: existing = {todo.id: todo for todo in todos.Todo.stream(db)} for pid, (unit, row, order) in selected.items(): @@ -206,7 +208,7 @@ def carry(rts: Path, root: Path, apply: bool, retire: bool) -> None: repo = Repo.of(Path(order.owner.file).parent) text = problems[pid].text proposed = todos.Assignment.of(owner, actor, generation) - todo = todos.Todo.ordered(pid, repo.github, text, actor, proposed, due, order) + todo = todos.Todo.ordered(pid, repo.github, text, actor, proposed, due, order, request) todo = todos.AssignTodo.by( actor, todo.revision, @@ -214,7 +216,7 @@ def carry(rts: Path, root: Path, apply: bool, retire: bool) -> None: generation, due, row.merged.wrap(order), - todo.goal, + todo.request.require(), todos.NoSourceReference(), ).apply(todo) history = tuple( diff --git a/tools/cutover/carry_owner_requests.py b/tools/cutover/carry_owner_requests.py index 6bcdcff2ae..2dbe94595b 100644 --- a/tools/cutover/carry_owner_requests.py +++ b/tools/cutover/carry_owner_requests.py @@ -20,6 +20,7 @@ from agent_comms.goals import Goal, UnorderedGoalOrder from agent_comms.installer.carry import Carried, Carry from agent_comms.locked_store import SQLiteFile +from agent_comms.lifecycle_capability import Complete from agent_comms.owner_requests import CompletedRequestState, OwnerRequestId, OpenRequestState, RequestDirective from agent_comms.registry_store import RegistryStore from agent_comms.thread_identity import RecordedThreadIncarnation @@ -38,8 +39,8 @@ class StoredRequestGoal(TypedRow): def request(self, commander: RecordedThreadIncarnation, created_at: float) -> OwnerRequest: if commander.created_at != self.owner_created_at: raise RelationViolationError("Retained request belongs to another commander incarnation.") - state = (CompletedRequestState(self.goal.progress) if self.goal.state.terminal - else OpenRequestState()) + state = (CompletedRequestState(self.goal.progress) + if self.goal.state.items_with(Complete, self.goal) else OpenRequestState()) return OwnerRequest(OwnerRequestId(self.goal.id), commander, self.goal.text, state, self.directives, created_at) @@ -68,21 +69,6 @@ class StoredRequestLink(TypedRow): UNORDERED = json.dumps(FieldCodec.encode(UnorderedGoalOrder())) -def rebuild(db: sqlite3.Connection, row: type[TypedTable], changed: dict[str, str]) -> None: - """Copy every declared writable field; spell only the actual format changes.""" - fields = row._writable_fields() - selected = ", ".join(f'{changed.get(item.name, row._column_list((item.name,)))} ' - f'AS {row._column_list((item.name,))}' for item in fields) - table = row.declared_name - temporary = f"request_carry_{table}" - db.execute(f'CREATE TEMP TABLE "{temporary}" AS SELECT {selected} FROM "{table}"') - row.drop(db) - row.create(db) - columns = row._column_list(tuple(item.name for item in fields)) - db.execute(f'INSERT INTO "{table}" ({columns}) SELECT {columns} FROM "{temporary}"') - db.execute(f'DROP TABLE "{temporary}"') - - def memory_copy(lifetime: ExitStack, path: Path) -> sqlite3.Connection: db = lifetime.enter_context(closing(sqlite3.connect(":memory:"))) CoordinationStore.identifiers_only(db) @@ -216,7 +202,7 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: linked = 0 if old_links: (linked,) = todos.execute(f'SELECT count(*) FROM "{Todo.declared_name}" WHERE goal IS NOT NULL').fetchone() - rebuild(todos, Todo, {"request": "json_extract(goal,'$.goal_id')"}) + Todo.rebuild(todos, {"request": "json_extract(goal,'$.goal_id')"}) if todo_columns or requests: TodoStore.require_schema(todos) for _ in Todo.stream(todos): @@ -227,7 +213,7 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: missing = set(RequestHistory.__dataclass_fields__) - set(history_columns) history_changed = bool(history_columns and (missing or prefixes)) if history_changed: - rebuild(history, GoalHistoryEntry, {name: "NULL" for name in missing}) + GoalHistoryEntry.rebuild(history, {name: "NULL" for name in missing}) for column, snapshots in sources.items(): for source in snapshots: goal_column = GoalHistoryEntry._column_list((column,)) diff --git a/tools/proofs/cn_phase3_record.py b/tools/proofs/cn_phase3_record.py index a25894ff9a..403debdedf 100644 --- a/tools/proofs/cn_phase3_record.py +++ b/tools/proofs/cn_phase3_record.py @@ -18,7 +18,8 @@ from agent_comms.repo import Repo from agent_comms.comms import Comms from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction -from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder +from agent_comms.goals import CountCheckResult, OrderCheck +from agent_comms.owner_requests import NewestOpenRequest from agent_comms.progress_claims import MergedClaim from agent_comms.recorded_time import RecordedEventTime from agent_comms.todos import ( @@ -74,14 +75,8 @@ def setup() -> None: comms.registry.declare( comms.registry.new_thread(name, frozenset(), str(CHECKOUT), created_at=time.time()) ) - comms.goals.update_goal( - "record-lead", - SetGoalAction( - text="Confirm the order record through the actual Core tools on this isolated bus", - goal_id="record-request", - order=RequestGoalOrder(), - ), - ) + comms.goals.todos.add_request(comms.registry.require('record-lead').incarnation, + 'Confirm the order record through the actual Core tools on this isolated bus') comms.goals.update_goal( "record-worker", SetGoalAction( @@ -95,6 +90,9 @@ def setup() -> None: def create_order(problem: str = "P1") -> None: now = time.time() repo = Repo.of(CHECKOUT) + comms = Comms(ROOT) + request = NewestOpenRequest().for_order( + comms.goals.todos, comms.registry.require('record-lead').incarnation) cli( "record-lead", CommsTodoCreateTool( @@ -103,7 +101,7 @@ def create_order(problem: str = "P1") -> None: problem=problem, repo=repo.github, text="Show the real verdict and strike records without a second source", - request="record-request", + request=request, commander="record-lead", assigner=Comms(ROOT).registry.require("record-lead").incarnation, review_at=RecordedEventTime(now + 3600), diff --git a/tools/prove_commander_dispatch.py b/tools/prove_commander_dispatch.py index de7eb609f7..d5cdfb9754 100644 --- a/tools/prove_commander_dispatch.py +++ b/tools/prove_commander_dispatch.py @@ -27,8 +27,7 @@ from agent_comms.comms import Comms from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec, PathText, Argument -from agent_comms.goal_actions import SetGoalAction -from agent_comms.goals import CountCheckResult, OrderCheck, RequestGoalOrder +from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.messages import Message from agent_comms.plan_phases import RecordPlanePhase from agent_comms.recorded_time import RecordedEventTime @@ -153,14 +152,8 @@ def apply(self, project: Path) -> int: ), StoppedThreadStatus(), ) - comms.goals.update_goal( - commander.name, - SetGoalAction( - text="Verify one rotation-started native unit on this scratch bus only.", - goal_id=uuid4().hex, - order=RequestGoalOrder(), - ), - ) + comms.goals.todos.add_request(commander.incarnation, + 'Verify one rotation-started native unit on this scratch bus only.') CommanderConfig( commander=commander.name, root=root, From 992a5e1d6992be45a86ae7ee69fcdc67ff792990 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:44:37 -0400 Subject: [PATCH 06/10] Derive request carry mechanics and preserve copied commander configuration --- src/agent_comms/commander/config.py | 22 +++++----- src/agent_comms/goal_history.py | 30 +++++++++++++ src/agent_comms/goals.py | 2 +- src/agent_comms/owner_requests.py | 2 +- src/agent_comms/storage_rules.py | 6 ++- src/agent_comms/todos.py | 4 +- src/agent_comms/typed_table.py | 63 ++++++++++++++++++++------- tests/round2/test_typed_table.py | 25 +++++++++++ tests/test_commander_forks.py | 4 +- tests/test_install.py | 6 +-- tests/test_proof_build.py | 4 +- tests/test_todos.py | 16 +++++-- tests/test_unowned_checkout_work.py | 4 +- tools/cutover/carry_owner_requests.py | 32 ++++---------- 14 files changed, 152 insertions(+), 68 deletions(-) diff --git a/src/agent_comms/commander/config.py b/src/agent_comms/commander/config.py index dbca5bdbd0..4072a46664 100644 --- a/src/agent_comms/commander/config.py +++ b/src/agent_comms/commander/config.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from importlib.resources import files from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Self +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator @@ -27,6 +27,7 @@ boundary_decoder, ) from ..installer.backup import UpgradeRefused +from ..locked_store import DurableFile from ..native_arguments import NativeArguments from ..repo import Repo from ..thread_text import AbsentText, GivenText, TextChoice @@ -48,7 +49,8 @@ def require_commander(thread: Thread) -> None: ) -class CommanderConfig(CommanderRole, BaseModel): +class CommanderConfig(CommanderRole, BaseModel, DurableFile): + filename: ClassVar[str] = 'commander.json' model_config = ConfigDict(frozen=True) commander: str @@ -60,9 +62,9 @@ class CommanderConfig(CommanderRole, BaseModel): disk: Path parked: frozenset[str] = frozenset() - @staticmethod - def path(home: Path | None = None) -> Path: - return (home or Path.home()) / '.config/agent-comms/commander.json' + @classmethod + def path(cls, home: Path | None = None) -> Path: + return (home or Path.home()) / '.config/agent-comms' / cls.filename @staticmethod def packaged_rules() -> Path: @@ -91,14 +93,10 @@ def selected_root(cls, root: Path, info: ValidationInfo) -> Path: def read(cls, path: Path | str) -> Self: return cls.model_validate_json(Path(path).read_text()) - @staticmethod - def root_path(root: Path) -> Path: - return root / 'commander.json' - @classmethod def read_root(cls, root: Path | str) -> Self: root = Path(root).resolve() - return cls.model_validate_json(cls.root_path(root).read_text(), context=root) + return cls.model_validate_json(cls.path_in(root).read_text(), context=root) @classmethod def on_machine(cls, commander: str, repos: tuple[Repo, ...], root: Path, home: Path, @@ -143,7 +141,7 @@ def write(self, home: Path) -> Path: self.require_commander(Comms(self.root).registry.require(self.commander)) path = self.path(home) path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - record = self.root_path(self.root) + record = self.path_in(self.root) record.write_text(self.model_dump_json(indent=1) + '\n') path.unlink(missing_ok=True) path.symlink_to(record) @@ -160,7 +158,7 @@ class CommanderRecord(ABC): @boundary_decoder def read(root: Path) -> CommanderRecord: root = root.resolve() - if CommanderConfig.root_path(root).is_file(): + if CommanderConfig.path_in(root).is_file(): return RecordedCommander(CommanderConfig.read_root(root)) return NoCommanderRecord() diff --git a/src/agent_comms/goal_history.py b/src/agent_comms/goal_history.py index 6ae8194f09..eff3204caf 100644 --- a/src/agent_comms/goal_history.py +++ b/src/agent_comms/goal_history.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json import os import sqlite3 import stat @@ -68,6 +69,35 @@ class GoalHistoryEntry(RequestHistory, TypedTable, DurableTables): ] = field(kw_only=True) indexes = (Index(("owner_created_at", "sequence")),) + @classmethod + def orders_with_replacement( + cls, db: sqlite3.Connection, column: str, replacement: str, *, + order_kind: str, where: str = "1", + ) -> sqlite3.Cursor: + """Read matching endpoint orders through this table's column declaration.""" + selected = cls._column_list((column,)) + return db.execute( + f'SELECT {cls._column_list(("sequence", "observed_at", "owner_created_at"))}, ' + f"json_set({selected}, '$.order', json(?)) AS goal, " + f"coalesce(json_extract({selected}, '$.order.directives'), '[]') AS directives " + f'FROM "{cls.declared_name}" ' + f"WHERE json_extract({selected}, '$.order.kind')=? AND ({where}) " + f'ORDER BY {cls._column_list(("sequence",))}', (replacement, order_kind)) + + @classmethod + def move_request_order( + cls, db: sqlite3.Connection, column: str, sequence: int, + request: RequestAtRevision, replacement: str, + ) -> None: + """Move an endpoint's request to its prefix without dropping the goal observation.""" + goal_column = cls._column_list((column,)) + prefix_column = cls._column_list(("request_" + column,)) + db.execute( + f'UPDATE "{cls.declared_name}" ' + f"SET {goal_column}=json_set({goal_column}, '$.order', json(?)), {prefix_column}=? " + f'WHERE {cls._column_list(("sequence",))}=?', + (replacement, json.dumps(FieldCodec.encode(request)), sequence)) + def reports_turn(self, turn_id: str) -> bool: """A committed goal revision records its original model reporting turn.""" return self.after.reports_turn(turn_id) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index 57ca49304b..0f70902abe 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -13,7 +13,7 @@ import tempfile from abc import abstractmethod from collections.abc import Iterator, Sequence -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Literal diff --git a/src/agent_comms/owner_requests.py b/src/agent_comms/owner_requests.py index 909937dc2e..23c60abc81 100644 --- a/src/agent_comms/owner_requests.py +++ b/src/agent_comms/owner_requests.py @@ -18,7 +18,7 @@ from agent_comms.wire_log import CheckedRead from agent_comms.messages import Message from agent_comms.registration import Registration - from agent_comms.thread_identity import RecordedThreadIncarnation, ThreadIncarnation + from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import OwnerRequest, Todo, TodoStore diff --git a/src/agent_comms/storage_rules.py b/src/agent_comms/storage_rules.py index f2263d6258..abcbc49ec3 100644 --- a/src/agent_comms/storage_rules.py +++ b/src/agent_comms/storage_rules.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar, Self from agent_comms.field_codec import FieldCodec, PathText +from agent_comms.locked_store import DurableFile from agent_comms.native_tool_call import SelectedToolDenied from agent_comms.problem_number import ProblemNumber from agent_comms.repo import Repo @@ -70,7 +71,8 @@ class MemoryUse: @dataclass(frozen=True) -class StorageRule(RoomAllocation): +class StorageRule(RoomAllocation, DurableFile): + filename: ClassVar[str] = 'storage.json' floor_gib: float = 5.0 # under this nothing that allocates may run cleanup_gib: float = 30.0 # under this a cleanup owner is due; it does not stop builds memory_gib: float = 3.0 # available memory under this makes a cleanup owner due @@ -93,7 +95,7 @@ def __post_init__(self): @classmethod def load(cls, root: Path): - path = root / 'storage.json' + path = cls.path_in(root) return FieldCodec.decode(cls, json.loads(path.read_text()) if path.exists() else {}) def initial_roots(self, name): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 6b22c0ce74..b4f7f0e935 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -106,9 +106,11 @@ from agent_comms.typed_table import ( Column, DurableStore, + DurableTables, ExpressionIndex, Generated, Index, + SharedFileTables, TextStorage, TypedTable, ) @@ -2999,7 +3001,7 @@ def apply(self, current): @dataclass(frozen=True, slots=True) -class OwnerRequest(TypedTable): +class OwnerRequest(TypedTable, SharedFileTables, DurableTables): """One owner's request, independent of any current turn goal.""" indexes = (Index(("commander_created_at", "state", "created_at")),) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 34e3380f9a..36662848c8 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -443,6 +443,12 @@ def for_field(cls, plan: FieldPlan) -> _Field: value=plan.value, ) + def rebuild_expression(self, stored: tuple[str, ...]) -> str: + """A carried value remains stored; table constraints own whether NULL is valid.""" + if self.wire_name in stored: + return _identifier(self.wire_name) + return "NULL" + def sql_literal(value: str | None | type[DeclaredFamily]) -> str: """Quote a declaration-owned SQL constant, never a query parameter.""" @@ -718,11 +724,12 @@ def create_schema(cls, db: sqlite3.Connection) -> None: @classmethod def derive_schema(cls, db: sqlite3.Connection) -> None: """Inside the caller's write transaction, the stored schema onto the declaration - wherever the declaration says how: a row's declared earlier table name, an index, - trigger or view rebuilt from its SQL, a declared object the file lacks. What it cannot - say, a table whose columns differ, is refused with the rows untouched.""" + wherever the declaration says how: a row's declared earlier table name, missing + nullable fields, an index, trigger or view rebuilt from its SQL, or a missing object. + Other table differences are refused with the rows untouched.""" for row in cls.schema_rows(): row.take_previous_name(db) + row.derive_nullable_fields(db) SQLiteCatalogObject.carry(db, cls.declared_schema(), frozenset(item.name for item in cls.stored_schema(db))) cls.require_schema(db) @@ -930,14 +937,15 @@ def _sql(cls, expression: str) -> str: return expression @classmethod - def schema_objects(cls, *, if_not_exists: bool = False) -> dict[str, str]: + def _table_sql(cls, *, if_not_exists: bool = False, columns: tuple[str, ...] = ()) -> str: existence = "IF NOT EXISTS " if if_not_exists else "" definitions = [] - keys = tuple(item.name for item in cls._columns() if item.column.primary_key) - auto_keys = tuple(item.name for item in cls._columns() if item.column.auto_increment) + fields = cls._fields_named(columns) if columns else cls._columns() + keys = tuple(item.name for item in fields if item.column.primary_key) + auto_keys = tuple(item.name for item in fields if item.column.auto_increment) if auto_keys and (auto_keys != keys or len(keys) != 1 or cls.without_rowid): raise TypeError("AUTOINCREMENT requires one rowid primary key") - for item in cls._columns(): + for item in fields: column = item.column sql = f"{_identifier(item.wire_name)} {item.storage.sql_type}" if column.auto_increment: @@ -963,10 +971,12 @@ def schema_objects(cls, *, if_not_exists: bool = False) -> dict[str, str]: definitions.extend(f"CHECK ({cls._sql(check)})" for check in cls.checks) definitions.extend(reference.sql(cls) for reference in cls.references()) table = _identifier(cls.declared_name) - statements = { - cls.declared_name: f"CREATE TABLE {existence}{table} ({', '.join(definitions)}) STRICT" - + (", WITHOUT ROWID" if cls.without_rowid else "") - } + return (f"CREATE TABLE {existence}{table} ({', '.join(definitions)}) STRICT" + + (", WITHOUT ROWID" if cls.without_rowid else "")) + + @classmethod + def schema_objects(cls, *, if_not_exists: bool = False) -> dict[str, str]: + statements = {cls.declared_name: cls._table_sql(if_not_exists=if_not_exists)} indexes = cls.indexes + tuple( Index((item.name,)) for item in cls._columns() if item.column.index ) @@ -1004,7 +1014,8 @@ def drop(cls, db: sqlite3.Connection) -> None: db.execute(f"DROP TABLE IF EXISTS {_identifier(cls.declared_name)}") @classmethod - def rebuild(cls, db: sqlite3.Connection, changed: dict[str, str]) -> None: + def rebuild(cls, db: sqlite3.Connection, + changed: Mapping[str, str] = types.MappingProxyType({})) -> None: """Copy writable fields into the declared schema, in the caller's transaction. Data conversions supply expressions only for the fields they change. @@ -1012,10 +1023,11 @@ def rebuild(cls, db: sqlite3.Connection, changed: dict[str, str]) -> None: """ cls._writable_fields(tuple(changed)) fields = cls._writable_fields() - selected = ", ".join( - f'{changed.get(item.name, cls._column_list((item.name,)))} ' - f'AS {cls._column_list((item.name,))}' for item in fields - ) + stored = cls.stored_columns(db) + expressions = (changed[item.name] if item.name in changed else item.rebuild_expression(stored) + for item in fields) + selected = ", ".join(f'{expression} AS {_identifier(item.wire_name)}' + for item, expression in zip(fields, expressions, strict=True)) table = _identifier(cls.declared_name) temporary = _identifier(f"rebuild_{cls.declared_name}") db.execute(f"CREATE TEMP TABLE {temporary} AS SELECT {selected} FROM {table}") @@ -1025,6 +1037,25 @@ def rebuild(cls, db: sqlite3.Connection, changed: dict[str, str]) -> None: db.execute(f"INSERT INTO {table} ({columns}) SELECT {columns} FROM {temporary}") db.execute(f"DROP TABLE {temporary}") + @classmethod + def derive_nullable_fields(cls, db: sqlite3.Connection) -> None: + """Only an exact earlier declaration missing nullable fields can grow rows. + + Extra columns or any other table difference still require a data conversion. + Compare through the same DDL owner; do not maintain a second column schema. + """ + stored = cls.stored_columns(db) + fields = cls._columns() + missing = tuple(item for item in fields if item.wire_name not in stored) + if not stored or not missing: + return + if not all(item.nullable and item.column.writable for item in missing): + return + previous = cls._table_sql(columns=tuple(item.name for item in fields if item.wire_name in stored)) + actual = {item.name: item.sql for item in cls.stored_objects(db)} + if actual.get(cls.declared_name) == previous: + cls.rebuild(db) + @classmethod def take_previous_name(cls, db: sqlite3.Connection) -> None: """Rows stored under a declared earlier name move to the declared one; rows under both diff --git a/tests/round2/test_typed_table.py b/tests/round2/test_typed_table.py index f53e4130dd..f08ad5e792 100644 --- a/tests/round2/test_typed_table.py +++ b/tests/round2/test_typed_table.py @@ -128,6 +128,31 @@ def test_rebuild_converts_a_field_and_preserves_the_declared_rows_and_constraint assert db.execute('PRAGMA foreign_key_check').fetchall() == [] with pytest.raises(sqlite3.IntegrityError): replace(rows[0], key='invalid', parent='missing').insert(db) + db.execute(f'ALTER TABLE "{TableChildRow.declared_name}" DROP COLUMN weight') + TableChildRow.rebuild(db) + assert TableChildRow.select(db, order_by=('key',)) == [replace(row, weight=None) for row in rows] + + +def test_writer_derives_missing_nullable_fields_but_refuses_unclaimed_data(): + from agent_comms.goal_history import GoalHistoryEntry, GoalHistoryError + from agent_comms.goals import NoGoal + + entry = GoalHistoryEntry(1, 'baseline', 1.0, NoGoal(), NoGoal(), + owner_created_at=1.0, state='committed') + with sqlite3.connect(':memory:') as db: + GoalHistoryEntry.create(db) + entry.insert(db) + for column in ('request_before', 'request_after'): + db.execute(f'ALTER TABLE "{GoalHistoryEntry.declared_name}" DROP COLUMN {column}') + db.commit() + GoalHistoryEntry.reconcile_schema(db) + assert GoalHistoryEntry.require(db, sequence=1) == entry + db.execute(f'ALTER TABLE "{GoalHistoryEntry.declared_name}" ADD COLUMN unclaimed TEXT') + db.execute(f'UPDATE "{GoalHistoryEntry.declared_name}" SET unclaimed=?', ('preserve this',)) + db.commit() + with pytest.raises(GoalHistoryError): + GoalHistoryEntry.reconcile_schema(db) + assert db.execute(f'SELECT unclaimed FROM "{GoalHistoryEntry.declared_name}"').fetchall() == [('preserve this',)] def test_atomic_creation_preserves_declared_indexes_triggers_and_strict_creation(): diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index c650315bd5..a0d0d0a4ce 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -60,7 +60,7 @@ def fork_bus(tmp_path): comms.registry.require('lead').incarnation, 'Repair the commander workflow') core = Repo.of(Path(__file__).parents[1]) config = Commander.on_machine('lead', (source, core), comms.root, tmp_path) - config.root_path(comms.root).write_text(config.model_dump_json()) + config.path_in(comms.root).write_text(config.model_dump_json()) now = time.time() order = PendingProblemOrder( id='first-order', owner=source.scope().shape.parents[0], scope=source.scope(), @@ -129,7 +129,7 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path fork = comms.registry.require('fork-one') saved = store.get('P40') assert saved.creator_identity == parent.incarnation - assert saved.goal.owner == parent.name + assert store.owner_request(saved.request.require()).commander == parent.incarnation assert saved.held().parent_identity == fork.incarnation assert saved.messages[-1].actor == fork.incarnation assert fork.parent.named(parent.name) diff --git a/tests/test_install.py b/tests/test_install.py index 92c826f279..2f4cb84253 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -169,7 +169,7 @@ def test_an_install_on_a_bus_with_no_commander_writes_no_commander_config(privat home = _harnessless_home(private_directory, monkeypatch) result = InstallCliCommand(project=private_directory).apply(comms) assert result.config == NoConfigFile() - assert not CommanderConfig.root_path(root).exists() + assert not CommanderConfig.path_in(root).exists() assert not CommanderConfig.path(home).exists() and not CommanderConfig.path(home).is_symlink() @@ -187,7 +187,7 @@ def test_an_install_refuses_flags_a_bus_without_a_commander_record_cannot_use( home = _harnessless_home(private_directory, monkeypatch) with pytest.raises(UpgradeRefused): InstallCliCommand(project=private_directory, **flags).apply(Comms(root)) - assert not CommanderConfig.root_path(root).exists() and not CommanderConfig.path(home).is_symlink() + assert not CommanderConfig.path_in(root).exists() and not CommanderConfig.path(home).is_symlink() def test_an_install_without_flags_keeps_the_recorded_commander_and_rehomes_its_paths( @@ -206,7 +206,7 @@ def test_an_install_without_flags_keeps_the_recorded_commander_and_rehomes_its_p result = InstallCliCommand(project=private_directory).apply(comms) expected = CommanderConfig.on_machine("lead", (), root, home, frozenset({"parked-unit"})) assert result.config == WrittenConfigFile(str(CommanderConfig.path(home))) - assert CommanderConfig.root_path(root).read_text() == expected.model_dump_json(indent=1) + "\n" + assert CommanderConfig.path_in(root).read_text() == expected.model_dump_json(indent=1) + "\n" assert CommanderConfig.read(CommanderConfig.path(home)) == expected diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index f1bdb7ad0d..2c4803f6dd 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -352,7 +352,7 @@ def test_install_due_compares_fetched_main_to_installed_build( before = Repo.git(checkout, "rev-parse", "HEAD") bus = Comms(tmp_path / "bus") config = CommanderConfig.on_machine("lead", (graph.repository,), bus.root, tmp_path / "home") - CommanderConfig.root_path(bus.root).write_text(config.model_dump_json()) + CommanderConfig.path_in(bus.root).write_text(config.model_dump_json()) due = InstallDue.violations(bus, "lead") @@ -392,7 +392,7 @@ def test_install_waits_for_merges_to_settle_into_one_batch( Repo.git(checkout, "push", "origin", f"{graph.newer}:refs/heads/main") bus = Comms(tmp_path / "bus") config = CommanderConfig.on_machine("lead", (graph.repository,), bus.root, tmp_path / "home") - CommanderConfig.root_path(bus.root).write_text(config.model_dump_json()) + CommanderConfig.path_in(bus.root).write_text(config.model_dump_json()) merged = int(Repo.git(checkout, "log", "-1", "--format=%ct", graph.newer)) monkeypatch.setattr(time, "time", lambda: merged + InstallDue.settle - 1) diff --git a/tests/test_todos.py b/tests/test_todos.py index 31b7f5ad0f..60e6aaf345 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -239,11 +239,22 @@ def test_merged_order_keeps_its_checked_fields(tmp_path: Path, source_repository @pytest.fixture -def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, configured_repository): +def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): """One cited source, its real bare remote and installed history; only GitHub is synthetic.""" graph = next(graph for graph in proof_build_checkouts if graph.repository.github == CorePackage.repository) source = graph.repository + # This Core fixture owns one Git history. Borrow plan input, not a second + # unrelated checkout claiming the same repository and installed commit. + current = Path(__file__).resolve().parents[1] + shutil.copytree(current / 'plans', source.checkout / 'plans') + module = source.checkout / 'src' / 'agent_comms' + module.parent.mkdir(parents=True) + shutil.copytree(current / 'src' / 'agent_comms', module, + ignore=shutil.ignore_patterns('__pycache__', 'skills', '_native')) + source.git(source.checkout, 'add', 'plans', 'src') + source.git(source.checkout, 'commit', '-qm', 'Declare current commander plans') + source.git(source.checkout, 'push', '-q', 'origin', 'HEAD:main') merged = graph.installed for number in (1, 2): source.git(source.checkout, 'push', 'origin', f'{merged}:refs/pull/{number}/head') @@ -272,9 +283,8 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, configured_reposi Registration(tmp_path / "registry.json").register(lead) bus = Comms(tmp_path) bus.goals.todos.add_request(lead.incarnation, "Repair the copied order workflow") - configured = configured_repository(bus, source) return Commander(commander=lead.name, root=tmp_path, state=tmp_path / "state", - repos=configured.repos, worktrees=source.checkout.parent, + repos=(source,), worktrees=source.checkout.parent, scratch=tmp_path, disk=tmp_path) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index 820f7bffe6..6b9c09649a 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -103,7 +103,7 @@ def decide(command: str, cwd: Path) -> None: @pytest.fixture -def checkout_bus(tmp_path: Path) -> Comms: +def checkout_bus(tmp_path: Path, configured_repository, installed_runtime) -> Comms: home = tmp_path / 'declared-project' home.mkdir() git(home, 'init', '-q', '--initial-branch=main') @@ -167,7 +167,7 @@ def checkout_bus(tmp_path: Path) -> Comms: 'Ready for work', position='answer', timestamp=time.time()),)) CommanderHeartbeat.register(c.registry) CommanderConfig( - commander='lead', root=root, state=tmp_path / 'state', repos=(), + commander='lead', root=root, state=tmp_path / 'state', repos=configured_repository(c).repos, worktrees=rule.checkout_base, scratch=rule.scratch_base, disk=tmp_path, ).write(tmp_path / 'config-home') return c diff --git a/tools/cutover/carry_owner_requests.py b/tools/cutover/carry_owner_requests.py index 2dbe94595b..0321cb80c7 100644 --- a/tools/cutover/carry_owner_requests.py +++ b/tools/cutover/carry_owner_requests.py @@ -16,7 +16,7 @@ from agent_comms.coordination_database import CoordinationStore from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec -from agent_comms.goal_history import GoalHistoryEntry, GoalHistoryStore, RequestHistory +from agent_comms.goal_history import GoalHistoryEntry, GoalHistoryStore from agent_comms.goals import Goal, UnorderedGoalOrder from agent_comms.installer.carry import Carried, Carry from agent_comms.locked_store import SQLiteFile @@ -79,15 +79,8 @@ def memory_copy(lifetime: ExitStack, path: Path) -> sqlite3.Connection: def historical(db: sqlite3.Connection, column: str, *, where: str = "1") -> list[HistoricalRequestGoal]: - # column is resolved from GoalHistoryEntry, never supplied by a caller. - selected = GoalHistoryEntry._column_list((column,)) - return HistoricalRequestGoal.read(db.execute( - f'SELECT sequence, observed_at, owner_created_at, ' - f"json_set({selected}, '$.order', json(?)) AS goal, " - f"coalesce(json_extract({selected}, '$.order.directives'), '[]') AS directives " - f'FROM "{GoalHistoryEntry.declared_name}" ' - f"WHERE json_extract({selected}, '$.order.kind')='request' AND ({where}) " - 'ORDER BY sequence', (UNORDERED,))) + return HistoricalRequestGoal.read(GoalHistoryEntry.orders_with_replacement( + db, column, UNORDERED, order_kind="request", where=where)) class OwnerRequests(Carry): @@ -193,9 +186,9 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: if not old_links and "request" not in todo_columns: raise RelationViolationError("Todo has neither the retired goal nor a request link.") if not OwnerRequest.stored_columns(todos): - OwnerRequest.create(todos) + OwnerRequest.reconcile_schema(todos) elif requests: - TodoStore.create_schema(todos) + TodoStore.reconcile_schema(todos) todo_changed = True for request in requests.values(): request.upsert(todos) @@ -210,20 +203,13 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: if list(todos.execute("PRAGMA foreign_key_check")): raise RelationViolationError("Carried Todo request links violate their foreign keys.") history_columns = GoalHistoryEntry.stored_columns(history) - missing = set(RequestHistory.__dataclass_fields__) - set(history_columns) - history_changed = bool(history_columns and (missing or prefixes)) + history_changed = bool(history_columns and (prefixes or GoalHistoryEntry.schema_differs(history))) if history_changed: - GoalHistoryEntry.rebuild(history, {name: "NULL" for name in missing}) + GoalHistoryEntry.reconcile_schema(history) for column, snapshots in sources.items(): for source in snapshots: - goal_column = GoalHistoryEntry._column_list((column,)) - prefix_column = GoalHistoryEntry._column_list(("request_" + column,)) - history.execute( - f'UPDATE "{GoalHistoryEntry.declared_name}" ' - f"SET {goal_column}=json_set({goal_column}, '$.order', json(?)), {prefix_column}=? " - "WHERE sequence=?", - (UNORDERED, json.dumps(FieldCodec.encode(prefixes[column, source.sequence])), source.sequence), - ) + GoalHistoryEntry.move_request_order( + history, column, source.sequence, prefixes[column, source.sequence], UNORDERED) if history_columns: GoalHistoryEntry.require_schema(history) for entry in GoalHistoryEntry.stream(history): From 1b2101bc8485293ce69164e9061ebf3fd6efdc55 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:47:36 -0400 Subject: [PATCH 07/10] Delete obsolete goal and todo dependencies after request migration --- src/agent_comms/commander/standing.py | 1 - tests/test_todos.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 39955edbf4..342e4033e5 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -18,7 +18,6 @@ from agent_comms.comms import Comms from agent_comms.goal_actions import ReissuedOrderGoalAction from agent_comms.goal_presentation import BlockedExecution -from agent_comms.goals import Goal from agent_comms.message_bus import WatchPass from agent_comms.message_page import MessageReadRequest, PageBounds from agent_comms.message_reference import MessageReference diff --git a/tests/test_todos.py b/tests/test_todos.py index 60e6aaf345..0a7eef1831 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -57,7 +57,6 @@ StrikeReason, Todo, TodoConflict, - TodoError, TodoState, TodoStore, WithdrawTodo, From 321b2de1f222405a0a05e7becba0e8f0e6c2463c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:00:51 -0400 Subject: [PATCH 08/10] Factor request persistence and own historical request prefixes --- src/agent_comms/channel_targets.py | 8 +- src/agent_comms/commander/order.py | 39 ++-- src/agent_comms/commander/request.py | 2 +- src/agent_comms/commander_parity/delivery.py | 7 +- src/agent_comms/commander_parity/lifecycle.py | 5 +- src/agent_comms/goal_actions.py | 2 +- src/agent_comms/goal_history.py | 27 ++- src/agent_comms/owner_requests.py | 64 ++++-- src/agent_comms/todos.py | 133 +++++------ src/agent_comms/typed_table.py | 10 +- src/agent_comms/work_dispatch.py | 2 +- tests/test_owner_requests.py | 152 ++++++++++--- tools/carry_rts_orders.py | 4 +- tools/cutover/carry_owner_requests.py | 208 +++++++++++++----- 14 files changed, 445 insertions(+), 218 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index e1b94021b3..9be37494d8 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -140,7 +140,11 @@ def contexts(self, declaration, channel): class NamedTarget(CommandTarget): name: str batchable = False - direct_names = () + + @property + def direct_names(self) -> tuple[str, ...]: + return () + guidance_scope = "" default_audience = MessageAudience.DIRECT @@ -200,7 +204,7 @@ def require_frozen_recipients(self, message, audience): message.role.require_recipients(self, audience) @property - def direct_names(self): + def direct_names(self) -> tuple[str, ...]: return self.names def canonical_name(self, canonical): diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 608842f8f8..918c3a6e20 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -52,6 +52,7 @@ from agent_comms.message_reference import MessageReference from agent_comms.messages import Message from agent_comms.native_tool_call import SelectedToolDenied +from agent_comms.owner_requests import NewestOpenRequest, RequestSelection from agent_comms.plan_phases import OrderPhase from agent_comms.problem_number import ProblemNumber from agent_comms.progress_claims import MergedClaim, WorkingClaim @@ -60,7 +61,6 @@ from agent_comms.standing_rules import HuntDue, OwedAnswer from agent_comms.storage_rules import UnitStorage from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.owner_requests import NewestOpenRequest, RequestSelection from agent_comms.todos import ( BluntText, CleanHunt, @@ -577,21 +577,6 @@ def say(self, units: str, body_file: str, refuse(body, *OUTGOING) print(self.post(VerdictAudience(tuple(units.split(','))), body.rstrip(), answers)) - def problem(self, order_file, unit, where, disk=False, - answers: tuple[MessageReference, ...] = (), *, request: RequestSelection): - """A problem is born owned: Core records it with its order in one step, or not at all.""" - document = OrderFile.read(Path(order_file).read_text()) - location = FileLine.from_wire(where) - checkout = self.repo_directory(document.order.check.directory) - if checkout.is_dir(): - self.comms.goals.todos.require_recurrence_declaration( - document.order, location, checkout, document.slop, time.time()) - pid = self.comms.goals.todos.allocate_problem() - self.send(unit, document, new=(pid, document.defect), - disk=disk, finding=LocatedProblemFinding(location), answers=answers, - request=request) - print(pid) - def close(self, pid, works, artifact=''): """Core owns closing: the installed evidence, the artifact and the no-open-order rule live on the Todo.""" evidence = artifact or self.comms.goals.todos.get(pid).order.closing_artifact() @@ -644,8 +629,7 @@ def posted(self, unit: str, todo: Todo, strike: StrikeTodo, n = struck(todo, unit) line = (f'{time.strftime("%Y-%m-%d")} {strike.at.clock()} {unit} (strike {n} on {todo.id}' f'{f", {todo.id} taken away" if n >= 2 else ""}): {strike.reason}') - self.on_record(line, 'Strike', VerdictAudience.of( - unit, self.comms.goals.todos.owner_request(todo.request.require())), answers) + self.on_record(line, 'Strike', VerdictAudience.of(unit, todo), answers) print(f'{unit}: {n} strike(s) on {todo.id}' + (f'. Reassign {todo.id} now and freeze its branch.' if n >= 2 else '')) def done(self, pid, sha, screenshot='', seen=''): @@ -716,8 +700,7 @@ def unstrike(self, task: str, why: str, at: StrikeSelector = NewestStrike()) -> n = struck(self.comms.goals.todos.get(task), unit) line = (f'{time.strftime("%Y-%m-%d %H:%M")} {unit} (strike voided on {task}, ' f'recorded {voided.at.clock()}; {n} strike(s) remain): {why}') - self.on_record(line, 'Void', VerdictAudience.of( - unit, self.comms.goals.todos.owner_request(todo.request.require()))) + self.on_record(line, 'Void', VerdictAudience.of(unit, todo)) print(f'{unit}: {n} strike(s) on {task}') def withdraw(self, pid: str, why: str, superseded_by: Successor = NoSuccessor()) -> None: @@ -734,8 +717,7 @@ def withdraw(self, pid: str, why: str, superseded_by: Successor = NoSuccessor()) unit = previous.owner withdrawal, = self.comms.goals.todos.get(pid).order.withdrawals(todo) self.on_record(f'{time.strftime("%Y-%m-%d %H:%M")} {withdrawal}', 'Withdrawal', - VerdictAudience.of( - unit, self.comms.goals.todos.owner_request(todo.request.require()))) + VerdictAudience.of(unit, todo)) self.post(VerdictAudience((unit,)), f'Withdrawn: {withdrawal}. Stop that work; it counts against no one.') print(withdrawal) @@ -1143,8 +1125,17 @@ class ProblemVerb(RequestedOrderVerb): disk: Annotated[bool, Argument(flags=("--disk",))] = False def run(self, ctx: Commander) -> None: - ctx.problem(self.order, self.to, self.where, self.disk, - self.answer_references(ctx), request=self.request) + document = OrderFile.read(Path(self.order).read_text()) + location = FileLine.from_wire(self.where) + checkout = ctx.repo_directory(document.order.check.directory) + if checkout.is_dir(): + ctx.comms.goals.todos.require_recurrence_declaration( + document.order, location, checkout, document.slop, time.time()) + pid = ctx.comms.goals.todos.allocate_problem() + ctx.send(self.to, document, new=(pid, document.defect), disk=self.disk, + finding=LocatedProblemFinding(location), answers=self.answer_references(ctx), + request=self.request) + print(pid) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/commander/request.py b/src/agent_comms/commander/request.py index 46fa7657bc..7b446ec5e8 100644 --- a/src/agent_comms/commander/request.py +++ b/src/agent_comms/commander/request.py @@ -21,7 +21,7 @@ class RequestVerb(CommanderVerb): class RequestMutation(HeldBusWrite, RequestVerb, declared_name=None): - """Request mutations share the same admission as order and directive publication.""" + """Request mutations use the same bus writer as orders and directives.""" def apply(self, ctx: Commander) -> None: self.write_when_open(ctx.comms, partial(super().apply, ctx)) diff --git a/src/agent_comms/commander_parity/delivery.py b/src/agent_comms/commander_parity/delivery.py index 19aaf01223..5ef9506afa 100644 --- a/src/agent_comms/commander_parity/delivery.py +++ b/src/agent_comms/commander_parity/delivery.py @@ -13,7 +13,7 @@ from agent_comms.change_report import FileLine from agent_comms.child_process import ChildOutcome, ChildResult, ExitedOutcome -from agent_comms.commander.order import OrderFile +from agent_comms.commander.order import OrderFile, ProblemVerb from agent_comms.commander_parity import ParityComparison from agent_comms.commander_parity.lifecycle import LifecycleRun from agent_comms.field_codec import FieldCodec @@ -153,9 +153,8 @@ def create_order(cls, run: LifecycleRun, repository: DeliveryRepository) -> Todo todo.id for todo in run.commander.comms.goals.todos.for_commander(run.commander.incarnation) } - run.commander.problem( - str(run.document), run.worker, repository.owner.to_wire() - ) + ProblemVerb(order=str(run.document), to=run.worker, + where=repository.owner.to_wire()).run(run.commander) (created,) = ( todo for todo in run.commander.comms.goals.todos.for_commander(run.commander.incarnation) diff --git a/src/agent_comms/commander_parity/lifecycle.py b/src/agent_comms/commander_parity/lifecycle.py index f773cc5428..ec58f1ed8e 100644 --- a/src/agent_comms/commander_parity/lifecycle.py +++ b/src/agent_comms/commander_parity/lifecycle.py @@ -24,7 +24,7 @@ from agent_comms.check_root import CheckRoot from agent_comms.child_process import BoundedRun, ChildResult from agent_comms.commander.config import CommanderConfig -from agent_comms.commander.order import Commander, OrderFile +from agent_comms.commander.order import Commander, OrderFile, ProblemVerb from agent_comms.commander.request import RequestAddVerb from agent_comms.commander_parity import ParityComparison, UnverifiedParity from agent_comms.field_codec import FieldCodec @@ -208,7 +208,8 @@ def invoke_command(command: tuple[str, ...], **options) -> ChildResult: def create(self) -> Todo: owner = OrderFile.read(self.document.read_text()).order.owner - self.commander.problem(str(self.document), self.worker, owner.to_wire()) + ProblemVerb(order=str(self.document), to=self.worker, + where=owner.to_wire()).run(self.commander) rows = tuple(self.commander.comms.goals.todos.for_commander(self.commander.incarnation)) if len(rows) != 1: raise ValueError(f"A fresh copied workflow must create one problem, got {len(rows)}") diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index b47da186fe..73141ab5af 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -23,6 +23,7 @@ UnorderedGoalOrder, ) from agent_comms.input_disposition import InputDispositions +from agent_comms.todos import PendingProblemOrder, SendBackVerdict, Todo, TodoOperation from .change_report import FileLine, NoSourceReference from .child_process import ProcessIdentity @@ -48,7 +49,6 @@ from .repository_name import RepositoryName from .thread_identity import ThreadIncarnation from .threads import Thread -from agent_comms.todos import PendingProblemOrder, SendBackVerdict, Todo, TodoOperation if TYPE_CHECKING: from .goal_management import Goals diff --git a/src/agent_comms/goal_history.py b/src/agent_comms/goal_history.py index eff3204caf..a8b194601f 100644 --- a/src/agent_comms/goal_history.py +++ b/src/agent_comms/goal_history.py @@ -15,17 +15,17 @@ import time from collections.abc import Iterator from contextlib import closing, contextmanager -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from pathlib import Path from typing import Annotated, Literal +from agent_comms.field_codec import WireOmitDefault +from agent_comms.owner_requests import NoRequestRevision, RequestAtRevision, RequestRevision from .coordination_database import CoordinationStore -from .field_codec import FieldCodec, MalformedRecord, HistoryExclude +from .field_codec import FieldCodec, HistoryExclude, MalformedRecord from .goals import GoalDeclaration, NoGoal from .locked_store import DurableFile, SQLiteFile -from agent_comms.owner_requests import RequestAtRevision -from agent_comms.field_codec import WireOmitDefault from .typed_table import Column, DurableTables, Index, JsonStorage, TypedTable @@ -37,8 +37,21 @@ class GoalHistoryError(RuntimeError): class RequestHistory: """Moved request data stays linked to its original history observation.""" - request_before: Annotated[RequestAtRevision | None, Column(storage=JsonStorage), WireOmitDefault()] = None - request_after: Annotated[RequestAtRevision | None, Column(storage=JsonStorage), WireOmitDefault()] = None + request_before: Annotated[ + RequestRevision, Column(storage=JsonStorage, nullable=True), WireOmitDefault(), + ] = NoRequestRevision() + request_after: Annotated[ + RequestRevision, Column(storage=JsonStorage, nullable=True), WireOmitDefault(), + ] = NoRequestRevision() + + @classmethod + def endpoint_columns(cls) -> dict[str, str]: + """Request-prefix fields declare the goal endpoint each preserves.""" + return {entry.name.removeprefix("request_"): entry.name for entry in fields(RequestHistory)} + + @property + def request_revisions(self) -> tuple[RequestRevision, ...]: + return self.request_before, self.request_after @dataclass(frozen=True) @@ -91,7 +104,7 @@ def move_request_order( ) -> None: """Move an endpoint's request to its prefix without dropping the goal observation.""" goal_column = cls._column_list((column,)) - prefix_column = cls._column_list(("request_" + column,)) + prefix_column = cls._column_list((cls.endpoint_columns()[column],)) db.execute( f'UPDATE "{cls.declared_name}" ' f"SET {goal_column}=json_set({goal_column}, '$.order', json(?)), {prefix_column}=? " diff --git a/src/agent_comms/owner_requests.py b/src/agent_comms/owner_requests.py index 23c60abc81..cceab35a5a 100644 --- a/src/agent_comms/owner_requests.py +++ b/src/agent_comms/owner_requests.py @@ -4,22 +4,28 @@ import time from abc import abstractmethod -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, ClassVar, Self from uuid import uuid4 from agent_comms.declared_family import DeclaredFamily from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import BoundedText, NullMember, SingleFieldValue, SubclassUnion +from agent_comms.field_codec import ( + BoundedText, + NullMember, + RecordMember, + SingleFieldValue, + SubclassUnion, +) if TYPE_CHECKING: from agent_comms.channel_targets import NamedTarget - from agent_comms.wire_log import CheckedRead from agent_comms.messages import Message from agent_comms.registration import Registration from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import OwnerRequest, Todo, TodoStore + from agent_comms.wire_log import CheckedRead class RequestValue(SubclassUnion, affix=""): @@ -33,10 +39,16 @@ def require(self) -> OwnerRequestId: ... class RequestSelection(RequestValue, declared_name=None): def for_message( - self, store: TodoStore, target: NamedTarget, registry: Registration, + self, + store: TodoStore, + target: NamedTarget, + registry: Registration, ) -> tuple[OwnerRequest, ...]: - return tuple(request for name in target.direct_names - for request in self.select(store, registry.require(name).incarnation)) + return tuple( + request + for name in target.direct_names + for request in self.select(store, registry.require(name).incarnation) + ) def for_order(self, store: TodoStore, commander: ThreadIncarnation) -> OwnerRequestId: selected = self.select(store, commander) @@ -46,7 +58,9 @@ def for_order(self, store: TodoStore, commander: ThreadIncarnation) -> OwnerRequ @abstractmethod def select( - self, store: TodoStore, commander: ThreadIncarnation, + self, + store: TodoStore, + commander: ThreadIncarnation, ) -> tuple[OwnerRequest, ...]: ... @@ -62,12 +76,17 @@ def require(self) -> Self: return self def for_message( - self, store: TodoStore, target: NamedTarget, registry: Registration, + self, + store: TodoStore, + target: NamedTarget, + registry: Registration, ) -> tuple[OwnerRequest, ...]: return self.select(store, registry.require(target.require_dm_peer()).incarnation) def select( - self, store: TodoStore, commander: ThreadIncarnation, + self, + store: TodoStore, + commander: ThreadIncarnation, ) -> tuple[OwnerRequest, ...]: request = store.owner_request(self) request.require_open(commander) @@ -83,7 +102,9 @@ def require(self) -> OwnerRequestId: @dataclass(frozen=True) class NewestOpenRequest(NullMember, RequestSelection): def select( - self, store: TodoStore, commander: ThreadIncarnation, + self, + store: TodoStore, + commander: ThreadIncarnation, ) -> tuple[OwnerRequest, ...]: return store.open_requests(commander, limit=1) @@ -99,7 +120,10 @@ def require_open(self, request: OwnerRequestId) -> Self: return self def complete( - self, request: OwnerRequestId, orders: Sequence[Todo], reason: str, + self, + request: OwnerRequestId, + orders: Sequence[Todo], + reason: str, ) -> CompletedRequestState: if orders: raise ValueError(f"Request {request.value} still has pending orders.") @@ -115,8 +139,19 @@ def __post_init__(self) -> None: raise ValueError("Completing an owner request requires a reason.") +class RequestRevision(SubclassUnion, affix="RequestRevision"): + @abstractmethod + def validate(self, requests: Mapping[OwnerRequestId, OwnerRequest]) -> None: ... + + +@dataclass(frozen=True) +class NoRequestRevision(NullMember, RequestRevision): + def validate(self, requests: Mapping[OwnerRequestId, OwnerRequest]) -> None: + pass + + @dataclass(frozen=True) -class RequestAtRevision: +class RequestAtRevision(RecordMember, RequestRevision): """A historical directive prefix, not another writable request state.""" id: OwnerRequestId @@ -126,11 +161,10 @@ def __post_init__(self) -> None: if self.directive_count < 0: raise ValueError("A directive prefix cannot have negative length.") - def directives(self, store: TodoStore) -> tuple[RequestDirective, ...]: - request = store.owner_request(self.id) + def validate(self, requests: Mapping[OwnerRequestId, OwnerRequest]) -> None: + request = requests[self.id] if self.directive_count > len(request.directives): raise RelationViolationError("Request history exceeds its recorded directives.") - return request.directives[:self.directive_count] @dataclass(frozen=True) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index b4f7f0e935..48ee47b889 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -36,7 +36,6 @@ UnrecordedBuildIdentity, ) from agent_comms.bus_publication import AgentOrigin -from agent_comms.head_review import AcceptedHeadReview from agent_comms.change_report import ( FileLine, MainRevision, @@ -56,7 +55,6 @@ ) from agent_comms.command import Command from agent_comms.declared_family import DeclaredFamily -from agent_comms.lifecycle_capability import LifecycleCapability, Pending from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( FieldCodec, @@ -71,6 +69,8 @@ boundary_decoder, ) from agent_comms.goals import CheckedCommit, OrderCheck, ProblemGoalOrder +from agent_comms.head_review import AcceptedHeadReview +from agent_comms.lifecycle_capability import LifecycleCapability, Pending from agent_comms.locked_store import RegistryFile from agent_comms.maintenance_barrier import MaintenanceBarrier from agent_comms.message_reference import MessageReference @@ -2514,9 +2514,9 @@ class VerdictAudience: threads: tuple[str, ...] @classmethod - def of(cls, unit: str, request: OwnerRequest) -> VerdictAudience: - """An order concerns its unit and the commander on its owner request.""" - return cls(tuple(dict.fromkeys((unit, request.commander.name)))) + def of(cls, unit: str, todo: Todo) -> VerdictAudience: + """The order records its commander; a request lookup adds no authority.""" + return cls(tuple(dict.fromkeys((unit, todo.creator)))) @property def target(self) -> NamedTarget: @@ -3040,14 +3040,19 @@ def at_revision(self, directives: tuple[RequestDirective, ...]) -> RequestAtRevi def directive_messages(self, source: CheckedRead) -> tuple[Message, ...]: return tuple(directive.original(source) for directive in self.directives) - def overdue_directives(self, source: CheckedRead, now: float) -> tuple[tuple[RequestDirective, Message], ...]: + def overdue_directives( + self, source: CheckedRead, now: float, + ) -> tuple[tuple[RequestDirective, Message], ...]: originals = tuple((directive, directive.original(source)) for directive in self.directives if directive.deadline < now) if not originals: return () - answered = {answer for reply in source.replies(tuple(message.reference for _, message in originals)) - if reply.audience.sender_lookup == self.commander.lookup for answer in reply.message.answers} - return tuple((directive, message) for directive, message in originals if message.reference not in answered) + replies = source.replies(tuple(message.reference for _, message in originals)) + answered = {answer for reply in replies + if reply.audience.sender_lookup == self.commander.lookup + for answer in reply.message.answers} + return tuple((directive, message) for directive, message in originals + if message.reference not in answered) @dataclass(frozen=True, slots=True) @@ -3362,8 +3367,64 @@ def allocate(cls, db: sqlite3.Connection) -> ProblemNumber: return cls.issue(db, ProblemNumber(number)) +class OwnerRequestStore(DurableStore): + """Request lifetime and serving-work queries share the store's transaction.""" + + def add_request(self, commander: ThreadIncarnation, text: str) -> OwnerRequest: + request = OwnerRequest.new(commander, text) + with self.writing() as db: + request.insert(db) + return request + + def owner_request(self, request: OwnerRequestId) -> OwnerRequest: + with self.snapshot() as db: + return OwnerRequest.require(db, id=request) + + def open_requests( + self, commander: ThreadIncarnation, *, limit: int = -1, + ) -> tuple[OwnerRequest, ...]: + with self.snapshot() as db: + return tuple(OwnerRequest.select( + db, where="commander_created_at=?", + parameters=(commander.require_recorded().created_at,), state=OpenRequestState(), + order_by=("created_at", "id"), descending=True, limit=limit, + )) + + def record_directive(self, request: OwnerRequest, directive: RequestDirective) -> None: + with self.writing() as db: + current = OwnerRequest.require(db, id=request.id) + current.require_open(request.commander) + replace(current, directives=(*current.directives, directive)).upsert(db) + + def complete_request( + self, commander: ThreadIncarnation, request: OwnerRequestId, reason: str, + ) -> OwnerRequest: + with self.writing() as db: + current = OwnerRequest.require(db, id=request) + state = current.require_open(commander) + completed = replace(current, state=state.complete( + request, self._serving(db, request), reason, + )) + completed.upsert(db) + return completed + + @staticmethod + def _serving(db: sqlite3.Connection, request: OwnerRequestId) -> tuple[Todo, ...]: + return tuple(Todo.select(db, request=request, where=Todo.serving_request)) + + def serving_orders(self, request: OwnerRequestId) -> tuple[Todo, ...]: + """Pending work and unaccepted claims, through the request's partial index.""" + with self.snapshot() as db: + return self._serving(db, request) + + def for_request(self, request: OwnerRequestId) -> tuple[Todo, ...]: + """Every order that served this independent request, open or done.""" + with self.snapshot() as db: + return tuple(Todo.select(db, request=request)) + + @dataclass(frozen=True, slots=True) -class TodoStore(DurableStore): +class TodoStore(OwnerRequestStore): """One SQLite row owns both todo state and its exclusive assignment. Assignment identity and revision deduplicate known operations. An uncertain @@ -3412,44 +3473,6 @@ def require_unit_in(cls, db: sqlite3.Connection, unit: ThreadIncarnation, f"{todo.order.checked().id} ({todo.id}); " "finish or release that order before assigning it again.") - def add_request(self, commander: ThreadIncarnation, text: str) -> OwnerRequest: - request = OwnerRequest.new(commander, text) - with self.writing() as db: - request.insert(db) - return request - - def owner_request(self, request: OwnerRequestId) -> OwnerRequest: - with self.snapshot() as db: - return OwnerRequest.require(db, id=request) - - def open_requests( - self, commander: ThreadIncarnation, *, limit: int | None = None, - ) -> tuple[OwnerRequest, ...]: - with self.snapshot() as db: - return tuple(OwnerRequest.select( - db, where="commander_created_at=?", - parameters=(commander.require_recorded().created_at,), state=OpenRequestState(), - order_by=("created_at", "id"), descending=True, limit=limit, - )) - - def record_directive(self, request: OwnerRequest, directive: RequestDirective) -> None: - with self.writing() as db: - current = OwnerRequest.require(db, id=request.id) - current.require_open(request.commander) - replace(current, directives=(*current.directives, directive)).upsert(db) - - def complete_request( - self, commander: ThreadIncarnation, request: OwnerRequestId, reason: str, - ) -> OwnerRequest: - with self.writing() as db: - current = OwnerRequest.require(db, id=request) - state = current.require_open(commander) - completed = replace(current, state=state.complete( - request, self._serving(db, request), reason, - )) - completed.upsert(db) - return completed - def record_hunt(self, commander: ThreadIncarnation, hunt: Hunt) -> HuntRecord: hunt.require_commander(self, commander) recorded = HuntRecord(uuid4().hex, commander, hunt) @@ -3634,20 +3657,6 @@ def holds_pending(self, owner: ThreadIncarnation) -> bool: return any(todo.pending for todo in self._select( '1', (), reader=partial(self.held_in, unit=owner))) - @staticmethod - def _serving(db: sqlite3.Connection, request: OwnerRequestId) -> tuple[Todo, ...]: - return tuple(Todo.select(db, request=request, where=Todo.serving_request)) - - def serving_orders(self, request: OwnerRequestId) -> tuple[Todo, ...]: - """Pending work and unaccepted claims, through the request's partial index.""" - with self.snapshot() as db: - return self._serving(db, request) - - def for_request(self, request: OwnerRequestId) -> tuple[Todo, ...]: - """Every order that served this independent request, open or done.""" - with self.snapshot() as db: - return tuple(Todo.select(db, request=request)) - def for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: return self._select("creator=? AND creator_created=?", (commander.name, commander.wire_birth), reader=self._open) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 36662848c8..d08f451283 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -1024,8 +1024,10 @@ def rebuild(cls, db: sqlite3.Connection, cls._writable_fields(tuple(changed)) fields = cls._writable_fields() stored = cls.stored_columns(db) - expressions = (changed[item.name] if item.name in changed else item.rebuild_expression(stored) - for item in fields) + expressions = ( + changed[item.name] if item.name in changed else item.rebuild_expression(stored) + for item in fields + ) selected = ", ".join(f'{expression} AS {_identifier(item.wire_name)}' for item, expression in zip(fields, expressions, strict=True)) table = _identifier(cls.declared_name) @@ -1051,7 +1053,9 @@ def derive_nullable_fields(cls, db: sqlite3.Connection) -> None: return if not all(item.nullable and item.column.writable for item in missing): return - previous = cls._table_sql(columns=tuple(item.name for item in fields if item.wire_name in stored)) + previous = cls._table_sql( + columns=tuple(item.name for item in fields if item.wire_name in stored), + ) actual = {item.name: item.sql for item in cls.stored_objects(db)} if actual.get(cls.declared_name) == previous: cls.rebuild(db) diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index e0ca843d9f..a0886dad88 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -14,13 +14,13 @@ from pathlib import Path from typing import TYPE_CHECKING, Self -from agent_comms.owner_requests import NewestOpenRequest from agent_comms.goal_actions import ( CreateProblemGoalAction, GoalActionContext, OrderGoalAction, ProblemOrderGoalAction, ) +from agent_comms.owner_requests import NewestOpenRequest from .native_settings import NativeThreadDefaults from .phase_tasks import PhaseTask diff --git a/tests/test_owner_requests.py b/tests/test_owner_requests.py index cf38dc78d5..1aff98fe00 100644 --- a/tests/test_owner_requests.py +++ b/tests/test_owner_requests.py @@ -6,23 +6,32 @@ import pytest from agent_comms.change_report import FileLine, MainRevision +from agent_comms.commander.order import Commander +from agent_comms.commander.request import main as request_main from agent_comms.comms import Comms from agent_comms.connector import CALLER, ConnectorSendTool -from agent_comms.errors import ToolRefusedError +from agent_comms.errors import RelationViolationError, ToolRefusedError from agent_comms.goals import OrderCheck -from agent_comms.order_runs import MechanicalOrderRun from agent_comms.messages import MessageReference +from agent_comms.order_runs import MechanicalOrderRun from agent_comms.owner_requests import NewestOpenRequest from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName from agent_comms.thread_status import StoppedThreadStatus +from agent_comms.todos import ( + ClaimOrder, + OrderClaim, + PendingProblemOrder, + TodoStore, + TriageScope, + WithdrawTodo, +) from agent_comms.tool_caller import ThreadCaller -from agent_comms.todos import ClaimOrder, OrderClaim, PendingProblemOrder, TodoStore, TriageScope, WithdrawTodo from agent_comms.tools import CommsSendTool -def test_independent_requests_orders_and_directives(comms: Comms) -> None: +def test_independent_requests_orders_and_directives(comms: Comms, durable_home, capsys) -> None: repository = Repo(checkout=Path.cwd(), github=RepositoryName("OpenHCSDev", "agent-comms")) for name in ("claude-ai", "commander", "worker"): thread = comms.registry.new_thread(name, frozenset(), str(repository.checkout)) @@ -30,13 +39,21 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: commander = comms.registry.require("commander") worker = comms.registry.require("worker") store = comms.goals.todos - first = store.add_request(commander.incarnation, "commander native in Core") - second = store.add_request(commander.incarnation, "virtual bus") + ctx = Commander.on_machine("commander", (repository,), comms.root, durable_home) + request_main(["add", "commander native in Core"], ctx) + first_id = capsys.readouterr().out.strip() + request_main(["add", "virtual bus"], ctx) + second_id = capsys.readouterr().out.strip() + second, first = store.open_requests(commander.incarnation) + assert (first.id.value, second.id.value) == (first_id, second_id) assert NewestOpenRequest().for_order(store, commander.incarnation) == second.id assert first.id.for_order(store, commander.incarnation) == first.id - assert [request.id for request in store.open_requests( - commander.incarnation.renamed("commander-renamed"), - )] == [second.id, first.id] + assert [ + request.id + for request in store.open_requests( + commander.incarnation.renamed("commander-renamed"), + ) + ] == [second.id, first.id] order = PendingProblemOrder( owner=FileLine.class_declaration(TodoStore, repository, MainRevision()), @@ -44,28 +61,44 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: check=OrderCheck(directory=str(repository.checkout), command="true"), edits=("Read the request owner and its consumers.",), banned=("Leave the saved bus and its sessions unchanged.",), - report="Report the result of the source investigation.", run=MechanicalOrderRun(), + report="Report the result of the source investigation.", + run=MechanicalOrderRun(), ) todo = store.create( - "P1", repository.github, "Inspect the request owner", - creator=commander, assigner=commander.incarnation, owner=worker, generation="request-family", + "P1", + repository.github, + "Inspect the request owner", + creator=commander, + assigner=commander.incarnation, + owner=worker, + generation="request-family", review_at=RecordedEventTime(order.at.recorded_at("Order") + 60), - order=order, request=first.id, + order=order, + request=first.id, ) assert store.serving_orders(first.id) == (todo,) assert not store.serving_orders(second.id) + request_main(["list"], ctx) + listed = capsys.readouterr().out + assert all( + text in listed + for text in (first_id, second_id, "P1", "commander native in Core", "virtual bus") + ) with pytest.raises(ValueError): - store.complete_request(commander.incarnation, first.id, "The work is finished.") + request_main(["complete", first_id, "--why", "The work is finished."], ctx) assert len(store.open_requests(commander.incarnation)) == 2 assert store.serving_orders(first.id) == (todo,) ConnectorSendTool( - sender="claude-ai", target="commander", request=first.id, + sender="claude-ai", + target="commander", + request=first.id, body="Read the request records and report the result.", ).apply(comms, CALLER) named = comms.bus.log.latest_sequence() ConnectorSendTool( - sender="claude-ai", target="commander", + sender="claude-ai", + target="commander", body="Read the stored orders and report the result.", ).apply(comms, CALLER) newest = comms.bus.log.latest_sequence() @@ -76,60 +109,107 @@ def test_independent_requests_orders_and_directives(comms: Comms) -> None: assert original.seq == named assert second.directives[0].original(source).seq == newest prefix = first.at_revision(first.directives) - assert prefix.directives(store) == first.directives + prefix.validate({first.id: first}) + with pytest.raises(RelationViolationError): + replace(prefix, directive_count=len(first.directives) + 1).validate({first.id: first}) claim = OrderClaim.for_order( - todo, worker.incarnation, 1, "a" * 40, "The request owner was inspected.", - MessageReference(original.seq, original.message_id), store=store, + todo, + worker.incarnation, + 1, + "a" * 40, + "The request owner was inspected.", + MessageReference(original.seq, original.message_id), + store=store, ) assert claim.message.target == first.commander.name assert claim.message.answers == (original.reference,) comms.messaging.send( - "commander", "claude-ai", "The request was read.", answers=(original.reference,), + "commander", + "claude-ai", + "The request was read.", + answers=(original.reference,), ) renamed = replace(first, commander=commander.incarnation.renamed("commander-renamed")) with comms.bus.log.checked_read() as source: assert not renamed.overdue_directives(source, first.directives[0].deadline + 1) CommsSendTool( - sender="worker", target="commander", body="The source records are available.", + sender="worker", + target="commander", + body="The source records are available.", ).apply(comms, ThreadCaller("worker")) assert store.owner_request(first.id).directives == first.directives assert store.owner_request(second.id).directives == second.directives before = comms.bus.log.latest_sequence() with pytest.raises(ValueError): ConnectorSendTool( - sender="claude-ai", target="#any", request=first.id, + sender="claude-ai", + target="#any", + request=first.id, body="Read the request records and report the result.", ).apply(comms, CALLER) with pytest.raises(ToolRefusedError): ConnectorSendTool( - sender="claude-ai", target="worker", request=first.id, + sender="claude-ai", + target="worker", + request=first.id, body="Read the request records and report the result.", ).apply(comms, CALLER) assert comms.bus.log.latest_sequence() == before - store.complete_request(commander.incarnation, second.id, "The independent request is finished.") + request_main(["complete", second_id, "--why", "The independent request is finished."], ctx) + capsys.readouterr() + request_main(["list"], ctx) + remaining = capsys.readouterr().out + assert first_id in remaining and "P1" in remaining + assert second_id not in remaining assert [request.id for request in store.open_requests(commander.incarnation)] == [first.id] - store.apply(todo.id, WithdrawTodo( - todo.revision, commander.incarnation, todo.held(), "The isolated request check is complete.", - )) + store.apply( + todo.id, + WithdrawTodo( + todo.revision, + commander.incarnation, + todo.held(), + "The isolated request check is complete.", + ), + ) store.complete_request(commander.incarnation, first.id, "No pending order remains.") assert not store.open_requests(commander.incarnation) - awaiting = store.add_request(commander.incarnation, "Wait for the commander's delivery verdict.") + awaiting = store.add_request( + commander.incarnation, "Wait for the commander's delivery verdict." + ) awaiting_order = order.reissued(order.at.recorded_at("Order") + 1) awaiting_todo = store.create( - "P2", repository.github, "Review the holder's delivery claim", creator=commander, - assigner=commander.incarnation, owner=worker, generation="claim-review", request=awaiting.id, - review_at=RecordedEventTime(awaiting_order.at.recorded_at("Order") + 60), order=awaiting_order, + "P2", + repository.github, + "Review the holder's delivery claim", + creator=commander, + assigner=commander.incarnation, + owner=worker, + generation="claim-review", + request=awaiting.id, + review_at=RecordedEventTime(awaiting_order.at.recorded_at("Order") + 60), + order=awaiting_order, ) awaiting_claim = OrderClaim.for_order( - awaiting_todo, worker.incarnation, 2, "b" * 40, "The request's work is ready for review.", - original.reference, store=store, + awaiting_todo, + worker.incarnation, + 2, + "b" * 40, + "The request's work is ready for review.", + original.reference, + store=store, + ) + claimed = store.apply( + awaiting_todo.id, + ClaimOrder( + awaiting_todo.revision, + worker.incarnation, + awaiting_todo.held().generation, + awaiting_claim, + ), ) - claimed = store.apply(awaiting_todo.id, ClaimOrder( - awaiting_todo.revision, worker.incarnation, awaiting_todo.held().generation, awaiting_claim, - )) assert store.serving_orders(awaiting.id) == (claimed,) with pytest.raises(ValueError): store.complete_request(commander.incarnation, awaiting.id, "The claim is not yet accepted.") diff --git a/tools/carry_rts_orders.py b/tools/carry_rts_orders.py index 92bee1d61c..7d6917f2d6 100644 --- a/tools/carry_rts_orders.py +++ b/tools/carry_rts_orders.py @@ -208,7 +208,9 @@ def carry(rts: Path, root: Path, apply: bool, retire: bool) -> None: repo = Repo.of(Path(order.owner.file).parent) text = problems[pid].text proposed = todos.Assignment.of(owner, actor, generation) - todo = todos.Todo.ordered(pid, repo.github, text, actor, proposed, due, order, request) + todo = todos.Todo.ordered( + pid, repo.github, text, actor, proposed, due, order, request + ) todo = todos.AssignTodo.by( actor, todo.revision, diff --git a/tools/cutover/carry_owner_requests.py b/tools/cutover/carry_owner_requests.py index 0321cb80c7..5343c369e7 100644 --- a/tools/cutover/carry_owner_requests.py +++ b/tools/cutover/carry_owner_requests.py @@ -3,6 +3,7 @@ Delete after every bus has run this step. The retired wire tag is understood only here; no request-goal member or compatibility decoder is registered. """ + from __future__ import annotations import json @@ -19,13 +20,25 @@ from agent_comms.goal_history import GoalHistoryEntry, GoalHistoryStore from agent_comms.goals import Goal, UnorderedGoalOrder from agent_comms.installer.carry import Carried, Carry -from agent_comms.locked_store import SQLiteFile from agent_comms.lifecycle_capability import Complete -from agent_comms.owner_requests import CompletedRequestState, OwnerRequestId, OpenRequestState, RequestDirective +from agent_comms.locked_store import SQLiteFile +from agent_comms.owner_requests import ( + CompletedRequestState, + OpenRequestState, + OwnerRequestId, + RequestDirective, +) from agent_comms.registry_store import RegistryStore from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.todos import OwnerRequest, Todo, TodoStore -from agent_comms.typed_table import Column, JsonStorage, SQLiteSchemaObject, TextStorage, TypedRow, TypedTable +from agent_comms.typed_table import ( + Column, + JsonStorage, + SQLiteSchemaObject, + TextStorage, + TypedRow, + TypedTable, +) @dataclass(frozen=True) @@ -38,11 +51,22 @@ class StoredRequestGoal(TypedRow): def request(self, commander: RecordedThreadIncarnation, created_at: float) -> OwnerRequest: if commander.created_at != self.owner_created_at: - raise RelationViolationError("Retained request belongs to another commander incarnation.") - state = (CompletedRequestState(self.goal.progress) - if self.goal.state.items_with(Complete, self.goal) else OpenRequestState()) - return OwnerRequest(OwnerRequestId(self.goal.id), commander, self.goal.text, - state, self.directives, created_at) + raise RelationViolationError( + "Retained request belongs to another commander incarnation." + ) + state = ( + CompletedRequestState(self.goal.progress) + if self.goal.state.items_with(Complete, self.goal) + else OpenRequestState() + ) + return OwnerRequest( + OwnerRequestId(self.goal.id), + commander, + self.goal.text, + state, + self.directives, + created_at, + ) @dataclass(frozen=True) @@ -78,17 +102,25 @@ def memory_copy(lifetime: ExitStack, path: Path) -> sqlite3.Connection: return db -def historical(db: sqlite3.Connection, column: str, *, where: str = "1") -> list[HistoricalRequestGoal]: - return HistoricalRequestGoal.read(GoalHistoryEntry.orders_with_replacement( - db, column, UNORDERED, order_kind="request", where=where)) +def historical( + db: sqlite3.Connection, column: str, *, where: str = "1" +) -> list[HistoricalRequestGoal]: + return HistoricalRequestGoal.read( + GoalHistoryEntry.orders_with_replacement( + db, column, UNORDERED, order_kind="request", where=where + ) + ) class OwnerRequests(Carry): @classmethod def backup_files(cls, root: Path) -> tuple[Path, ...]: registry = RegistryStore.at(root) - paths = (*registry.written, *SQLiteFile.files(TodoStore.path_in(root)), - *SQLiteFile.files(GoalHistoryStore.path_in(root))) + paths = ( + *registry.written, + *SQLiteFile.files(TodoStore.path_in(root)), + *SQLiteFile.files(GoalHistoryStore.path_in(root)), + ) for path in paths: cls.require_confined(root, path) return tuple(path for path in paths if path.exists()) @@ -108,36 +140,54 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: registry.private_guard_unlocked() raw = registry.path.read_text() sql = lifetime.enter_context(closing(sqlite3.connect(":memory:"))) - current = CurrentRequestGoal.read(sql.execute( - "SELECT key AS name, fullkey || '.goal.order' AS path, " - "json_extract(value, '$.created_at') AS owner_created_at, " - "json_set(json_extract(value, '$.goal'), '$.order', json(?)) AS goal, " - "coalesce(json_extract(value, '$.goal.order.directives'), '[]') AS directives " - "FROM json_each(?, '$.threads') WHERE json_extract(value, '$.goal.order.kind')='request'", - (UNORDERED, raw))) - for source in current: - (raw,) = sql.execute('SELECT json_set(?, ?, json(?))', (raw, source.path, UNORDERED)).fetchone() + current = CurrentRequestGoal.read( + sql.execute( + "SELECT key AS name, fullkey || '.goal.order' AS path, " + "json_extract(value, '$.created_at') AS owner_created_at, " + "json_set(json_extract(value, '$.goal'), '$.order', json(?)) AS goal, " + "coalesce(json_extract(value, '$.goal.order.directives'), '[]') AS directives " + "FROM json_each(?, '$.threads') " + "WHERE json_extract(value, '$.goal.order.kind')='request'", + (UNORDERED, raw), + ) + ) + for current_source in current: + (raw,) = sql.execute( + "SELECT json_set(?, ?, json(?))", (raw, current_source.path, UNORDERED) + ).fetchone() document = registry._decode(FieldCodec.parse(raw)) - owners = {thread.incarnation.require_recorded().created_at: thread.incarnation.require_recorded() - for thread in document.threads.values()} + owners = {} + for thread in document.threads.values(): + recorded_commander = thread.incarnation.require_recorded() + owners[recorded_commander.created_at] = recorded_commander todos = memory_copy(lifetime, todo_path) history = memory_copy(lifetime, history_path) todo_columns = Todo.stored_columns(todos) old_links = "goal" in todo_columns if old_links: - for link in StoredRequestLink.read(todos.execute( - "SELECT json_extract(goal,'$.goal_id') AS request, " - "json_extract(goal,'$.owner') AS owner, json_extract(goal,'$.owner_created') AS owner_created " - f'FROM "{Todo.declared_name}" WHERE goal IS NOT NULL GROUP BY goal')): + for link in StoredRequestLink.read( + todos.execute( + "SELECT json_extract(goal,'$.goal_id') AS request, " + "json_extract(goal,'$.owner') AS owner, " + "json_extract(goal,'$.owner_created') AS owner_created " + f'FROM "{Todo.declared_name}" WHERE goal IS NOT NULL GROUP BY goal' + ) + ): # Deleted commanders still have their original incarnation # on the orders they issued. Do not manufacture a new actor. - owners.setdefault(link.owner_created, RecordedThreadIncarnation(link.owner, link.owner_created)) + owners.setdefault( + link.owner_created, + RecordedThreadIncarnation(link.owner, link.owner_created), + ) if OwnerRequest.stored_columns(todos): differences = SQLiteSchemaObject.differences( - TypedTable.objects_of(todos, (OwnerRequest,)), OwnerRequest.schema_objects(), + TypedTable.objects_of(todos, (OwnerRequest,)), + OwnerRequest.schema_objects(), ) if differences: - raise RelationViolationError(f"Request table differs from its declaration: {differences}") + raise RelationViolationError( + f"Request table differs from its declaration: {differences}" + ) requests = {request.id: request for request in OwnerRequest.stream(todos)} else: requests = {} @@ -145,10 +195,16 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: sources: dict[str, list[HistoricalRequestGoal]] = {} first_seen: dict[OwnerRequestId, float] = {} if GoalHistoryEntry.stored_columns(history): - for column in ("before", "after"): + for column in GoalHistoryEntry.endpoint_columns(): sources[column] = historical(history, column) - committed = sorted((source for column in sources for source in historical( - history, column, where="state='committed'")), key=lambda source: source.sequence) + committed = sorted( + ( + source + for column in sources + for source in historical(history, column, where="state='committed'") + ), + key=lambda source: source.sequence, + ) for source in committed: identity = OwnerRequestId(source.goal.id) first_seen.setdefault(identity, source.observed_at) @@ -156,19 +212,28 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: commander = owners[source.owner_created_at] except KeyError: raise RelationViolationError( - f"Request {identity.value} has no retained commander incarnation.") from None + f"Request {identity.value} has no retained commander incarnation." + ) from None requests[identity] = source.request(commander, first_seen[identity]) - for source in current: - identity = OwnerRequestId(source.goal.id) + for current_source in current: + identity = OwnerRequestId(current_source.goal.id) first_seen.setdefault(identity, time.time()) - requests[identity] = source.request(document.threads[source.name].incarnation.require_recorded(), - first_seen[identity]) + requests[identity] = current_source.request( + document.threads[current_source.name].incarnation.require_recorded(), + first_seen[identity], + ) for identity, previous in existing.items(): if identity in requests: proposed = requests[identity] if (previous.commander, previous.text, previous.state, previous.directives) != ( - proposed.commander, proposed.text, proposed.state, proposed.directives): - raise RelationViolationError(f"Request {identity.value} differs while finishing its carry.") + proposed.commander, + proposed.text, + proposed.state, + proposed.directives, + ): + raise RelationViolationError( + f"Request {identity.value} differs while finishing its carry." + ) requests[identity] = previous # Validate every historical endpoint, including pending/aborted/ # uncertain intents. They cannot become new accepted directives. @@ -178,13 +243,19 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: identity = OwnerRequestId(source.goal.id) if identity not in requests: raise RelationViolationError( - f"Request {identity.value} at history {source.sequence} exists only in an unresolved intent.") - prefixes[column, source.sequence] = requests[identity].at_revision(source.directives) + f"Request {identity.value} at history {source.sequence} " + "exists only in an unresolved intent." + ) + prefixes[column, source.sequence] = requests[identity].at_revision( + source.directives + ) new_rows = len(requests.keys() - existing.keys()) todo_changed = old_links or new_rows > 0 if todo_columns: if not old_links and "request" not in todo_columns: - raise RelationViolationError("Todo has neither the retired goal nor a request link.") + raise RelationViolationError( + "Todo has neither the retired goal nor a request link." + ) if not OwnerRequest.stored_columns(todos): OwnerRequest.reconcile_schema(todos) elif requests: @@ -194,43 +265,62 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: request.upsert(todos) linked = 0 if old_links: - (linked,) = todos.execute(f'SELECT count(*) FROM "{Todo.declared_name}" WHERE goal IS NOT NULL').fetchone() + (linked,) = todos.execute( + f'SELECT count(*) FROM "{Todo.declared_name}" WHERE goal IS NOT NULL' + ).fetchone() Todo.rebuild(todos, {"request": "json_extract(goal,'$.goal_id')"}) if todo_columns or requests: TodoStore.require_schema(todos) for _ in Todo.stream(todos): pass if list(todos.execute("PRAGMA foreign_key_check")): - raise RelationViolationError("Carried Todo request links violate their foreign keys.") + raise RelationViolationError( + "Carried Todo request links violate their foreign keys." + ) history_columns = GoalHistoryEntry.stored_columns(history) - history_changed = bool(history_columns and (prefixes or GoalHistoryEntry.schema_differs(history))) + history_changed = bool( + history_columns and (prefixes or GoalHistoryEntry.schema_differs(history)) + ) if history_changed: GoalHistoryEntry.reconcile_schema(history) for column, snapshots in sources.items(): for source in snapshots: GoalHistoryEntry.move_request_order( - history, column, source.sequence, prefixes[column, source.sequence], UNORDERED) + history, + column, + source.sequence, + prefixes[column, source.sequence], + UNORDERED, + ) if history_columns: GoalHistoryEntry.require_schema(history) for entry in GoalHistoryEntry.stream(history): - for prefix in (entry.request_before, entry.request_after): - if prefix is not None: - request = OwnerRequest.require(todos, id=prefix.id) - if prefix.directive_count > len(request.directives): - raise RelationViolationError("Carried history exceeds its request directives.") + for prefix in entry.request_revisions: + prefix.validate(requests) rewritten: list[str] = [] if apply: - for changed, path, prepared in ((todo_changed, todo_path, todos), (history_changed, history_path, history)): + for changed, path, prepared in ( + (todo_changed, todo_path, todos), + (history_changed, history_path, history), + ): if changed: prepared.commit() with closing(CoordinationStore.writer(path)) as destination: prepared.backup(destination) path.chmod(0o600) - rewritten.extend(str(file.relative_to(root)) for file in SQLiteFile.files(path)) + rewritten.extend( + str(file.relative_to(root)) for file in SQLiteFile.files(path) + ) if current: registry._write_unlocked(raw) rewritten.extend(str(path.relative_to(root)) for path in registry.written) - return Carried(new_rows + int(old_links) + int(history_changed) + len(current), - detail={"requests": new_rows, "linked_todos": linked, - "directives": sum(len(request.directives) for request in requests.values()), - "history_endpoints": len(prefixes)}, rewritten=tuple(rewritten)) + return Carried( + new_rows + int(old_links) + int(history_changed) + len(current), + detail={ + "requests": new_rows, + "linked_todos": linked, + "directives": sum(len(request.directives) for request in requests.values()), + "history_endpoints": len(prefixes), + }, + rewritten=tuple(rewritten), + ) From fbadb9e42566cd49885ebe92c286ab5d5d82518e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:07:41 -0400 Subject: [PATCH 09/10] Preserve owner request identity across every parity and verdict caller --- src/agent_comms/commander/judge.py | 3 +-- src/agent_comms/commander_parity/deletion.py | 5 +++-- src/agent_comms/commander_parity/handoff.py | 8 ++++---- src/agent_comms/commander_parity/lifecycle.py | 2 +- src/agent_comms/commander_parity/story.py | 5 ++--- src/agent_comms/commander_parity/wording.py | 8 ++++---- src/agent_comms/standing_rules.py | 3 ++- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index e385bc3f5a..a5d15aa8f0 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -351,8 +351,7 @@ def check_edits(self, edits: list[Edit]) -> None: def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: for todo in todos: - audience = todo.verdicts[-1].audience.of( - todo.held().owner, ctx.comms.goals.todos.owner_request(todo.request.require())) + audience = todo.verdicts[-1].audience.of(todo.held().owner, todo) roast(ctx, audience, todo.holder, self.lines, self.roast, self.fix, self.slop()) line = (f'{time.strftime("%Y-%m-%d %H:%M")} {todo.holder} ' f'(strike {todo.strike_count} on {todo.id}; caught by {ctx.actor_name} at review): ' diff --git a/src/agent_comms/commander_parity/deletion.py b/src/agent_comms/commander_parity/deletion.py index ce2a1f6073..5c7d864ba5 100644 --- a/src/agent_comms/commander_parity/deletion.py +++ b/src/agent_comms/commander_parity/deletion.py @@ -19,6 +19,7 @@ from ..field_codec import FieldCodec from ..goal_actions import CreateProblemGoalAction from ..goals import CountCheckResult, OrderCheck +from ..owner_requests import NewestOpenRequest from ..progress_claims import MergedClaim from ..recorded_time import RecordedEventTime from ..repo import Repo @@ -249,8 +250,8 @@ def send(cls, run: LifecycleRun, document: OrderFile) -> Todo: repo=Repo.of(Path(document.order.check.directory)).github, text=document.defect, order=document.order, - request=document.order.request( - run.commander.comms.registry.require(run.commander.commander), ("P1",) + request=NewestOpenRequest().for_order( + run.commander.comms.goals.todos, run.commander.incarnation, ), review_at=RecordedEventTime(time.time() + 3600), commander=run.commander.commander, diff --git a/src/agent_comms/commander_parity/handoff.py b/src/agent_comms/commander_parity/handoff.py index 76b6d431c1..7886378e09 100644 --- a/src/agent_comms/commander_parity/handoff.py +++ b/src/agent_comms/commander_parity/handoff.py @@ -66,14 +66,16 @@ def current(run: LifecycleRun, receiver: str, document: OrderFile, pid: str) -> return run.invoke( "import sys; from agent_comms.change_report import FileLine; " "from agent_comms.commander.order import Commander, OrderFile, ReassignReason; " + "from agent_comms.owner_requests import OwnerRequestId; " "Commander.model_validate_json(sys.argv[1]).send(sys.argv[2], " "OrderFile.read(sys.argv[3]), reassign=ReassignReason(sys.argv[4]), " - "traced=FileLine.from_wire(sys.argv[5]))", + "traced=FileLine.from_wire(sys.argv[5]), request=OwnerRequestId(sys.argv[6]))", run.commander.model_dump_json(), receiver, json.dumps(FieldCodec.encode(document)), "Compare the actual refusal to hand completed checked work to another real unit.", f"{Path(__file__).resolve()}:1", + run.commander.comms.goals.todos.get(pid).request.require().value, ) @staticmethod @@ -89,9 +91,7 @@ def native(run: LifecycleRun, receiver: str, document: OrderFile, pid: str) -> C order=document.order, review_at=RecordedEventTime(time.time() + 3600), commander=context.commander, - request=document.order.request( - context.comms.registry.require(context.commander), (pid,) - ), + request=todo.request.require(), traced=FileLine.from_wire(f"{Path(__file__).resolve()}:1"), ), ) diff --git a/src/agent_comms/commander_parity/lifecycle.py b/src/agent_comms/commander_parity/lifecycle.py index ec58f1ed8e..cfd7c3603e 100644 --- a/src/agent_comms/commander_parity/lifecycle.py +++ b/src/agent_comms/commander_parity/lifecycle.py @@ -298,7 +298,7 @@ def exercise(cls, run: LifecycleRun) -> tuple[Todo, ...]: run.worker, "The copied workflow is released before its next native assignment." ) document = replace(OrderFile.read(run.document.read_text()), problems=(before.id,)) - run.commander.send(run.worker, document, clean=True) + run.commander.send(run.worker, document, clean=True, request=before.request.require()) return before, run.commander.comms.goals.todos.get(before.id) @classmethod diff --git a/src/agent_comms/commander_parity/story.py b/src/agent_comms/commander_parity/story.py index b20249ad50..765ea55292 100644 --- a/src/agent_comms/commander_parity/story.py +++ b/src/agent_comms/commander_parity/story.py @@ -43,6 +43,7 @@ def exercise(cls, run: LifecycleRun) -> tuple[Todo, ...]: run.worker, document, clean=True, + request=created.request.require(), ) run.commander.strike(run.worker, created.id, cls.strike_reason, evidence=(created.order.checked().owner,)) @@ -53,9 +54,7 @@ def exercise(cls, run: LifecycleRun) -> tuple[Todo, ...]: order=CreateProblemGoalAction( problem="P2", order=replace(document.order, at=saved.order.checked().at), - request=run.commander.comms.registry.require(run.commander.commander) - .goal.require() - .id, + request=saved.request.require(), review_at=saved.review_at, commander=run.commander.commander, assigner=run.commander.actor_identity, diff --git a/src/agent_comms/commander_parity/wording.py b/src/agent_comms/commander_parity/wording.py index 0bfbaa50c6..9968e1e19a 100644 --- a/src/agent_comms/commander_parity/wording.py +++ b/src/agent_comms/commander_parity/wording.py @@ -132,22 +132,22 @@ def agrees(current: WordingOutcome, native: WordingOutcome) -> bool: def describe(self) -> str: return super().describe() + ( f"; current={OrderFile.problems_found.__qualname__}; " - f"native_entry={OrderGoalAction.request_ref.__qualname__}" + f"native_entry={OrderGoalAction.require_ready.__qualname__}" ) @staticmethod - def native_request(context: CommanderContext, todo: Todo, text: str) -> object: + def native_request(context: CommanderContext, todo: Todo, text: str) -> None: order = replace(todo.order.checked().copy_as(PendingProblemOrder), edits=(text,)) action = OrderGoalAction( problem=todo.id, expected_revision=todo.revision, order=order, - request=context.comms.registry.require(context.commander).goal.require().id, + request=todo.request.require(), review_at=todo.review_at, commander=context.commander, assigner=context.incarnation, ) - return action.request_ref( + action.require_ready( GoalActionContext( context.comms.goals, context.comms.registry.require(todo.held().owner), diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index e3a7f107cb..37eb3f76fc 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -349,7 +349,8 @@ def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: thread = comms.registry.require(commander) requests = comms.goals.todos.open_requests(thread.incarnation) with comms.bus.log.checked_read() as source: - return tuple(message for request in requests for message in request.directive_messages(source)) + return tuple(message for request in requests + for message in request.directive_messages(source)) def began(self) -> float: return self.directive.deadline From d5b9c0ab88d9b187a56f34d1f2f759f6029d9236 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:52:19 -0400 Subject: [PATCH 10/10] Copy only the declared plan module into commander fixtures --- tests/test_todos.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_todos.py b/tests/test_todos.py index 904c9eea48..72125da574 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -31,6 +31,7 @@ from agent_comms.goal_actions import SetGoalAction from agent_comms.goals import OrderCheck from agent_comms.owner_requests import NewestOpenRequest +from agent_comms.plan_phases import PlanPhase from agent_comms.launch_root import LaunchRoot from agent_comms.child_process import ProcessIdentity from agent_comms.recorded_time import RecordedEventTime @@ -246,9 +247,9 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): # Borrow only the plan inputs; the proof fixture owns the commit graph. current = Path(__file__).resolve().parents[1] shutil.copytree(current / 'plans', source.checkout / 'plans') - module = source.checkout / 'src' / 'agent_comms' - module.mkdir(parents=True) - shutil.copyfile(current / 'src' / 'agent_comms' / '__init__.py', module / '__init__.py') + module = source.module_file(PlanPhase.__module__) + (source.checkout / module).parent.mkdir(parents=True) + shutil.copyfile(current / module, source.checkout / module) source.git(source.checkout, 'add', 'plans', 'src') source.git(source.checkout, 'commit', '-qm', 'Declare current commander plans') source.git(source.checkout, 'push', '-q', 'origin', 'HEAD:main')