diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index da922b028a..21b64ce196 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1471,10 +1471,11 @@ def changed(name: str) -> bool: if (named := self.NAMED_FILE.findall(edit)) and not any(map(changed, named))) def copy_as[Kind: ProblemOrder](self, kind: type[Kind], **evidence) -> Kind: - return kind(**{item.name: getattr(self, item.name) for item in fields(self)}, **evidence) + return kind(**{item.name: getattr(self, item.name) for item in fields(kind) + if item.name not in evidence}, **evidence) def merged(self, sha: str) -> ProblemOrder: - return MergedProblemOrder(original=self, sha=sha) + return self.copy_as(MergedProblemOrder, sha=sha) @property def pull_request_reference(self) -> str: @@ -1549,16 +1550,11 @@ def waiting_on_install(self, todo: Todo, read: dict[str, OrderProgress]) -> tupl @dataclass(frozen=True, kw_only=True) -class MergedProblemOrder(ProblemOrder): - """An installed merge, retaining its original order without inventing a check or app proof.""" - original: CheckedProblemOrder +class MergedProblemOrder(CheckedProblemOrder): + """The checked order installed by a merge, with its actual merge identity.""" sha: str line: ClassVar[str] = "Merged and installed" - @property - def id(self) -> str: - return self.original.id - def require_close(self) -> None: pass diff --git a/tests/test_installed_merge.py b/tests/test_installed_merge.py index e6b21fd9fb..4e435dbdbf 100644 --- a/tests/test_installed_merge.py +++ b/tests/test_installed_merge.py @@ -1,27 +1,33 @@ """Merge observation closes the real stored order, without a completion request.""" -from dataclasses import replace +from dataclasses import fields, replace +import json import subprocess import pytest from agent_comms.repo import Repo from agent_comms.field_codec import FieldCodec -from agent_comms.todos import (DoneTodoState, MergedProblemOrder, OperationDelivery, +from agent_comms.todos import (CheckedProblemOrder, DoneTodoState, MergedProblemOrder, OperationDelivery, MergedPullRequest, PullRequests, UnorderedProblemOrder) from agent_comms.tools import CommsOrderDoneTool +from agent_comms.recorded_time import RecordedEventTime +from cutover.carry_merged_orders import MergedOrders from test_commander_problem_orders import working from test_order_progress import checkout, commit, deadline_lines, git @pytest.mark.parametrize('delivered', (False, True)) +@pytest.mark.parametrize('legacy_depth', (0, 1, 2)) def test_installed_merge_closes_pending_or_delivered_order_once( - commander, commander_script, checkout, tmp_path, monkeypatch, capsys, delivered): + commander, commander_script, checkout, tmp_path, monkeypatch, capsys, + delivered, legacy_depth): comms, caller = commander script, _ = commander_script todo, unit = checkout if delivered: CommsOrderDoneTool(problem=todo.id, order_id=todo.order.id, - delivery=OperationDelivery(), claims=working(comms)).apply(comms, caller) + delivery=OperationDelivery(at=RecordedEventTime(500)), + claims=working(comms)).apply(comms, caller) todo = comms.goals.todos.get(todo.id) original = todo.order commit(unit) @@ -59,11 +65,29 @@ def test_installed_merge_closes_pending_or_delivered_order_once( assert closed.state is DoneTodoState and closed.assignment is None assert closed.revision == todo.revision + 1 and not closed.pending assert isinstance(closed.order, MergedProblemOrder) - assert closed.order.sha == sha and closed.order.original == original + assert closed.order.sha == sha + assert all(getattr(closed.order, item.name) == getattr(original, item.name) + for item in fields(CheckedProblemOrder)) assert closed.order.id == original.id + assert "original" not in FieldCodec.encode(closed.order) assert closed.strikes == todo.strikes assert FieldCodec.decode(type(closed), FieldCodec.encode(closed)) == closed + if legacy_depth: + legacy = {'kind': 'merged', 'original': FieldCodec.encode(original), 'sha': sha} + if legacy_depth == 2: + closed = replace(closed, order=closed.order.folded('P2', 'Same root cause.')) + legacy = {**FieldCodec.encode(closed.order), 'original': legacy} + encoded = json.dumps(legacy) + with comms.goals.todos.writing() as db: + db.execute('UPDATE todo SET "order"=? WHERE id=?', (encoded, todo.id)) + assert MergedOrders.carry(comms.root, apply=False).changed == 1 + with comms.goals.todos.writing() as db: + assert db.execute('SELECT "order" FROM todo WHERE id=?', (todo.id,)).fetchone()[0] == encoded + assert MergedOrders.carry(comms.root, apply=True).changed == 1 + assert MergedOrders.carry(comms.root, apply=True).changed == 0 + assert comms.goals.todos.get(todo.id) == closed + def no_child(*args, **kwargs): pytest.fail('A repeated closed-order read launched a subprocess') diff --git a/tools/cutover/carry_merged_orders.py b/tools/cutover/carry_merged_orders.py new file mode 100644 index 0000000000..33a3964384 --- /dev/null +++ b/tools/cutover/carry_merged_orders.py @@ -0,0 +1,67 @@ +"""Carry retired merged wrappers through the checked order's own transition.""" + +from __future__ import annotations + +import json +from contextlib import closing +from dataclasses import dataclass +from pathlib import Path + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.field_codec import FieldCodec +from agent_comms.todos import CheckedProblemOrder, MergedProblemOrder, Todo, TodoStore +from agent_comms.typed_table import TypedRow + +from .carry import Carried, StoreCarry + + +@dataclass(frozen=True) +class SavedMergedOrder(TypedRow): + """The old wrapper projected and decoded once at the SQLite boundary.""" + + id: str + path: str + checked: CheckedProblemOrder + sha: str + + +class MergedOrders(StoreCarry): + store = TodoStore + + @classmethod + def carry(cls, root: Path, *, apply: bool) -> Carried: + path = cls.store.path_in(root) + if not path.exists(): + return Carried(0) + parameters = (MergedProblemOrder.declared_name,) + with closing(CoordinationStore.writer(path)) as db: + db.execute("BEGIN IMMEDIATE") + # Folded provenance can contain a merged order too. The transition + # belongs to CheckedProblemOrder, not a second field list here. + saved = SavedMergedOrder.read( + db.execute( + f"SELECT t.id, n.path AS path," + f" json_extract(t.\"order\", n.path || '.original') AS checked," + f" json_extract(t.\"order\", n.path || '.sha') AS sha" + f' FROM "{Todo.declared_name}" t, json_tree(t."order") n' + " WHERE n.key='kind' AND n.value=?" + " AND json_type(t.\"order\", n.path || '.original')='object'" + " ORDER BY length(n.path) DESC", + parameters, + ) + ) + for row in saved: + merged = row.checked.merged(row.sha) + db.execute( + f'UPDATE "{Todo.declared_name}"' + ' SET "order"=json_set("order", ?, json(?)) WHERE id=?', + (row.path, json.dumps(FieldCodec.encode(merged)), row.id), + ) + db.commit() if apply else db.rollback() + return Carried( + len(saved), {"merged_orders": len(saved)}, (path.name,) if apply and saved else () + ) + + +if __name__ == "__main__": + MergedOrders.main()