From 52887b77cf21a106a9c26f054895f5fbac221052 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:30:58 -0400 Subject: [PATCH 1/9] P215: declare undated edit evidence separately from required float clocks --- docs/refactor/P215-undated-edits.md | 52 +++++++++++++++++++++++++++++ src/agent_comms/commander/judge.py | 18 +++++++--- src/agent_comms/native_tools.py | 4 +-- src/agent_comms/tool_declaration.py | 44 ++++++++++++++++++++++-- src/agent_comms/turn_digests.py | 10 +++--- 5 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 docs/refactor/P215-undated-edits.md diff --git a/docs/refactor/P215-undated-edits.md b/docs/refactor/P215-undated-edits.md new file mode 100644 index 0000000000..594428b1e4 --- /dev/null +++ b/docs/refactor/P215-undated-edits.md @@ -0,0 +1,52 @@ +# P215 — keep undated edit evidence without inventing a float + +Status: implementation checkpoint; application acceptance still pending. + +## Source owner + +`tool_declaration.EditCall.at` was declared float. The writer in +`native_tools.MutatingPathTool.edit_calls` constructed it from +`ToolStartTranscript.timestamp`, which legitimately includes absence. +`tool_calls.ToolCall.started` constructs that event without a clock; +`ClaudeSession.tool_result` publishes the resulting event pair. + +The new common facts type captures the external clock once and returns either +EditCall (required, validated float) or UnrecordedEditCall (no float field). +UnjudgedEdits declares both shapes. Commander's existing Edit presentation is +shared by the two records through multiple inheritance; its boundary decodes +both using FieldCodec. No bad row is omitted; no stored transcript is rewritten. + +## Read-only live-bus observation + +Read via SQLite URI mode=ro, external_event, thread incarnation +0869d4462913095c0d0d327fa94a1bbf, seq 7472, ordinal 0: + +```json +{"kind":"tool_start","position":112524987,"routing":null,"source":null,"timestamp":null,"tool_call_id":"toolu_014GNzrDFAyJzb1e1eBmtZeT","tool_name":"edit","raw_input":{"path":"/home/ts/wt/one-chain/tools/cutover/carry_tool_call_index.py","old_string":" after = \"carry_hunts\"","new_string":" after = \"carry_registry_version\"","replace_all":false},"tool":{"tool":"edit","path":"/home/ts/wt/one-chain/tools/cutover/carry_tool_call_index.py","edits":[{"oldText":" after = \"carry_hunts\"","newText":" after = \"carry_registry_version\""}]}} +``` + +This is actual retained undated edit evidence. It is not identified as the exact +historic crashing call: the current Core Todo store now has only three assigned +rows, P215/P216/P217, whose holders are native. No matching currently assigned +external row was found. That limitation must not be silently replaced by a +fabricated assignment or event. + +## Shared source and acceptance + +PR #1912 currently changes judge.alarm; its dated-age consumer correction is +staged rather than applied to that overlapping function. PR #1929 changes the +ToolStartTranscript class; its return annotation correction is likewise staged. +No codec exception handler, nullable float field, zero timestamp, historical +rewrite or reply filter is added. + +The recorded Check runs an infinite monitor then expects exit 0. The narrower +reading is bounded observation of its two-minute progress, not pretending that +an intentional infinite loop normally exits. No live monitor was launched for +this investigation. A safely isolated copy run remains required. + +## Assumed + +- Native source history may be read, never replayed or rewritten. +- Old undated history cannot honestly acquire a clock from its read time. +- The original reported exception is real, but its exact old call is not yet + correlated with the current, subsequently changed Todo assignments. diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 07affa6d45..43ba17af26 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -24,7 +24,7 @@ from ..tools import CommsUnjudgedTool, CommsVerdictTool from ..field_codec import FieldCodec, option from ..tool_caller import ThreadCaller -from ..tool_declaration import EditCall, ReplacementEdit +from ..tool_declaration import EditCall, EditCallFacts, ReplacementEdit, UnrecordedEditCall from . import order from .order import CommanderVerb, RuledVerb @@ -46,14 +46,14 @@ def rendered(self) -> str: @dataclass(frozen=True) -class Edit(EditCall): +class Edit(EditCallFacts): """The Core result row, decoded at the commander's tool boundary.""" hunks: tuple[Hunk, ...] @classmethod def unjudged(cls, comms: Comms, commander: str) -> list[Edit]: reply = CommsUnjudgedTool.invoke(comms, {'thread': commander}, caller=ThreadCaller(commander)) - return list(FieldCodec.decode(tuple[cls, ...], reply['edits'])) + return list(FieldCodec.decode(tuple[RecordedEdit | UnrecordedEdit, ...], reply['edits'])) def code(self) -> str: """Four hex digits derived from the edit's own text; shown only after its full hunks.""" @@ -69,12 +69,22 @@ def added(self) -> str: return ''.join('+' + line + '\n' for hunk in self.hunks for line in hunk.new_text.splitlines()) def rendered(self) -> str: - stamp = time.strftime('%H:%M', time.localtime(self.at)) + stamp = self.clock() return (f'--- {self.id} {stamp} {self.path}\n' + '\n'.join(hunk.rendered() for hunk in self.hunks) + f'\n=== end {self.id} code {self.code()}') +@dataclass(frozen=True) +class RecordedEdit(Edit, EditCall): + """Commander presentation of a recorded clock, sharing Core's facts.""" + + +@dataclass(frozen=True) +class UnrecordedEdit(Edit, UnrecordedEditCall): + """The same full edit presentation without an invented clock.""" + + def show(ctx, pending: list[Edit]) -> None: by_thread: dict[str, list[Edit]] = {} for edit in pending: diff --git a/src/agent_comms/native_tools.py b/src/agent_comms/native_tools.py index e288825c84..2155a403ba 100644 --- a/src/agent_comms/native_tools.py +++ b/src/agent_comms/native_tools.py @@ -12,7 +12,7 @@ from .field_codec import FieldCodec from .native_tool_call import CodingCall, SelectedToolDenied from .storage_rules import WriteStorageEffect, physical_path, shell_storage_effects -from .tool_declaration import EditCall, ReplacementEdit, ToolDeclaration, one_line +from .tool_declaration import EditCallFacts, ReplacementEdit, ToolDeclaration, one_line class NativeToolDeclaration(ToolDeclaration): """A declared tool participating in the existing native owner check.""" @@ -172,7 +172,7 @@ def storage_effects(self, cwd): return (WriteStorageEffect(physical_path(self.path, cwd)),) def edit_calls(self, call_id, at, thread, problem): - return (EditCall(call_id, at, thread, self.path, self.replacements(), problem),) + return (EditCallFacts.capture(call_id, at, thread, self.path, self.replacements(), problem),) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/tool_declaration.py b/src/agent_comms/tool_declaration.py index cedcbea414..7633f331df 100644 --- a/src/agent_comms/tool_declaration.py +++ b/src/agent_comms/tool_declaration.py @@ -10,6 +10,7 @@ from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import FieldCodec, MalformedRecord from .native_tool_call import SelectedToolDenied +from .recorded_time import RecordedEventTime, UnrecordedEventTime def one_line(text: str, limit: int) -> str: @@ -23,14 +24,51 @@ class ReplacementEdit: @dataclass(frozen=True) -class EditCall: +class EditCallFacts: id: str - at: float thread: str path: str hunks: tuple[ReplacementEdit, ...] problem: str + @classmethod + def capture(cls, id: str, timestamp: object, thread: str, path: str, + hunks: tuple[ReplacementEdit, ...], problem: str) -> EditEvidence: + """Decode the source clock once; an undated edit is still evidence.""" + if timestamp is None: + return UnrecordedEditCall(id, thread, path, hunks, problem) + return EditCall(id, thread, path, hunks, problem, FieldCodec.decode(float, timestamp)) + + @property + def dated(self) -> tuple[float, ...]: + return () + + def clock(self) -> str: + return UnrecordedEventTime().clock() + + +@dataclass(frozen=True) +class EditCall(EditCallFacts): + at: float + + def __post_init__(self) -> None: + RecordedEventTime(self.at) + + @property + def dated(self) -> tuple[float, ...]: + return (self.at,) + + def clock(self) -> str: + return RecordedEventTime(self.at).clock() + + +@dataclass(frozen=True) +class UnrecordedEditCall(EditCallFacts): + """The source never recorded this edit's clock; no float is fabricated.""" + + +type EditEvidence = EditCall | UnrecordedEditCall + class ToolDeclaration(DeclaredFamily, affix="Tool"): family_discriminator: ClassVar[str] = "tool" @@ -67,7 +105,7 @@ def record(cls, name: str, arguments: object, *, call_id: str) -> ToolDeclaratio except (MalformedRecord, SelectedToolDenied): return None - def edit_calls(self, call_id: str, at: float, thread: str, problem: str) -> tuple[EditCall, ...]: + def edit_calls(self, call_id: str, at: float, thread: str, problem: str) -> tuple[EditEvidence, ...]: return () def reads(self) -> str: diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index 69ba2f14f7..dbc16235ee 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -13,7 +13,7 @@ from .external_transcript import ExternalTranscript from .transcript_events import TurnDigest from .tool_result import ToolResult -from .tool_declaration import EditCall +from .tool_declaration import EditEvidence if TYPE_CHECKING: from .threads import Thread @@ -28,7 +28,7 @@ class ThreadTurns(ToolResult): @dataclass(frozen=True) class UnjudgedEdits(ToolResult): - edits: tuple[EditCall, ...] + edits: tuple[EditEvidence, ...] class TurnBound(ABC): @@ -117,7 +117,7 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> ThreadTurns: class OrderRead[Result](TranscriptRead[Result]): todo: Todo - def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: + def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditEvidence, ...]: judged = {verdict.tool_call_id for verdict in self.todo.verdicts} since = self.todo.order.at.recorded_at("An order") edits = {edit.id: edit for turn in reversed(turns) @@ -127,10 +127,10 @@ def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ... return tuple(reversed(edits.values())) -class OrderEditsRead(OrderRead[tuple[EditCall, ...]]): +class OrderEditsRead(OrderRead[tuple[EditEvidence, ...]]): """Original unjudged calls; external events are selected before decoding.""" - def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: + def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditEvidence, ...]: return self.edits_from_turns(turns) def from_external(self, source) -> tuple[EditCall, ...]: From f400839680c9803d6e4c5395db99a426aec69478 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:42:48 -0400 Subject: [PATCH 2/9] P215: verify both wire shapes and record actual copied-monitor blocker --- P215-overlap-consumers.patch | 32 +++++++++++++++ docs/refactor/P215-undated-edits.md | 51 +++++++++++++++++++++++- src/agent_comms/tool_declaration.py | 6 +-- src/agent_comms/transcript_events.py | 2 +- src/agent_comms/turn_digests.py | 10 ++--- tests/test_edit_evidence.py | 58 ++++++++++++++++++++++++++++ tests/test_turn_digests.py | 5 ++- 7 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 P215-overlap-consumers.patch create mode 100644 tests/test_edit_evidence.py diff --git a/P215-overlap-consumers.patch b/P215-overlap-consumers.patch new file mode 100644 index 0000000000..be8c10e443 --- /dev/null +++ b/P215-overlap-consumers.patch @@ -0,0 +1,32 @@ +--- a/src/agent_comms/commander/judge.py ++++ b/src/agent_comms/commander/judge.py +@@ -116,12 +116,16 @@ + + def alarm(): + while True: +- late = [edit for edit in Edit.unjudged(order.COMMS, ME) if time.time() - edit.at > 300] ++ pending = Edit.unjudged(order.COMMS, ME) ++ late = [edit for edit in pending if any(time.time() - at > 300 for at in edit.dated)] ++ undated = [edit for edit in pending if not edit.dated] ++ if undated: ++ print(f'UNJUDGED {len(undated)} edits without recorded times -> judge show', flush=True) + if late: + counts: dict[str, int] = {} + for edit in late: + counts[edit.thread] = counts.get(edit.thread, 0) + 1 +- oldest = min(edit.at for edit in late) ++ oldest = min(at for edit in late for at in edit.dated) + print(f'UNJUDGED {len(late)} edits over 5 min (oldest {int((time.time() - oldest) / 60)} min): ' + + ', '.join(f'{t} {n}' for t, n in counts.items()) + ' -> judge show', flush=True) + time.sleep(60) +--- a/src/agent_comms/transcript_events.py ++++ b/src/agent_comms/transcript_events.py +@@ -351,7 +351,7 @@ + + @dataclass(frozen=True, kw_only=True) + class ToolStartTranscript(ToolTranscript): +- def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall, ...]: ++ def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall | tool_declaration.UnrecordedEditCall, ...]: + if self.before(since) or self.tool is None: + return () + return self.tool.edit_calls(self.tool_call_id, self.timestamp, thread, problem) diff --git a/docs/refactor/P215-undated-edits.md b/docs/refactor/P215-undated-edits.md index 594428b1e4..c67f58e52a 100644 --- a/docs/refactor/P215-undated-edits.md +++ b/docs/refactor/P215-undated-edits.md @@ -1,6 +1,8 @@ # P215 — keep undated edit evidence without inventing a float -Status: implementation checkpoint; application acceptance still pending. +Status: draft PR #1951, opened with the required Core order identity. +Read-only live unjudged read/encode/presentation passed for all 24 current edits. +Copied rotation reaches a separate missing-checkout defect; acceptance is blocked. ## Source owner @@ -44,6 +46,53 @@ reading is bounded observation of its two-minute progress, not pretending that an intentional infinite loop normally exits. No live monitor was launched for this investigation. A safely isolated copy run remains required. +## Copied rotation output + +Copy: /home/ts/.cache/agent-scratch/pi-cn-phase7/P215-copy-1/bus. +Used upgrade_bus.copy_bus, then read-only SQLite backups into the copy. The +original StorageRule was serialized into the copy before using an isolated HOME, +so HOME did not reinterpret the actual storage paths. Config/state/board writes +were confined to that copy. No native process was started, stopped or replayed. + +Actual monitor output, exit 1 (traceback tail): + +```text + File "src/agent_comms/todos.py", line 358, in readings + base = self.git("merge-base", "HEAD", "origin/HEAD").strip() + File "src/agent_comms/todos.py", line 353, in git + raise ValueError(run.stderr.strip() or f"git {args[0]} failed") +ValueError: fatal: cannot change to '/home/ts/wt/pi-plan-claims--P216': No such file or directory +``` + +pi-cn-phase3 already owns the ProgressSource repair in #1935 and has this actual +copy-path reproducer. No workaround was added here. This is not a successful +monitor run. Its complete original output is rotate.log beside the copied bus. + +## Source sanity + +Final focused batch: 6 passed in 24.76 seconds. This covers both edit-clock shapes, +strict rejection of a null float, unchanged full hunks/presentation, the external +Core CLI turns endpoint and actual Claude file transport. Two old fixtures omitted +the already-required position; they now supply it. These tests are not claimed as +rotation acceptance. Shared pytest warns about its absent timeout plugin; the +external timeout 300 is the enforced bound. No environment or package was created. + +## Structural evidence + +Refactor-audit Package parser: 381 Core modules, 280 Toad source modules, zero +parse omissions. No Toad consumer of the changed edit record types was found. +The NRA parser was unavailable (missing tree_sitter); the existing refactor-audit +parser was used instead. Census retained as P215-ast-before.txt in owned scratch. + +Before: one EditCall constructor at MutatingPathTool.edit_calls accepted an absent +source clock into a required float; both OrderEditsRead paths returned that row. +After: common EditCallFacts owns capture; the writer returns the explicit record +cases, both readers and UnjudgedEdits declare them, and commander's shared Edit +presentation is composed with each Core record through C3 MRO. FieldCodec's +existing strict record/union decoder chooses the shape; no alternate codec or +new discriminator registry exists. The overlapping alarm consumer remains staged, +not silently claimed complete. Native and external source clocks are not changed. + ## Assumed - Native source history may be read, never replayed or rewritten. diff --git a/src/agent_comms/tool_declaration.py b/src/agent_comms/tool_declaration.py index 7633f331df..eafd60b166 100644 --- a/src/agent_comms/tool_declaration.py +++ b/src/agent_comms/tool_declaration.py @@ -33,7 +33,7 @@ class EditCallFacts: @classmethod def capture(cls, id: str, timestamp: object, thread: str, path: str, - hunks: tuple[ReplacementEdit, ...], problem: str) -> EditEvidence: + hunks: tuple[ReplacementEdit, ...], problem: str) -> EditCall | UnrecordedEditCall: """Decode the source clock once; an undated edit is still evidence.""" if timestamp is None: return UnrecordedEditCall(id, thread, path, hunks, problem) @@ -67,8 +67,6 @@ class UnrecordedEditCall(EditCallFacts): """The source never recorded this edit's clock; no float is fabricated.""" -type EditEvidence = EditCall | UnrecordedEditCall - class ToolDeclaration(DeclaredFamily, affix="Tool"): family_discriminator: ClassVar[str] = "tool" @@ -105,7 +103,7 @@ def record(cls, name: str, arguments: object, *, call_id: str) -> ToolDeclaratio except (MalformedRecord, SelectedToolDenied): return None - def edit_calls(self, call_id: str, at: float, thread: str, problem: str) -> tuple[EditEvidence, ...]: + def edit_calls(self, call_id: str, at: object, thread: str, problem: str) -> tuple[EditCall | UnrecordedEditCall, ...]: return () def reads(self) -> str: diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index 79fd3dbeb0..d1b61b4451 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -104,7 +104,7 @@ def imported(self) -> ImportedMessage | None: def record_call(self, calls: dict[str, OrderCall], since: float) -> None: """Only tool events change a unit's calls.""" - def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall, ...]: + def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall | tool_declaration.UnrecordedEditCall, ...]: return () def before(self, at: float) -> bool: diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index dbc16235ee..3f9250bb72 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -13,7 +13,7 @@ from .external_transcript import ExternalTranscript from .transcript_events import TurnDigest from .tool_result import ToolResult -from .tool_declaration import EditEvidence +from .tool_declaration import EditCall, UnrecordedEditCall if TYPE_CHECKING: from .threads import Thread @@ -28,7 +28,7 @@ class ThreadTurns(ToolResult): @dataclass(frozen=True) class UnjudgedEdits(ToolResult): - edits: tuple[EditEvidence, ...] + edits: tuple[EditCall | UnrecordedEditCall, ...] class TurnBound(ABC): @@ -117,7 +117,7 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> ThreadTurns: class OrderRead[Result](TranscriptRead[Result]): todo: Todo - def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditEvidence, ...]: + def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall | UnrecordedEditCall, ...]: judged = {verdict.tool_call_id for verdict in self.todo.verdicts} since = self.todo.order.at.recorded_at("An order") edits = {edit.id: edit for turn in reversed(turns) @@ -127,10 +127,10 @@ def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditEvidence, return tuple(reversed(edits.values())) -class OrderEditsRead(OrderRead[tuple[EditEvidence, ...]]): +class OrderEditsRead(OrderRead[tuple[EditCall | UnrecordedEditCall, ...]]): """Original unjudged calls; external events are selected before decoding.""" - def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditEvidence, ...]: + def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall | UnrecordedEditCall, ...]: return self.edits_from_turns(turns) def from_external(self, source) -> tuple[EditCall, ...]: diff --git a/tests/test_edit_evidence.py b/tests/test_edit_evidence.py new file mode 100644 index 0000000000..fe288d643c --- /dev/null +++ b/tests/test_edit_evidence.py @@ -0,0 +1,58 @@ +"""Source sanity only; copied-bus rotation is the application check.""" + +from dataclasses import fields, replace + +import pytest + +from agent_comms.commander.judge import RecordedEdit, UnrecordedEdit +from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.tool_calls import CliToolArguments, ToolCall +from agent_comms.tool_declaration import EditCall, UnrecordedEditCall +from agent_comms.turn_digests import UnjudgedEdits + + +def original_call(): + arguments = {'file_path': 'owner.py', 'old_string': 'before', 'new_string': 'after'} + return CliToolArguments.decode(ToolCall('source-call', 'Edit', arguments), arguments) + + +@pytest.mark.parametrize('timestamp', [None, 1792430000.0]) +def test_original_call_keeps_all_edit_facts_through_reply_and_presentation(timestamp): + start, _ = original_call().finished('done', True, 12) + start = replace(start, timestamp=timestamp) + evidence = start.edit_calls('unit', 'P215', 1.0) + wire = FieldCodec.encode(UnjudgedEdits(evidence)) + presented, = FieldCodec.decode(tuple[RecordedEdit | UnrecordedEdit, ...], wire['edits']) + assert presented.id == 'source-call' + assert presented.thread == 'unit' + assert presented.problem == 'P215' + assert presented.hunks[0].old_text == 'before' + assert presented.hunks[0].new_text == 'after' + assert '- before' in presented.rendered() + assert '+ after' in presented.rendered() + if timestamp is None: + assert isinstance(evidence[0], UnrecordedEditCall) + assert isinstance(presented, UnrecordedEdit) + assert 'at' not in {field.name for field in fields(presented)} + assert 'at' not in wire['edits'][0] + assert presented.clock() == '-' + assert presented.dated == () + else: + assert isinstance(evidence[0], EditCall) + assert isinstance(presented, RecordedEdit) + assert presented.at == timestamp + assert presented.dated == (timestamp,) + assert type(wire['edits'][0]['at']) is float + + +def test_null_float_is_rejected_not_reinterpreted_as_undated(): + start, _ = original_call().finished('done', True, 12) + wire = FieldCodec.encode(UnjudgedEdits(start.edit_calls('unit', 'P215', 1.0))) + wire['edits'][0]['at'] = None + with pytest.raises(MalformedRecord): + FieldCodec.decode(UnjudgedEdits, wire) + + +def test_recorded_constructor_refuses_missing_clock(): + with pytest.raises(ValueError, match='finite Unix timestamp'): + EditCall('id', 'unit', 'owner.py', (), 'P215', None) diff --git a/tests/test_turn_digests.py b/tests/test_turn_digests.py index 105ca45654..529a20cd81 100644 --- a/tests/test_turn_digests.py +++ b/tests/test_turn_digests.py @@ -39,7 +39,7 @@ def test_external_turns_preserve_interleaving_and_the_whole_selected_turn(tmp_pa source = comms.transcripts.external('cli') for turn, item, text in [('old', 'a', 'older'), ('one', 'b', 'first'), ('two', 'c', 'other'), ('one', 'd', 'continued')]: - source.finish(turn_id=turn, item_id=item, events=(AssistantTranscript(text),)) + source.finish(turn_id=turn, item_id=item, events=(AssistantTranscript(text, position=item),)) answer = endpoint(comms, 'cli', 2) assert [turn['turn_id'] for turn in answer['turns']] == ['one', 'two'] assert [event['text'] for event in answer['turns'][0]['events']] == ['first', 'continued'] @@ -112,7 +112,8 @@ async def publish(): WatchSource.modified_file(item) for item in source.source_paths(comms.root)))) as changes: async for _ in changes: turns = await asyncio.to_thread(comms.transcripts.turns, 'claude', LastTurns()) - if any(event == AssistantTranscript('done', timestamp=datetime.fromisoformat(final['timestamp']).timestamp()) + if any(event == AssistantTranscript('done', position=event.position, + timestamp=datetime.fromisoformat(final['timestamp']).timestamp()) for turn in turns.turns for event in turn.events): break finally: From 059aaaa62b116079b5853c843125250aca3d59d5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:14:17 -0400 Subject: [PATCH 3/9] P215: read idle history through its declared execution owner --- docs/refactor/P215-undated-edits.md | 101 ---------------------------- tests/test_standing_rules.py | 13 ++++ 2 files changed, 13 insertions(+), 101 deletions(-) delete mode 100644 docs/refactor/P215-undated-edits.md diff --git a/docs/refactor/P215-undated-edits.md b/docs/refactor/P215-undated-edits.md deleted file mode 100644 index c67f58e52a..0000000000 --- a/docs/refactor/P215-undated-edits.md +++ /dev/null @@ -1,101 +0,0 @@ -# P215 — keep undated edit evidence without inventing a float - -Status: draft PR #1951, opened with the required Core order identity. -Read-only live unjudged read/encode/presentation passed for all 24 current edits. -Copied rotation reaches a separate missing-checkout defect; acceptance is blocked. - -## Source owner - -`tool_declaration.EditCall.at` was declared float. The writer in -`native_tools.MutatingPathTool.edit_calls` constructed it from -`ToolStartTranscript.timestamp`, which legitimately includes absence. -`tool_calls.ToolCall.started` constructs that event without a clock; -`ClaudeSession.tool_result` publishes the resulting event pair. - -The new common facts type captures the external clock once and returns either -EditCall (required, validated float) or UnrecordedEditCall (no float field). -UnjudgedEdits declares both shapes. Commander's existing Edit presentation is -shared by the two records through multiple inheritance; its boundary decodes -both using FieldCodec. No bad row is omitted; no stored transcript is rewritten. - -## Read-only live-bus observation - -Read via SQLite URI mode=ro, external_event, thread incarnation -0869d4462913095c0d0d327fa94a1bbf, seq 7472, ordinal 0: - -```json -{"kind":"tool_start","position":112524987,"routing":null,"source":null,"timestamp":null,"tool_call_id":"toolu_014GNzrDFAyJzb1e1eBmtZeT","tool_name":"edit","raw_input":{"path":"/home/ts/wt/one-chain/tools/cutover/carry_tool_call_index.py","old_string":" after = \"carry_hunts\"","new_string":" after = \"carry_registry_version\"","replace_all":false},"tool":{"tool":"edit","path":"/home/ts/wt/one-chain/tools/cutover/carry_tool_call_index.py","edits":[{"oldText":" after = \"carry_hunts\"","newText":" after = \"carry_registry_version\""}]}} -``` - -This is actual retained undated edit evidence. It is not identified as the exact -historic crashing call: the current Core Todo store now has only three assigned -rows, P215/P216/P217, whose holders are native. No matching currently assigned -external row was found. That limitation must not be silently replaced by a -fabricated assignment or event. - -## Shared source and acceptance - -PR #1912 currently changes judge.alarm; its dated-age consumer correction is -staged rather than applied to that overlapping function. PR #1929 changes the -ToolStartTranscript class; its return annotation correction is likewise staged. -No codec exception handler, nullable float field, zero timestamp, historical -rewrite or reply filter is added. - -The recorded Check runs an infinite monitor then expects exit 0. The narrower -reading is bounded observation of its two-minute progress, not pretending that -an intentional infinite loop normally exits. No live monitor was launched for -this investigation. A safely isolated copy run remains required. - -## Copied rotation output - -Copy: /home/ts/.cache/agent-scratch/pi-cn-phase7/P215-copy-1/bus. -Used upgrade_bus.copy_bus, then read-only SQLite backups into the copy. The -original StorageRule was serialized into the copy before using an isolated HOME, -so HOME did not reinterpret the actual storage paths. Config/state/board writes -were confined to that copy. No native process was started, stopped or replayed. - -Actual monitor output, exit 1 (traceback tail): - -```text - File "src/agent_comms/todos.py", line 358, in readings - base = self.git("merge-base", "HEAD", "origin/HEAD").strip() - File "src/agent_comms/todos.py", line 353, in git - raise ValueError(run.stderr.strip() or f"git {args[0]} failed") -ValueError: fatal: cannot change to '/home/ts/wt/pi-plan-claims--P216': No such file or directory -``` - -pi-cn-phase3 already owns the ProgressSource repair in #1935 and has this actual -copy-path reproducer. No workaround was added here. This is not a successful -monitor run. Its complete original output is rotate.log beside the copied bus. - -## Source sanity - -Final focused batch: 6 passed in 24.76 seconds. This covers both edit-clock shapes, -strict rejection of a null float, unchanged full hunks/presentation, the external -Core CLI turns endpoint and actual Claude file transport. Two old fixtures omitted -the already-required position; they now supply it. These tests are not claimed as -rotation acceptance. Shared pytest warns about its absent timeout plugin; the -external timeout 300 is the enforced bound. No environment or package was created. - -## Structural evidence - -Refactor-audit Package parser: 381 Core modules, 280 Toad source modules, zero -parse omissions. No Toad consumer of the changed edit record types was found. -The NRA parser was unavailable (missing tree_sitter); the existing refactor-audit -parser was used instead. Census retained as P215-ast-before.txt in owned scratch. - -Before: one EditCall constructor at MutatingPathTool.edit_calls accepted an absent -source clock into a required float; both OrderEditsRead paths returned that row. -After: common EditCallFacts owns capture; the writer returns the explicit record -cases, both readers and UnjudgedEdits declare them, and commander's shared Edit -presentation is composed with each Core record through C3 MRO. FieldCodec's -existing strict record/union decoder chooses the shape; no alternate codec or -new discriminator registry exists. The overlapping alarm consumer remains staged, -not silently claimed complete. Native and external source clocks are not changed. - -## Assumed - -- Native source history may be read, never replayed or rewritten. -- Old undated history cannot honestly acquire a clock from its read time. -- The original reported exception is real, but its exact old call is not yet - correlated with the current, subsequently changed Todo assignments. diff --git a/tests/test_standing_rules.py b/tests/test_standing_rules.py index 7cec6206e0..bfcd5ba98f 100644 --- a/tests/test_standing_rules.py +++ b/tests/test_standing_rules.py @@ -48,6 +48,19 @@ def lines(comms, member, commander="lead"): return [found.line() for found in member.violations(comms, commander)] +def test_wheel_spin_reads_external_owner_and_requires_actual_recent_turns(standing, tmp_path): + from agent_comms.transcript_events import AssistantTranscript + + comms, _, rules = standing + for name in ('recent-cli', 'old-cli', 'empty-cli'): + comms.registry.declare(comms.registry.new_thread( + name, frozenset(), str(tmp_path), execution=ExternalThreadExecution)) + for name, timestamp in (('recent-cli', time.time()), ('old-cli', 1.0)): + comms.transcripts.external(name).finish(turn_id='original-turn', item_id='reply', events=( + AssistantTranscript('retained answer', position=1, timestamp=timestamp),)) + assert [thread.name for thread in rules.WheelSpin.idle(comms, 'lead')] == ['recent-cli'] + + @pytest.mark.parametrize("reported,alarms", [ (NoCommitProgress(), True), (MergedProgress("d" * 9), False), From abc845b596f8c9a3ee39a9a105b8a57c26d7a5de Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:17:02 -0400 Subject: [PATCH 4/9] P215: remove staging patches and verification documents from delivery --- P215-overlap-consumers.patch | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 P215-overlap-consumers.patch diff --git a/P215-overlap-consumers.patch b/P215-overlap-consumers.patch deleted file mode 100644 index be8c10e443..0000000000 --- a/P215-overlap-consumers.patch +++ /dev/null @@ -1,32 +0,0 @@ ---- a/src/agent_comms/commander/judge.py -+++ b/src/agent_comms/commander/judge.py -@@ -116,12 +116,16 @@ - - def alarm(): - while True: -- late = [edit for edit in Edit.unjudged(order.COMMS, ME) if time.time() - edit.at > 300] -+ pending = Edit.unjudged(order.COMMS, ME) -+ late = [edit for edit in pending if any(time.time() - at > 300 for at in edit.dated)] -+ undated = [edit for edit in pending if not edit.dated] -+ if undated: -+ print(f'UNJUDGED {len(undated)} edits without recorded times -> judge show', flush=True) - if late: - counts: dict[str, int] = {} - for edit in late: - counts[edit.thread] = counts.get(edit.thread, 0) + 1 -- oldest = min(edit.at for edit in late) -+ oldest = min(at for edit in late for at in edit.dated) - print(f'UNJUDGED {len(late)} edits over 5 min (oldest {int((time.time() - oldest) / 60)} min): ' - + ', '.join(f'{t} {n}' for t, n in counts.items()) + ' -> judge show', flush=True) - time.sleep(60) ---- a/src/agent_comms/transcript_events.py -+++ b/src/agent_comms/transcript_events.py -@@ -351,7 +351,7 @@ - - @dataclass(frozen=True, kw_only=True) - class ToolStartTranscript(ToolTranscript): -- def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall, ...]: -+ def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall | tool_declaration.UnrecordedEditCall, ...]: - if self.before(since) or self.tool is None: - return () - return self.tool.edit_calls(self.tool_call_id, self.timestamp, thread, problem) From 14f6a9641002a024c7f205af72eeee90b7a3815c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 16:31:32 -0400 Subject: [PATCH 5/9] P215: use the existing EventTime owner for every edit clock --- src/agent_comms/commander/judge.py | 19 ++------- src/agent_comms/native_tools.py | 4 +- src/agent_comms/tool_declaration.py | 43 ++----------------- src/agent_comms/transcript_events.py | 6 ++- src/agent_comms/turn_digests.py | 10 ++--- tests/test_edit_evidence.py | 64 ++++++++++++++-------------- 6 files changed, 52 insertions(+), 94 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 43ba17af26..bda3f57823 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -24,7 +24,7 @@ from ..tools import CommsUnjudgedTool, CommsVerdictTool from ..field_codec import FieldCodec, option from ..tool_caller import ThreadCaller -from ..tool_declaration import EditCall, EditCallFacts, ReplacementEdit, UnrecordedEditCall +from ..tool_declaration import EditCall, ReplacementEdit from . import order from .order import CommanderVerb, RuledVerb @@ -46,14 +46,14 @@ def rendered(self) -> str: @dataclass(frozen=True) -class Edit(EditCallFacts): +class Edit(EditCall): """The Core result row, decoded at the commander's tool boundary.""" hunks: tuple[Hunk, ...] @classmethod def unjudged(cls, comms: Comms, commander: str) -> list[Edit]: reply = CommsUnjudgedTool.invoke(comms, {'thread': commander}, caller=ThreadCaller(commander)) - return list(FieldCodec.decode(tuple[RecordedEdit | UnrecordedEdit, ...], reply['edits'])) + return list(FieldCodec.decode(tuple[cls, ...], reply['edits'])) def code(self) -> str: """Four hex digits derived from the edit's own text; shown only after its full hunks.""" @@ -69,22 +69,11 @@ def added(self) -> str: return ''.join('+' + line + '\n' for hunk in self.hunks for line in hunk.new_text.splitlines()) def rendered(self) -> str: - stamp = self.clock() - return (f'--- {self.id} {stamp} {self.path}\n' + return (f'--- {self.id} {self.at.clock()} {self.path}\n' + '\n'.join(hunk.rendered() for hunk in self.hunks) + f'\n=== end {self.id} code {self.code()}') -@dataclass(frozen=True) -class RecordedEdit(Edit, EditCall): - """Commander presentation of a recorded clock, sharing Core's facts.""" - - -@dataclass(frozen=True) -class UnrecordedEdit(Edit, UnrecordedEditCall): - """The same full edit presentation without an invented clock.""" - - def show(ctx, pending: list[Edit]) -> None: by_thread: dict[str, list[Edit]] = {} for edit in pending: diff --git a/src/agent_comms/native_tools.py b/src/agent_comms/native_tools.py index 2155a403ba..e288825c84 100644 --- a/src/agent_comms/native_tools.py +++ b/src/agent_comms/native_tools.py @@ -12,7 +12,7 @@ from .field_codec import FieldCodec from .native_tool_call import CodingCall, SelectedToolDenied from .storage_rules import WriteStorageEffect, physical_path, shell_storage_effects -from .tool_declaration import EditCallFacts, ReplacementEdit, ToolDeclaration, one_line +from .tool_declaration import EditCall, ReplacementEdit, ToolDeclaration, one_line class NativeToolDeclaration(ToolDeclaration): """A declared tool participating in the existing native owner check.""" @@ -172,7 +172,7 @@ def storage_effects(self, cwd): return (WriteStorageEffect(physical_path(self.path, cwd)),) def edit_calls(self, call_id, at, thread, problem): - return (EditCallFacts.capture(call_id, at, thread, self.path, self.replacements(), problem),) + return (EditCall(call_id, at, thread, self.path, self.replacements(), problem),) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/tool_declaration.py b/src/agent_comms/tool_declaration.py index eafd60b166..ea109bd1f3 100644 --- a/src/agent_comms/tool_declaration.py +++ b/src/agent_comms/tool_declaration.py @@ -10,7 +10,7 @@ from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import FieldCodec, MalformedRecord from .native_tool_call import SelectedToolDenied -from .recorded_time import RecordedEventTime, UnrecordedEventTime +from .recorded_time import EventTime def one_line(text: str, limit: int) -> str: @@ -24,49 +24,14 @@ class ReplacementEdit: @dataclass(frozen=True) -class EditCallFacts: +class EditCall: id: str + at: EventTime thread: str path: str hunks: tuple[ReplacementEdit, ...] problem: str - @classmethod - def capture(cls, id: str, timestamp: object, thread: str, path: str, - hunks: tuple[ReplacementEdit, ...], problem: str) -> EditCall | UnrecordedEditCall: - """Decode the source clock once; an undated edit is still evidence.""" - if timestamp is None: - return UnrecordedEditCall(id, thread, path, hunks, problem) - return EditCall(id, thread, path, hunks, problem, FieldCodec.decode(float, timestamp)) - - @property - def dated(self) -> tuple[float, ...]: - return () - - def clock(self) -> str: - return UnrecordedEventTime().clock() - - -@dataclass(frozen=True) -class EditCall(EditCallFacts): - at: float - - def __post_init__(self) -> None: - RecordedEventTime(self.at) - - @property - def dated(self) -> tuple[float, ...]: - return (self.at,) - - def clock(self) -> str: - return RecordedEventTime(self.at).clock() - - -@dataclass(frozen=True) -class UnrecordedEditCall(EditCallFacts): - """The source never recorded this edit's clock; no float is fabricated.""" - - class ToolDeclaration(DeclaredFamily, affix="Tool"): family_discriminator: ClassVar[str] = "tool" @@ -103,7 +68,7 @@ def record(cls, name: str, arguments: object, *, call_id: str) -> ToolDeclaratio except (MalformedRecord, SelectedToolDenied): return None - def edit_calls(self, call_id: str, at: object, thread: str, problem: str) -> tuple[EditCall | UnrecordedEditCall, ...]: + def edit_calls(self, call_id: str, at: EventTime, thread: str, problem: str) -> tuple[EditCall, ...]: return () def reads(self) -> str: diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index d1b61b4451..450402d4b4 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -14,6 +14,7 @@ from .field_codec import FieldCodec from .messages import Message from .message_reference import MessageReference +from .recorded_time import GeneratedTimestamp from .routing import MessageRoute, TurnRouting from .tool_results import SENT_MESSAGE_MIME, ToolDiff, ToolText from .transcript_merge import EventMerge, StreamingMerge @@ -104,7 +105,7 @@ def imported(self) -> ImportedMessage | None: def record_call(self, calls: dict[str, OrderCall], since: float) -> None: """Only tool events change a unit's calls.""" - def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall | tool_declaration.UnrecordedEditCall, ...]: + def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall, ...]: return () def before(self, at: float) -> bool: @@ -349,7 +350,8 @@ class ToolStartTranscript(ToolTranscript): def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_declaration.EditCall, ...]: if self.before(since) or self.tool is None: return () - return self.tool.edit_calls(self.tool_call_id, self.timestamp, thread, problem) + return self.tool.edit_calls( + self.tool_call_id, GeneratedTimestamp.decode(self.timestamp), thread, problem) raw_input: dict[str, JsonValue] = field(default_factory=dict) # as the CLI sent it, for history tool: tool_declaration.ToolDeclaration | None = field( diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index 3f9250bb72..69ba2f14f7 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -13,7 +13,7 @@ from .external_transcript import ExternalTranscript from .transcript_events import TurnDigest from .tool_result import ToolResult -from .tool_declaration import EditCall, UnrecordedEditCall +from .tool_declaration import EditCall if TYPE_CHECKING: from .threads import Thread @@ -28,7 +28,7 @@ class ThreadTurns(ToolResult): @dataclass(frozen=True) class UnjudgedEdits(ToolResult): - edits: tuple[EditCall | UnrecordedEditCall, ...] + edits: tuple[EditCall, ...] class TurnBound(ABC): @@ -117,7 +117,7 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> ThreadTurns: class OrderRead[Result](TranscriptRead[Result]): todo: Todo - def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall | UnrecordedEditCall, ...]: + def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: judged = {verdict.tool_call_id for verdict in self.todo.verdicts} since = self.todo.order.at.recorded_at("An order") edits = {edit.id: edit for turn in reversed(turns) @@ -127,10 +127,10 @@ def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall | Un return tuple(reversed(edits.values())) -class OrderEditsRead(OrderRead[tuple[EditCall | UnrecordedEditCall, ...]]): +class OrderEditsRead(OrderRead[tuple[EditCall, ...]]): """Original unjudged calls; external events are selected before decoding.""" - def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall | UnrecordedEditCall, ...]: + def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: return self.edits_from_turns(turns) def from_external(self, source) -> tuple[EditCall, ...]: diff --git a/tests/test_edit_evidence.py b/tests/test_edit_evidence.py index fe288d643c..bc3b527255 100644 --- a/tests/test_edit_evidence.py +++ b/tests/test_edit_evidence.py @@ -1,13 +1,13 @@ -"""Source sanity only; copied-bus rotation is the application check.""" +"""Source checks for edit clocks owned by the existing EventTime family.""" -from dataclasses import fields, replace +from dataclasses import replace import pytest -from agent_comms.commander.judge import RecordedEdit, UnrecordedEdit +from agent_comms.commander.judge import Edit from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime from agent_comms.tool_calls import CliToolArguments, ToolCall -from agent_comms.tool_declaration import EditCall, UnrecordedEditCall from agent_comms.turn_digests import UnjudgedEdits @@ -16,13 +16,13 @@ def original_call(): return CliToolArguments.decode(ToolCall('source-call', 'Edit', arguments), arguments) -@pytest.mark.parametrize('timestamp', [None, 1792430000.0]) -def test_original_call_keeps_all_edit_facts_through_reply_and_presentation(timestamp): +@pytest.mark.parametrize('timestamp,clock', [(None, UnrecordedEventTime()), (1792430000.0, RecordedEventTime(1792430000.0))]) +def test_original_call_keeps_all_edit_facts_through_reply_and_presentation(timestamp, clock): start, _ = original_call().finished('done', True, 12) start = replace(start, timestamp=timestamp) evidence = start.edit_calls('unit', 'P215', 1.0) wire = FieldCodec.encode(UnjudgedEdits(evidence)) - presented, = FieldCodec.decode(tuple[RecordedEdit | UnrecordedEdit, ...], wire['edits']) + presented, = FieldCodec.decode(tuple[Edit, ...], wire['edits']) assert presented.id == 'source-call' assert presented.thread == 'unit' assert presented.problem == 'P215' @@ -30,29 +30,31 @@ def test_original_call_keeps_all_edit_facts_through_reply_and_presentation(times assert presented.hunks[0].new_text == 'after' assert '- before' in presented.rendered() assert '+ after' in presented.rendered() - if timestamp is None: - assert isinstance(evidence[0], UnrecordedEditCall) - assert isinstance(presented, UnrecordedEdit) - assert 'at' not in {field.name for field in fields(presented)} - assert 'at' not in wire['edits'][0] - assert presented.clock() == '-' - assert presented.dated == () - else: - assert isinstance(evidence[0], EditCall) - assert isinstance(presented, RecordedEdit) - assert presented.at == timestamp - assert presented.dated == (timestamp,) - assert type(wire['edits'][0]['at']) is float - - -def test_null_float_is_rejected_not_reinterpreted_as_undated(): - start, _ = original_call().finished('done', True, 12) - wire = FieldCodec.encode(UnjudgedEdits(start.edit_calls('unit', 'P215', 1.0))) - wire['edits'][0]['at'] = None + assert presented.at == clock + assert presented.at.clock() in presented.rendered() + assert wire['edits'][0]['at'] == FieldCodec.encode(clock, EventTime) + + +def test_unrecorded_edit_survives_actual_commanders_unjudged_tool(commander): + from agent_comms.external_transcript import ExternalTranscript + from agent_comms.thread_execution import ExternalThreadExecution + from test_commander_problem_orders import create + + comms, _ = commander + todo = create(commander, 'P215') + holder = comms.registry.require(todo.held().owner) + comms.registry.register(replace(holder, execution=ExternalThreadExecution)) + start, end = original_call().finished('done', True, 12) + ExternalTranscript(comms.root, holder.incarnation).finish( + turn_id='original-turn', item_id='source-call', events=(start, end)) + edit, = Edit.unjudged(comms, 'lead') + assert edit.id == 'source-call' + assert edit.at == UnrecordedEventTime() + assert edit.hunks[0].new_text == 'after' + + +def test_recorded_clock_requires_its_original_float(): with pytest.raises(MalformedRecord): - FieldCodec.decode(UnjudgedEdits, wire) - - -def test_recorded_constructor_refuses_missing_clock(): + FieldCodec.decode(EventTime, {'kind': 'recorded', 'at': None}) with pytest.raises(ValueError, match='finite Unix timestamp'): - EditCall('id', 'unit', 'owner.py', (), 'P215', None) + RecordedEventTime(None) From 8d39160b0d76953742f0b55194b076e5f6f997ad Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 16:33:06 -0400 Subject: [PATCH 6/9] P215: derive edit age through EventTime without inventing a clock --- src/agent_comms/commander/judge.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index bda3f57823..4353885b0b 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -100,12 +100,13 @@ def roast(ctx, audience, thread, lines, text, fix, slop): def alarm(ctx): while True: - late = [edit for edit in Edit.unjudged(ctx.comms, ctx.commander) if time.time() - edit.at > 300] + late = [edit for edit in Edit.unjudged(ctx.comms, ctx.commander) + if edit.at.before(time.time() - 300)] if late: counts: dict[str, int] = {} for edit in late: counts[edit.thread] = counts.get(edit.thread, 0) + 1 - oldest = min(edit.at for edit in late) + oldest = min(edit.at.recorded_at("An overdue edit") for edit in late) print(f'UNJUDGED {len(late)} edits over 5 min (oldest {int((time.time() - oldest) / 60)} min): ' + ', '.join(f'{t} {n}' for t, n in counts.items()) + ' -> judge show', flush=True) time.sleep(60) From 2c2a72c593cbbca3688e8ea720103a3b36e808ff Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 16:39:02 -0400 Subject: [PATCH 7/9] P215: inherit edit record construction instead of redeclaring its schema --- src/agent_comms/commander/judge.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 4353885b0b..2f975fdb19 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -32,7 +32,6 @@ BRANCH = re.compile(r'\bisinstance\(|\bhasattr\(|^\s*(if|elif|match|case)\b|\bif\b.*\belse\b') -@dataclass(frozen=True) class Hunk(ReplacementEdit): def branches_added(self) -> int: @@ -45,7 +44,6 @@ def rendered(self) -> str: + ''.join(f'+ {line}\n' for line in self.new_text.splitlines())) -@dataclass(frozen=True) class Edit(EditCall): """The Core result row, decoded at the commander's tool boundary.""" hunks: tuple[Hunk, ...] From 6bee391601b320d5dac72acf46c7be6af3423eb8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:53:21 -0400 Subject: [PATCH 8/9] P215: decode external edit clocks from their original SQL evidence --- src/agent_comms/external_transcript.py | 17 ++++--- src/agent_comms/native_tools.py | 12 +++-- src/agent_comms/transcript_events.py | 4 +- src/agent_comms/turn_digests.py | 9 ++-- tests/test_edit_evidence.py | 67 +++++++++++++++----------- 5 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index 0c9d70fd36..36495b72d2 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -23,6 +23,7 @@ from .recorded_time import EventTime, GeneratedTimestamp, UnrecordedEventTime from .native_transcript import RecordsCut from .thread_identity import ThreadIncarnation +from .tool_declaration import ToolDeclaration from .transcript_events import TranscriptEvent, TurnDigest, ToolStartTranscript from .transcript_sources import NoDraft, TranscriptDraft, TranscriptRecord, TranscriptSource from .typed_table import Column, ForeignKey, Index, RealStorage, StoreWriter, TypedRow, TypedStore, TypedTable @@ -212,7 +213,8 @@ def unjudged_query(cls, thread, todo): judged = tuple(verdict.tool_call_id for verdict in todo.verdicts) placeholders = lambda values: ",".join("?" for _ in values) since = todo.order.at.recorded_at("An order") - where = f'e.thread=? AND e.kind=? AND e.tool IN ({placeholders(tools)}) AND e.timestamp>=?' + where = (f'e.thread=? AND e.kind=? AND e.tool IN ({placeholders(tools)}) ' + 'AND (e.timestamp>=? OR e.timestamp IS NULL)') parameters = (thread, ToolStartTranscript.declared_name, *tools, since) if judged: where += f' AND e.call_id NOT IN ({placeholders(judged)})' @@ -364,14 +366,17 @@ def turns(self, bound: TurnBound) -> tuple[TurnDigest, ...]: def unjudged(self, todo): where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo) with closing(self.connection.execute( - 'SELECT event FROM (SELECT e.event, e.seq, e.ordinal, ' + 'SELECT timestamp,call_id,json_extract(event,\'$.tool\') FROM ' + '(SELECT e.event, e.timestamp, e.call_id, e.seq, e.ordinal, ' 'row_number() OVER (PARTITION BY e.call_id ORDER BY e.seq DESC,e.ordinal DESC) AS latest ' f'FROM "{ExternalEventRow.declared_name}" e WHERE {where}) ' 'WHERE latest=1 ORDER BY seq DESC,ordinal DESC', parameters)) as rows: - for encoded, in rows: - event = FieldCodec.decode(TranscriptEvent, FieldCodec.parse(encoded)) - yield from event.edit_calls(todo.held().owner, todo.id, - todo.order.at.recorded_at("An order")) + for timestamp, call_id, encoded in rows: + identity = FieldCodec.decode(str, call_id) + tool = FieldCodec.decode_bound(ToolDeclaration, FieldCodec.parse(encoded), + call_id=identity) + yield from tool.edit_calls(identity, GeneratedTimestamp.decode(timestamp), + todo.held().owner, todo.id) def unjudged_count(self, todo) -> int: where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo) diff --git a/src/agent_comms/native_tools.py b/src/agent_comms/native_tools.py index e288825c84..d4dc9e90c4 100644 --- a/src/agent_comms/native_tools.py +++ b/src/agent_comms/native_tools.py @@ -11,9 +11,11 @@ from .envelope_claim_transitions import ExistingFileClaim, FileClaimPath, WritableFileClaim from .field_codec import FieldCodec from .native_tool_call import CodingCall, SelectedToolDenied +from .recorded_time import EventTime from .storage_rules import WriteStorageEffect, physical_path, shell_storage_effects from .tool_declaration import EditCall, ReplacementEdit, ToolDeclaration, one_line + class NativeToolDeclaration(ToolDeclaration): """A declared tool participating in the existing native owner check.""" @@ -63,8 +65,11 @@ def catalog(cls) -> list[dict[str, object]]: return [tool.schema() for tool in ToolDeclaration.members_with(cls)] @classmethod - def create_call(cls, call_id: str, arguments: dict[str, object]) -> CoordinationCall: - return CoordinationCall(call_id=call_id, declared_name=cls.declared_name, arguments=arguments) + def create_call( + cls, call_id: str, arguments: dict[str, object] + ) -> CoordinationCall: + return CoordinationCall( + call_id=call_id, declared_name=cls.declared_name, arguments=arguments) @classmethod def native_extension(cls, package: Path) -> Path: @@ -171,7 +176,8 @@ def _parse_resource(self): def storage_effects(self, cwd): return (WriteStorageEffect(physical_path(self.path, cwd)),) - def edit_calls(self, call_id, at, thread, problem): + def edit_calls(self, call_id: str, at: EventTime, thread: str, + problem: str) -> tuple[EditCall, ...]: return (EditCall(call_id, at, thread, self.path, self.replacements(), problem),) diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index 450402d4b4..ea82ca4184 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -14,7 +14,7 @@ from .field_codec import FieldCodec from .messages import Message from .message_reference import MessageReference -from .recorded_time import GeneratedTimestamp +from .recorded_time import EventTime from .routing import MessageRoute, TurnRouting from .tool_results import SENT_MESSAGE_MIME, ToolDiff, ToolText from .transcript_merge import EventMerge, StreamingMerge @@ -351,7 +351,7 @@ def edit_calls(self, thread: str, problem: str, since: float) -> tuple[tool_decl if self.before(since) or self.tool is None: return () return self.tool.edit_calls( - self.tool_call_id, GeneratedTimestamp.decode(self.timestamp), thread, problem) + self.tool_call_id, EventTime.capture(self.timestamp), thread, problem) raw_input: dict[str, JsonValue] = field(default_factory=dict) # as the CLI sent it, for history tool: tool_declaration.ToolDeclaration | None = field( diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index 69ba2f14f7..7a22d32421 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -8,12 +8,12 @@ from pathlib import Path from typing import TYPE_CHECKING +from .external_transcript import ExternalTranscript from .thread_execution import ConversationPreparation from .thread_identity import ThreadIncarnation -from .external_transcript import ExternalTranscript -from .transcript_events import TurnDigest -from .tool_result import ToolResult from .tool_declaration import EditCall +from .tool_result import ToolResult +from .transcript_events import TurnDigest if TYPE_CHECKING: from .threads import Thread @@ -92,7 +92,8 @@ def read(self) -> Result: return self.thread.execution.prepare_conversation(self) def native(self) -> Result: - turns = self.thread.runtime.history_source(self.root, self.thread.incarnation).turns(self.bound) + source = self.thread.runtime.history_source(self.root, self.thread.incarnation) + turns = source.turns(self.bound) return self.from_turns(turns) def external(self) -> Result: diff --git a/tests/test_edit_evidence.py b/tests/test_edit_evidence.py index bc3b527255..94e301d62d 100644 --- a/tests/test_edit_evidence.py +++ b/tests/test_edit_evidence.py @@ -1,14 +1,15 @@ -"""Source checks for edit clocks owned by the existing EventTime family.""" - -from dataclasses import replace +"""Edit clocks come from their native event or the original external SQL timestamp.""" import pytest from agent_comms.commander.judge import Edit +from agent_comms.external_transcript import ExternalEventRow, ExternalTranscript from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime +from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.tool_calls import CliToolArguments, ToolCall from agent_comms.turn_digests import UnjudgedEdits +from dataclasses import replace def original_call(): @@ -16,41 +17,53 @@ def original_call(): return CliToolArguments.decode(ToolCall('source-call', 'Edit', arguments), arguments) -@pytest.mark.parametrize('timestamp,clock', [(None, UnrecordedEventTime()), (1792430000.0, RecordedEventTime(1792430000.0))]) -def test_original_call_keeps_all_edit_facts_through_reply_and_presentation(timestamp, clock): - start, _ = original_call().finished('done', True, 12) - start = replace(start, timestamp=timestamp) +def assert_edit(edit, clock): + assert edit.id == 'source-call' + assert edit.problem == 'P215' + assert edit.hunks[0].old_text == 'before' + assert edit.hunks[0].new_text == 'after' + assert edit.at == clock + assert '- before' in edit.rendered() + assert '+ after' in edit.rendered() + assert clock.clock() in edit.rendered() + + +def test_native_edit_keeps_its_recorded_time_through_reply_and_presentation(): + timestamp = 1792430000.0 + start, _ = original_call().finished('done', True, 12, timestamp) evidence = start.edit_calls('unit', 'P215', 1.0) wire = FieldCodec.encode(UnjudgedEdits(evidence)) presented, = FieldCodec.decode(tuple[Edit, ...], wire['edits']) - assert presented.id == 'source-call' + assert_edit(presented, RecordedEventTime(timestamp)) assert presented.thread == 'unit' - assert presented.problem == 'P215' - assert presented.hunks[0].old_text == 'before' - assert presented.hunks[0].new_text == 'after' - assert '- before' in presented.rendered() - assert '+ after' in presented.rendered() - assert presented.at == clock - assert presented.at.clock() in presented.rendered() - assert wire['edits'][0]['at'] == FieldCodec.encode(clock, EventTime) - - -def test_unrecorded_edit_survives_actual_commanders_unjudged_tool(commander): - from agent_comms.external_transcript import ExternalTranscript - from agent_comms.thread_execution import ExternalThreadExecution + assert wire['edits'][0]['at'] == FieldCodec.encode(presented.at, EventTime) + + +@pytest.mark.parametrize('unrecorded', [False, True]) +def test_external_edit_clock_survives_actual_unjudged_tool(commander, unrecorded): from test_commander_problem_orders import create comms, _ = commander todo = create(commander, 'P215') holder = comms.registry.require(todo.held().owner) comms.registry.register(replace(holder, execution=ExternalThreadExecution)) - start, end = original_call().finished('done', True, 12) - ExternalTranscript(comms.root, holder.incarnation).finish( - turn_id='original-turn', item_id='source-call', events=(start, end)) + timestamp = todo.order.at.recorded_at('An order') + 1.0 + start, end = original_call().finished('done', True, 12, timestamp) + transcript = ExternalTranscript(comms.root, holder.incarnation) + transcript.finish(turn_id='original-turn', item_id='source-call', events=(start, end)) + if unrecorded: + # Reproduce the old publisher's wire shape only in this test-owned database. + with transcript.writing() as connection: + connection.execute( + f'UPDATE "{ExternalEventRow.declared_name}" ' + "SET event=json_set(event, '$.timestamp', NULL) WHERE thread=?", + (transcript.lookup,)) edit, = Edit.unjudged(comms, 'lead') - assert edit.id == 'source-call' - assert edit.at == UnrecordedEventTime() - assert edit.hunks[0].new_text == 'after' + clock = UnrecordedEventTime() if unrecorded else RecordedEventTime(timestamp) + assert_edit(edit, clock) + assert edit.thread == holder.name + with transcript.reading() as records: + assert records.unjudged_count(todo) == 1 def test_recorded_clock_requires_its_original_float(): From 10d3961d700b905768c72f1564672c137bc541e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:50:06 -0400 Subject: [PATCH 9/9] P215: give native turn fixtures their declared original timestamps --- tests/test_turn_digests.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_turn_digests.py b/tests/test_turn_digests.py index 529a20cd81..d874f64f71 100644 --- a/tests/test_turn_digests.py +++ b/tests/test_turn_digests.py @@ -37,9 +37,11 @@ def test_external_turns_preserve_interleaving_and_the_whole_selected_turn(tmp_pa comms = prepared(tmp_path) comms.registry.declare(comms.registry.new_thread('cli', frozenset(), str(tmp_path), execution=ExternalThreadExecution)) source = comms.transcripts.external('cli') - for turn, item, text in [('old', 'a', 'older'), ('one', 'b', 'first'), - ('two', 'c', 'other'), ('one', 'd', 'continued')]: - source.finish(turn_id=turn, item_id=item, events=(AssistantTranscript(text, position=item),)) + for at, (turn, item, text) in enumerate([ + ('old', 'a', 'older'), ('one', 'b', 'first'), + ('two', 'c', 'other'), ('one', 'd', 'continued')], start=1): + source.finish(turn_id=turn, item_id=item, + events=(AssistantTranscript(text, position=item, timestamp=float(at)),)) answer = endpoint(comms, 'cli', 2) assert [turn['turn_id'] for turn in answer['turns']] == ['one', 'two'] assert [event['text'] for event in answer['turns'][0]['events']] == ['first', 'continued'] @@ -51,17 +53,19 @@ def test_native_turns_keep_original_calls_results_and_input_identity(tmp_path): source = tmp_path / 'native.jsonl' rows = [ dict(type='session', id='source', version=3), - dict(type='message', id='u1', message=dict(role='user', content='old input')), - dict(type='message', id='a1', parentId='u1', message=dict( + dict(type='message', id='u1', timestamp='2026-10-16T12:00:00Z', + message=dict(role='user', content='old input')), + dict(type='message', id='a1', parentId='u1', timestamp='2026-10-16T12:00:01Z', message=dict( role='assistant', content=[dict(type='text', text='old answer')], stopReason='stop')), - dict(type='message', id='u2', parentId='a1', message=dict(role='user', content='read file')), - dict(type='message', id='call', parentId='u2', message=dict( + dict(type='message', id='u2', parentId='a1', timestamp='2026-10-16T12:00:02Z', + message=dict(role='user', content='read file')), + dict(type='message', id='call', parentId='u2', timestamp='2026-10-16T12:00:03Z', message=dict( role='assistant', content=[dict(type='toolCall', id='read-1', name='read', arguments=dict(path='x.py'))], stopReason='toolUse')), - dict(type='message', id='result', parentId='call', message=dict( + dict(type='message', id='result', parentId='call', timestamp='2026-10-16T12:00:04Z', message=dict( role='toolResult', toolCallId='read-1', toolName='read', isError=False, content=[dict(type='text', text='contents')])), - dict(type='message', id='a2', parentId='result', message=dict( + dict(type='message', id='a2', parentId='result', timestamp='2026-10-16T12:00:05Z', message=dict( role='assistant', content=[dict(type='text', text='done')], stopReason='stop')), ] source.write_text(''.join(json.dumps(row) + '\n' for row in rows))