From a94d260e799227b1f4114f204b4b927e3d61557e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:34:41 -0400 Subject: [PATCH 01/41] Verify legacy check reads, native correction and pre-execution CLI refusal --- tests/test_commander_order_json.py | 19 ++++++++++ tests/test_order_check_writes.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/test_order_check_writes.py diff --git a/tests/test_commander_order_json.py b/tests/test_commander_order_json.py index 44fb83bc52..09883b0996 100644 --- a/tests/test_commander_order_json.py +++ b/tests/test_commander_order_json.py @@ -49,3 +49,22 @@ def run(*args): assert after.order.report == original['order']['report'] assert after.order.check == before.order.check assert after.order.id in accepted.stdout + + +def test_json_revision_check_refuses_before_execution(commander_script, tmp_path): + commander, repo = commander_script + path = Path(order_body(repo)) + document = json.loads(path.read_text()) + marker = tmp_path / 'must-not-run' + document['order']['check']['command'] = f'touch "{marker}"; git checkout main' + path.write_text(json.dumps(document)) + result = subprocess.run( + [str(Path(sys.executable).with_name('agent-comms-commander')), + '--root', str(commander.comms.root), 'order', 'problem', str(path), + '--to', 'worker', '--due', '23:59', '--where', f'{__file__}:1'], + capture_output=True, text=True, timeout=60, + env={**os.environ, 'PYTHONPATH': str(Path(__file__).parents[1] / 'src')}) + assert result.returncode != 0 + assert 'never checks out' in result.stderr + assert not marker.exists() + assert commander.comms.goals.todos.for_commander(commander.incarnation) == () diff --git a/tests/test_order_check_writes.py b/tests/test_order_check_writes.py new file mode 100644 index 0000000000..f9791d8405 --- /dev/null +++ b/tests/test_order_check_writes.py @@ -0,0 +1,60 @@ +"""Saved check commands are data; revision policy is enforced when writing new checks.""" +from dataclasses import replace + +import pytest + +from agent_comms.field_codec import FieldCodec +from agent_comms.goals import OrderCheck +from agent_comms.tool_caller import ThreadCaller +from agent_comms.todos import AssignTodo +from agent_comms.tools import CommsOrderCheckTool, CommsSendTool +from test_commander_problem_orders import create, order + + +@pytest.mark.parametrize('command', ['git checkout main', 'git -C /repo switch work', + 'true && git checkout HEAD']) +def test_saved_checks_decode_but_new_checks_refuse_revision_selection(command): + saved = OrderCheck('.', command) + assert FieldCodec.decode(OrderCheck, FieldCodec.encode(saved)) == saved + with pytest.raises(ValueError, match='never checks out'): + saved.require_writable() + with pytest.raises(ValueError, match='never checks out'): + OrderCheck.parse(f'. :: {command} -> 0') + + +def test_native_new_order_refuses_without_writing(commander): + comms, _ = commander + with pytest.raises(ValueError, match='never checks out'): + create(commander, 'P1', command='git checkout main') + assert comms.goals.todos.for_commander(comms.registry.require('lead').incarnation) == () + + +def test_saved_order_can_be_read_sent_about_and_explicitly_corrected(commander): + comms, caller = commander + current = create(commander, 'P1') + legacy_check = OrderCheck(str(comms.root), 'git checkout main && printf 0') + historical = replace(current, order=replace(current.order, check=legacy_check)) + store = comms.goals.todos + # Seed a historical row, not a new authored order through the public write API. + with store.writing() as db: + historical.upsert(db) + assert store.get('P1') == historical + assert store.for_commander(comms.registry.require('lead').incarnation) == (historical,) + CommsSendTool(sender='worker', target='lead', body='The recorded check selects a revision.').apply( + comms, ThreadCaller('worker')) + with pytest.raises(ValueError, match='never checks out'): + store.apply('P1', AssignTodo(historical.revision, historical.assignment, + historical.deadline, order(comms.root, 'replacement', command=legacy_check.command), + historical.goal)) + with pytest.raises(ValueError, match='never checks out'): + CommsOrderCheckTool(problem='P1', check=legacy_check, + why='The command must not change the current revision.').apply(comms, caller) + assert store.get('P1') == historical + safe = OrderCheck(str(comms.root), 'printf 0') + CommsOrderCheckTool(problem='P1', check=safe, + why='The command must not change the current revision.').apply(comms, caller) + corrected = store.get('P1') + assert corrected.order.id == historical.order.id + assert corrected.revision == historical.revision + 1 + assert corrected.order.check == safe + assert corrected.order.check_corrections[-1].previous == legacy_check From ff3f87b4e03c80bcc8aac0a0e412e6854f85c63b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:17:21 -0400 Subject: [PATCH 02/41] Carry legacy home commander configuration through its root-record writer --- tools/cutover/carry_commander_config.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tools/cutover/carry_commander_config.py diff --git a/tools/cutover/carry_commander_config.py b/tools/cutover/carry_commander_config.py new file mode 100644 index 0000000000..8408029fff --- /dev/null +++ b/tools/cutover/carry_commander_config.py @@ -0,0 +1,27 @@ +"""Move the old home-only commander record through its existing writer.""" +from __future__ import annotations + +from pathlib import Path + +from agent_comms.commander.config import CommanderConfig +from .carry import Carried, Carry + + +class CommanderConfigRoot(Carry): + @classmethod + def carry(cls, root: Path, *, apply: bool) -> Carried: + home = Path.home() + old = CommanderConfig.path(home) + record = CommanderConfig.root_path(root) + if record.exists() or old.is_symlink() or not old.is_file(): + return Carried(0) + # A cutover rehearsal selects its copied root, never the live root named + # in the old machine file. The selected root owns the new record. + config = CommanderConfig.read(old).model_copy(update={"root": root}) + if apply: + config.write(home) + return Carried(1, {"commander_configs": 1}, (record.name,) if apply else ()) + + +if __name__ == "__main__": + CommanderConfigRoot.main() From 5b1dd0cbfc7eb969823c38860bd534bc9fd9a5fd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:23:42 -0400 Subject: [PATCH 03/41] Verify commander-config relocation on a copied registered root --- tests/test_carry_commander_config.py | 26 +++++++++++++++++++++++++ tools/cutover/carry_commander_config.py | 1 + 2 files changed, 27 insertions(+) create mode 100644 tests/test_carry_commander_config.py diff --git a/tests/test_carry_commander_config.py b/tests/test_carry_commander_config.py new file mode 100644 index 0000000000..94f613638f --- /dev/null +++ b/tests/test_carry_commander_config.py @@ -0,0 +1,26 @@ +"""Old home-only commander facts move through the config's own writer.""" +import shutil + +from agent_comms.commander.config import CommanderConfig +from cutover.carry_commander_config import CommanderConfigRoot + + +def test_home_record_is_carried_once_on_a_copied_root(commander, tmp_path, monkeypatch): + comms, _ = commander + original = CommanderConfig.read_root(comms.root) + before = CommanderConfig.root_path(comms.root).read_bytes() + root = tmp_path / "copied-root" + shutil.copytree(comms.root, root) + CommanderConfig.root_path(root).unlink() + home = tmp_path / "copied-home" + old = CommanderConfig.path(home) + old.parent.mkdir(parents=True) + old.write_text(original.model_dump_json()) + monkeypatch.setenv("HOME", str(home)) + assert CommanderConfigRoot.carry(root, apply=False).changed == 1 + assert not old.is_symlink() and not CommanderConfig.root_path(root).exists() + assert CommanderConfigRoot.carry(root, apply=True).changed == 1 + assert old.is_symlink() and old.resolve() == CommanderConfig.root_path(root) + assert CommanderConfig.read_root(root) == original.model_copy(update={"root": root}) + assert CommanderConfigRoot.carry(root, apply=True).changed == 0 + assert CommanderConfig.root_path(comms.root).read_bytes() == before diff --git a/tools/cutover/carry_commander_config.py b/tools/cutover/carry_commander_config.py index 8408029fff..5b58cc00fe 100644 --- a/tools/cutover/carry_commander_config.py +++ b/tools/cutover/carry_commander_config.py @@ -4,6 +4,7 @@ from pathlib import Path from agent_comms.commander.config import CommanderConfig + from .carry import Carried, Carry From d9c3f03c3528e047051d1f85157674e008c990a6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:42:56 -0400 Subject: [PATCH 04/41] Preserve existing root commander facts when an old home file remains --- tests/test_carry_commander_config.py | 14 ++++++++++++++ tools/cutover/carry_commander_config.py | 1 + 2 files changed, 15 insertions(+) diff --git a/tests/test_carry_commander_config.py b/tests/test_carry_commander_config.py index 94f613638f..edb69171c7 100644 --- a/tests/test_carry_commander_config.py +++ b/tests/test_carry_commander_config.py @@ -1,4 +1,5 @@ """Old home-only commander facts move through the config's own writer.""" + import shutil from agent_comms.commander.config import CommanderConfig @@ -24,3 +25,16 @@ def test_home_record_is_carried_once_on_a_copied_root(commander, tmp_path, monke assert CommanderConfig.read_root(root) == original.model_copy(update={"root": root}) assert CommanderConfigRoot.carry(root, apply=True).changed == 0 assert CommanderConfig.root_path(comms.root).read_bytes() == before + + +def test_root_record_is_not_overwritten_from_an_old_home_file(commander, tmp_path, monkeypatch): + comms, _ = commander + before = CommanderConfig.root_path(comms.root).read_bytes() + home = tmp_path / "old-home" + old = CommanderConfig.path(home) + old.parent.mkdir(parents=True) + old.write_text("Not a config: an existing root must not read this old file.") + monkeypatch.setenv("HOME", str(home)) + assert CommanderConfigRoot.carry(comms.root, apply=True).changed == 0 + assert old.is_file() and not old.is_symlink() + assert CommanderConfig.root_path(comms.root).read_bytes() == before diff --git a/tools/cutover/carry_commander_config.py b/tools/cutover/carry_commander_config.py index 5b58cc00fe..5ec5e2f516 100644 --- a/tools/cutover/carry_commander_config.py +++ b/tools/cutover/carry_commander_config.py @@ -1,4 +1,5 @@ """Move the old home-only commander record through its existing writer.""" + from __future__ import annotations from pathlib import Path From 81c9c559b83219f6dedb888e6709f0ebabe10ff6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 13:43:55 -0400 Subject: [PATCH 05/41] Make commander cutover precede presentation phases --- docs/refactor/shared-plan-claims-A.md | 127 ++++++++++++++++++++++++++ src/agent_comms/plan_phases.py | 22 +++-- tests/test_plan_phases.py | 57 ++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 docs/refactor/shared-plan-claims-A.md create mode 100644 tests/test_plan_phases.py diff --git a/docs/refactor/shared-plan-claims-A.md b/docs/refactor/shared-plan-claims-A.md new file mode 100644 index 0000000000..6ac3765e76 --- /dev/null +++ b/docs/refactor/shared-plan-claims-A.md @@ -0,0 +1,127 @@ +# Shared plan claims: A dependency checkpoint + +Owner: pi-plan-claims. Branch: feat/shared-plan-claims. Base: 011084fc5. +This checkpoint changes commander-native dependency declarations only. Shared +claims are the next, separate commit. Nothing is installed or merged here. + +## Change and limit + +`CutoverPhase.after` now names `RecordPlanePhase`, `ReviewQueuePhase` and +`ControlPlanePhase`. `StoryPhase` and `CommanderPagePhase` follow cutover, whose +prerequisites own their source records. No phase depends on either view. +`SkillPhase` continues to follow cutover. + +**There is no declared executable commander parity gate.** The three requirements +in `plans/commander-native-plan.md:199-203` are prose: native owners on main, a +shadow session comparing native behaviour to scripts, and no private-state writes. +No phase or check implements that comparison. `PlanPhase.done()` reads order +closure, not parity. This change does not satisfy or encode the missing parity +prerequisite and does not authorize deleting the scripts. + +Consequently there is no real parity-check output from which to enumerate failing +matrix rows for phases 3, 4 and 7, map each to a check class, or assign each +measured failure. Those results are **unavailable**, not passing. The commander +must identify an existing external check if one was intended, or commission its +implementation separately. No substitute parity check was invented in A. + +## Actual Core output, before and after + +Both reads used this checkout's `Todo.select`, `PlanPhase.ready` and +`PlanPhase.ordered` against a SQLite read-only connection to +`/home/ts/.agent-comms/todos.sqlite3`, selecting `creator='claude-code'`. Each +returned 214 retained orders. No synthetic completion state was used here. +This is the proposed Core source evaluating real retained state, not an installed +runtime update. "Next startable" is the same ready-and-not-ordered predicate +used by `PlanIdle.violations`. + +Before: + +```text +BEFORE: live commander claude-code; todos: 214 +Core ready: record_plane, commander_page +Core next startable (ready, no open order): commander_page +record_plane done= False ordered= True after= retained_store open= P107,P167,P195,P197,P198,P204,P208,P213 +review_queue done= True ordered= False after= record_plane open= +story done= True ordered= False after= review_queue open= +commander_page done= False ordered= False after= story open= +control_plane done= False ordered= True after= record_plane open= P100,P199 +cutover done= False ordered= True after= review_queue,story,commander_page,control_plane open= P207 +skill done= False ordered= False after= cutover open= +``` + +After: + +```text +AFTER: live commander claude-code; todos: 214 +Core ready: record_plane +Core next startable (ready, no open order): (none) +record_plane done= False ordered= True after= retained_store open= P107,P167,P195,P197,P198,P204,P208,P213 +review_queue done= True ordered= False after= record_plane open= +control_plane done= False ordered= True after= record_plane open= P100,P199 +cutover done= False ordered= True after= record_plane,review_queue,control_plane open= P207 +story done= True ordered= False after= cutover open= +commander_page done= False ordered= False after= cutover open= +skill done= False ordered= False after= cutover open= +``` + +The retained state contradicts the initial description that nobody started the +story: Core reports `story` and `review_queue` done. Its unfinished native work +is in record plane and control plane. These are order-state observations, not +parity failures. + +## Source ownership evidence + +Reused refactor-audit's `audit.findings.Package.load` AST substrate at the base: +379 production modules, 476 test modules and 34 tool modules parsed, no omissions. +The first attempt to use NRA's parser could not import `tree_sitter` in the +available Python environment; the existing refactor-audit parser succeeded. + +- `plan_phases.py` alone declares phase classes, dependency writes, completion, + readiness and ordering decisions. No second dependency list was introduced. +- Production consumers: `standing_rules.py` (`PlanIdle`), `todos.py` + (`ProblemOrder.serves` and serve command), `tools.py` (serve tool), + `commander/order.py` (order parsing and serve verb). +- Test consumers at the base: `test_standing_rules.py` and `test_tool_replies.py`. +- Before: cutover depended on story and commander page; page depended on story. + After: cutover depends on the three native phases; both views depend on cutover; + no dependency names either view. Wire names and all consumer APIs are unchanged. +- Dynamic membership is derived through `DeclaredFamily.members_with`; static + name enumeration does not prove absence of arbitrary external runtime mutation. +- Search for `parity` across production, tests, tools and CI found no commander + comparison check. Unrelated Pi prompt-byte and entry-index parity tests do not + compare commander behaviour. + +## Verification + +The existing native test environment for this checkout was verified and reused; +no runtime package was built or installed. One sequential, bounded test run: + +```text +PYTHONPATH=src timeout 120 /home/ts/code/projects/agent-comms/.venv/bin/python -X faulthandler -m pytest --native-cache /home/ts/.cache/agent-comms/native -n 0 --no-cov -p no:cacheprovider -q tests/test_plan_phases.py tests/test_standing_rules.py -k 'cutover_waits or plan_idle' +2 passed, 16 deselected, 14 warnings in 4.63s +``` + +The new family test uses real checked orders and the serve tool. It verifies +cutover waits for each native prerequisite independently, unfinished views cannot +block cutover, and the views and skill become ready after cutover. The existing +PlanIdle test still passes. Warnings were an existing unknown pytest timeout +option and pytest cleanup trying to retire unrelated busy DQDock snapshot paths; +no manual cleanup of those paths was attempted. + +`/home/ts/bin/agent-resource-check` is absent on this machine. Before this small +run, available RAM was 3.7 GiB and disk free was 26 GiB. No parallel workers or +large build was launched. + +## Next identity choice + +B will use one shared phase family with an unnamed parent per plan. Existing +commander-native phase names remain stable; the remaining-slop declarations get +unambiguous names derived from their classes. A plan parent groups discovery and +rendering without copying phase readiness/claim behaviour into a second family. +Markdown/board references identify declarations, never titles or new UUIDs. + +Assumed: the missing parity comparison is reported, not invented; its absence +blocks parity acceptance and script deletion, but does not prevent reviewing this +dependency-only checkpoint. Both machines can write the approved shared claim +store. pi-cn-phase7 owns dispatch and will propose any checked-task capability; +this checkpoint does not fabricate runnable task bodies from phase names. diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 2a78e0159f..0a58d54706 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -82,20 +82,26 @@ class ReviewQueuePhase(PlanPhase): after = (RecordPlanePhase,) -class StoryPhase(PlanPhase): - after = (ReviewQueuePhase,) +class ControlPlanePhase(PlanPhase): + after = (RecordPlanePhase,) -class CommanderPagePhase(PlanPhase): - after = (StoryPhase,) +class CutoverPhase(PlanPhase): + # The parity gate in the Markdown plan has no executable declaration yet. + # These dependencies do not establish parity or authorize deleting scripts. + after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) -class ControlPlanePhase(PlanPhase): - after = (RecordPlanePhase,) +class StoryPhase(PlanPhase): + """A view of the native records, not a prerequisite for their cutover.""" + + after = (CutoverPhase,) -class CutoverPhase(PlanPhase): - after = (ReviewQueuePhase, ControlPlanePhase) +class CommanderPagePhase(PlanPhase): + """A view of the native records and control plane after their cutover.""" + + after = (CutoverPhase,) class SkillPhase(PlanPhase): diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py new file mode 100644 index 0000000000..123edd2d7b --- /dev/null +++ b/tests/test_plan_phases.py @@ -0,0 +1,57 @@ +from dataclasses import replace + +from agent_comms.plan_phases import ( + CommanderPagePhase, + ControlPlanePhase, + CutoverPhase, + PlanPhase, + RecordPlanePhase, + ReviewQueuePhase, + SkillPhase, + StoryPhase, +) +from agent_comms.todos import DoneTodoState +from agent_comms.tools import CommsOrderServeTool + +from test_commander_problem_orders import create + + +def test_cutover_waits_for_native_work_and_views_follow_cutover(commander): + comms, caller = commander + required = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) + phases = (*required, CutoverPhase, StoryPhase, CommanderPagePhase) + todos = [] + for index, phase in enumerate(phases, 1): + todo = create(commander, f"P{index}") + CommsOrderServeTool(problem=todo.id, serves=phase).apply(comms, caller) + todos.append(comms.goals.todos.get(todo.id)) + + native_done = tuple( + replace(todo, state=DoneTodoState) if todo.order.serves in required else todo + for todo in todos + ) + assert CutoverPhase.ready(native_done) + assert not StoryPhase.ready(native_done) + assert not CommanderPagePhase.ready(native_done) + assert not SkillPhase.ready(native_done) + + for unfinished in required: + snapshot = tuple( + todo if todo.order.serves in (unfinished, CutoverPhase) + else replace(todo, state=DoneTodoState) + for todo in todos + ) + assert not CutoverPhase.ready(snapshot) + + cutover_done = tuple( + replace(todo, state=DoneTodoState) if todo.order.serves is CutoverPhase else todo + for todo in native_done + ) + assert StoryPhase.ready(cutover_done) + assert CommanderPagePhase.ready(cutover_done) + assert SkillPhase.ready(cutover_done) + assert not any( + dependency in (StoryPhase, CommanderPagePhase) + for phase in PlanPhase.members_with(PlanPhase) + for dependency in phase.after + ) From a272bf31084e6154421a00f485f9b3f086b3670f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 13:51:03 -0400 Subject: [PATCH 06/41] Make commander views wait on their source phases --- docs/refactor/shared-plan-claims-A.md | 56 +++++++++++++++++++++++++++ src/agent_comms/plan_phases.py | 6 +-- tests/test_plan_phases.py | 13 +++++-- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/refactor/shared-plan-claims-A.md b/docs/refactor/shared-plan-claims-A.md index 6ac3765e76..1935b957a3 100644 --- a/docs/refactor/shared-plan-claims-A.md +++ b/docs/refactor/shared-plan-claims-A.md @@ -1,5 +1,9 @@ # Shared plan claims: A dependency checkpoint +The original A dependency design below is superseded by the A2 correction at +this file's end: views wait on their source phases, not cutover. A2's completion +rule and executable parity gate remain in progress; B has not started. + Owner: pi-plan-claims. Branch: feat/shared-plan-claims. Base: 011084fc5. This checkpoint changes commander-native dependency declarations only. Shared claims are the next, separate commit. Nothing is installed or merged here. @@ -125,3 +129,55 @@ blocks parity acceptance and script deletion, but does not prevent reviewing thi dependency-only checkpoint. Both machines can write the approved shared claim store. pi-cn-phase7 owns dispatch and will propose any checked-task capability; this checkpoint does not fabricate runnable task bodies from phase names. + +## A2: corrected view prerequisites and false story completion + +Owner correction 1753: story waits on record plane; commander page waits on +record plane and control plane. Neither waits on cutover, and no phase waits +on either view. The earlier dependency on cutover was my mistake. + +Actual source evaluation of the same 214 retained commander orders: + +```text +Before A2 (A commit): +Core ready: record_plane +Core next startable (ready, no open order): (none) +story done= True after= cutover +commander_page done= False after= cutover + +After the A2 view dependency correction: +Core ready: record_plane +Core next startable (ready, no open order): (none) +cutover done= False after= record_plane,review_queue,control_plane +story done= True after= record_plane +commander_page done= False after= record_plane,control_plane +``` + +Story's only serving order is P149, revision 8, installed/done, order +`b258d208cd26`, serving `story`. Its text is: + +> every send-back wakes all units because verdicts declare no audience + +Its actual Works record is: + +> Merged to main as 74176e31a (#1678, delivered commit f72e47943); closed on merge evidence by owner directive 1520, run not re-observed: a send-back reaches only its unit and its order's thread, so other units take no turn for it, while a verdict every unit must act on still reaches all of them + +This is legitimately related to the story phase, but it is not acceptance of the +whole phase's order/fleet story tools. The wrong rule is `PlanPhase.done`: all +currently known serving subtasks closed does not mean the whole phase passed. +The retained record was not edited or reopened. The completion-rule repair will +use the phase's explicitly declared full-phase checked task, not infer completion +from this serving set. pi-cn-phase7 owns that task type and its accepted-order +semantics; I own its use in PlanPhase. Owner-confirmed review queue delivery is +PR #1654, merge 41e4ec7319432755d75dcd8dbbab35839a4eb005. + +The expanded family test confirms story can start after record plane while +control plane and cutover are still open; page starts after record and control +plane without waiting for cutover. Both focused tests passed (2 passed, 16 +deselected, 1 pre-existing timeout-option warning, 5.37 seconds). The run used +`--basetemp /home/ts/.cache/agent-scratch/pi-plan-claims/a2-view-check/pytest`; +that owned disposable directory is retired after the run. + +A2's executable per-matrix-row comparison is newly authorized. It is not yet +implemented. No failing parity rows or passing parity acceptance are asserted +by this view-dependency checkpoint. B remains after A2. diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 0a58d54706..43a527cc5a 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -95,13 +95,13 @@ class CutoverPhase(PlanPhase): class StoryPhase(PlanPhase): """A view of the native records, not a prerequisite for their cutover.""" - after = (CutoverPhase,) + after = (RecordPlanePhase,) class CommanderPagePhase(PlanPhase): - """A view of the native records and control plane after their cutover.""" + """A view of the native records and control plane, independent of cutover.""" - after = (CutoverPhase,) + after = (RecordPlanePhase, ControlPlanePhase) class SkillPhase(PlanPhase): diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py index 123edd2d7b..82eaa1283b 100644 --- a/tests/test_plan_phases.py +++ b/tests/test_plan_phases.py @@ -16,7 +16,7 @@ from test_commander_problem_orders import create -def test_cutover_waits_for_native_work_and_views_follow_cutover(commander): +def test_views_wait_for_native_work_but_never_block_cutover(commander): comms, caller = commander required = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) phases = (*required, CutoverPhase, StoryPhase, CommanderPagePhase) @@ -26,13 +26,20 @@ def test_cutover_waits_for_native_work_and_views_follow_cutover(commander): CommsOrderServeTool(problem=todo.id, serves=phase).apply(comms, caller) todos.append(comms.goals.todos.get(todo.id)) + record_done = tuple( + replace(todo, state=DoneTodoState) if todo.order.serves is RecordPlanePhase else todo + for todo in todos + ) + assert StoryPhase.ready(record_done) + assert not CommanderPagePhase.ready(record_done) + native_done = tuple( replace(todo, state=DoneTodoState) if todo.order.serves in required else todo for todo in todos ) assert CutoverPhase.ready(native_done) - assert not StoryPhase.ready(native_done) - assert not CommanderPagePhase.ready(native_done) + assert StoryPhase.ready(native_done) + assert CommanderPagePhase.ready(native_done) assert not SkillPhase.ready(native_done) for unfinished in required: From c0da9abdfc7d00251f8a9ee7a678bd8ccf56958e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 13:53:55 -0400 Subject: [PATCH 07/41] Complete plan phases only through declared acceptance --- docs/refactor/shared-plan-claims-A.md | 41 +++++++- plans/commander-native-plan.md | 2 +- src/agent_comms/plan_phases.py | 12 ++- src/agent_comms/todos.py | 2 + src/agent_comms/work_dispatch.py | 144 ++++++++++++++++++++++++++ tests/test_plan_phases.py | 67 ++++++++++-- 6 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 src/agent_comms/work_dispatch.py diff --git a/docs/refactor/shared-plan-claims-A.md b/docs/refactor/shared-plan-claims-A.md index 1935b957a3..1d55cf68d8 100644 --- a/docs/refactor/shared-plan-claims-A.md +++ b/docs/refactor/shared-plan-claims-A.md @@ -2,7 +2,8 @@ The original A dependency design below is superseded by the A2 correction at this file's end: views wait on their source phases, not cutover. A2's completion -rule and executable parity gate remain in progress; B has not started. +rule is now corrected; its executable parity gate remains in progress. B has not +started. Owner: pi-plan-claims. Branch: feat/shared-plan-claims. Base: 011084fc5. This checkpoint changes commander-native dependency declarations only. Shared @@ -181,3 +182,41 @@ that owned disposable directory is retired after the run. A2's executable per-matrix-row comparison is newly authorized. It is not yet implemented. No failing parity rows or passing parity acceptance are asserted by this view-dependency checkpoint. B remains after A2. + +### A2 completion-rule repair + +Integrated `work_dispatch.py` exactly from pi-cn-phase7's d123d18e2 (Git blob +67cccd2507bf12f5f69748a81c10675677912309), its imports/task declaration, and the +`ProblemOrder.accepted` declarations. No phase-7 enforcement or trace changes +were imported. pi-cn-phase7 retains ownership of dispatch implementation and its +verification. No production task briefs or automatic launches were added here. + +`PlanPhase.done` now asks its declared task about acceptance of the tasks serving +that phase. An unspecified full-phase task returns false. `CheckedPhaseTask` +matches the exact declared problem identity and requires a closed installed +order; a withdrawn/folded order is not accepted. Review queue is explicitly a +previously delivered phase, owner-confirmed as #1654, not inferred from a closed +subset of its subtasks. The Markdown plan now describes this same rule. + +Actual Core output after the completion-rule correction: + +```text +A2 completion correction; retained commander orders: 214 +Core ready: record_plane +Core next startable (ready, no open order): (none) +review_queue done= True after= record_plane +cutover done= False after= record_plane,review_queue,control_plane +story done= False after= record_plane +commander_page done= False after= record_plane,control_plane +P149 accepted= True +Story task= UnspecifiedPhaseTask +``` + +P149 is still legitimately closed; the story phase no longer falsely reads done. +Three focused tests passed (3 passed, 16 deselected, one existing timeout-option +warning, 7.26 seconds), covering phase/view prerequisites, exact whole-phase +acceptance versus closed subtasks/withdrawal/wrong-phase records, and PlanIdle. +The owned run directory was +`/home/ts/.cache/agent-scratch/pi-plan-claims/a2-completion-check/pytest`; disposable +run output is removed after acceptance of this check. No original records changed. +The full parity comparison remains unimplemented, so A2 as a whole is unfinished. diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 7e1eeae84b..888180377f 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -206,4 +206,4 @@ Toad is a view of Core, so the commander's page is one more view over Core's ans ## Order and dependencies -The phases and what each waits for are declared once, in `src/agent_comms/plan_phases.py` (the `PlanPhase` family). An order names the phase it serves with a `Serves: ` line, stored as `ProblemOrder.serves`; a phase is done when every order serving it is closed. Each rotation prints `PLAN IDLE` for every phase whose dependencies are done and that has no open order (`PlanIdle` in `src/agent_comms/standing_rules.py`). +The phases and what each waits for are declared once, in `src/agent_comms/plan_phases.py` (the `PlanPhase` family). An order names the phase it serves with a `Serves: ` line, stored as `ProblemOrder.serves`. A phase is done when its explicitly declared full-phase checked task has accepted installed evidence; closing related subtasks does not complete the phase. Earlier delivered phases record their delivery on their declarations. Each rotation prints `PLAN IDLE` for every phase whose dependencies are done and that has no open order (`PlanIdle` in `src/agent_comms/standing_rules.py`). diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 43a527cc5a..d93984f5f0 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -3,7 +3,8 @@ This family is the one owner of the plan's order and dependencies; ``plans/commander-native-plan.md`` points here. An order names the phase it serves in ``ProblemOrder.serves``; an order outside the plan serves -``UnplannedPhase``. A phase is done when every order serving it is closed. +``UnplannedPhase``. A phase is done only when its declared full-phase task is accepted; closing +related subtasks does not establish completion of the phase. """ from __future__ import annotations @@ -12,9 +13,11 @@ from typing import TYPE_CHECKING, ClassVar from .declared_family import DeclaredFamily +from .work_dispatch import UnspecifiedPhaseTask if TYPE_CHECKING: from .todos import Todo + from .work_dispatch import PhaseTask class OrderPhase(DeclaredFamily, affix="Phase"): @@ -27,6 +30,7 @@ class UnplannedPhase(OrderPhase): class PlanPhase(OrderPhase, declared_name=None): after: ClassVar[tuple[type[PlanPhase], ...]] = () + task: ClassVar[PhaseTask] = UnspecifiedPhaseTask() @classmethod def serving(cls, todos: Iterable[Todo]) -> tuple[Todo, ...]: @@ -34,8 +38,7 @@ def serving(cls, todos: Iterable[Todo]) -> tuple[Todo, ...]: @classmethod def done(cls, todos: Iterable[Todo]) -> bool: - serving = cls.serving(todos) - return bool(serving) and not any(todo.state.open for todo in serving) + return cls.task.done(cls.serving(todos)) @classmethod def ordered(cls, todos: Iterable[Todo]) -> bool: @@ -78,8 +81,9 @@ class RecordPlanePhase(PlanPhase): after = (RetainedStorePhase,) -class ReviewQueuePhase(PlanPhase): +class ReviewQueuePhase(MergedPhase): after = (RecordPlanePhase,) + delivered = "41e4ec7319432755d75dcd8dbbab35839a4eb005 (#1654)" class ControlPlanePhase(PlanPhase): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 046202fe31..af62b2719f 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1313,6 +1313,7 @@ def instructions(self) -> tuple[str, ...]: class ProblemOrder(DeclaredFamily, affix="ProblemOrder"): """A problem's order state; an unassigned finding is not a fabricated order.""" pending: ClassVar[bool] = False + accepted: ClassVar[bool] = False line: ClassVar[str] = "No order" pull_request_label: ClassVar[str] = "Agent-Comms-Order:" finding: ProblemFinding = field(default_factory=UnlocatedProblemFinding, @@ -1619,6 +1620,7 @@ def waiting_on_install(self, todo: Todo, read: dict[str, OrderProgress]) -> tupl class InstalledProblemOrder(DeliveredProblemOrder): works: str artifact: str + accepted: ClassVar[bool] = True line: ClassVar[str] = "Installed" def __post_init__(self): diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py new file mode 100644 index 0000000000..67cccd2507 --- /dev/null +++ b/src/agent_comms/work_dispatch.py @@ -0,0 +1,144 @@ +"""Checked work starts through Core's existing order and native lifecycle owners. + +A phase declares an existing PendingProblemOrder, not a second task format. Work +without that declaration stays visible; readiness is not permission to invent an +acceptance check. Failed startup state stays retained for explicit disposition. +""" + +from __future__ import annotations + +import subprocess +import time +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from .comms import Comms + from .goal_actions import ProblemOrderGoalAction + from .plan_phases import PlanPhase + from .threads import Thread + from .todos import PendingProblemOrder, Todo + + +@dataclass(frozen=True) +class WorkDispatch: + """One checked native order: allocate its unit, publish custody, then start it.""" + + action: ProblemOrderGoalAction + revision: int + + def start(self, comms: Comms) -> Thread: + from .goal_actions import GoalActionContext + from .native_settings import NativeThreadDefaults + from .storage_rules import CheckoutStorageEffect + from .thread_status import StoppedThreadStatus + + defaults = NativeThreadDefaults() + # A concurrent rotation of this same row contends on one git checkout, + # not on two newly generated unit names. Never adopt a partial attempt. + name = f"unit-{self.action.problem.lower()}-r{self.revision}" + storage = comms.registry.storage_rule + checkout = storage.order_checkout(name, self.action.problem) + candidate = comms.registry.new_thread( + name, frozenset(), str(checkout), model=defaults.model, + thinking_level=defaults.thinking_level, + ) + self.action.request_ref(GoalActionContext(comms.goals, candidate)) + comms.registry.require_declaration_available(candidate, StoppedThreadStatus()) + CheckoutStorageEffect(checkout).check(storage, candidate) + subprocess.run( + ("git", "-C", self.action.order.check.directory, "fetch", "origin", "main"), + check=True, capture_output=True, text=True, timeout=60, + ) + subprocess.run( + ("git", "-C", self.action.order.check.directory, "worktree", "add", "-b", + f"auto/{name}", str(checkout), "origin/main"), + check=True, capture_output=True, text=True, timeout=60, + ) + location = candidate.runtime.new_location(comms.root, checkout) + child = comms.registry.declare( + replace(candidate, session_file=location.read_key), StoppedThreadStatus(), + ) + # A failed publication leaves a stopped unit and its checkout, not an + # untracked running task. A failed/uncertain start never releases custody. + comms.goals.update_goal(child.name, self.action) + comms.owners.start(child.name) + return comms.registry.require(child.name) + + @classmethod + def reissue(cls, comms: Comms, commander: Thread, todo: Todo) -> None: + """Retained checked work needs no already-idle unit.""" + from .goal_actions import OrderGoalAction + + current = comms.goals.todos.get(todo.id) + current.require_revision(todo.revision) + if current.holder or not current.pending: + return + action = OrderGoalAction( + problem=current.id, expected_revision=current.revision, + order=current.order.reissued(time.time()), request=current.goal.request(), + deadline=current.deadline, commander=commander.name, + ) + cls(action, revision=current.revision).start(comms) + + +class PhaseTask(ABC): + instruction: ClassVar[str] + + @abstractmethod + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: ... + + @abstractmethod + def done(self, todos: Iterable[Todo]) -> bool: ... + + +class UnspecifiedPhaseTask(PhaseTask): + instruction = "no checked task declared; supply its source, check and brief" + + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: + """Readiness alone cannot authorize a fabricated order.""" + + def done(self, todos: Iterable[Todo]) -> bool: + return False + + +@dataclass(frozen=True) +class CheckedPhaseTask(PhaseTask): + """An explicitly authored task; its order owns source, check, edits and run.""" + + problem: str + repo: str + text: str + order: PendingProblemOrder + deadline_seconds: float + instruction = "Core starts the declared checked task in its own unit checkout" + + def __post_init__(self) -> None: + if not 0 < self.deadline_seconds <= 24 * 60 * 60: + raise ValueError("A phase task needs a positive deadline of at most one day.") + + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: + from .goal_actions import CreateProblemGoalAction + from .recorded_time import RecordedEventTime + + history = comms.goals.todos.history_for_commander(commander.incarnation) + if not phase.ready(history) or phase.ordered(history): + return + # An existing unowned row belongs to UnownedWork, which reissues that + # row's actual order instead of replacing it with a phase template. + if any(todo.state.open for todo in phase.serving(history)): + return + now = time.time() + order = replace(self.order.reissued(now), serves=phase) + action = CreateProblemGoalAction( + problem=self.problem, repo=self.repo, text=self.text, order=order, + request=order.request(commander, (self.problem,)), commander=commander.name, + deadline=RecordedEventTime(now + self.deadline_seconds), + ) + WorkDispatch(action, revision=0).start(comms) + + def done(self, todos: Iterable[Todo]) -> bool: + return any(todo.id == self.problem and not todo.state.open and todo.order.accepted + for todo in todos) diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py index 82eaa1283b..dc17a3e3e8 100644 --- a/tests/test_plan_phases.py +++ b/tests/test_plan_phases.py @@ -6,35 +6,60 @@ CutoverPhase, PlanPhase, RecordPlanePhase, - ReviewQueuePhase, SkillPhase, StoryPhase, ) -from agent_comms.todos import DoneTodoState +from agent_comms.todos import ( + DoneTodoState, + InstalledProblemOrder, + OperationDelivery, + UnorderedProblemOrder, + WithdrawnProblemOrder, +) from agent_comms.tools import CommsOrderServeTool +from agent_comms.work_dispatch import CheckedPhaseTask from test_commander_problem_orders import create -def test_views_wait_for_native_work_but_never_block_cutover(commander): +def accepted(todo): + installed = todo.order.copy_as( + InstalledProblemOrder, delivery=OperationDelivery(), + works="The declared whole phase worked through its acceptance run.", + artifact="phase-acceptance.txt", + ) + return replace(todo, state=DoneTodoState, assignment=None, order=installed) + + +def task(todo): + return CheckedPhaseTask( + problem=todo.id, repo=todo.repo, text=todo.text, + order=todo.order, deadline_seconds=300, + ) + + +def test_views_wait_for_native_work_but_never_block_cutover(commander, monkeypatch): comms, caller = commander - required = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) + # ReviewQueuePhase is explicitly delivered as #1654, not inferred from a subtask. + required = (RecordPlanePhase, ControlPlanePhase) phases = (*required, CutoverPhase, StoryPhase, CommanderPagePhase) todos = [] for index, phase in enumerate(phases, 1): todo = create(commander, f"P{index}") CommsOrderServeTool(problem=todo.id, serves=phase).apply(comms, caller) - todos.append(comms.goals.todos.get(todo.id)) + todo = comms.goals.todos.get(todo.id) + monkeypatch.setattr(phase, "task", task(todo)) + todos.append(todo) record_done = tuple( - replace(todo, state=DoneTodoState) if todo.order.serves is RecordPlanePhase else todo + accepted(todo) if todo.order.serves is RecordPlanePhase else todo for todo in todos ) assert StoryPhase.ready(record_done) assert not CommanderPagePhase.ready(record_done) native_done = tuple( - replace(todo, state=DoneTodoState) if todo.order.serves in required else todo + accepted(todo) if todo.order.serves in required else todo for todo in todos ) assert CutoverPhase.ready(native_done) @@ -44,14 +69,13 @@ def test_views_wait_for_native_work_but_never_block_cutover(commander): for unfinished in required: snapshot = tuple( - todo if todo.order.serves in (unfinished, CutoverPhase) - else replace(todo, state=DoneTodoState) + todo if todo.order.serves in (unfinished, CutoverPhase) else accepted(todo) for todo in todos ) assert not CutoverPhase.ready(snapshot) cutover_done = tuple( - replace(todo, state=DoneTodoState) if todo.order.serves is CutoverPhase else todo + accepted(todo) if todo.order.serves is CutoverPhase else todo for todo in native_done ) assert StoryPhase.ready(cutover_done) @@ -62,3 +86,26 @@ def test_views_wait_for_native_work_but_never_block_cutover(commander): for phase in PlanPhase.members_with(PlanPhase) for dependency in phase.after ) + + +def test_closed_related_subtask_does_not_accept_a_whole_phase(commander, monkeypatch): + comms, caller = commander + subtask = create(commander, "P149") + CommsOrderServeTool(problem=subtask.id, serves=StoryPhase).apply(comms, caller) + subtask = accepted(comms.goals.todos.get(subtask.id)) + assert not StoryPhase.done((subtask,)) + + whole = create(commander, "P999") + CommsOrderServeTool(problem=whole.id, serves=StoryPhase).apply(comms, caller) + whole = comms.goals.todos.get(whole.id) + monkeypatch.setattr(StoryPhase, "task", task(whole)) + assert not StoryPhase.done((subtask, whole)) + assert StoryPhase.done((subtask, accepted(whole))) + + withdrawn = whole.order.copy_as( + WithdrawnProblemOrder, previous=whole.held(), why="The order was sent in error.", + ) + assert not StoryPhase.done((replace(whole, state=DoneTodoState, order=withdrawn),)) + assert not UnorderedProblemOrder().accepted + wrong_phase = replace(accepted(whole), order=replace(accepted(whole).order, serves=ControlPlanePhase)) + assert not StoryPhase.done((wrong_phase,)) From 5ec3e1adc25a5d54c563d8b109ba4c538e24aa1b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:28:27 -0400 Subject: [PATCH 08/41] Declare every parity row and fail closed on incomplete comparisons --- docs/refactor/commander-parity-progress.md | 86 ++++++++ parity-gate.patch | 22 +++ phase-task-consumers.patch | 33 ++++ phase-task-tests.patch | 36 ++++ phase7-task-declarations.patch | 97 +++++++++ src/agent_comms/commander_parity/__init__.py | 1 + src/agent_comms/commander_parity/rows.py | 191 ++++++++++++++++++ src/agent_comms/commander_parity/text.py | 44 +++++ src/agent_comms/commander_parity_cli.py | 110 +++++++++++ src/agent_comms/parity_checks.py | 196 +++++++++++++++++++ tests/test_commander_parity.py | 57 ++++++ work-dispatch-followup.patch | 36 ++++ 12 files changed, 909 insertions(+) create mode 100644 docs/refactor/commander-parity-progress.md create mode 100644 parity-gate.patch create mode 100644 phase-task-consumers.patch create mode 100644 phase-task-tests.patch create mode 100644 phase7-task-declarations.patch create mode 100644 src/agent_comms/commander_parity/__init__.py create mode 100644 src/agent_comms/commander_parity/rows.py create mode 100644 src/agent_comms/commander_parity/text.py create mode 100644 src/agent_comms/commander_parity_cli.py create mode 100644 src/agent_comms/parity_checks.py create mode 100644 tests/test_commander_parity.py create mode 100644 work-dispatch-followup.patch diff --git a/docs/refactor/commander-parity-progress.md b/docs/refactor/commander-parity-progress.md new file mode 100644 index 0000000000..fe9758310a --- /dev/null +++ b/docs/refactor/commander-parity-progress.md @@ -0,0 +1,86 @@ +# Commander parity checkpoint (2026-10-19) + +## Working source application paths + +`CheckRoot._session` incorrectly called `NativeRecord.project` on an already +normalized `ExternalRecord`. The projection boundary already yields events. +`ImportedSessionWriter.import_events` now consumes those events, retaining each +original timestamp; the single caller no longer assumes a native record. +The replaced `import_record` method and its ImportBuffer/ImportLimits dependency +are deleted, not kept as an alternate path. Full session import still uses +`writer.append` and is unchanged. + +Actual source CLI over the live saved `pi-plan-claims` native history: +`check-root --threads pi-plan-claims --into /native-history-live-01 +-- python -c {root}` returned 0. +177 conversation events retained timestamps present in the original native +source. The disposable root was removed. No input was submitted or replayed; +original sessions, custody and uncertain inputs were untouched. + +Actual saved `claude-code` external startup now refuses precisely: +`Copied conversation event has no original timestamp`. Read-only inspection of +its latest 200 source records found 164 ToolStartTranscript and 164 +ToolEndTranscript events with no recorded time, plus 35 dated AssistantTranscript +events. No original time exists to preserve for those tool events. They are not +retimed to hide the mismatch. Full saved-external startup remains unaccepted. + +## Partial parity, never full acceptance + +Core declares all 33 matrix rows as classes, with native owning phases. Identity +comes from each declaration, not the prose title. The class registry drives the +runner and computed completion; the command decodes the Markdown matrix and +checks its row identities against the declarations. A class-owned checked task +must be accepted through the existing Todo order before that row is done. +Unspecified tasks and partial coverage cannot complete a row. No parity state +column, parity_done boolean or parallel evidence store was added. + +Real isolated CLI worker compared native FieldCodec decoding with current +`commander.order.refuse`: 11 text cases across rows 9, 10 and 25 agree with their +expected outcomes. These are explicitly PARTIAL comparisons, not three finished +rows. The other 30 declarations have no observations. All 33 rows remain +unverified; no observed case failed. Report exit is 1. Each row reports its +class identity, number, native owning phases and coverage. Native owning phases +are attribution, not prerequisites on view completion. + +`CommanderParityCheck.all_done` consumes accepted existing checked tasks; it +returns false for the current declarations. No script deletion is authorized. +The actual Cutover/ParityPhase source edit was denied by stale claims: +`Resource already claimed by 'pi-plan-claims'`. `parity-gate.patch` contains the +exact requested dependency and computed phase; it passes `git apply --check` +but has NOT been applied. The source after-line ratchet still returns 0. + +## Confirmation and staging + +After the coherent source changes: 13 focused tests passed serially +(test_check_root.py, test_commander_parity.py, test_plan_phases.py). These include +native clock preservation, dated external projection without text truncation, +refusal of undated source, actual subprocess worker, codec round trip and all +matrix declarations. Pytest reports its existing unknown `timeout` config warning. +No merged or installed build is claimed. + +Before edits, refactor-audit Package.load parsed 380 production / 477 test / 34 +tool modules with no omissions. AST evidence found one import_record declaration +and one caller; there are now zero of either, one import_events declaration in +native_entries.py and one caller in check_root.py. Runtime source dispatch is +polymorphic; actual native and external paths above corroborate its resolution. + +Pending patches are source under this persistent checkout, never shell-applied +to denied files: +- parity-gate.patch: computed ParityPhase and exact four-part Cutover dependency. +- phase7-task-declarations.patch: peer-authored Record/Control task() factories. +- work-dispatch-followup.patch: finite positive deadlines and exact task identity. +- phase-task-consumers.patch: PlanIdle/proof consumers, against peer #1932. +- phase-task-tests.patch: fixture factory migration; state-only commander fixtures + explicitly disable production starts. Do not run them with live factories. + +P900003/P900007 were absent in the real global Todo store at this checkpoint; +this is not a reservation or permission to overwrite a later collision. No live +board record was created. Consumer staging depends on pi-cn-phase7 #1932; its +native dispatch proof is not whole-phase acceptance. + +Scratch owner: pi-plan-claims. Only scratch root: +/home/ts/.cache/agent-scratch/pi-plan-claims (13 MiB at checkpoint); source checkout +27 MiB; free filesystem 25 GiB. Test subdirectories parity-worker-check, +parity-final-check and parity-confirm are disposable after active borrower +checks. Real-copy directories were already retired by CheckRoot. Patch handoffs +stay in /home/ts/wt/pi-plan-claims--P1. B shared GitHub claims remains unstarted. diff --git a/parity-gate.patch b/parity-gate.patch new file mode 100644 index 0000000000..07d28697c7 --- /dev/null +++ b/parity-gate.patch @@ -0,0 +1,22 @@ +--- a/src/agent_comms/plan_phases.py ++++ b/src/agent_comms/plan_phases.py +@@ -93,8 +93,16 @@ ++class ParityPhase(PlanPhase): ++ after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) ++ ++ @classmethod ++ def done(cls, todos: Iterable[Todo]) -> bool: ++ from .parity_checks import CommanderParityCheck ++ ++ return CommanderParityCheck.all_done(todos) ++ ++ + class CutoverPhase(PlanPhase): +- # The parity gate in the Markdown plan has no executable declaration yet. +- # These dependencies do not establish parity or authorize deleting scripts. +- after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) ++ after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase, ParityPhase) + + + class StoryPhase(PlanPhase): + """A view of the native records, not a prerequisite for their cutover.""" diff --git a/phase-task-consumers.patch b/phase-task-consumers.patch new file mode 100644 index 0000000000..7072ab06a6 --- /dev/null +++ b/phase-task-consumers.patch @@ -0,0 +1,33 @@ +--- a/src/agent_comms/standing_rules.py ++++ b/src/agent_comms/standing_rules.py +@@ -218,12 +218,12 @@ + ) + + def enforce(self, comms: Comms, commander: str) -> None: +- self.phase.task.start(comms, comms.registry.require(commander), self.phase) ++ self.phase.task().start(comms, comms.registry.require(commander), self.phase) + + def line(self) -> str: + return ( + f"PLAN IDLE: phase {self.phase.declared_name} ready, no open order" +- f" -> {self.phase.task.instruction}" ++ f" -> {self.phase.task().instruction}" + ) + + +--- a/tools/prove_commander_dispatch.py ++++ b/tools/prove_commander_dispatch.py +@@ -94,11 +94,12 @@ + at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), + run=MechanicalOrderRun(), + ) +- RecordPlanePhase.task = CheckedPhaseTask( ++ checked_task = CheckedPhaseTask( + problem, Repo.of(project).github, + "Verify rotation starts a native unit and receives its first real reply.", + order, deadline_seconds=300, + ) ++ RecordPlanePhase.task = classmethod(lambda cls: checked_task) + from agent_comms.commander import rotate + + print(f"ROOT {root}", flush=True) diff --git a/phase-task-tests.patch b/phase-task-tests.patch new file mode 100644 index 0000000000..556152f1ad --- /dev/null +++ b/phase-task-tests.patch @@ -0,0 +1,36 @@ +--- a/tests/test_plan_phases.py ++++ b/tests/test_plan_phases.py +@@ -48,7 +48,7 @@ + todo = create(commander, f"P{index}") + CommsOrderServeTool(problem=todo.id, serves=phase).apply(comms, caller) + todo = comms.goals.todos.get(todo.id) +- monkeypatch.setattr(phase, "task", task(todo)) ++ monkeypatch.setattr(phase, "task", classmethod(lambda cls, value=task(todo): value)) + todos.append(todo) + + record_done = tuple( +@@ -98,7 +98,7 @@ + whole = create(commander, "P999") + CommsOrderServeTool(problem=whole.id, serves=StoryPhase).apply(comms, caller) + whole = comms.goals.todos.get(whole.id) +- monkeypatch.setattr(StoryPhase, "task", task(whole)) ++ monkeypatch.setattr(StoryPhase, "task", classmethod(lambda cls: task(whole))) + assert not StoryPhase.done((subtask, whole)) + assert StoryPhase.done((subtask, accepted(whole))) + +--- a/tests/conftest.py ++++ b/tests/conftest.py +@@ -262,8 +262,13 @@ + from agent_comms.goals import RequestGoalOrder + from agent_comms.todos import PullRequests + from agent_comms.tool_caller import ThreadCaller ++ from agent_comms.plan_phases import PlanPhase ++ from agent_comms.work_dispatch import UnspecifiedPhaseTask + + monkeypatch.setattr(PullRequests, "read", lambda self, repo, state, order: ()) ++ # This fixture observes board state; it never opts into production task starts. ++ for phase in PlanPhase.members_with(PlanPhase): ++ monkeypatch.setattr(phase, "task", classmethod(lambda cls: UnspecifiedPhaseTask())) + + for created, name in enumerate(("lead", "worker"), 1): + comms.registry.declare(comms.registry.new_thread(name, frozenset({'comms'}), str(tmp_path), created_at=float(created))) diff --git a/phase7-task-declarations.patch b/phase7-task-declarations.patch new file mode 100644 index 0000000000..7211d37ef2 --- /dev/null +++ b/phase7-task-declarations.patch @@ -0,0 +1,97 @@ +--- a/src/agent_comms/plan_phases.py ++++ b/src/agent_comms/plan_phases.py +@@ -30,7 +30,9 @@ + + class PlanPhase(OrderPhase, declared_name=None): + after: ClassVar[tuple[type[PlanPhase], ...]] = () +- task: ClassVar[PhaseTask] = UnspecifiedPhaseTask() ++ @classmethod ++ def task(cls) -> PhaseTask: ++ return UnspecifiedPhaseTask() + + @classmethod + def serving(cls, todos: Iterable[Todo]) -> tuple[Todo, ...]: +@@ -38,7 +40,7 @@ + + @classmethod + def done(cls, todos: Iterable[Todo]) -> bool: +- return cls.task.done(cls.serving(todos)) ++ return cls.task().done(cls.serving(todos)) + + @classmethod + def ordered(cls, todos: Iterable[Todo]) -> bool: +@@ -80,6 +82,36 @@ + class RecordPlanePhase(PlanPhase): + after = (RetainedStorePhase,) + ++ @classmethod ++ def task(cls) -> PhaseTask: ++ import shlex ++ import sys ++ from pathlib import Path ++ from .change_report import FileLine ++ from .goals import OrderCheck ++ from .recorded_time import RecordedEventTime ++ from .todos import ApplicationOrderRun, PendingProblemOrder ++ from .work_dispatch import CheckedPhaseTask ++ ++ checkout = Path(__file__).resolve().parents[2] ++ check = "timeout 300 " + shlex.join((sys.executable, "-X", "faulthandler", ++ "-m", "pytest", "-o", "addopts=", "-n", "0", ++ "tests/test_commander_problem_orders.py", "tests/test_commander_run_rules.py")) ++ return CheckedPhaseTask( ++ problem="P900003", repo="OpenHCSDev/agent-comms", ++ text="Complete and verify the full native commander record plane.", ++ deadline_seconds=7200, ++ order=PendingProblemOrder( ++ id="record-plane-full-task", owner=FileLine(str(Path(__file__).with_name("todos.py")), 1066), ++ check=OrderCheck(str(checkout), check), ++ edits=("Finish native verdict, strike, transfer, delivery and build-bound closing through the existing Todo and Goal owners.",), ++ banned=("No private verdict or strike files; no fixture pass claimed as native application acceptance.",), ++ report="Report the delivered commit and the copied-root native record-plane observations with their build revisions.", ++ run=ApplicationOrderRun("On a copied root, a real send-back, missed deadline and false completion each produce one record; a second strike fires the unit once; missing Works and wrong-build evidence are refused; a delivered checked order cannot transfer; renaming a deletion target cannot pass; only real installed evidence closes the whole task."), ++ at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), ++ ), ++ ) ++ + + class ReviewQueuePhase(MergedPhase): + after = (RecordPlanePhase,) +@@ -88,6 +120,37 @@ + + class ControlPlanePhase(PlanPhase): + after = (RecordPlanePhase,) ++ ++ @classmethod ++ def task(cls) -> PhaseTask: ++ import shlex ++ import sys ++ from pathlib import Path ++ from .change_report import FileLine ++ from .goals import OrderCheck ++ from .recorded_time import RecordedEventTime ++ from .todos import ApplicationOrderRun, PendingProblemOrder ++ from .work_dispatch import CheckedPhaseTask ++ ++ checkout = Path(__file__).resolve().parents[2] ++ check = "timeout 300 " + shlex.join((sys.executable, "-X", "faulthandler", ++ "-m", "pytest", "-o", "addopts=", "-n", "0", ++ "tests/test_work_dispatch.py", "tests/test_standing_rules.py", ++ "tests/test_storage_rules.py", "tests/test_turn_digests.py")) ++ return CheckedPhaseTask( ++ problem="P900007", repo="OpenHCSDev/agent-comms", ++ text="Complete and verify the full native commander control plane.", ++ deadline_seconds=7200, ++ order=PendingProblemOrder( ++ id="control-plane-full-task", owner=FileLine(str(Path(__file__).with_name("standing_rules.py")), 25), ++ check=OrderCheck(str(checkout), check), ++ edits=("Move the minute wake into Core scheduling; dispatch checked ready work through native order and lifecycle owners; derive standing rules, load, growth, storage and time from their original owners; delete the replaced script and private-state decisions.",), ++ banned=("No live-bus proof writes, phase-name dispatch map, second codec, private heartbeat files or fixture pass claimed as native application acceptance.",), ++ report="Name the started scratch unit, retained order, first reply and build; report native session parity, duplicate decisions, time totals and every removed competing path.", ++ run=ApplicationOrderRun("Scratch rotation starts a real Pi unit for a declared ready task and receives its first reply; a second rotation duplicates neither unit nor order; a full native commander session uses no script process; replay of a preserved hour produces each required decision once and UnitTime classes sum to elapsed time; stopping the commander ends its wake."), ++ at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), ++ ), ++ ) + + + class CutoverPhase(PlanPhase): diff --git a/src/agent_comms/commander_parity/__init__.py b/src/agent_comms/commander_parity/__init__.py new file mode 100644 index 0000000000..8d925713b5 --- /dev/null +++ b/src/agent_comms/commander_parity/__init__.py @@ -0,0 +1 @@ +"""Commander parity checks, discovered from their own declarations.""" diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py new file mode 100644 index 0000000000..6d80abe987 --- /dev/null +++ b/src/agent_comms/commander_parity/rows.py @@ -0,0 +1,191 @@ +"""The rest of the matrix, explicitly unverified until real comparisons exist. + +Each declaration owns its identity and native integration phases. Owning a row +is not completing that phase; view phases never become phase prerequisites. +""" + +from ..parity_checks import UnimplementedParityCheck +from ..plan_phases import ( + CommanderPagePhase, ControlPlanePhase, CutoverPhase, OneOrderOwnerPhase, + RecordPlanePhase, ReviewQueuePhase, StoryPhase, +) + + +class CreateProblemParityCheck(UnimplementedParityCheck): + number = 1 + capability = "Create a problem with owner, order and deadline" + phases = (OneOrderOwnerPhase,) + + +class AssignOrderParityCheck(UnimplementedParityCheck): + number = 2 + capability = "Assign, reassign and drop an order" + phases = (OneOrderOwnerPhase,) + + +class DeliveredCommitParityCheck(UnimplementedParityCheck): + number = 3 + capability = "Complete only on the delivered commit" + phases = (OneOrderOwnerPhase, RecordPlanePhase) + + +class InstalledEvidenceParityCheck(UnimplementedParityCheck): + number = 4 + capability = "Close with installed evidence" + phases = (OneOrderOwnerPhase,) + + +class BoardParityCheck(UnimplementedParityCheck): + number = 5 + capability = "Board, overdue, silent and spinning" + phases = (OneOrderOwnerPhase,) + + +class BranchHygieneParityCheck(UnimplementedParityCheck): + number = 6 + capability = "Branch hygiene" + phases = (OneOrderOwnerPhase,) + + +class StrikeLimitParityCheck(UnimplementedParityCheck): + number = 7 + capability = "Strikes and the strike limit" + phases = (RecordPlanePhase,) + + +class VerdictParityCheck(UnimplementedParityCheck): + number = 8 + capability = "Verdicts on a unit's tool calls" + phases = (RecordPlanePhase,) + + +class OrderWorkParityCheck(UnimplementedParityCheck): + number = 11 + capability = "What a unit did during its order" + phases = (ReviewQueuePhase,) + + +class UnjudgedParityCheck(UnimplementedParityCheck): + number = 12 + capability = "What the commander has not judged yet" + phases = (ReviewQueuePhase,) + + +class PublicStoryParityCheck(UnimplementedParityCheck): + number = 13 + capability = "Public story and roasts" + phases = (StoryPhase,) + + +class ScheduledWakeParityCheck(UnimplementedParityCheck): + number = 14 + capability = "Heartbeat: next unit every minute" + phases = (ControlPlanePhase,) + + +class StandingRulesParityCheck(UnimplementedParityCheck): + number = 15 + capability = "Standing rules broken right now" + phases = (ControlPlanePhase,) + + +class RuleReviewParityCheck(UnimplementedParityCheck): + number = 16 + capability = "Rule review" + phases = (ControlPlanePhase,) + + +class LoadForkingParityCheck(UnimplementedParityCheck): + number = 17 + capability = "Load and forking" + phases = (ControlPlanePhase,) + + +class GrowthAlarmParityCheck(UnimplementedParityCheck): + number = 18 + capability = "Growth alarm" + phases = (ControlPlanePhase,) + + +class StorageHygieneParityCheck(UnimplementedParityCheck): + number = 19 + capability = "Storage hygiene" + phases = (ControlPlanePhase,) + + +class OrderOnlyContactParityCheck(UnimplementedParityCheck): + number = 20 + capability = "Commander reaches units only through orders" + phases = (ControlPlanePhase,) + + +class ConfigurationParityCheck(UnimplementedParityCheck): + number = 21 + capability = "Paths, repositories and commander identity" + phases = (CutoverPhase,) + + +class CommanderPageParityCheck(UnimplementedParityCheck): + number = 22 + capability = "Seeing the fleet: board, strikes, queue, story and alarms" + phases = (CommanderPagePhase,) + + +class ApplicationRunParityCheck(UnimplementedParityCheck): + number = 23 + capability = "An order states the user-visible run that closes it" + phases = (RecordPlanePhase,) + + +class TracedTransferParityCheck(UnimplementedParityCheck): + number = 24 + capability = "Reassigning past two owners needs the commander's trace" + phases = (RecordPlanePhase,) + + +class DecisionAlertsParityCheck(UnimplementedParityCheck): + number = 26 + capability = "Alerts carry only decisions" + phases = (ControlPlanePhase,) + + +class MomentumParityCheck(UnimplementedParityCheck): + number = 27 + capability = "Momentum prediction per unit compared with the outcome" + phases = (CommanderPagePhase,) + + +class MeasuredLoadParityCheck(UnimplementedParityCheck): + number = 28 + capability = "Measured load and self-report" + phases = (ControlPlanePhase,) + + +class FinishedWorkTransferParityCheck(UnimplementedParityCheck): + number = 29 + capability = "No handoff of finished, checked work" + phases = (RecordPlanePhase,) + + +class BuildBoundProofParityCheck(UnimplementedParityCheck): + number = 30 + capability = "A proof is bound to the build it ran on" + phases = (RecordPlanePhase,) + + +class DeletionTargetParityCheck(UnimplementedParityCheck): + number = 31 + capability = "An order states its intent as a deletion target" + phases = (RecordPlanePhase,) + + +class UnitTimeParityCheck(UnimplementedParityCheck): + number = 32 + capability = "Where units' time goes, classified" + phases = (CommanderPagePhase, ControlPlanePhase) + + +class OrderProgressParityCheck(UnimplementedParityCheck): + number = 33 + capability = "Order progress beside its deadline; the unit sees no due time" + phases = (OneOrderOwnerPhase, ControlPlanePhase) diff --git a/src/agent_comms/commander_parity/text.py b/src/agent_comms/commander_parity/text.py new file mode 100644 index 0000000000..26bce229d2 --- /dev/null +++ b/src/agent_comms/commander_parity/text.py @@ -0,0 +1,44 @@ +"""The native field codec and current commander's real wording refusals.""" + +from ..parity_checks import TextParityCheck, TextSample +from ..plan_phases import RecordPlanePhase +from ..todos import OrderText, PlainText, StrikeReason + + +class PlainWordsParityCheck(TextParityCheck): + number = 9 + capability = "Plain words in verdicts and strikes" + phases = (RecordPlanePhase,) + samples = ( + TextSample(PlainText, "The file now reads the order from its original row.", True), + TextSample(PlainText, "The receipt certifies the authority.", False), + TextSample(PlainText, "Remove the quoted `authority` from that message.", True), + TextSample(StrikeReason, "The unit changed another file and left the ordered repair broken.", True), + TextSample(StrikeReason, "The edit broke.", False), + ) + + +class OrderWordingParityCheck(TextParityCheck): + number = 10 + capability = "Refuse soft or vague orders" + phases = (RecordPlanePhase,) + samples = ( + TextSample(OrderText, "Repair the existing reader and remove the duplicate store.", True), + TextSample(OrderText, "Could you repair the existing reader when you get a chance?", False), + TextSample(OrderText, "Consider keeping both stores if possible.", False), + TextSample(OrderText, "Remove the literal `please` from the help message now.", True), + ) + + +class UnitWordingParityCheck(TextParityCheck): + number = 25 + capability = "Unit wording gets a correction, not a strike" + phases = (RecordPlanePhase,) + samples = ( + TextSample(StrikeReason, + "The unit wrote `canonical` instead of explaining the file that actually failed.", + False), + TextSample(StrikeReason, + "The unit edited `/project/reader.py` but left the requested repair completely broken.", + True), + ) diff --git a/src/agent_comms/commander_parity_cli.py b/src/agent_comms/commander_parity_cli.py new file mode 100644 index 0000000000..0de76d13e9 --- /dev/null +++ b/src/agent_comms/commander_parity_cli.py @@ -0,0 +1,110 @@ +"""Run comparisons through the actual application, with isolated script configuration.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +from .check_root import CheckRoot +from .cli_commands import CliCommand, option +from .commander.config import CommanderConfig +from .comms import Comms +from .field_codec import FieldCodec, PathText +from .launch_runtime import RestartEnvironment, ThreadLaunch +from .parity_checks import CommanderComparison, CommanderParityCheck, CommanderParityReport + + +@dataclass(frozen=True) +class ParityMatrix: + """Decode the existing Markdown matrix once; never identify a row by its title.""" + + rows: tuple[int, ...] + + @classmethod + def read(cls, path: Path) -> ParityMatrix: + text = path.read_text() + try: + section = text.split("## Parity matrix\n", 1)[1].split("\n## ", 1)[0] + except IndexError as error: + raise ValueError(f"No parity matrix in {path}") from error + rows = [] + for line in section.splitlines(): + if not line.startswith("|"): + continue + first = line.split("|")[1].strip() + if first == "#" or first and set(first) <= {"-", ":"}: + continue + rows.append(int(first)) + if not rows or any(number <= 0 for number in rows) or len(set(rows)) != len(rows): + raise ValueError("A parity matrix needs unique positive row numbers") + return cls(tuple(rows)) + + def compare(self, context: CommanderComparison) -> CommanderParityReport: + checks = CommanderParityCheck.members_with(CommanderParityCheck) + numbers = tuple(check.number for check in checks) + if len(set(numbers)) != len(numbers) or set(numbers) - set(self.rows): + raise ValueError("Parity declarations duplicate a row or name a row outside the matrix") + comparisons = tuple( + check.compare(context) for check in sorted(checks, key=lambda check: check.number)) + complete = {row.check.number for row in comparisons + if row.coverage.complete and row.observations} + return CommanderParityReport( + comparisons, tuple(number for number in self.rows if number not in complete)) + + +@dataclass(frozen=True, kw_only=True) +class CommanderParityCliCommand(CliCommand, declared_name="commander-parity"): + help = "Compare native/current commander behaviour in an isolated saved-history root" + plan: Annotated[Path, PathText] = option(help="Markdown plan containing the numbered parity matrix") + into: Annotated[Path, PathText] = option(help="New directory under your agent scratch directory") + + def apply(self, ctx: Comms) -> CommanderParityReport: + config = CommanderConfig.installed() + if config.root.resolve() != ctx.root.resolve(): + raise ValueError("The current commander configuration belongs to another root") + with CheckRoot(ctx, (config.commander,), self.into).open() as copied: + home = copied.root / "home" + config.model_copy(update={ + "root": copied.root, "state": copied.root / "state", + "board": copied.root / "board.md", "narrative": copied.root / "narrative.md", + "scratch": copied.root / "scratch", "disk": copied.root, + }).write(home) + environment = ThreadLaunch.strip(dict(os.environ)) + RestartEnvironment.strip(environment, "root") + environment.update(HOME=str(home), PYTHONPATH=os.pathsep.join(sys.path)) + # A separate interpreter prevents the current scripts' import-time + # globals from ever binding this check to the original live root. + result = subprocess.run( + (sys.executable, "-m", "agent_comms.cli", "--root", str(copied.root), + "commander-parity-observe", "--plan", str(self.plan.resolve())), + env=environment, capture_output=True, text=True, timeout=60, + ) + try: + report = FieldCodec.decode(CommanderParityReport, FieldCodec.parse(result.stdout)) + except ValueError as error: + raise ValueError( + f"Parity worker produced no report (exit {result.returncode}): " + f"{result.stdout}\n{result.stderr}" + ) from error + if result.returncode != report.exit_code(): + raise ValueError(f"Parity worker exit disagrees with its report: {result.stderr}") + return report + + +@dataclass(frozen=True, kw_only=True) +class ObserveCommanderParityCliCommand(CliCommand, declared_name="commander-parity-observe"): + help = "Observe declared comparisons; commander-parity supplies an isolated root" + plan: Annotated[Path, PathText] = option(help="Markdown plan containing the numbered parity matrix") + + def apply(self, ctx: Comms) -> CommanderParityReport: + # This is the real current commander's function, not a reimplementation + # of its refusals or a mocked transport. Its globals are isolated above. + from .commander import order + + if order.COMMS.root.resolve() != ctx.root.resolve(): + raise ValueError("The current scripts opened another root; no comparison ran") + return ParityMatrix.read(self.plan).compare(CommanderComparison(order.refuse)) diff --git a/src/agent_comms/parity_checks.py b/src/agent_comms/parity_checks.py new file mode 100644 index 0000000000..b9e9b00094 --- /dev/null +++ b/src/agent_comms/parity_checks.py @@ -0,0 +1,196 @@ +"""Declared commander comparisons and their actual observations. + +A missing comparison is unverified, never a passing feature. The phase owns this +check family; row identity, numbering and reporting derive from its declarations. +""" + +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from .cli_result import CliResult +from .declared_family import DeclaredFamily +from .field_codec import FieldCodec, projected +from .plan_phases import PlanPhase + +if TYPE_CHECKING: + from .todos import OrderWords, Todo + from .work_dispatch import PhaseTask + + +class ProbeObservation(DeclaredFamily, affix="Observation"): + accepted: ClassVar[bool] + + +@dataclass(frozen=True) +class AcceptedObservation(ProbeObservation): + accepted = True + + +@dataclass(frozen=True) +class RefusedObservation(ProbeObservation): + reason: str + accepted = False + + +@dataclass(frozen=True) +class TextSample: + family: type[OrderWords] + text: str + accepted: bool + + +class ParityObservation(DeclaredFamily, affix="Observation"): + @property + @abstractmethod + def passed(self) -> bool: ... + + +@dataclass(frozen=True) +class TextProbeResult(ParityObservation): + family: str + text: str + expected_acceptance: bool + native: ProbeObservation + current: ProbeObservation + + @projected(view="wire") + def passed(self) -> bool: + return self.native.accepted == self.current.accepted == self.expected_acceptance + + +@dataclass(frozen=True) +class CommanderComparison: + """The current script's real boundary, supplied only in an isolated process.""" + + refuse: Callable[[str, type[OrderWords]], None] + + def text(self, sample: TextSample) -> TextProbeResult: + try: + FieldCodec.decode(sample.family, sample.text) + except ValueError as error: + native = RefusedObservation(str(error)) + else: + native = AcceptedObservation() + try: + self.refuse(sample.text, sample.family) + except SystemExit as error: + current = RefusedObservation(str(error)) + else: + current = AcceptedObservation() + return TextProbeResult(sample.family.__name__, sample.text, sample.accepted, + native, current) + + +class ComparisonCoverage(DeclaredFamily, affix="Coverage"): + complete: ClassVar[bool] + + +@dataclass(frozen=True) +class PartialCoverage(ComparisonCoverage): + missing: tuple[str, ...] + complete = False + + def __post_init__(self) -> None: + if not self.missing: + raise ValueError("A partial comparison names what it did not exercise") + + +@dataclass(frozen=True) +class CompleteCoverage(ComparisonCoverage): + complete = True + + +class CommanderParityCheck( + DeclaredFamily, affix="ParityCheck", discovery_package="agent_comms.commander_parity" +): + number: ClassVar[int] + phases: ClassVar[tuple[type[PlanPhase], ...]] + capability: ClassVar[str] + coverage: ClassVar[ComparisonCoverage] + + @classmethod + @abstractmethod + def compare(cls, context: CommanderComparison) -> ParityRowResult: ... + + @classmethod + def task(cls) -> PhaseTask: + from .work_dispatch import UnspecifiedPhaseTask + + return UnspecifiedPhaseTask() + + @classmethod + def done(cls, todos: Iterable[Todo]) -> bool: + # Probe agreement alone never accepts a row. Its checked task must also + # have passed and received the existing order's application acceptance. + return cls.coverage.complete and cls.task().done(todos) + + @classmethod + def all_done(cls, todos: Iterable[Todo]) -> bool: + checks = cls.members_with(cls) + history = tuple(todos) + return bool(checks) and all(check.done(history) for check in checks) + + +class UnimplementedParityCheck(CommanderParityCheck, declared_name=None): + coverage = PartialCoverage(("The native/current workflow comparison is not implemented.",)) + + @classmethod + def compare(cls, context: CommanderComparison) -> ParityRowResult: + return ParityRowResult(cls, ()) + + +class TextParityCheck(CommanderParityCheck, declared_name=None): + samples: ClassVar[tuple[TextSample, ...]] + coverage = PartialCoverage(( + "Only native field decoding and current script refusal samples ran; " + "the row's complete verdict/order/correction workflow has not run.", + )) + + @classmethod + def compare(cls, context: CommanderComparison) -> ParityRowResult: + return ParityRowResult(cls, tuple(context.text(sample) for sample in cls.samples)) + + +@dataclass(frozen=True) +class ParityRowResult: + check: type[CommanderParityCheck] + observations: tuple[ParityObservation, ...] + + @projected(view="wire") + def number(self) -> int: + return self.check.number + + @projected(view="wire") + def coverage(self) -> ComparisonCoverage: + return self.check.coverage + + @projected(view="wire") + def phases(self) -> tuple[type[PlanPhase], ...]: + return self.check.phases + + @projected(view="wire") + def passed(self) -> bool: + return bool(self.observations) and all(item.passed for item in self.observations) + + +@dataclass(frozen=True) +class CommanderParityReport(CliResult): + comparisons: tuple[ParityRowResult, ...] + unverified_rows: tuple[int, ...] + + @projected(view="wire") + def passed(self) -> bool: + return bool(self.comparisons) and not self.unverified_rows and all( + item.passed for item in self.comparisons) + + @projected(view="wire") + def failing_rows(self) -> tuple[int, ...]: + return tuple(item.check.number for item in self.comparisons + if item.observations and not item.passed) + + def exit_code(self) -> int: + return 0 if self.passed else 1 diff --git a/tests/test_commander_parity.py b/tests/test_commander_parity.py new file mode 100644 index 0000000000..a99a489b3f --- /dev/null +++ b/tests/test_commander_parity.py @@ -0,0 +1,57 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from agent_comms.commander_parity_cli import ParityMatrix +from agent_comms.field_codec import FieldCodec +from agent_comms.parity_checks import CommanderParityCheck, CommanderParityReport + + +PLAN = Path(__file__).resolve().parents[1] / "plans/commander-native-plan.md" + + +def test_actual_native_current_comparisons_never_pass_unverified_rows(commander_script): + order, _ = commander_script + environment = {**os.environ, "PYTHONPATH": str(PLAN.parents[1] / "src")} + completed = subprocess.run( + (sys.executable, "-m", "agent_comms.cli", "--root", str(order.COMMS.root), + "commander-parity-observe", "--plan", str(PLAN)), + env=environment, capture_output=True, text=True, timeout=30, + ) + assert completed.stdout, completed.stderr + report = FieldCodec.decode(CommanderParityReport, json.loads(completed.stdout)) + declarations = CommanderParityCheck.members_with(CommanderParityCheck) + assert declarations + assert {row.check for row in report.comparisons} == set(declarations) + observed = tuple(row for row in report.comparisons if row.observations) + assert observed and all(row.passed for row in observed) + assert all(row.phases == row.check.phases and row.phases for row in report.comparisons) + assert {check.number for check in declarations} == set(ParityMatrix.read(PLAN).rows) + assert not CommanderParityCheck.all_done(()) + complete_numbers = {row.check.number for row in report.comparisons + if row.coverage.complete and row.observations} + assert report.unverified_rows == tuple( + number for number in ParityMatrix.read(PLAN).rows if number not in complete_numbers) + assert report.unverified_rows + assert not report.passed + assert report.failing_rows == () + assert completed.returncode == report.exit_code() == 1 + assert FieldCodec.decode(CommanderParityReport, report.to_wire()) == report + + +def test_matrix_numbers_are_not_titles_and_invalid_or_duplicate_numbers_are_refused(tmp_path): + plan = tmp_path / "plan.md" + plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n" + "| 9 | Renamed freely |\n| 10 | Another name |\n") + assert ParityMatrix.read(plan).rows == (9, 10) + plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n" + "| 9 | First |\n| 9 | Second |\n") + with pytest.raises(ValueError, match="unique positive"): + ParityMatrix.read(plan) + plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n| x | Invalid |\n") + with pytest.raises(ValueError): + ParityMatrix.read(plan) diff --git a/work-dispatch-followup.patch b/work-dispatch-followup.patch new file mode 100644 index 0000000000..af5f18949a --- /dev/null +++ b/work-dispatch-followup.patch @@ -0,0 +1,36 @@ +diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py +index 67cccd250..e002df055 100644 +--- a/src/agent_comms/work_dispatch.py ++++ b/src/agent_comms/work_dispatch.py +@@ -7,6 +7,7 @@ acceptance check. Failed startup state stays retained for explicit disposition. + + from __future__ import annotations + ++import math + import subprocess + import time + from abc import ABC, abstractmethod +@@ -116,8 +117,8 @@ class CheckedPhaseTask(PhaseTask): + instruction = "Core starts the declared checked task in its own unit checkout" + + def __post_init__(self) -> None: +- if not 0 < self.deadline_seconds <= 24 * 60 * 60: +- raise ValueError("A phase task needs a positive deadline of at most one day.") ++ if not math.isfinite(self.deadline_seconds) or self.deadline_seconds <= 0: ++ raise ValueError("A phase task needs a finite positive deadline interval.") + + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: + from .goal_actions import CreateProblemGoalAction +@@ -126,9 +127,9 @@ class CheckedPhaseTask(PhaseTask): + history = comms.goals.todos.history_for_commander(commander.incarnation) + if not phase.ready(history) or phase.ordered(history): + return +- # An existing unowned row belongs to UnownedWork, which reissues that +- # row's actual order instead of replacing it with a phase template. +- if any(todo.state.open for todo in phase.serving(history)): ++ # This task's existing row belongs to UnownedWork. Other phase subtasks ++ # neither supply this task's check nor replace its explicit identity. ++ if any(todo.id == self.problem for todo in history): + return + now = time.time() + order = replace(self.order.reissued(now), serves=phase) From 0963f5563f9b755178234c289614cae7b87a8732 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:32:49 -0400 Subject: [PATCH 09/41] Record retired parity scratch and integrated-source checks --- docs/refactor/commander-parity-progress.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/refactor/commander-parity-progress.md b/docs/refactor/commander-parity-progress.md index fe9758310a..5cbe09dbb3 100644 --- a/docs/refactor/commander-parity-progress.md +++ b/docs/refactor/commander-parity-progress.md @@ -79,8 +79,10 @@ board record was created. Consumer staging depends on pi-cn-phase7 #1932; its native dispatch proof is not whole-phase acceptance. Scratch owner: pi-plan-claims. Only scratch root: -/home/ts/.cache/agent-scratch/pi-plan-claims (13 MiB at checkpoint); source checkout +/home/ts/.cache/agent-scratch/pi-plan-claims (8 KiB after cleanup); source checkout 27 MiB; free filesystem 25 GiB. Test subdirectories parity-worker-check, -parity-final-check and parity-confirm are disposable after active borrower -checks. Real-copy directories were already retired by CheckRoot. Patch handoffs -stay in /home/ts/wt/pi-plan-claims--P1. B shared GitHub claims remains unstarted. +parity-final-check and parity-confirm were removed after lsof found no active +borrowers. Real-copy directories were already retired by CheckRoot. Patch +handoffs stay in /home/ts/wt/pi-plan-claims--P1. B shared GitHub claims remains +unstarted. The user's concurrent remote main merge was integrated normally; +all 13 focused checks passed again afterward. No PR/main merge was performed. From 392799ece845a58a182ec46b84e24f653656ca73 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:04:22 -0400 Subject: [PATCH 10/41] Move the parity declarations out of #1928 The parity rows restate the Markdown matrix's row numbers and capability text in each class, and five .patch files were committed at the repo root. They return in a follow-up PR with the rows as the single source. Co-Authored-By: Claude Opus 5.5 --- docs/refactor/commander-parity-progress.md | 88 --------- parity-gate.patch | 22 --- phase-task-consumers.patch | 33 ---- phase-task-tests.patch | 36 ---- phase7-task-declarations.patch | 97 --------- src/agent_comms/commander_parity/__init__.py | 1 - src/agent_comms/commander_parity/rows.py | 191 ------------------ src/agent_comms/commander_parity/text.py | 44 ----- src/agent_comms/commander_parity_cli.py | 110 ----------- src/agent_comms/parity_checks.py | 196 ------------------- tests/test_commander_parity.py | 57 ------ work-dispatch-followup.patch | 36 ---- 12 files changed, 911 deletions(-) delete mode 100644 docs/refactor/commander-parity-progress.md delete mode 100644 parity-gate.patch delete mode 100644 phase-task-consumers.patch delete mode 100644 phase-task-tests.patch delete mode 100644 phase7-task-declarations.patch delete mode 100644 src/agent_comms/commander_parity/__init__.py delete mode 100644 src/agent_comms/commander_parity/rows.py delete mode 100644 src/agent_comms/commander_parity/text.py delete mode 100644 src/agent_comms/commander_parity_cli.py delete mode 100644 src/agent_comms/parity_checks.py delete mode 100644 tests/test_commander_parity.py delete mode 100644 work-dispatch-followup.patch diff --git a/docs/refactor/commander-parity-progress.md b/docs/refactor/commander-parity-progress.md deleted file mode 100644 index 5cbe09dbb3..0000000000 --- a/docs/refactor/commander-parity-progress.md +++ /dev/null @@ -1,88 +0,0 @@ -# Commander parity checkpoint (2026-10-19) - -## Working source application paths - -`CheckRoot._session` incorrectly called `NativeRecord.project` on an already -normalized `ExternalRecord`. The projection boundary already yields events. -`ImportedSessionWriter.import_events` now consumes those events, retaining each -original timestamp; the single caller no longer assumes a native record. -The replaced `import_record` method and its ImportBuffer/ImportLimits dependency -are deleted, not kept as an alternate path. Full session import still uses -`writer.append` and is unchanged. - -Actual source CLI over the live saved `pi-plan-claims` native history: -`check-root --threads pi-plan-claims --into /native-history-live-01 --- python -c {root}` returned 0. -177 conversation events retained timestamps present in the original native -source. The disposable root was removed. No input was submitted or replayed; -original sessions, custody and uncertain inputs were untouched. - -Actual saved `claude-code` external startup now refuses precisely: -`Copied conversation event has no original timestamp`. Read-only inspection of -its latest 200 source records found 164 ToolStartTranscript and 164 -ToolEndTranscript events with no recorded time, plus 35 dated AssistantTranscript -events. No original time exists to preserve for those tool events. They are not -retimed to hide the mismatch. Full saved-external startup remains unaccepted. - -## Partial parity, never full acceptance - -Core declares all 33 matrix rows as classes, with native owning phases. Identity -comes from each declaration, not the prose title. The class registry drives the -runner and computed completion; the command decodes the Markdown matrix and -checks its row identities against the declarations. A class-owned checked task -must be accepted through the existing Todo order before that row is done. -Unspecified tasks and partial coverage cannot complete a row. No parity state -column, parity_done boolean or parallel evidence store was added. - -Real isolated CLI worker compared native FieldCodec decoding with current -`commander.order.refuse`: 11 text cases across rows 9, 10 and 25 agree with their -expected outcomes. These are explicitly PARTIAL comparisons, not three finished -rows. The other 30 declarations have no observations. All 33 rows remain -unverified; no observed case failed. Report exit is 1. Each row reports its -class identity, number, native owning phases and coverage. Native owning phases -are attribution, not prerequisites on view completion. - -`CommanderParityCheck.all_done` consumes accepted existing checked tasks; it -returns false for the current declarations. No script deletion is authorized. -The actual Cutover/ParityPhase source edit was denied by stale claims: -`Resource already claimed by 'pi-plan-claims'`. `parity-gate.patch` contains the -exact requested dependency and computed phase; it passes `git apply --check` -but has NOT been applied. The source after-line ratchet still returns 0. - -## Confirmation and staging - -After the coherent source changes: 13 focused tests passed serially -(test_check_root.py, test_commander_parity.py, test_plan_phases.py). These include -native clock preservation, dated external projection without text truncation, -refusal of undated source, actual subprocess worker, codec round trip and all -matrix declarations. Pytest reports its existing unknown `timeout` config warning. -No merged or installed build is claimed. - -Before edits, refactor-audit Package.load parsed 380 production / 477 test / 34 -tool modules with no omissions. AST evidence found one import_record declaration -and one caller; there are now zero of either, one import_events declaration in -native_entries.py and one caller in check_root.py. Runtime source dispatch is -polymorphic; actual native and external paths above corroborate its resolution. - -Pending patches are source under this persistent checkout, never shell-applied -to denied files: -- parity-gate.patch: computed ParityPhase and exact four-part Cutover dependency. -- phase7-task-declarations.patch: peer-authored Record/Control task() factories. -- work-dispatch-followup.patch: finite positive deadlines and exact task identity. -- phase-task-consumers.patch: PlanIdle/proof consumers, against peer #1932. -- phase-task-tests.patch: fixture factory migration; state-only commander fixtures - explicitly disable production starts. Do not run them with live factories. - -P900003/P900007 were absent in the real global Todo store at this checkpoint; -this is not a reservation or permission to overwrite a later collision. No live -board record was created. Consumer staging depends on pi-cn-phase7 #1932; its -native dispatch proof is not whole-phase acceptance. - -Scratch owner: pi-plan-claims. Only scratch root: -/home/ts/.cache/agent-scratch/pi-plan-claims (8 KiB after cleanup); source checkout -27 MiB; free filesystem 25 GiB. Test subdirectories parity-worker-check, -parity-final-check and parity-confirm were removed after lsof found no active -borrowers. Real-copy directories were already retired by CheckRoot. Patch -handoffs stay in /home/ts/wt/pi-plan-claims--P1. B shared GitHub claims remains -unstarted. The user's concurrent remote main merge was integrated normally; -all 13 focused checks passed again afterward. No PR/main merge was performed. diff --git a/parity-gate.patch b/parity-gate.patch deleted file mode 100644 index 07d28697c7..0000000000 --- a/parity-gate.patch +++ /dev/null @@ -1,22 +0,0 @@ ---- a/src/agent_comms/plan_phases.py -+++ b/src/agent_comms/plan_phases.py -@@ -93,8 +93,16 @@ -+class ParityPhase(PlanPhase): -+ after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) -+ -+ @classmethod -+ def done(cls, todos: Iterable[Todo]) -> bool: -+ from .parity_checks import CommanderParityCheck -+ -+ return CommanderParityCheck.all_done(todos) -+ -+ - class CutoverPhase(PlanPhase): -- # The parity gate in the Markdown plan has no executable declaration yet. -- # These dependencies do not establish parity or authorize deleting scripts. -- after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) -+ after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase, ParityPhase) - - - class StoryPhase(PlanPhase): - """A view of the native records, not a prerequisite for their cutover.""" diff --git a/phase-task-consumers.patch b/phase-task-consumers.patch deleted file mode 100644 index 7072ab06a6..0000000000 --- a/phase-task-consumers.patch +++ /dev/null @@ -1,33 +0,0 @@ ---- a/src/agent_comms/standing_rules.py -+++ b/src/agent_comms/standing_rules.py -@@ -218,12 +218,12 @@ - ) - - def enforce(self, comms: Comms, commander: str) -> None: -- self.phase.task.start(comms, comms.registry.require(commander), self.phase) -+ self.phase.task().start(comms, comms.registry.require(commander), self.phase) - - def line(self) -> str: - return ( - f"PLAN IDLE: phase {self.phase.declared_name} ready, no open order" -- f" -> {self.phase.task.instruction}" -+ f" -> {self.phase.task().instruction}" - ) - - ---- a/tools/prove_commander_dispatch.py -+++ b/tools/prove_commander_dispatch.py -@@ -94,11 +94,12 @@ - at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), - run=MechanicalOrderRun(), - ) -- RecordPlanePhase.task = CheckedPhaseTask( -+ checked_task = CheckedPhaseTask( - problem, Repo.of(project).github, - "Verify rotation starts a native unit and receives its first real reply.", - order, deadline_seconds=300, - ) -+ RecordPlanePhase.task = classmethod(lambda cls: checked_task) - from agent_comms.commander import rotate - - print(f"ROOT {root}", flush=True) diff --git a/phase-task-tests.patch b/phase-task-tests.patch deleted file mode 100644 index 556152f1ad..0000000000 --- a/phase-task-tests.patch +++ /dev/null @@ -1,36 +0,0 @@ ---- a/tests/test_plan_phases.py -+++ b/tests/test_plan_phases.py -@@ -48,7 +48,7 @@ - todo = create(commander, f"P{index}") - CommsOrderServeTool(problem=todo.id, serves=phase).apply(comms, caller) - todo = comms.goals.todos.get(todo.id) -- monkeypatch.setattr(phase, "task", task(todo)) -+ monkeypatch.setattr(phase, "task", classmethod(lambda cls, value=task(todo): value)) - todos.append(todo) - - record_done = tuple( -@@ -98,7 +98,7 @@ - whole = create(commander, "P999") - CommsOrderServeTool(problem=whole.id, serves=StoryPhase).apply(comms, caller) - whole = comms.goals.todos.get(whole.id) -- monkeypatch.setattr(StoryPhase, "task", task(whole)) -+ monkeypatch.setattr(StoryPhase, "task", classmethod(lambda cls: task(whole))) - assert not StoryPhase.done((subtask, whole)) - assert StoryPhase.done((subtask, accepted(whole))) - ---- a/tests/conftest.py -+++ b/tests/conftest.py -@@ -262,8 +262,13 @@ - from agent_comms.goals import RequestGoalOrder - from agent_comms.todos import PullRequests - from agent_comms.tool_caller import ThreadCaller -+ from agent_comms.plan_phases import PlanPhase -+ from agent_comms.work_dispatch import UnspecifiedPhaseTask - - monkeypatch.setattr(PullRequests, "read", lambda self, repo, state, order: ()) -+ # This fixture observes board state; it never opts into production task starts. -+ for phase in PlanPhase.members_with(PlanPhase): -+ monkeypatch.setattr(phase, "task", classmethod(lambda cls: UnspecifiedPhaseTask())) - - for created, name in enumerate(("lead", "worker"), 1): - comms.registry.declare(comms.registry.new_thread(name, frozenset({'comms'}), str(tmp_path), created_at=float(created))) diff --git a/phase7-task-declarations.patch b/phase7-task-declarations.patch deleted file mode 100644 index 7211d37ef2..0000000000 --- a/phase7-task-declarations.patch +++ /dev/null @@ -1,97 +0,0 @@ ---- a/src/agent_comms/plan_phases.py -+++ b/src/agent_comms/plan_phases.py -@@ -30,7 +30,9 @@ - - class PlanPhase(OrderPhase, declared_name=None): - after: ClassVar[tuple[type[PlanPhase], ...]] = () -- task: ClassVar[PhaseTask] = UnspecifiedPhaseTask() -+ @classmethod -+ def task(cls) -> PhaseTask: -+ return UnspecifiedPhaseTask() - - @classmethod - def serving(cls, todos: Iterable[Todo]) -> tuple[Todo, ...]: -@@ -38,7 +40,7 @@ - - @classmethod - def done(cls, todos: Iterable[Todo]) -> bool: -- return cls.task.done(cls.serving(todos)) -+ return cls.task().done(cls.serving(todos)) - - @classmethod - def ordered(cls, todos: Iterable[Todo]) -> bool: -@@ -80,6 +82,36 @@ - class RecordPlanePhase(PlanPhase): - after = (RetainedStorePhase,) - -+ @classmethod -+ def task(cls) -> PhaseTask: -+ import shlex -+ import sys -+ from pathlib import Path -+ from .change_report import FileLine -+ from .goals import OrderCheck -+ from .recorded_time import RecordedEventTime -+ from .todos import ApplicationOrderRun, PendingProblemOrder -+ from .work_dispatch import CheckedPhaseTask -+ -+ checkout = Path(__file__).resolve().parents[2] -+ check = "timeout 300 " + shlex.join((sys.executable, "-X", "faulthandler", -+ "-m", "pytest", "-o", "addopts=", "-n", "0", -+ "tests/test_commander_problem_orders.py", "tests/test_commander_run_rules.py")) -+ return CheckedPhaseTask( -+ problem="P900003", repo="OpenHCSDev/agent-comms", -+ text="Complete and verify the full native commander record plane.", -+ deadline_seconds=7200, -+ order=PendingProblemOrder( -+ id="record-plane-full-task", owner=FileLine(str(Path(__file__).with_name("todos.py")), 1066), -+ check=OrderCheck(str(checkout), check), -+ edits=("Finish native verdict, strike, transfer, delivery and build-bound closing through the existing Todo and Goal owners.",), -+ banned=("No private verdict or strike files; no fixture pass claimed as native application acceptance.",), -+ report="Report the delivered commit and the copied-root native record-plane observations with their build revisions.", -+ run=ApplicationOrderRun("On a copied root, a real send-back, missed deadline and false completion each produce one record; a second strike fires the unit once; missing Works and wrong-build evidence are refused; a delivered checked order cannot transfer; renaming a deletion target cannot pass; only real installed evidence closes the whole task."), -+ at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), -+ ), -+ ) -+ - - class ReviewQueuePhase(MergedPhase): - after = (RecordPlanePhase,) -@@ -88,6 +120,37 @@ - - class ControlPlanePhase(PlanPhase): - after = (RecordPlanePhase,) -+ -+ @classmethod -+ def task(cls) -> PhaseTask: -+ import shlex -+ import sys -+ from pathlib import Path -+ from .change_report import FileLine -+ from .goals import OrderCheck -+ from .recorded_time import RecordedEventTime -+ from .todos import ApplicationOrderRun, PendingProblemOrder -+ from .work_dispatch import CheckedPhaseTask -+ -+ checkout = Path(__file__).resolve().parents[2] -+ check = "timeout 300 " + shlex.join((sys.executable, "-X", "faulthandler", -+ "-m", "pytest", "-o", "addopts=", "-n", "0", -+ "tests/test_work_dispatch.py", "tests/test_standing_rules.py", -+ "tests/test_storage_rules.py", "tests/test_turn_digests.py")) -+ return CheckedPhaseTask( -+ problem="P900007", repo="OpenHCSDev/agent-comms", -+ text="Complete and verify the full native commander control plane.", -+ deadline_seconds=7200, -+ order=PendingProblemOrder( -+ id="control-plane-full-task", owner=FileLine(str(Path(__file__).with_name("standing_rules.py")), 25), -+ check=OrderCheck(str(checkout), check), -+ edits=("Move the minute wake into Core scheduling; dispatch checked ready work through native order and lifecycle owners; derive standing rules, load, growth, storage and time from their original owners; delete the replaced script and private-state decisions.",), -+ banned=("No live-bus proof writes, phase-name dispatch map, second codec, private heartbeat files or fixture pass claimed as native application acceptance.",), -+ report="Name the started scratch unit, retained order, first reply and build; report native session parity, duplicate decisions, time totals and every removed competing path.", -+ run=ApplicationOrderRun("Scratch rotation starts a real Pi unit for a declared ready task and receives its first reply; a second rotation duplicates neither unit nor order; a full native commander session uses no script process; replay of a preserved hour produces each required decision once and UnitTime classes sum to elapsed time; stopping the commander ends its wake."), -+ at=RecordedEventTime(1), first_call_deadline=RecordedEventTime(121), -+ ), -+ ) - - - class CutoverPhase(PlanPhase): diff --git a/src/agent_comms/commander_parity/__init__.py b/src/agent_comms/commander_parity/__init__.py deleted file mode 100644 index 8d925713b5..0000000000 --- a/src/agent_comms/commander_parity/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Commander parity checks, discovered from their own declarations.""" diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py deleted file mode 100644 index 6d80abe987..0000000000 --- a/src/agent_comms/commander_parity/rows.py +++ /dev/null @@ -1,191 +0,0 @@ -"""The rest of the matrix, explicitly unverified until real comparisons exist. - -Each declaration owns its identity and native integration phases. Owning a row -is not completing that phase; view phases never become phase prerequisites. -""" - -from ..parity_checks import UnimplementedParityCheck -from ..plan_phases import ( - CommanderPagePhase, ControlPlanePhase, CutoverPhase, OneOrderOwnerPhase, - RecordPlanePhase, ReviewQueuePhase, StoryPhase, -) - - -class CreateProblemParityCheck(UnimplementedParityCheck): - number = 1 - capability = "Create a problem with owner, order and deadline" - phases = (OneOrderOwnerPhase,) - - -class AssignOrderParityCheck(UnimplementedParityCheck): - number = 2 - capability = "Assign, reassign and drop an order" - phases = (OneOrderOwnerPhase,) - - -class DeliveredCommitParityCheck(UnimplementedParityCheck): - number = 3 - capability = "Complete only on the delivered commit" - phases = (OneOrderOwnerPhase, RecordPlanePhase) - - -class InstalledEvidenceParityCheck(UnimplementedParityCheck): - number = 4 - capability = "Close with installed evidence" - phases = (OneOrderOwnerPhase,) - - -class BoardParityCheck(UnimplementedParityCheck): - number = 5 - capability = "Board, overdue, silent and spinning" - phases = (OneOrderOwnerPhase,) - - -class BranchHygieneParityCheck(UnimplementedParityCheck): - number = 6 - capability = "Branch hygiene" - phases = (OneOrderOwnerPhase,) - - -class StrikeLimitParityCheck(UnimplementedParityCheck): - number = 7 - capability = "Strikes and the strike limit" - phases = (RecordPlanePhase,) - - -class VerdictParityCheck(UnimplementedParityCheck): - number = 8 - capability = "Verdicts on a unit's tool calls" - phases = (RecordPlanePhase,) - - -class OrderWorkParityCheck(UnimplementedParityCheck): - number = 11 - capability = "What a unit did during its order" - phases = (ReviewQueuePhase,) - - -class UnjudgedParityCheck(UnimplementedParityCheck): - number = 12 - capability = "What the commander has not judged yet" - phases = (ReviewQueuePhase,) - - -class PublicStoryParityCheck(UnimplementedParityCheck): - number = 13 - capability = "Public story and roasts" - phases = (StoryPhase,) - - -class ScheduledWakeParityCheck(UnimplementedParityCheck): - number = 14 - capability = "Heartbeat: next unit every minute" - phases = (ControlPlanePhase,) - - -class StandingRulesParityCheck(UnimplementedParityCheck): - number = 15 - capability = "Standing rules broken right now" - phases = (ControlPlanePhase,) - - -class RuleReviewParityCheck(UnimplementedParityCheck): - number = 16 - capability = "Rule review" - phases = (ControlPlanePhase,) - - -class LoadForkingParityCheck(UnimplementedParityCheck): - number = 17 - capability = "Load and forking" - phases = (ControlPlanePhase,) - - -class GrowthAlarmParityCheck(UnimplementedParityCheck): - number = 18 - capability = "Growth alarm" - phases = (ControlPlanePhase,) - - -class StorageHygieneParityCheck(UnimplementedParityCheck): - number = 19 - capability = "Storage hygiene" - phases = (ControlPlanePhase,) - - -class OrderOnlyContactParityCheck(UnimplementedParityCheck): - number = 20 - capability = "Commander reaches units only through orders" - phases = (ControlPlanePhase,) - - -class ConfigurationParityCheck(UnimplementedParityCheck): - number = 21 - capability = "Paths, repositories and commander identity" - phases = (CutoverPhase,) - - -class CommanderPageParityCheck(UnimplementedParityCheck): - number = 22 - capability = "Seeing the fleet: board, strikes, queue, story and alarms" - phases = (CommanderPagePhase,) - - -class ApplicationRunParityCheck(UnimplementedParityCheck): - number = 23 - capability = "An order states the user-visible run that closes it" - phases = (RecordPlanePhase,) - - -class TracedTransferParityCheck(UnimplementedParityCheck): - number = 24 - capability = "Reassigning past two owners needs the commander's trace" - phases = (RecordPlanePhase,) - - -class DecisionAlertsParityCheck(UnimplementedParityCheck): - number = 26 - capability = "Alerts carry only decisions" - phases = (ControlPlanePhase,) - - -class MomentumParityCheck(UnimplementedParityCheck): - number = 27 - capability = "Momentum prediction per unit compared with the outcome" - phases = (CommanderPagePhase,) - - -class MeasuredLoadParityCheck(UnimplementedParityCheck): - number = 28 - capability = "Measured load and self-report" - phases = (ControlPlanePhase,) - - -class FinishedWorkTransferParityCheck(UnimplementedParityCheck): - number = 29 - capability = "No handoff of finished, checked work" - phases = (RecordPlanePhase,) - - -class BuildBoundProofParityCheck(UnimplementedParityCheck): - number = 30 - capability = "A proof is bound to the build it ran on" - phases = (RecordPlanePhase,) - - -class DeletionTargetParityCheck(UnimplementedParityCheck): - number = 31 - capability = "An order states its intent as a deletion target" - phases = (RecordPlanePhase,) - - -class UnitTimeParityCheck(UnimplementedParityCheck): - number = 32 - capability = "Where units' time goes, classified" - phases = (CommanderPagePhase, ControlPlanePhase) - - -class OrderProgressParityCheck(UnimplementedParityCheck): - number = 33 - capability = "Order progress beside its deadline; the unit sees no due time" - phases = (OneOrderOwnerPhase, ControlPlanePhase) diff --git a/src/agent_comms/commander_parity/text.py b/src/agent_comms/commander_parity/text.py deleted file mode 100644 index 26bce229d2..0000000000 --- a/src/agent_comms/commander_parity/text.py +++ /dev/null @@ -1,44 +0,0 @@ -"""The native field codec and current commander's real wording refusals.""" - -from ..parity_checks import TextParityCheck, TextSample -from ..plan_phases import RecordPlanePhase -from ..todos import OrderText, PlainText, StrikeReason - - -class PlainWordsParityCheck(TextParityCheck): - number = 9 - capability = "Plain words in verdicts and strikes" - phases = (RecordPlanePhase,) - samples = ( - TextSample(PlainText, "The file now reads the order from its original row.", True), - TextSample(PlainText, "The receipt certifies the authority.", False), - TextSample(PlainText, "Remove the quoted `authority` from that message.", True), - TextSample(StrikeReason, "The unit changed another file and left the ordered repair broken.", True), - TextSample(StrikeReason, "The edit broke.", False), - ) - - -class OrderWordingParityCheck(TextParityCheck): - number = 10 - capability = "Refuse soft or vague orders" - phases = (RecordPlanePhase,) - samples = ( - TextSample(OrderText, "Repair the existing reader and remove the duplicate store.", True), - TextSample(OrderText, "Could you repair the existing reader when you get a chance?", False), - TextSample(OrderText, "Consider keeping both stores if possible.", False), - TextSample(OrderText, "Remove the literal `please` from the help message now.", True), - ) - - -class UnitWordingParityCheck(TextParityCheck): - number = 25 - capability = "Unit wording gets a correction, not a strike" - phases = (RecordPlanePhase,) - samples = ( - TextSample(StrikeReason, - "The unit wrote `canonical` instead of explaining the file that actually failed.", - False), - TextSample(StrikeReason, - "The unit edited `/project/reader.py` but left the requested repair completely broken.", - True), - ) diff --git a/src/agent_comms/commander_parity_cli.py b/src/agent_comms/commander_parity_cli.py deleted file mode 100644 index 0de76d13e9..0000000000 --- a/src/agent_comms/commander_parity_cli.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Run comparisons through the actual application, with isolated script configuration.""" - -from __future__ import annotations - -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Annotated - -from .check_root import CheckRoot -from .cli_commands import CliCommand, option -from .commander.config import CommanderConfig -from .comms import Comms -from .field_codec import FieldCodec, PathText -from .launch_runtime import RestartEnvironment, ThreadLaunch -from .parity_checks import CommanderComparison, CommanderParityCheck, CommanderParityReport - - -@dataclass(frozen=True) -class ParityMatrix: - """Decode the existing Markdown matrix once; never identify a row by its title.""" - - rows: tuple[int, ...] - - @classmethod - def read(cls, path: Path) -> ParityMatrix: - text = path.read_text() - try: - section = text.split("## Parity matrix\n", 1)[1].split("\n## ", 1)[0] - except IndexError as error: - raise ValueError(f"No parity matrix in {path}") from error - rows = [] - for line in section.splitlines(): - if not line.startswith("|"): - continue - first = line.split("|")[1].strip() - if first == "#" or first and set(first) <= {"-", ":"}: - continue - rows.append(int(first)) - if not rows or any(number <= 0 for number in rows) or len(set(rows)) != len(rows): - raise ValueError("A parity matrix needs unique positive row numbers") - return cls(tuple(rows)) - - def compare(self, context: CommanderComparison) -> CommanderParityReport: - checks = CommanderParityCheck.members_with(CommanderParityCheck) - numbers = tuple(check.number for check in checks) - if len(set(numbers)) != len(numbers) or set(numbers) - set(self.rows): - raise ValueError("Parity declarations duplicate a row or name a row outside the matrix") - comparisons = tuple( - check.compare(context) for check in sorted(checks, key=lambda check: check.number)) - complete = {row.check.number for row in comparisons - if row.coverage.complete and row.observations} - return CommanderParityReport( - comparisons, tuple(number for number in self.rows if number not in complete)) - - -@dataclass(frozen=True, kw_only=True) -class CommanderParityCliCommand(CliCommand, declared_name="commander-parity"): - help = "Compare native/current commander behaviour in an isolated saved-history root" - plan: Annotated[Path, PathText] = option(help="Markdown plan containing the numbered parity matrix") - into: Annotated[Path, PathText] = option(help="New directory under your agent scratch directory") - - def apply(self, ctx: Comms) -> CommanderParityReport: - config = CommanderConfig.installed() - if config.root.resolve() != ctx.root.resolve(): - raise ValueError("The current commander configuration belongs to another root") - with CheckRoot(ctx, (config.commander,), self.into).open() as copied: - home = copied.root / "home" - config.model_copy(update={ - "root": copied.root, "state": copied.root / "state", - "board": copied.root / "board.md", "narrative": copied.root / "narrative.md", - "scratch": copied.root / "scratch", "disk": copied.root, - }).write(home) - environment = ThreadLaunch.strip(dict(os.environ)) - RestartEnvironment.strip(environment, "root") - environment.update(HOME=str(home), PYTHONPATH=os.pathsep.join(sys.path)) - # A separate interpreter prevents the current scripts' import-time - # globals from ever binding this check to the original live root. - result = subprocess.run( - (sys.executable, "-m", "agent_comms.cli", "--root", str(copied.root), - "commander-parity-observe", "--plan", str(self.plan.resolve())), - env=environment, capture_output=True, text=True, timeout=60, - ) - try: - report = FieldCodec.decode(CommanderParityReport, FieldCodec.parse(result.stdout)) - except ValueError as error: - raise ValueError( - f"Parity worker produced no report (exit {result.returncode}): " - f"{result.stdout}\n{result.stderr}" - ) from error - if result.returncode != report.exit_code(): - raise ValueError(f"Parity worker exit disagrees with its report: {result.stderr}") - return report - - -@dataclass(frozen=True, kw_only=True) -class ObserveCommanderParityCliCommand(CliCommand, declared_name="commander-parity-observe"): - help = "Observe declared comparisons; commander-parity supplies an isolated root" - plan: Annotated[Path, PathText] = option(help="Markdown plan containing the numbered parity matrix") - - def apply(self, ctx: Comms) -> CommanderParityReport: - # This is the real current commander's function, not a reimplementation - # of its refusals or a mocked transport. Its globals are isolated above. - from .commander import order - - if order.COMMS.root.resolve() != ctx.root.resolve(): - raise ValueError("The current scripts opened another root; no comparison ran") - return ParityMatrix.read(self.plan).compare(CommanderComparison(order.refuse)) diff --git a/src/agent_comms/parity_checks.py b/src/agent_comms/parity_checks.py deleted file mode 100644 index b9e9b00094..0000000000 --- a/src/agent_comms/parity_checks.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Declared commander comparisons and their actual observations. - -A missing comparison is unverified, never a passing feature. The phase owns this -check family; row identity, numbering and reporting derive from its declarations. -""" - -from __future__ import annotations - -from abc import abstractmethod -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar - -from .cli_result import CliResult -from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, projected -from .plan_phases import PlanPhase - -if TYPE_CHECKING: - from .todos import OrderWords, Todo - from .work_dispatch import PhaseTask - - -class ProbeObservation(DeclaredFamily, affix="Observation"): - accepted: ClassVar[bool] - - -@dataclass(frozen=True) -class AcceptedObservation(ProbeObservation): - accepted = True - - -@dataclass(frozen=True) -class RefusedObservation(ProbeObservation): - reason: str - accepted = False - - -@dataclass(frozen=True) -class TextSample: - family: type[OrderWords] - text: str - accepted: bool - - -class ParityObservation(DeclaredFamily, affix="Observation"): - @property - @abstractmethod - def passed(self) -> bool: ... - - -@dataclass(frozen=True) -class TextProbeResult(ParityObservation): - family: str - text: str - expected_acceptance: bool - native: ProbeObservation - current: ProbeObservation - - @projected(view="wire") - def passed(self) -> bool: - return self.native.accepted == self.current.accepted == self.expected_acceptance - - -@dataclass(frozen=True) -class CommanderComparison: - """The current script's real boundary, supplied only in an isolated process.""" - - refuse: Callable[[str, type[OrderWords]], None] - - def text(self, sample: TextSample) -> TextProbeResult: - try: - FieldCodec.decode(sample.family, sample.text) - except ValueError as error: - native = RefusedObservation(str(error)) - else: - native = AcceptedObservation() - try: - self.refuse(sample.text, sample.family) - except SystemExit as error: - current = RefusedObservation(str(error)) - else: - current = AcceptedObservation() - return TextProbeResult(sample.family.__name__, sample.text, sample.accepted, - native, current) - - -class ComparisonCoverage(DeclaredFamily, affix="Coverage"): - complete: ClassVar[bool] - - -@dataclass(frozen=True) -class PartialCoverage(ComparisonCoverage): - missing: tuple[str, ...] - complete = False - - def __post_init__(self) -> None: - if not self.missing: - raise ValueError("A partial comparison names what it did not exercise") - - -@dataclass(frozen=True) -class CompleteCoverage(ComparisonCoverage): - complete = True - - -class CommanderParityCheck( - DeclaredFamily, affix="ParityCheck", discovery_package="agent_comms.commander_parity" -): - number: ClassVar[int] - phases: ClassVar[tuple[type[PlanPhase], ...]] - capability: ClassVar[str] - coverage: ClassVar[ComparisonCoverage] - - @classmethod - @abstractmethod - def compare(cls, context: CommanderComparison) -> ParityRowResult: ... - - @classmethod - def task(cls) -> PhaseTask: - from .work_dispatch import UnspecifiedPhaseTask - - return UnspecifiedPhaseTask() - - @classmethod - def done(cls, todos: Iterable[Todo]) -> bool: - # Probe agreement alone never accepts a row. Its checked task must also - # have passed and received the existing order's application acceptance. - return cls.coverage.complete and cls.task().done(todos) - - @classmethod - def all_done(cls, todos: Iterable[Todo]) -> bool: - checks = cls.members_with(cls) - history = tuple(todos) - return bool(checks) and all(check.done(history) for check in checks) - - -class UnimplementedParityCheck(CommanderParityCheck, declared_name=None): - coverage = PartialCoverage(("The native/current workflow comparison is not implemented.",)) - - @classmethod - def compare(cls, context: CommanderComparison) -> ParityRowResult: - return ParityRowResult(cls, ()) - - -class TextParityCheck(CommanderParityCheck, declared_name=None): - samples: ClassVar[tuple[TextSample, ...]] - coverage = PartialCoverage(( - "Only native field decoding and current script refusal samples ran; " - "the row's complete verdict/order/correction workflow has not run.", - )) - - @classmethod - def compare(cls, context: CommanderComparison) -> ParityRowResult: - return ParityRowResult(cls, tuple(context.text(sample) for sample in cls.samples)) - - -@dataclass(frozen=True) -class ParityRowResult: - check: type[CommanderParityCheck] - observations: tuple[ParityObservation, ...] - - @projected(view="wire") - def number(self) -> int: - return self.check.number - - @projected(view="wire") - def coverage(self) -> ComparisonCoverage: - return self.check.coverage - - @projected(view="wire") - def phases(self) -> tuple[type[PlanPhase], ...]: - return self.check.phases - - @projected(view="wire") - def passed(self) -> bool: - return bool(self.observations) and all(item.passed for item in self.observations) - - -@dataclass(frozen=True) -class CommanderParityReport(CliResult): - comparisons: tuple[ParityRowResult, ...] - unverified_rows: tuple[int, ...] - - @projected(view="wire") - def passed(self) -> bool: - return bool(self.comparisons) and not self.unverified_rows and all( - item.passed for item in self.comparisons) - - @projected(view="wire") - def failing_rows(self) -> tuple[int, ...]: - return tuple(item.check.number for item in self.comparisons - if item.observations and not item.passed) - - def exit_code(self) -> int: - return 0 if self.passed else 1 diff --git a/tests/test_commander_parity.py b/tests/test_commander_parity.py deleted file mode 100644 index a99a489b3f..0000000000 --- a/tests/test_commander_parity.py +++ /dev/null @@ -1,57 +0,0 @@ -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from agent_comms.commander_parity_cli import ParityMatrix -from agent_comms.field_codec import FieldCodec -from agent_comms.parity_checks import CommanderParityCheck, CommanderParityReport - - -PLAN = Path(__file__).resolve().parents[1] / "plans/commander-native-plan.md" - - -def test_actual_native_current_comparisons_never_pass_unverified_rows(commander_script): - order, _ = commander_script - environment = {**os.environ, "PYTHONPATH": str(PLAN.parents[1] / "src")} - completed = subprocess.run( - (sys.executable, "-m", "agent_comms.cli", "--root", str(order.COMMS.root), - "commander-parity-observe", "--plan", str(PLAN)), - env=environment, capture_output=True, text=True, timeout=30, - ) - assert completed.stdout, completed.stderr - report = FieldCodec.decode(CommanderParityReport, json.loads(completed.stdout)) - declarations = CommanderParityCheck.members_with(CommanderParityCheck) - assert declarations - assert {row.check for row in report.comparisons} == set(declarations) - observed = tuple(row for row in report.comparisons if row.observations) - assert observed and all(row.passed for row in observed) - assert all(row.phases == row.check.phases and row.phases for row in report.comparisons) - assert {check.number for check in declarations} == set(ParityMatrix.read(PLAN).rows) - assert not CommanderParityCheck.all_done(()) - complete_numbers = {row.check.number for row in report.comparisons - if row.coverage.complete and row.observations} - assert report.unverified_rows == tuple( - number for number in ParityMatrix.read(PLAN).rows if number not in complete_numbers) - assert report.unverified_rows - assert not report.passed - assert report.failing_rows == () - assert completed.returncode == report.exit_code() == 1 - assert FieldCodec.decode(CommanderParityReport, report.to_wire()) == report - - -def test_matrix_numbers_are_not_titles_and_invalid_or_duplicate_numbers_are_refused(tmp_path): - plan = tmp_path / "plan.md" - plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n" - "| 9 | Renamed freely |\n| 10 | Another name |\n") - assert ParityMatrix.read(plan).rows == (9, 10) - plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n" - "| 9 | First |\n| 9 | Second |\n") - with pytest.raises(ValueError, match="unique positive"): - ParityMatrix.read(plan) - plan.write_text("## Parity matrix\n\n| # | Capability |\n|---|---|\n| x | Invalid |\n") - with pytest.raises(ValueError): - ParityMatrix.read(plan) diff --git a/work-dispatch-followup.patch b/work-dispatch-followup.patch deleted file mode 100644 index af5f18949a..0000000000 --- a/work-dispatch-followup.patch +++ /dev/null @@ -1,36 +0,0 @@ -diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py -index 67cccd250..e002df055 100644 ---- a/src/agent_comms/work_dispatch.py -+++ b/src/agent_comms/work_dispatch.py -@@ -7,6 +7,7 @@ acceptance check. Failed startup state stays retained for explicit disposition. - - from __future__ import annotations - -+import math - import subprocess - import time - from abc import ABC, abstractmethod -@@ -116,8 +117,8 @@ class CheckedPhaseTask(PhaseTask): - instruction = "Core starts the declared checked task in its own unit checkout" - - def __post_init__(self) -> None: -- if not 0 < self.deadline_seconds <= 24 * 60 * 60: -- raise ValueError("A phase task needs a positive deadline of at most one day.") -+ if not math.isfinite(self.deadline_seconds) or self.deadline_seconds <= 0: -+ raise ValueError("A phase task needs a finite positive deadline interval.") - - def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: - from .goal_actions import CreateProblemGoalAction -@@ -126,9 +127,9 @@ class CheckedPhaseTask(PhaseTask): - history = comms.goals.todos.history_for_commander(commander.incarnation) - if not phase.ready(history) or phase.ordered(history): - return -- # An existing unowned row belongs to UnownedWork, which reissues that -- # row's actual order instead of replacing it with a phase template. -- if any(todo.state.open for todo in phase.serving(history)): -+ # This task's existing row belongs to UnownedWork. Other phase subtasks -+ # neither supply this task's check nor replace its explicit identity. -+ if any(todo.id == self.problem for todo in history): - return - now = time.time() - order = replace(self.order.reissued(now), serves=phase) From 778cc508f97adef97406b6bc70b91c0b1e2d5096 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:08:36 -0400 Subject: [PATCH 11/41] Keep #1928 to plan phases and order claims The check-history clock copy goes with the parity work in its own PR; its missing source clocks are the desktop's #1940. Reword two comments that used banned vocabulary. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/work_dispatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index 67cccd2507..7d105589e0 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -24,7 +24,7 @@ @dataclass(frozen=True) class WorkDispatch: - """One checked native order: allocate its unit, publish custody, then start it.""" + """One checked native order: allocate its unit, record its claim, then start it.""" action: ProblemOrderGoalAction revision: int @@ -62,7 +62,7 @@ def start(self, comms: Comms) -> Thread: replace(candidate, session_file=location.read_key), StoppedThreadStatus(), ) # A failed publication leaves a stopped unit and its checkout, not an - # untracked running task. A failed/uncertain start never releases custody. + # untracked running task. A failed/uncertain start never releases the claim. comms.goals.update_goal(child.name, self.action) comms.owners.start(child.name) return comms.registry.require(child.name) From 60f63372fb5c853a05404c1ea8f9c60dbb803b42 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:09:37 -0400 Subject: [PATCH 12/41] Keep the plan-claims report out of the tree Co-Authored-By: Claude Opus 5.5 --- docs/refactor/shared-plan-claims-A.md | 222 -------------------------- 1 file changed, 222 deletions(-) delete mode 100644 docs/refactor/shared-plan-claims-A.md diff --git a/docs/refactor/shared-plan-claims-A.md b/docs/refactor/shared-plan-claims-A.md deleted file mode 100644 index 1d55cf68d8..0000000000 --- a/docs/refactor/shared-plan-claims-A.md +++ /dev/null @@ -1,222 +0,0 @@ -# Shared plan claims: A dependency checkpoint - -The original A dependency design below is superseded by the A2 correction at -this file's end: views wait on their source phases, not cutover. A2's completion -rule is now corrected; its executable parity gate remains in progress. B has not -started. - -Owner: pi-plan-claims. Branch: feat/shared-plan-claims. Base: 011084fc5. -This checkpoint changes commander-native dependency declarations only. Shared -claims are the next, separate commit. Nothing is installed or merged here. - -## Change and limit - -`CutoverPhase.after` now names `RecordPlanePhase`, `ReviewQueuePhase` and -`ControlPlanePhase`. `StoryPhase` and `CommanderPagePhase` follow cutover, whose -prerequisites own their source records. No phase depends on either view. -`SkillPhase` continues to follow cutover. - -**There is no declared executable commander parity gate.** The three requirements -in `plans/commander-native-plan.md:199-203` are prose: native owners on main, a -shadow session comparing native behaviour to scripts, and no private-state writes. -No phase or check implements that comparison. `PlanPhase.done()` reads order -closure, not parity. This change does not satisfy or encode the missing parity -prerequisite and does not authorize deleting the scripts. - -Consequently there is no real parity-check output from which to enumerate failing -matrix rows for phases 3, 4 and 7, map each to a check class, or assign each -measured failure. Those results are **unavailable**, not passing. The commander -must identify an existing external check if one was intended, or commission its -implementation separately. No substitute parity check was invented in A. - -## Actual Core output, before and after - -Both reads used this checkout's `Todo.select`, `PlanPhase.ready` and -`PlanPhase.ordered` against a SQLite read-only connection to -`/home/ts/.agent-comms/todos.sqlite3`, selecting `creator='claude-code'`. Each -returned 214 retained orders. No synthetic completion state was used here. -This is the proposed Core source evaluating real retained state, not an installed -runtime update. "Next startable" is the same ready-and-not-ordered predicate -used by `PlanIdle.violations`. - -Before: - -```text -BEFORE: live commander claude-code; todos: 214 -Core ready: record_plane, commander_page -Core next startable (ready, no open order): commander_page -record_plane done= False ordered= True after= retained_store open= P107,P167,P195,P197,P198,P204,P208,P213 -review_queue done= True ordered= False after= record_plane open= -story done= True ordered= False after= review_queue open= -commander_page done= False ordered= False after= story open= -control_plane done= False ordered= True after= record_plane open= P100,P199 -cutover done= False ordered= True after= review_queue,story,commander_page,control_plane open= P207 -skill done= False ordered= False after= cutover open= -``` - -After: - -```text -AFTER: live commander claude-code; todos: 214 -Core ready: record_plane -Core next startable (ready, no open order): (none) -record_plane done= False ordered= True after= retained_store open= P107,P167,P195,P197,P198,P204,P208,P213 -review_queue done= True ordered= False after= record_plane open= -control_plane done= False ordered= True after= record_plane open= P100,P199 -cutover done= False ordered= True after= record_plane,review_queue,control_plane open= P207 -story done= True ordered= False after= cutover open= -commander_page done= False ordered= False after= cutover open= -skill done= False ordered= False after= cutover open= -``` - -The retained state contradicts the initial description that nobody started the -story: Core reports `story` and `review_queue` done. Its unfinished native work -is in record plane and control plane. These are order-state observations, not -parity failures. - -## Source ownership evidence - -Reused refactor-audit's `audit.findings.Package.load` AST substrate at the base: -379 production modules, 476 test modules and 34 tool modules parsed, no omissions. -The first attempt to use NRA's parser could not import `tree_sitter` in the -available Python environment; the existing refactor-audit parser succeeded. - -- `plan_phases.py` alone declares phase classes, dependency writes, completion, - readiness and ordering decisions. No second dependency list was introduced. -- Production consumers: `standing_rules.py` (`PlanIdle`), `todos.py` - (`ProblemOrder.serves` and serve command), `tools.py` (serve tool), - `commander/order.py` (order parsing and serve verb). -- Test consumers at the base: `test_standing_rules.py` and `test_tool_replies.py`. -- Before: cutover depended on story and commander page; page depended on story. - After: cutover depends on the three native phases; both views depend on cutover; - no dependency names either view. Wire names and all consumer APIs are unchanged. -- Dynamic membership is derived through `DeclaredFamily.members_with`; static - name enumeration does not prove absence of arbitrary external runtime mutation. -- Search for `parity` across production, tests, tools and CI found no commander - comparison check. Unrelated Pi prompt-byte and entry-index parity tests do not - compare commander behaviour. - -## Verification - -The existing native test environment for this checkout was verified and reused; -no runtime package was built or installed. One sequential, bounded test run: - -```text -PYTHONPATH=src timeout 120 /home/ts/code/projects/agent-comms/.venv/bin/python -X faulthandler -m pytest --native-cache /home/ts/.cache/agent-comms/native -n 0 --no-cov -p no:cacheprovider -q tests/test_plan_phases.py tests/test_standing_rules.py -k 'cutover_waits or plan_idle' -2 passed, 16 deselected, 14 warnings in 4.63s -``` - -The new family test uses real checked orders and the serve tool. It verifies -cutover waits for each native prerequisite independently, unfinished views cannot -block cutover, and the views and skill become ready after cutover. The existing -PlanIdle test still passes. Warnings were an existing unknown pytest timeout -option and pytest cleanup trying to retire unrelated busy DQDock snapshot paths; -no manual cleanup of those paths was attempted. - -`/home/ts/bin/agent-resource-check` is absent on this machine. Before this small -run, available RAM was 3.7 GiB and disk free was 26 GiB. No parallel workers or -large build was launched. - -## Next identity choice - -B will use one shared phase family with an unnamed parent per plan. Existing -commander-native phase names remain stable; the remaining-slop declarations get -unambiguous names derived from their classes. A plan parent groups discovery and -rendering without copying phase readiness/claim behaviour into a second family. -Markdown/board references identify declarations, never titles or new UUIDs. - -Assumed: the missing parity comparison is reported, not invented; its absence -blocks parity acceptance and script deletion, but does not prevent reviewing this -dependency-only checkpoint. Both machines can write the approved shared claim -store. pi-cn-phase7 owns dispatch and will propose any checked-task capability; -this checkpoint does not fabricate runnable task bodies from phase names. - -## A2: corrected view prerequisites and false story completion - -Owner correction 1753: story waits on record plane; commander page waits on -record plane and control plane. Neither waits on cutover, and no phase waits -on either view. The earlier dependency on cutover was my mistake. - -Actual source evaluation of the same 214 retained commander orders: - -```text -Before A2 (A commit): -Core ready: record_plane -Core next startable (ready, no open order): (none) -story done= True after= cutover -commander_page done= False after= cutover - -After the A2 view dependency correction: -Core ready: record_plane -Core next startable (ready, no open order): (none) -cutover done= False after= record_plane,review_queue,control_plane -story done= True after= record_plane -commander_page done= False after= record_plane,control_plane -``` - -Story's only serving order is P149, revision 8, installed/done, order -`b258d208cd26`, serving `story`. Its text is: - -> every send-back wakes all units because verdicts declare no audience - -Its actual Works record is: - -> Merged to main as 74176e31a (#1678, delivered commit f72e47943); closed on merge evidence by owner directive 1520, run not re-observed: a send-back reaches only its unit and its order's thread, so other units take no turn for it, while a verdict every unit must act on still reaches all of them - -This is legitimately related to the story phase, but it is not acceptance of the -whole phase's order/fleet story tools. The wrong rule is `PlanPhase.done`: all -currently known serving subtasks closed does not mean the whole phase passed. -The retained record was not edited or reopened. The completion-rule repair will -use the phase's explicitly declared full-phase checked task, not infer completion -from this serving set. pi-cn-phase7 owns that task type and its accepted-order -semantics; I own its use in PlanPhase. Owner-confirmed review queue delivery is -PR #1654, merge 41e4ec7319432755d75dcd8dbbab35839a4eb005. - -The expanded family test confirms story can start after record plane while -control plane and cutover are still open; page starts after record and control -plane without waiting for cutover. Both focused tests passed (2 passed, 16 -deselected, 1 pre-existing timeout-option warning, 5.37 seconds). The run used -`--basetemp /home/ts/.cache/agent-scratch/pi-plan-claims/a2-view-check/pytest`; -that owned disposable directory is retired after the run. - -A2's executable per-matrix-row comparison is newly authorized. It is not yet -implemented. No failing parity rows or passing parity acceptance are asserted -by this view-dependency checkpoint. B remains after A2. - -### A2 completion-rule repair - -Integrated `work_dispatch.py` exactly from pi-cn-phase7's d123d18e2 (Git blob -67cccd2507bf12f5f69748a81c10675677912309), its imports/task declaration, and the -`ProblemOrder.accepted` declarations. No phase-7 enforcement or trace changes -were imported. pi-cn-phase7 retains ownership of dispatch implementation and its -verification. No production task briefs or automatic launches were added here. - -`PlanPhase.done` now asks its declared task about acceptance of the tasks serving -that phase. An unspecified full-phase task returns false. `CheckedPhaseTask` -matches the exact declared problem identity and requires a closed installed -order; a withdrawn/folded order is not accepted. Review queue is explicitly a -previously delivered phase, owner-confirmed as #1654, not inferred from a closed -subset of its subtasks. The Markdown plan now describes this same rule. - -Actual Core output after the completion-rule correction: - -```text -A2 completion correction; retained commander orders: 214 -Core ready: record_plane -Core next startable (ready, no open order): (none) -review_queue done= True after= record_plane -cutover done= False after= record_plane,review_queue,control_plane -story done= False after= record_plane -commander_page done= False after= record_plane,control_plane -P149 accepted= True -Story task= UnspecifiedPhaseTask -``` - -P149 is still legitimately closed; the story phase no longer falsely reads done. -Three focused tests passed (3 passed, 16 deselected, one existing timeout-option -warning, 7.26 seconds), covering phase/view prerequisites, exact whole-phase -acceptance versus closed subtasks/withdrawal/wrong-phase records, and PlanIdle. -The owned run directory was -`/home/ts/.cache/agent-scratch/pi-plan-claims/a2-completion-check/pytest`; disposable -run output is removed after acceptance of this check. No original records changed. -The full parity comparison remains unimplemented, so A2 as a whole is unfinished. From 984dd38534304aaf2e7d03e3321940b29859df0f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 15:11:54 -0400 Subject: [PATCH 13/41] Format work_dispatch.py Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/work_dispatch.py | 54 ++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index 7d105589e0..b1a45bc934 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -42,7 +42,10 @@ def start(self, comms: Comms) -> Thread: storage = comms.registry.storage_rule checkout = storage.order_checkout(name, self.action.problem) candidate = comms.registry.new_thread( - name, frozenset(), str(checkout), model=defaults.model, + name, + frozenset(), + str(checkout), + model=defaults.model, thinking_level=defaults.thinking_level, ) self.action.request_ref(GoalActionContext(comms.goals, candidate)) @@ -50,16 +53,32 @@ def start(self, comms: Comms) -> Thread: CheckoutStorageEffect(checkout).check(storage, candidate) subprocess.run( ("git", "-C", self.action.order.check.directory, "fetch", "origin", "main"), - check=True, capture_output=True, text=True, timeout=60, + check=True, + capture_output=True, + text=True, + timeout=60, ) subprocess.run( - ("git", "-C", self.action.order.check.directory, "worktree", "add", "-b", - f"auto/{name}", str(checkout), "origin/main"), - check=True, capture_output=True, text=True, timeout=60, + ( + "git", + "-C", + self.action.order.check.directory, + "worktree", + "add", + "-b", + f"auto/{name}", + str(checkout), + "origin/main", + ), + check=True, + capture_output=True, + text=True, + timeout=60, ) location = candidate.runtime.new_location(comms.root, checkout) child = comms.registry.declare( - replace(candidate, session_file=location.read_key), StoppedThreadStatus(), + replace(candidate, session_file=location.read_key), + StoppedThreadStatus(), ) # A failed publication leaves a stopped unit and its checkout, not an # untracked running task. A failed/uncertain start never releases the claim. @@ -77,9 +96,12 @@ def reissue(cls, comms: Comms, commander: Thread, todo: Todo) -> None: if current.holder or not current.pending: return action = OrderGoalAction( - problem=current.id, expected_revision=current.revision, - order=current.order.reissued(time.time()), request=current.goal.request(), - deadline=current.deadline, commander=commander.name, + problem=current.id, + expected_revision=current.revision, + order=current.order.reissued(time.time()), + request=current.goal.request(), + deadline=current.deadline, + commander=commander.name, ) cls(action, revision=current.revision).start(comms) @@ -133,12 +155,18 @@ def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None now = time.time() order = replace(self.order.reissued(now), serves=phase) action = CreateProblemGoalAction( - problem=self.problem, repo=self.repo, text=self.text, order=order, - request=order.request(commander, (self.problem,)), commander=commander.name, + problem=self.problem, + repo=self.repo, + text=self.text, + order=order, + request=order.request(commander, (self.problem,)), + commander=commander.name, deadline=RecordedEventTime(now + self.deadline_seconds), ) WorkDispatch(action, revision=0).start(comms) def done(self, todos: Iterable[Todo]) -> bool: - return any(todo.id == self.problem and not todo.state.open and todo.order.accepted - for todo in todos) + return any( + todo.id == self.problem and not todo.state.open and todo.order.accepted + for todo in todos + ) From 926878002a924e4fa396b4faee907334612dcf0a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 16:23:50 -0400 Subject: [PATCH 14/41] Break phase task import cycle at its shared capability owner --- src/agent_comms/phase_tasks.py | 33 +++++++++++++++++++ src/agent_comms/plan_phases.py | 3 +- src/agent_comms/todos.py | 4 +++ src/agent_comms/work_dispatch.py | 46 ++++++++------------------- tests/test_phase_task_dependencies.py | 27 ++++++++++++++++ 5 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 src/agent_comms/phase_tasks.py create mode 100644 tests/test_phase_task_dependencies.py diff --git a/src/agent_comms/phase_tasks.py b/src/agent_comms/phase_tasks.py new file mode 100644 index 0000000000..31d380dfd8 --- /dev/null +++ b/src/agent_comms/phase_tasks.py @@ -0,0 +1,33 @@ +"""Phase task capabilities, independent of order storage and work dispatch.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from .comms import Comms + from .plan_phases import PlanPhase + from .threads import Thread + from .todos import Todo + + +class PhaseTask(ABC): + instruction: ClassVar[str] + + @abstractmethod + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: ... + + @abstractmethod + def done(self, todos: Iterable[Todo]) -> bool: ... + + +class UnspecifiedPhaseTask(PhaseTask): + instruction = "no checked task declared; supply its source, check and brief" + + def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: + """Readiness alone cannot authorize a fabricated order.""" + + def done(self, todos: Iterable[Todo]) -> bool: + return False diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index d93984f5f0..bbb309bd26 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -13,11 +13,10 @@ from typing import TYPE_CHECKING, ClassVar from .declared_family import DeclaredFamily -from .work_dispatch import UnspecifiedPhaseTask +from .phase_tasks import PhaseTask, UnspecifiedPhaseTask if TYPE_CHECKING: from .todos import Todo - from .work_dispatch import PhaseTask class OrderPhase(DeclaredFamily, affix="Phase"): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index af62b2719f..874d9c8a12 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1334,6 +1334,10 @@ def checked(self) -> CheckedProblemOrder: def require_transfer(self, todo: Todo) -> None: raise ValueError("This problem cannot receive another order.") + def reissued(self, at: float) -> PendingProblemOrder: + """This order sent again at ``at`` to a new owner, its first-call window kept.""" + raise ValueError("Only an undelivered order can change owner.") + def require_close(self): raise ValueError("Closing requires recorded installed application evidence.") diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index b1a45bc934..e8d10f4404 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -9,14 +9,24 @@ import subprocess import time -from abc import ABC, abstractmethod from collections.abc import Iterable from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING + +from .goal_actions import ( + CreateProblemGoalAction, + GoalActionContext, + OrderGoalAction, + ProblemOrderGoalAction, +) +from .native_settings import NativeThreadDefaults +from .phase_tasks import PhaseTask +from .recorded_time import RecordedEventTime +from .storage_rules import CheckoutStorageEffect +from .thread_status import StoppedThreadStatus if TYPE_CHECKING: from .comms import Comms - from .goal_actions import ProblemOrderGoalAction from .plan_phases import PlanPhase from .threads import Thread from .todos import PendingProblemOrder, Todo @@ -30,11 +40,6 @@ class WorkDispatch: revision: int def start(self, comms: Comms) -> Thread: - from .goal_actions import GoalActionContext - from .native_settings import NativeThreadDefaults - from .storage_rules import CheckoutStorageEffect - from .thread_status import StoppedThreadStatus - defaults = NativeThreadDefaults() # A concurrent rotation of this same row contends on one git checkout, # not on two newly generated unit names. Never adopt a partial attempt. @@ -89,8 +94,6 @@ def start(self, comms: Comms) -> Thread: @classmethod def reissue(cls, comms: Comms, commander: Thread, todo: Todo) -> None: """Retained checked work needs no already-idle unit.""" - from .goal_actions import OrderGoalAction - current = comms.goals.todos.get(todo.id) current.require_revision(todo.revision) if current.holder or not current.pending: @@ -106,26 +109,6 @@ def reissue(cls, comms: Comms, commander: Thread, todo: Todo) -> None: cls(action, revision=current.revision).start(comms) -class PhaseTask(ABC): - instruction: ClassVar[str] - - @abstractmethod - def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: ... - - @abstractmethod - def done(self, todos: Iterable[Todo]) -> bool: ... - - -class UnspecifiedPhaseTask(PhaseTask): - instruction = "no checked task declared; supply its source, check and brief" - - def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: - """Readiness alone cannot authorize a fabricated order.""" - - def done(self, todos: Iterable[Todo]) -> bool: - return False - - @dataclass(frozen=True) class CheckedPhaseTask(PhaseTask): """An explicitly authored task; its order owns source, check, edits and run.""" @@ -142,9 +125,6 @@ def __post_init__(self) -> None: raise ValueError("A phase task needs a positive deadline of at most one day.") def start(self, comms: Comms, commander: Thread, phase: type[PlanPhase]) -> None: - from .goal_actions import CreateProblemGoalAction - from .recorded_time import RecordedEventTime - history = comms.goals.todos.history_for_commander(commander.incarnation) if not phase.ready(history) or phase.ordered(history): return diff --git a/tests/test_phase_task_dependencies.py b/tests/test_phase_task_dependencies.py new file mode 100644 index 0000000000..02fd3986b7 --- /dev/null +++ b/tests/test_phase_task_dependencies.py @@ -0,0 +1,27 @@ +"""Phase declarations stay usable before action and storage modules are loaded.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + +from agent_comms.phase_tasks import UnspecifiedPhaseTask +from agent_comms.todos import UnorderedProblemOrder + + +@pytest.mark.parametrize("module", ("plan_phases", "work_dispatch", "goal_actions", "todos")) +def test_import_order_does_not_reintroduce_dispatch_cycle(module): + checkout = Path(__file__).resolve().parents[1] + result = subprocess.run( + (sys.executable, "-c", f"import agent_comms.{module}"), + cwd=checkout, + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + + +def test_unspecified_phase_and_unordered_problem_cannot_start_checked_work(): + assert not UnspecifiedPhaseTask().done(()) + with pytest.raises(ValueError, match="Only an undelivered order"): + UnorderedProblemOrder().reissued(1) From ab315464bd0df8db4662c22444e5e6d39a5051e0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:07:13 -0400 Subject: [PATCH 15/41] Preserve phase identity when an installed merge retains its checked order --- src/agent_comms/plan_phases.py | 4 ++-- src/agent_comms/todos.py | 20 ++++++++++++----- tests/test_installed_merge.py | 7 +++++- tests/test_plan_phases.py | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index bbb309bd26..c42d40d3c8 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -2,7 +2,7 @@ This family is the one owner of the plan's order and dependencies; ``plans/commander-native-plan.md`` points here. An order names the phase it -serves in ``ProblemOrder.serves``; an order outside the plan serves +serves through ``ProblemOrder.phase``; an order outside the plan serves ``UnplannedPhase``. A phase is done only when its declared full-phase task is accepted; closing related subtasks does not establish completion of the phase. """ @@ -33,7 +33,7 @@ class PlanPhase(OrderPhase, declared_name=None): @classmethod def serving(cls, todos: Iterable[Todo]) -> tuple[Todo, ...]: - return tuple(todo for todo in todos if todo.order.serves is cls) + return tuple(todo for todo in todos if todo.order.phase is cls) @classmethod def done(cls, todos: Iterable[Todo]) -> bool: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 874d9c8a12..ae859510d7 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1331,13 +1331,16 @@ def pull_request_ids(cls, body: str) -> tuple[str, ...]: def checked(self) -> CheckedProblemOrder: raise ValueError("This problem has no checked order.") + @property + def phase(self) -> type[OrderPhase]: + return UnplannedPhase + + def for_phase(self, phase: type[OrderPhase]) -> ProblemOrder: + raise ValueError("Only a checked order can declare a plan phase.") + def require_transfer(self, todo: Todo) -> None: raise ValueError("This problem cannot receive another order.") - def reissued(self, at: float) -> PendingProblemOrder: - """This order sent again at ``at`` to a new owner, its first-call window kept.""" - raise ValueError("Only an undelivered order can change owner.") - def require_close(self): raise ValueError("Closing requires recorded installed application evidence.") @@ -1418,6 +1421,13 @@ def checked(self) -> CheckedProblemOrder: default_factory=NoOrderDeletion, metadata={"wire_omit_default": True}) serves: type[OrderPhase] = field(default=UnplannedPhase, metadata={"wire_omit_default": True}) + @property + def phase(self) -> type[OrderPhase]: + return self.serves + + def for_phase(self, phase: type[OrderPhase]) -> CheckedProblemOrder: + return replace(self, serves=phase) + def __post_init__(self): problems = [] if not self.edits or not self.banned: @@ -2173,7 +2183,7 @@ class ServeTodo(TodoOperation): def apply(self, current: Todo) -> Todo: current.require_revision(self.expected_revision) current.require_commander(self.actor) - return replace(current, order=replace(current.order, serves=self.serves), + return replace(current, order=current.order.for_phase(self.serves), revision=current.revision + 1) diff --git a/tests/test_installed_merge.py b/tests/test_installed_merge.py index 4e435dbdbf..f075327787 100644 --- a/tests/test_installed_merge.py +++ b/tests/test_installed_merge.py @@ -7,9 +7,10 @@ from agent_comms.repo import Repo from agent_comms.field_codec import FieldCodec +from agent_comms.plan_phases import RecordPlanePhase from agent_comms.todos import (CheckedProblemOrder, DoneTodoState, MergedProblemOrder, OperationDelivery, MergedPullRequest, PullRequests, UnorderedProblemOrder) -from agent_comms.tools import CommsOrderDoneTool +from agent_comms.tools import CommsOrderDoneTool, CommsOrderServeTool from agent_comms.recorded_time import RecordedEventTime from cutover.carry_merged_orders import MergedOrders from test_commander_problem_orders import working @@ -29,6 +30,8 @@ def test_installed_merge_closes_pending_or_delivered_order_once( delivery=OperationDelivery(at=RecordedEventTime(500)), claims=working(comms)).apply(comms, caller) todo = comms.goals.todos.get(todo.id) + CommsOrderServeTool(problem=todo.id, serves=RecordPlanePhase).apply(comms, caller) + todo = comms.goals.todos.get(todo.id) original = todo.order commit(unit) git(unit, 'switch', '-q', 'main') @@ -70,6 +73,8 @@ def test_installed_merge_closes_pending_or_delivered_order_once( for item in fields(CheckedProblemOrder)) assert closed.order.id == original.id assert "original" not in FieldCodec.encode(closed.order) + assert closed.order.phase is RecordPlanePhase + assert RecordPlanePhase.serving((closed,)) == (closed,) assert closed.strikes == todo.strikes assert FieldCodec.decode(type(closed), FieldCodec.encode(closed)) == closed diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py index dc17a3e3e8..670b334b83 100644 --- a/tests/test_plan_phases.py +++ b/tests/test_plan_phases.py @@ -1,5 +1,9 @@ from dataclasses import replace +import pytest + +from agent_comms.field_codec import FieldCodec +from agent_comms.standing_rules import PlanIdle from agent_comms.plan_phases import ( CommanderPagePhase, ControlPlanePhase, @@ -8,10 +12,13 @@ RecordPlanePhase, SkillPhase, StoryPhase, + UnplannedPhase, ) from agent_comms.todos import ( DoneTodoState, + FoldedProblemOrder, InstalledProblemOrder, + ProblemOrder, OperationDelivery, UnorderedProblemOrder, WithdrawnProblemOrder, @@ -88,6 +95,40 @@ def test_views_wait_for_native_work_but_never_block_cutover(commander, monkeypat ) +def test_unordered_findings_have_no_invented_phase_declaration(commander): + comms, caller = commander + todo = create(commander, "P1") + finding = replace(todo, assignment=None, order=UnorderedProblemOrder()) + with comms.goals.todos.writing() as db: + finding.upsert(db) + assert finding.order.phase is UnplannedPhase + assert not RecordPlanePhase.serving((finding,)) + assert [rule.phase for rule in PlanIdle.violations(comms, caller.name)] == [RecordPlanePhase] + with pytest.raises(ValueError, match="Only a checked order"): + CommsOrderServeTool(problem=finding.id, serves=StoryPhase).apply(comms, caller) + assert comms.goals.todos.get(finding.id) == finding + + +def test_retained_phase_is_derived_and_round_trips_without_a_second_field(commander): + todo = create(commander, "P1") + original = todo.order.for_phase(RecordPlanePhase) + retained = ( + original.merged("a" * 40), + FoldedProblemOrder(original=original, into="P2", why="Keep one owner"), + ) + for order in retained: + saved = replace(todo, order=order, state=DoneTodoState, assignment=None) + assert RecordPlanePhase.serving((saved,)) == (saved,) + wire = FieldCodec.encode(order) + assert "serves" not in wire + restored = FieldCodec.decode(ProblemOrder, wire) + assert restored == order and restored.phase is RecordPlanePhase + changed = replace(order, original=original.for_phase(ControlPlanePhase)) + assert changed.phase is ControlPlanePhase + assert original.phase is RecordPlanePhase + assert not order.accepted + + def test_closed_related_subtask_does_not_accept_a_whole_phase(commander, monkeypatch): comms, caller = commander subtask = create(commander, "P149") From 21f5780a3a6b22a07ff140c6b39e2e478141e791 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:24:28 -0400 Subject: [PATCH 16/41] Integrate checked phase dispatch with current commander and merge owners --- src/agent_comms/work_dispatch.py | 1 + tests/test_installed_merge.py | 16 ++++++++------- tests/test_plan_phases.py | 35 ++++++++++++++------------------ 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index e8d10f4404..e9e880a3ec 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -105,6 +105,7 @@ def reissue(cls, comms: Comms, commander: Thread, todo: Todo) -> None: request=current.goal.request(), deadline=current.deadline, commander=commander.name, + assigner=commander.incarnation, ) cls(action, revision=current.revision).start(comms) diff --git a/tests/test_installed_merge.py b/tests/test_installed_merge.py index f075327787..7618ebd849 100644 --- a/tests/test_installed_merge.py +++ b/tests/test_installed_merge.py @@ -2,6 +2,7 @@ from dataclasses import fields, replace import json import subprocess +import time import pytest @@ -46,8 +47,7 @@ def test_installed_merge_closes_pending_or_delivered_order_once( git(installed, 'fetch', '-q', 'origin', 'main') git(installed, 'checkout', '-q', '-B', 'main', base) repo = Repo(checkout=installed, github=todo.repo) - config = script.CONFIG.model_copy(update={'repos': (repo,)}) - monkeypatch.setattr(script, 'CONFIG', config) + actor = comms.registry.require(caller.name).incarnation merge = MergedPullRequest(1, 'fix/p1', sha, merge=sha, merged_at=500, updated_at=500, orders=(original.id,)) monkeypatch.setattr(PullRequests, 'read', lambda self, repo, state, order: @@ -55,14 +55,15 @@ def test_installed_merge_closes_pending_or_delivered_order_once( # The object being available locally is not installation: HEAD must contain it. assert not repo.contains(sha) - assert script.COMMANDER.orders(script.CONFIG.repos) == (todo,) + comms.goals.todos.reconcile_merges(actor, (repo,), comms.registry.storage_rule) + assert script.held_orders() == (todo,) assert comms.goals.todos.get(todo.id) == todo if not delivered: assert 'waiting on install' in deadline_lines(script)[0] git(installed, 'checkout', '-q', '-B', 'main', sha) assert repo.contains(sha) - assert script.COMMANDER.orders(script.CONFIG.repos) == () + comms.goals.todos.reconcile_merges(actor, (repo,), comms.registry.storage_rule) assert script.held_orders() == () closed = comms.goals.todos.get(todo.id) assert closed.state is DoneTodoState and closed.assignment is None @@ -98,9 +99,10 @@ def no_child(*args, **kwargs): with monkeypatch.context() as repeat: repeat.setattr(subprocess, 'run', no_child) - assert script.COMMANDER.orders(script.CONFIG.repos) == () + comms.goals.todos.reconcile_merges(actor, (repo,), comms.registry.storage_rule) + assert script.held_orders() == () assert comms.goals.todos.get(todo.id) == closed - script.board() + script.write_board() assert f'| {todo.id}:' not in (tmp_path / 'board.md').read_text() assert deadline_lines(script) == [] assert comms.goals.todos.get(todo.id) == closed @@ -111,7 +113,7 @@ class TickComplete(Exception): def next_tick(seconds): raise TickComplete - monkeypatch.setattr(script.time, 'sleep', next_tick) + monkeypatch.setattr(time, 'sleep', next_tick) with pytest.raises(TickComplete): script.overdue() assert 'OVERDUE:' not in capsys.readouterr().out diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py index 670b334b83..669ccccd83 100644 --- a/tests/test_plan_phases.py +++ b/tests/test_plan_phases.py @@ -21,7 +21,6 @@ ProblemOrder, OperationDelivery, UnorderedProblemOrder, - WithdrawnProblemOrder, ) from agent_comms.tools import CommsOrderServeTool from agent_comms.work_dispatch import CheckedPhaseTask @@ -109,24 +108,22 @@ def test_unordered_findings_have_no_invented_phase_declaration(commander): assert comms.goals.todos.get(finding.id) == finding -def test_retained_phase_is_derived_and_round_trips_without_a_second_field(commander): +def test_merged_phase_is_inherited_and_round_trips_without_a_wrapper(commander): todo = create(commander, "P1") original = todo.order.for_phase(RecordPlanePhase) - retained = ( - original.merged("a" * 40), - FoldedProblemOrder(original=original, into="P2", why="Keep one owner"), - ) - for order in retained: - saved = replace(todo, order=order, state=DoneTodoState, assignment=None) - assert RecordPlanePhase.serving((saved,)) == (saved,) - wire = FieldCodec.encode(order) - assert "serves" not in wire - restored = FieldCodec.decode(ProblemOrder, wire) - assert restored == order and restored.phase is RecordPlanePhase - changed = replace(order, original=original.for_phase(ControlPlanePhase)) - assert changed.phase is ControlPlanePhase - assert original.phase is RecordPlanePhase - assert not order.accepted + order = original.merged("a" * 40) + saved = replace(todo, order=order, state=DoneTodoState, assignment=None) + assert RecordPlanePhase.serving((saved,)) == (saved,) + wire = FieldCodec.encode(order) + assert "original" not in wire + restored = FieldCodec.decode(ProblemOrder, wire) + assert restored == order and restored.phase is RecordPlanePhase + changed = order.for_phase(ControlPlanePhase) + assert changed.phase is ControlPlanePhase + assert original.phase is RecordPlanePhase + assert not order.accepted + folded = FoldedProblemOrder(original=original, into="P2", why="Keep one owner") + assert folded.phase is UnplannedPhase def test_closed_related_subtask_does_not_accept_a_whole_phase(commander, monkeypatch): @@ -143,9 +140,7 @@ def test_closed_related_subtask_does_not_accept_a_whole_phase(commander, monkeyp assert not StoryPhase.done((subtask, whole)) assert StoryPhase.done((subtask, accepted(whole))) - withdrawn = whole.order.copy_as( - WithdrawnProblemOrder, previous=whole.held(), why="The order was sent in error.", - ) + withdrawn = whole.order.withdrawn(whole.held(), "The order was sent in error.", None) assert not StoryPhase.done((replace(whole, state=DoneTodoState, order=withdrawn),)) assert not UnorderedProblemOrder().accepted wrong_phase = replace(accepted(whole), order=replace(accepted(whole).order, serves=ControlPlanePhase)) From e36e9718ebcd53a4f69fb70ef5d1870a6ab101d7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 18:43:44 -0400 Subject: [PATCH 17/41] Run both ratchets and practice installs for GitHub merge groups Use the existing workflow identity for bootstrap dispatch. All jobs target ts-gaming; private checks stay behind a restricted host entrypoint. Enqueue approved heads only after all checks pass. --- .github/workflows/debt-ratchet.yml | 85 ++++++++++++++++++------------ 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 2f267f1842..5921689c2b 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -1,47 +1,64 @@ -name: Debt ratchet +name: Merge queue on: - pull_request: - branches: [main] + merge_group: + types: [checks_requested] + pull_request_review: + types: [submitted] workflow_dispatch: - inputs: - base: - required: true - type: string - head: - required: true - type: string permissions: contents: read + pull-requests: read concurrency: - group: debt-ratchet-${{ github.ref }} - cancel-in-progress: true + group: merge-queue-${{ github.event.merge_group.head_sha || github.sha }} + cancel-in-progress: false jobs: - debt-ratchet: - name: Debt ratchet - runs-on: ubuntu-latest - timeout-minutes: 5 + ratchet: + if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' + name: debt-${{ matrix.kind }} + runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + kind: [src, tools] + steps: + - name: Check the exact GitHub run through the restricted host entrypoint + env: + RUN_ID: ${{ github.run_id }} + CHECK_KIND: ${{ matrix.kind }} + run: | + set -euo pipefail + ssh -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID $CHECK_KIND" + + practice-install: + if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' + name: install-rehearsal + runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ github.event.pull_request.head.sha || inputs.head }} - - uses: actions/setup-python@v5 - with: - python-version-file: pyproject.toml - - name: Install development dependencies - run: python -m pip install --disable-pip-version-check -e src/agent_comms/skills/refactor-audit '.[all,dev]' - - name: Compare touched production files + - name: Practice install; private buses and logs never enter the runner sandbox env: - PR_BASE: ${{ github.event.pull_request.base.sha || inputs.base }} - PR_HEAD: ${{ github.event.pull_request.head.sha || inputs.head }} - run: python tools/debt_ratchet.py --root src/agent_comms --base "$PR_BASE" --head "$PR_HEAD" - - name: Run every marked surface guard - if: ${{ github.event_name == 'workflow_dispatch' }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + ssh -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID install" + + enqueue-approved: + if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' + needs: [ratchet, practice-install] + runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] + timeout-minutes: 5 + steps: + - name: Enter GitHub's queue after approval and successful checks env: - PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' - run: python -m pytest -p pytest_asyncio.plugin -o addopts='' --strict-markers -m refactor_guard -q + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + ssh -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID enqueue" From 9305d62aedb3d958cf8f81d2f8a903a6b36a4251 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:02:19 -0400 Subject: [PATCH 18/41] Isolate Actions jobs and authorize host checks by GitHub run identity Working checkpoint for #2023. Four focused authorization/schema checks, Ruff and shell syntax pass. Native runner is registered as ID 21, initially offline. Required source allocation, P714-owned review-context export, real queue merge and private-copy allocation remain unfinished; no success or installation is claimed for those paths. --- .github/workflows/debt-ratchet.yml | 31 +++- docs/merge-queue.md | 59 ++++++ tests/test_merge_queue.py | 56 ++++++ tools/merge_gate_command.sh | 22 +++ tools/merge_queue.py | 282 +++++++++++++++++++++++++++++ tools/run_merge_runner.sh | 23 +++ 6 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 docs/merge-queue.md create mode 100644 tests/test_merge_queue.py create mode 100755 tools/merge_gate_command.sh create mode 100644 tools/merge_queue.py create mode 100755 tools/run_merge_runner.sh diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 5921689c2b..f6e872acd9 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -1,23 +1,40 @@ name: Merge queue on: + status: merge_group: types: [checks_requested] - pull_request_review: - types: [submitted] workflow_dispatch: + inputs: + enter_queue: + description: Enqueue the already reviewed head after successful checks + type: boolean + default: false permissions: contents: read - pull-requests: read concurrency: - group: merge-queue-${{ github.event.merge_group.head_sha || github.sha }} + group: merge-queue-${{ github.event.merge_group.head_sha || github.event.sha || github.sha }} cancel-in-progress: false jobs: + dispatch-reviewed: + if: github.event_name == 'status' && github.event.context == vars.AGENT_COMMS_REVIEW_CONTEXT && github.event.state == 'success' + runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] + timeout-minutes: 5 + steps: + - name: Dispatch checks on the approved PR head, not the default-branch status event + env: + RUN_ID: ${{ github.run_id }} + REVIEWED_HEAD: ${{ github.event.sha }} + run: | + set -euo pipefail + ssh -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID dispatch $REVIEWED_HEAD" + ratchet: - if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' + if: github.event_name != 'status' name: debt-${{ matrix.kind }} runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] timeout-minutes: 10 @@ -36,7 +53,7 @@ jobs: -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID $CHECK_KIND" practice-install: - if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' + if: github.event_name != 'status' name: install-rehearsal runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] timeout-minutes: 60 @@ -50,7 +67,7 @@ jobs: -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID install" enqueue-approved: - if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' + if: github.event_name == 'workflow_dispatch' && inputs.enter_queue needs: [ratchet, practice-install] runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] timeout-minutes: 5 diff --git a/docs/merge-queue.md b/docs/merge-queue.md new file mode 100644 index 0000000000..2305473d3c --- /dev/null +++ b/docs/merge-queue.md @@ -0,0 +1,59 @@ +# Automatic merge queue + +Owner: pi-install-gate. Draft PR #2023. Not accepted or enabled yet. + +GitHub owns queue order, proposed merge identity and failed-entry removal. The +existing debt workflow runs `debt-src`, `debt-tools` and `install-rehearsal` on +`merge_group`; there is no second merge scheduler. + +Approval is the existing exact-head `agent-comms/review` verdict, not a new +native GitHub review requirement. All current contributors use the same GitHub +identity, so requiring them to approve their own native PRs would strand them. +A status event runs on the default branch, not its referenced PR head. The +restricted host entrypoint validates the real approved head and dispatches the +workflow on its PR branch. Successful checks then request auto-merge against +that exact head. GitHub builds the singleton merge group and reruns all checks. + +## Isolation + +The registered repository runner is `ts-gaming-agent-comms-merge` (ID 21), with +labels `self-hosted`, `Linux`, `X64`, `ts-gaming`, `agent-comms-merge`. +The runner namespace exposes only the vendor runtime, its work/log directories, +system binaries and a sealed runner home. Host homes, buses, personal tokens, +SSH agents and desktop sockets are not mounted. Its SSH key is restricted to +loopback and one fixed command. No workflow supplies a shell command, checkout, +interpreter or log location to the host. + +`tools/merge_queue.py` decodes GitHub answers with the existing FieldCodec and +ExternalRecord. Declared event/check families own eligibility and behavior. +The receiver checks the actual in-progress GitHub run and its workflow path. +An administrator can dispatch qualification checks. A merge group also requires +approval of its original PR parent. The existing install gate still owns source +coverage, private snapshotting and installer invocation. SSH returns an outcome; +all host diagnostics and bus copies stay outside the runner namespace. + +The vendor release is 2.338.0, checked against its GitHub release SHA-256 before +extraction. Owned package, runner state and qualification logs are under +`/home/ts/.cache/agent-scratch/pi-install-gate/`; together with the two existing +checkouts they remain below the current 3 GB limit. P2 remains borrowed for the +pinned read-only audit scripts. Do not retire it while a check needs it. + +## Acceptance checklist + +- [x] First checkpoint committed/pushed within ten minutes (f0dc3df0c). +- [x] Native queue ruleset accepted by GitHub, staged disabled (25439636). +- [x] Runner registered with the repository, initially offline. +- [x] Minimal runner namespace works without host home mounts. +- [ ] Isolated runner executes the three real checks. +- [ ] Review context comes from P714's existing verdict declaration. +- [ ] New host entrypoint has commander-granted source allocation and clean ratchets. +- [ ] Bootstrap changes are reviewed and merged; production entrypoints use main. +- [ ] Queue enabled, with all three checks required and no bypass. +- [ ] First genuinely approved PR merges through the queue without a commander merge. +- [ ] Runtime/storage/install candidates pass actual two-machine private rehearsals. + +The last item is blocked by the existing 3 GB worker allocation versus the +26 GiB desktop bus, independently of runner registration. Private copies are +therefore default-off in the host entrypoint. Non-install-source changes invoke +the real gate and may pass its existing outside-surface result; that is not a +successful private install rehearsal. Do not report it as one. diff --git a/tests/test_merge_queue.py b/tests/test_merge_queue.py new file mode 100644 index 0000000000..a379b75cba --- /dev/null +++ b/tests/test_merge_queue.py @@ -0,0 +1,56 @@ +"""Authorization/schema checks; acceptance still requires an actual queued merge.""" + +import unittest +from pathlib import Path +from unittest.mock import patch + +from agent_comms.declared_family import UnknownFamilyMember +from agent_comms.field_codec import FieldCodec, MalformedRecord +from merge_queue import ( + GateError, + Host, + InstallQueueCheck, + QueueCheck, + SrcQueueCheck, + Target, + WorkflowDispatchRunEvent, + WorkflowRun, +) + + +class MergeQueueTests(unittest.TestCase): + def test_declared_check_is_the_wire_decoder(self): + check = QueueCheck.member_named("src").from_tokens(["123", "src"]) + self.assertIs(type(check), SrcQueueCheck) + self.assertEqual(check.run_id, 123) + with self.assertRaises(UnknownFamilyMember): + QueueCheck.member_named("ratchet") + + def test_github_event_decodes_to_its_behavior(self): + record = FieldCodec.decode(WorkflowRun, { + "event": "workflow_dispatch", "head_sha": "a" * 40, + "head_branch": "feature", "status": "in_progress", + "path": ".github/workflows/debt-ratchet.yml", "actor": {"login": "owner"}, + }) + self.assertIs(record.event, WorkflowDispatchRunEvent) + with self.assertRaises(MalformedRecord): + FieldCodec.decode(WorkflowRun, { + "event": "pull_request", "head_sha": "a" * 40, + "head_branch": "feature", "status": "in_progress", + "path": ".github/workflows/debt-ratchet.yml", "actor": {"login": "owner"}, + }) + + def test_a_workflow_cannot_supply_git_options(self): + host = Host(Path("."), "owner/repo", Path("."), False) + with patch("merge_queue.run") as command: + with self.assertRaises(GateError): + host.fetch("--upload-pack=anything") + command.assert_not_called() + + def test_unallocated_private_copies_fail_before_installer_execution(self): + host = Host(Path("."), "owner/repo", Path("."), False) + with patch("merge_queue.affected", return_value=["src/agent_comms/comms.py"]): + with patch.object(Host, "command") as command: + with self.assertRaisesRegex(GateError, "allocation is not authorized"): + InstallQueueCheck(123).check(host, Target("a" * 40, "b" * 40)) + command.assert_not_called() diff --git a/tools/merge_gate_command.sh b/tools/merge_gate_command.sh new file mode 100755 index 0000000000..09781cbc93 --- /dev/null +++ b/tools/merge_gate_command.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Only this fixed command belongs in the loopback-only authorized_keys entry. +# No forwarded environment, arbitrary shell command, or workflow stdin is used. +set -euo pipefail +state=${1:?usage: merge_gate_command.sh STATE_ROOT MAIN_CHECKOUT PYTHON} +repo=${2:?main checkout required} +python=${3:?shared check interpreter required} +mkdir -p "$state/host-logs" +log=$(mktemp "$state/host-logs/check-XXXXXXXX.log") +exec "$log" 2>&1; then + printf 'check passed\n' +else + printf 'check failed; private diagnostics retained on ts-gaming\n' + exit 1 +fi diff --git a/tools/merge_queue.py b/tools/merge_queue.py new file mode 100644 index 0000000000..6cf2d272a6 --- /dev/null +++ b/tools/merge_queue.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Restricted SSH entrypoint for the repository's isolated Actions runner. + +GitHub owns queue order and run identity. The existing install gate owns source +coverage and rehearsal. A workflow receives exit status, never bus data or logs. +""" + +from __future__ import annotations + +import argparse +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar, Literal, Self + +from install_gate import REPO, ExternalRecord, GateError, RepositoryAnswer, Target, affected, run + +from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import FieldCodec + +WORKFLOW = ".github/workflows/debt-ratchet.yml" + + +@dataclass(frozen=True) +class ApprovedHead(ExternalRecord): + state: Literal["success"] + context: ClassVar[str] = "agent-comms/review" + + @classmethod + def require(cls, host: Host, head: str) -> None: + query = f'.statuses | map(select(.context == "{cls.context}")) | first' + answer = run("gh", "api", f"repos/{host.repository}/commits/{head}/status", "--jq", query) + FieldCodec.decode(cls, FieldCodec.parse(answer)) + + +@dataclass(frozen=True) +class PullRequestNumber(ExternalRecord): + number: int + + +@dataclass(frozen=True) +class QueuedPullRequest(ExternalRecord): + number: int + head: str = field(metadata={"wire_name": "headRefOid"}) + branch: str = field(metadata={"wire_name": "headRefName"}) + base: Literal["main"] = field(metadata={"wire_name": "baseRefName"}) + cross_repository: Literal[False] = field(metadata={"wire_name": "isCrossRepository"}) + draft: Literal[False] = field(metadata={"wire_name": "isDraft"}) + state: Literal["OPEN"] + + @classmethod + def for_head(cls, host: Host, head: str) -> Self: + links = FieldCodec.decode( + tuple[PullRequestNumber, ...], + FieldCodec.parse(run( + "gh", "api", f"repos/{host.repository}/commits/{head}/pulls", "--jq", + f'[.[] | select(.head.sha == "{head}" and .state == "open") | {{number: .number}}]', + )), + ) + fields = ",".join(key for _, key in FieldCodec.record_fields(cls)) + candidates = tuple( + FieldCodec.decode( + cls, + FieldCodec.parse( + run( + "gh", "pr", "view", link.number, "--repo", host.repository, "--json", fields + ) + ), + ) + for link in links + ) + matching = tuple(pr for pr in candidates if pr.head == head) + if len(matching) != 1: + raise GateError("An approved head must identify exactly one open, local PR") + return matching[0] + + +@dataclass(frozen=True) +class Actor(ExternalRecord): + login: str + + +@dataclass(frozen=True) +class Administrator(ExternalRecord): + permission: Literal["admin"] + + +class RunEvent(DeclaredFamily, affix="RunEvent"): + @classmethod + def target(cls, host: Host, workflow: WorkflowRun) -> Target: + raise NotImplementedError + + +class WorkflowDispatchRunEvent(RunEvent, declared_name="workflow_dispatch"): + @classmethod + def target(cls, host: Host, workflow: WorkflowRun) -> Target: + FieldCodec.decode( + Administrator, + FieldCodec.parse( + run( + "gh", "api", + f"repos/{host.repository}/collaborators/{workflow.actor.login}/permission", + ) + ), + ) + head = host.fetch(workflow.head_sha) + base = run("git", "merge-base", "origin/main", head, cwd=host.repo) + return Target(head, base) + + +class MergeGroupRunEvent(RunEvent, declared_name="merge_group"): + @classmethod + def target(cls, host: Host, workflow: WorkflowRun) -> Target: + if not workflow.head_branch.startswith("gh-readonly-queue/main/"): + raise GateError("Not a main-branch merge group") + head = host.fetch(workflow.head_sha) + parents = run("git", "show", "-s", "--format=%P", head, cwd=host.repo).split() + if len(parents) != 2: + raise GateError("The singleton merge queue requires a two-parent proposed merge") + ApprovedHead.require(host, parents[1]) + return Target(head, parents[0]) + + +@dataclass(frozen=True) +class WorkflowIdentity(ExternalRecord): + head_sha: str + head_branch: str + status: Literal["in_progress"] + path: str + actor: Actor + + @classmethod + def require(cls, host: Host, identifier: int) -> Self: + result = FieldCodec.decode( + cls, + FieldCodec.parse( + run("gh", "api", f"repos/{host.repository}/actions/runs/{identifier}") + ), + ) + if result.path.partition("@")[0] != WORKFLOW: + raise GateError("The SSH key is restricted to the merge queue workflow") + return result + + +@dataclass(frozen=True) +class WorkflowRun(WorkflowIdentity): + event: type[RunEvent] + + +@dataclass(frozen=True) +class StatusWorkflowRun(WorkflowIdentity): + event: Literal["status"] + + +@dataclass(frozen=True) +class Host: + repo: Path + repository: str + scratch: Path + private_copies_authorized: bool + + def fetch(self, head: str) -> str: + if re.fullmatch(r"[0-9a-f]{40}", head) is None: + raise GateError("A check requires a full GitHub commit identifier") + run("git", "fetch", "origin", head, cwd=self.repo) + return run("git", "rev-parse", f"{head}^{{commit}}", cwd=self.repo) + + def command(self, *arguments: str | Path | int) -> None: + subprocess.run(tuple(map(str, arguments)), cwd=self.repo, check=True) + + +@dataclass(frozen=True) +class QueueCheck(DeclaredFamily, affix="QueueCheck"): + run_id: int + + @classmethod + def from_tokens(cls, tokens: list[str]) -> Self: + identifier, _ = tokens + return cls(int(identifier)) + + def execute(self, host: Host) -> None: + workflow = WorkflowRun.require(host, self.run_id) + self.check(host, workflow.event.target(host, workflow)) + + def check(self, host: Host, target: Target) -> None: + raise NotImplementedError + + +class RatchetQueueCheck(QueueCheck, declared_name=None): + root: ClassVar[str] + + def check(self, host: Host, target: Target) -> None: + host.command( + sys.executable, "-X", "faulthandler", host.repo / "tools/debt_ratchet.py", + "--root", self.root, "--base", target.base, "--head", target.head, + ) + + +class SrcQueueCheck(RatchetQueueCheck): + root = "src/agent_comms" + + +class ToolsQueueCheck(RatchetQueueCheck): + root = "tools" + + +class InstallQueueCheck(QueueCheck): + def check(self, host: Host, target: Target) -> None: + if not host.private_copies_authorized and not target.outside( + tuple(affected(host.repo, target.base, target.head)) + ): + raise GateError( + "Private-copy allocation is not authorized: worker cap 3 GB, desktop bus 26 GiB" + ) + host.command( + sys.executable, "-X", "faulthandler", host.repo / "tools/install_gate.py", + "--repo", host.repo, "--revision", target.head, "--base", target.base, + "--scratch", host.scratch, + ) + + +class EnqueueQueueCheck(QueueCheck): + def check(self, host: Host, target: Target) -> None: + ApprovedHead.require(host, target.head) + pr = QueuedPullRequest.for_head(host, target.head) + host.command( + "gh", "pr", "merge", pr.number, "--repo", host.repository, + "--auto", "--merge", "--match-head-commit", pr.head, + ) + + +@dataclass(frozen=True) +class DispatchQueueCheck(QueueCheck): + head: str + + @classmethod + def from_tokens(cls, tokens: list[str]) -> Self: + identifier, _, head = tokens + return cls(int(identifier), head) + + def execute(self, host: Host) -> None: + StatusWorkflowRun.require(host, self.run_id) + head = host.fetch(self.head) + ApprovedHead.require(host, head) + pr = QueuedPullRequest.for_head(host, head) + host.command( + "gh", "workflow", "run", WORKFLOW, "--repo", host.repository, + "--ref", pr.branch, "-f", "enter_queue=true", + ) + + +class HostArguments(argparse.Namespace): + request: str + repo: Path + scratch: Path + private_copies_authorized: bool + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("request", help="SSH_ORIGINAL_COMMAND, supplied as one quoted argument") + parser.add_argument("--repo", type=Path, default=REPO) + parser.add_argument("--scratch", type=Path, required=True) + parser.add_argument("--private-copies-authorized", action="store_true") + args = parser.parse_args(namespace=HostArguments()) + tokens = shlex.split(args.request) + if len(tokens) < 2: + parser.error("Expected RUN_ID CHECK_KIND [APPROVED_HEAD]") + check = QueueCheck.member_named(tokens[1]).from_tokens(tokens) + repository = FieldCodec.decode( + RepositoryAnswer, + FieldCodec.parse(run("gh", "repo", "view", "--json", "nameWithOwner", cwd=args.repo)), + ) + check.execute(Host(args.repo, repository.name, args.scratch, args.private_copies_authorized)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_merge_runner.sh b/tools/run_merge_runner.sh new file mode 100755 index 0000000000..d392b0c745 --- /dev/null +++ b/tools/run_merge_runner.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# A repo runner is not a login shell. Hide host homes, buses, auth and sockets. +set -euo pipefail +state=${1:?usage: run_merge_runner.sh STATE_ROOT} +mkdir -p "$state/actions-runner/_work" "$state/actions-runner/_diag" +exec env -i \ + HOME=/home/ts USER=ts PATH=/usr/bin:/bin LANG=C.UTF-8 \ + DOTNET_CLI_HOME=/tmp DOTNET_GCHeapHardLimit=0x10000000 \ + AGENT_COMMS_GATE_KEY=/home/ts/.ssh/merge-gate-ed25519 \ + AGENT_COMMS_GATE_ENDPOINT=ts@127.0.0.1 \ + bwrap --unshare-all --share-net --die-with-parent --new-session --cap-drop ALL \ + --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \ + --ro-bind /etc/passwd /etc/passwd --ro-bind /etc/group /etc/group \ + --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ + --ro-bind /etc/resolv.conf /etc/resolv.conf --ro-bind /etc/hosts /etc/hosts \ + --ro-bind /etc/ssl/certs /etc/ssl/certs \ + --ro-bind /etc/ca-certificates /etc/ca-certificates \ + --dev /dev --proc /proc --tmpfs /tmp \ + --ro-bind "$state/runner-home" /home/ts \ + --ro-bind "$state/actions-runner" /runner \ + --bind "$state/actions-runner/_work" /runner/_work \ + --bind "$state/actions-runner/_diag" /runner/_diag \ + --chdir /runner /runner/bin/Runner.Listener run From 3bcb88e20c8e7fd0bbcb9b80df1390f660f03913 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:25:50 -0400 Subject: [PATCH 19/41] P714 post exact head review status from the accepted verdict owner Src-Budget: 66 --- src/agent_comms/connector.py | 3 ++ src/agent_comms/goal_management.py | 2 +- src/agent_comms/todos.py | 85 ++++++++++++++++++++++++------ src/agent_comms/tool_caller.py | 4 ++ src/agent_comms/tools.py | 6 ++- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index d91dfcf5f2..04005198f1 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -65,6 +65,9 @@ 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.""" + def judged(self, comms, problem, verdict): + return verdict.reviewed(comms.goals, problem, comms.registry.require(self.name).incarnation) + 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.""" diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index ad690653be..c324b05d51 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -57,7 +57,7 @@ def todos(self) -> TodoStore: def change_problem(self, problem, operation, commander): actor = self.registry.require(commander).incarnation current = self.todos.get(problem) - current.require_commander(actor) + operation.require_caller(current, actor) return operation.prepare(self, current).record(self, problem) def apply_problem(self, problem: str, operation: TodoOperation) -> Todo: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 046202fe31..7a79e11b81 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1888,6 +1888,9 @@ def __post_init__(self) -> None: def prepare(self, goals, current): return self + def require_caller(self, current: Todo, actor: ThreadIncarnation) -> None: + current.require_commander(actor) + def record(self, goals: Goals, problem: str) -> Todo: """Write this operation; one that also revises the holder's Goal routes through it.""" return goals.apply_problem(problem, self) @@ -1976,7 +1979,6 @@ class OrderVerdict(TodoOperation, declared_name=None): audience: ClassVar[type[VerdictAudience]] = VerdictAudience actor: ThreadIncarnation previous: Assignment - tool_call_id: str at: EventTime = field(default_factory=lambda: EventTime.capture(time.time()), kw_only=True) @abstractmethod @@ -1986,20 +1988,48 @@ def judged(self, current: Todo) -> Todo: ... @abstractmethod def line(self) -> str: ... + def require_actor(self, current: Todo) -> None: + current.require_commander(self.actor) + + def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: + raise TodoError("The connector reviewer accepts a PR head, not an edit verdict.") + def apply(self, current: Todo) -> Todo: current.require_revision(self.expected_revision) - current.require_commander(self.actor) + self.require_actor(current) current.require_assignment(self.previous) - _text(self.tool_call_id, "Judged tool call ID", 200) judged = self.judged(current) return replace(judged, verdicts=(*judged.verdicts, self), revision=current.revision + 1) @dataclass(frozen=True) -class AcceptedVerdict(OrderVerdict): - """The tool call carries out the order clause it quotes.""" +class ToolCallVerdict(OrderVerdict, declared_name=None): + tool_call_id: str + + def apply(self, current: Todo) -> Todo: + _text(self.tool_call_id, "Judged tool call ID", 200) + return super().apply(current) + + +@dataclass(frozen=True) +class QuotedVerdict(OrderVerdict, declared_name=None): quote: str + def require_quote(self, text: str) -> None: + quoted = " ".join(self.quote.split()) + if len(quoted.split()) < 4 or quoted not in " ".join(text.split()): + raise ValueError("Quote 4+ words of the order this verdict carries out.") + + def judged(self, current: Todo) -> Todo: + self.require_quote(current.order.instructions(current)) + return current + + +@dataclass(frozen=True) +class AcceptedVerdict(QuotedVerdict, ToolCallVerdict): + """The tool call carries out the order clause it quotes.""" + line = "Accepted" + @classmethod def require_branches(cls, added: int, no_owner: str | None) -> None: if added <= 0: @@ -2007,16 +2037,41 @@ def require_branches(cls, added: int, no_owner: str | None) -> None: if not (no_owner and len(no_owner.split()) >= 12 and re.search(r'\bbecause\b', no_owner) and re.search(r'/\S+:\d+', no_owner)): raise ValueError('Added branches need --no-owner: 12+ words with because and an absolute file:line.') - line = "Accepted" - def require_quote(self, text: str) -> None: - quoted = " ".join(self.quote.split()) - if len(quoted.split()) < 4 or quoted not in " ".join(text.split()): - raise ValueError("Quote 4+ words of the order this tool call carries out.") - def judged(self, current: Todo) -> Todo: - self.require_quote(current.order.instructions(current)) - return current +@dataclass(frozen=True) +class ReviewedHeadVerdict(QuotedVerdict): + """The connector reviewer accepts this immutable commit, never an inferred checkout head.""" + head: str + + def __post_init__(self) -> None: + super().__post_init__() + GitCommitText.decode(self.head) + + @property + def line(self) -> str: + return f"Reviewed head {self.head}" + + def require_caller(self, current: Todo, actor: ThreadIncarnation) -> None: + raise TodoError("Only the connector reviewer records a PR-head verdict.") + + def require_actor(self, current: Todo) -> None: + """reviewed checked the connector's registered reviewer before recording.""" + + def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: + if self.actor != reviewer: + raise TodoError("The verdict actor must be the connector reviewer recording it.") + current = goals.todos.get(problem) + recorded = self.prepare(goals, current).record(goals, problem) + posted = subprocess.run( + ("gh", "api", "--method", "POST", f"repos/{recorded.repo}/statuses/{self.head}", + "-f", "state=success", "-f", "context=agent-comms/review", + "-f", f"description=Accepted by {reviewer.name} on {problem}"), + capture_output=True, text=True, timeout=60, + ) + if posted.returncode: + raise TodoError(posted.stderr.strip() or f"Review status on {self.head} failed.") + return recorded @dataclass(frozen=True) @@ -2042,7 +2097,7 @@ def judged(self, current: Todo) -> Todo: @dataclass(frozen=True) -class SupersededVerdict(OrderVerdict): +class SupersededVerdict(ToolCallVerdict): replacement: str reason: str line = "Superseded" @@ -2063,7 +2118,7 @@ def judged(self, current: Todo) -> Todo: @dataclass(frozen=True) -class SendBackVerdict(OrderVerdict): +class SendBackVerdict(ToolCallVerdict): """The tool call goes back; the same write strikes the holder and revises the order Goal.""" reason: StrikeReason diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 00723d54df..bf96491625 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -15,6 +15,7 @@ from .comms import Comms from .messages import Message from .tools import ToolRequest + from .todos import OrderVerdict, Todo @dataclass(frozen=True) @@ -86,6 +87,9 @@ def sent(self, comms: Comms, message: Message) -> None: class ThreadCaller(ToolCaller): name: str + def judged(self, comms: Comms, problem: str, verdict: OrderVerdict) -> Todo: + return comms.goals.change_problem(problem, verdict, self.name) + def invoke(self, request: ToolRequest[ThreadCaller], comms: Comms) -> ToolResult: return request.apply(comms, self) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 9e3e6dc714..61d4d7fd18 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -753,7 +753,11 @@ class CommsVerdictTool(CommsChangeProblemTool): description = ("Record the commander's verdict on one tool call the holder made for its " "order: an accepted verdict quotes 4+ words of the order; a send-back strikes " "the holder and revises its Goal in the same write.") - operation: OrderVerdict = tool_field("Accepted or send-back verdict on the judged tool call ID") + operation: OrderVerdict = tool_field("Edit verdict, or the connector reviewer's PR-head verdict") + + def apply(self, comms: Comms, caller: ThreadCaller) -> TodoChanged: + todo = caller.judged(comms, self.id, self.operation) + return TodoChanged(todo, todo.strike_count) @dataclass(frozen=True, kw_only=True) From 130d8dffd6b22ef0136d5aa8c6be805ac0a563f3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:33:39 -0400 Subject: [PATCH 20/41] Keep head reviews in the connector-only declared tool and command family The ordinary TodoOperation capability excludes reviewed heads; only the connector tool schema admits them. ToolCallVerdict retains commander validation. Shared TodoCommand owns revision and persistence; no caller guard or disabled actor hook remains. Src-Budget: 72 --- src/agent_comms/connector.py | 36 +++++++++++++++++++++++------- src/agent_comms/goal_management.py | 6 ++--- src/agent_comms/todos.py | 29 ++++++++---------------- src/agent_comms/tool_caller.py | 4 ---- src/agent_comms/tools.py | 10 ++++----- 5 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 04005198f1..b3437d78be 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -32,7 +32,7 @@ from contextlib import contextmanager, suppress from dataclasses import dataclass, field, fields, replace from pathlib import Path -from typing import Annotated, ClassVar, Literal, Self, get_args +from typing import Annotated, Any, ClassVar, Literal, Self, get_args from urllib.parse import urlencode from aiohttp import web @@ -52,9 +52,11 @@ ) from .messages import Message from .commander.standing import UnownedWheelSpin -from .todos import DirectiveText +from .todos import DirectiveText, ReviewedHeadVerdict from .tool_caller import ThreadCaller -from .tools import CommsChannelsTool, ReadTool, ToolRequest +from .tools import ( + CommsChannelsTool, CommsVerdictTool, ReadTool, TodoChanged, ToolRequest, tool_field, +) LOOPBACK = "127.0.0.1" PORT = 18766 @@ -65,9 +67,6 @@ 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.""" - def judged(self, comms, problem, verdict): - return verdict.reviewed(comms.goals, problem, comms.registry.require(self.name).incarnation) - 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.""" @@ -81,6 +80,24 @@ def sent(self, comms: Comms, message: Message) -> None: ) +class ConnectorTool(ToolRequest[DirectorCaller], affix="ConnectorTool"): + """Connector-only declarations are absent from the native/CLI tool registry.""" + + +@dataclass(frozen=True, kw_only=True) +class ConnectorVerdictTool(ConnectorTool, declared_name=CommsVerdictTool.declared_name): + label = "Review PR head" + description = "Record the connector reviewer's acceptance of the exact PR commit it reviewed." + id: str = tool_field("Problem ID") + operation: ReviewedHeadVerdict = tool_field("Accepted PR-head verdict") + + def apply(self, comms: Comms, caller: DirectorCaller) -> TodoChanged: + todo = self.operation.reviewed( + comms.goals, self.id, comms.registry.require(caller.name).incarnation + ) + return TodoChanged(todo, todo.strike_count) + + CALLER = DirectorCaller("claude-ai") # Owner-typed calls act as this registered bus thread. ClaudeCallback = Literal["https://claude.ai/api/mcp/auth_callback"] CALLBACK: str = get_args(ClaudeCallback)[0] @@ -346,8 +363,11 @@ def resource_metadata_url(self) -> str: return f"{self.issuer}/.well-known/oauth-protected-resource" @staticmethod - def exposed() -> dict[str, type[ToolRequest]]: - return {tool.declared_name: tool for tool in ToolRequest.members_with(ToolRequest)} + def exposed() -> dict[str, type[ToolRequest[Any]]]: + tools: tuple[type[ToolRequest[Any]], ...] = ( + *ToolRequest.members_with(ToolRequest), *ConnectorTool.members_with(ConnectorTool) + ) + return {tool.declared_name: tool for tool in tools} @contextmanager def opened_wire(self) -> Iterator[Comms]: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index c324b05d51..dafa3bd13a 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -34,7 +34,7 @@ from .store_files import SharedLock, _store_lock from .threads import Thread from .turn_lease import TurnCompletion -from .todos import SendBackVerdict, StrikeReason, Todo, TodoOperation, TodoStore +from .todos import SendBackVerdict, StrikeReason, Todo, TodoCommand, TodoStore from .wire_log import ConversationSourceWindowRequest _LOG = logging.getLogger(__name__) @@ -57,10 +57,10 @@ def todos(self) -> TodoStore: def change_problem(self, problem, operation, commander): actor = self.registry.require(commander).incarnation current = self.todos.get(problem) - operation.require_caller(current, actor) + current.require_commander(actor) return operation.prepare(self, current).record(self, problem) - def apply_problem(self, problem: str, operation: TodoOperation) -> Todo: + def apply_problem(self, problem: str, operation: TodoCommand) -> Todo: with _store_lock(self._wire_lock_path): applied: Todo = self.todos.apply(problem, operation) return applied diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 7a79e11b81..51b33107c0 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1878,7 +1878,7 @@ def closed(cls, current: Todo, order: ProblemOrder) -> Todo: @dataclass(frozen=True) -class TodoOperation(Command, DeclaredFamily, affix="TodoOperation"): +class TodoCommand(Command, DeclaredFamily, affix="TodoOperation"): expected_revision: int def __post_init__(self) -> None: @@ -1888,9 +1888,6 @@ def __post_init__(self) -> None: def prepare(self, goals, current): return self - def require_caller(self, current: Todo, actor: ThreadIncarnation) -> None: - current.require_commander(actor) - def record(self, goals: Goals, problem: str) -> Todo: """Write this operation; one that also revises the holder's Goal routes through it.""" return goals.apply_problem(problem, self) @@ -1899,6 +1896,10 @@ def record(self, goals: Goals, problem: str) -> Todo: def apply(self, current: Todo) -> Todo: ... +class TodoOperation(TodoCommand, declared_name=None): + """Commands the ordinary commander's tools accept.""" + + @dataclass(frozen=True) class AssignTodo(TodoOperation): """The order's commander gives the problem to a unit.""" @@ -1974,7 +1975,7 @@ def addressed(self, target: str, body: str) -> str: @dataclass(frozen=True) -class OrderVerdict(TodoOperation, declared_name=None): +class OrderVerdict(TodoCommand, declared_name=None): """The commander's verdict on one tool call the holder made for its order, kept on the order.""" audience: ClassVar[type[VerdictAudience]] = VerdictAudience actor: ThreadIncarnation @@ -1988,25 +1989,19 @@ def judged(self, current: Todo) -> Todo: ... @abstractmethod def line(self) -> str: ... - def require_actor(self, current: Todo) -> None: - current.require_commander(self.actor) - - def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: - raise TodoError("The connector reviewer accepts a PR head, not an edit verdict.") - def apply(self, current: Todo) -> Todo: current.require_revision(self.expected_revision) - self.require_actor(current) current.require_assignment(self.previous) judged = self.judged(current) return replace(judged, verdicts=(*judged.verdicts, self), revision=current.revision + 1) @dataclass(frozen=True) -class ToolCallVerdict(OrderVerdict, declared_name=None): +class ToolCallVerdict(OrderVerdict, TodoOperation, declared_name=None): tool_call_id: str def apply(self, current: Todo) -> Todo: + current.require_commander(self.actor) _text(self.tool_call_id, "Judged tool call ID", 200) return super().apply(current) @@ -2052,12 +2047,6 @@ def __post_init__(self) -> None: def line(self) -> str: return f"Reviewed head {self.head}" - def require_caller(self, current: Todo, actor: ThreadIncarnation) -> None: - raise TodoError("Only the connector reviewer records a PR-head verdict.") - - def require_actor(self, current: Todo) -> None: - """reviewed checked the connector's registered reviewer before recording.""" - def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: if self.actor != reviewer: raise TodoError("The verdict actor must be the connector reviewer recording it.") @@ -2747,7 +2736,7 @@ def create( current.require_assignment(proposed.assignment) return current - def apply(self, todo_id: str, command: TodoOperation) -> Todo: + def apply(self, todo_id: str, command: TodoCommand) -> Todo: with self.writing() as db: current = self._current(db, todo_id) result = command.apply(current) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index bf96491625..00723d54df 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -15,7 +15,6 @@ from .comms import Comms from .messages import Message from .tools import ToolRequest - from .todos import OrderVerdict, Todo @dataclass(frozen=True) @@ -87,9 +86,6 @@ def sent(self, comms: Comms, message: Message) -> None: class ThreadCaller(ToolCaller): name: str - def judged(self, comms: Comms, problem: str, verdict: OrderVerdict) -> Todo: - return comms.goals.change_problem(problem, verdict, self.name) - def invoke(self, request: ToolRequest[ThreadCaller], comms: Comms) -> ToolResult: return request.apply(comms, self) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 61d4d7fd18..f438bc251a 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -63,7 +63,7 @@ from .thread_management import RenameThreadResult from .transcript_events import SentTranscript from .todos import (CorrectCheckTodo, ServeTodo, DeliverTodo, InstallTodo, OrderDelivery, - OrderProgress, OrderVerdict, PlainText, PullRequests, StrikeReason, Todo, + OrderProgress, ToolCallVerdict, PlainText, PullRequests, StrikeReason, Todo, TodoOperation, VerdictAudience) from .change_report import ChangeReport from .messages import InfoKind, MessageKind, NoticeRole, SendableMessageKind @@ -753,11 +753,9 @@ class CommsVerdictTool(CommsChangeProblemTool): description = ("Record the commander's verdict on one tool call the holder made for its " "order: an accepted verdict quotes 4+ words of the order; a send-back strikes " "the holder and revises its Goal in the same write.") - operation: OrderVerdict = tool_field("Edit verdict, or the connector reviewer's PR-head verdict") - - def apply(self, comms: Comms, caller: ThreadCaller) -> TodoChanged: - todo = caller.judged(comms, self.id, self.operation) - return TodoChanged(todo, todo.strike_count) + operation: ToolCallVerdict = tool_field( + "Accepted or send-back verdict on the judged tool call ID" + ) @dataclass(frozen=True, kw_only=True) From f382d3ef9804528dbb6c336615f2f1a2e52554ce Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:37:05 -0400 Subject: [PATCH 21/41] Type the two tool catalogs without erasing their callers Src-Budget: 78 --- src/agent_comms/connector.py | 15 ++++++++------- src/agent_comms/todos.py | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index b3437d78be..2ae58606fa 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -32,7 +32,7 @@ from contextlib import contextmanager, suppress from dataclasses import dataclass, field, fields, replace from pathlib import Path -from typing import Annotated, Any, ClassVar, Literal, Self, get_args +from typing import Annotated, ClassVar, Literal, Self, get_args from urllib.parse import urlencode from aiohttp import web @@ -363,11 +363,12 @@ def resource_metadata_url(self) -> str: return f"{self.issuer}/.well-known/oauth-protected-resource" @staticmethod - def exposed() -> dict[str, type[ToolRequest[Any]]]: - tools: tuple[type[ToolRequest[Any]], ...] = ( - *ToolRequest.members_with(ToolRequest), *ConnectorTool.members_with(ConnectorTool) - ) - return {tool.declared_name: tool for tool in tools} + def exposed() -> dict[str, type[ToolRequest] | type[ConnectorTool]]: + tools: dict[str, type[ToolRequest] | type[ConnectorTool]] = { + tool.declared_name: tool for tool in ToolRequest.members_with(ToolRequest) + } + tools.update({tool.declared_name: tool for tool in ConnectorTool.members_with(ConnectorTool)}) + return tools @contextmanager def opened_wire(self) -> Iterator[Comms]: @@ -377,7 +378,7 @@ def opened_wire(self) -> Iterator[Comms]: yield comms def invoke( - self, tool: type[ToolRequest], arguments: Mapping[str, object] + self, tool: type[ToolRequest] | type[ConnectorTool], arguments: Mapping[str, object] ) -> dict[str, JsonValue]: with self.opened_wire() as comms: return tool.invoke(comms, arguments, caller=CALLER) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 51b33107c0..c86fe652e6 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1885,7 +1885,7 @@ def __post_init__(self) -> None: if FieldCodec.decode(int, self.expected_revision) <= 0: raise TodoError("Todo operation requires a positive exact revision") - def prepare(self, goals, current): + def prepare(self, goals: Goals, current: Todo) -> Self: return self def record(self, goals: Goals, problem: str) -> Todo: From 2d743f36da52134f7bed1a55fd195317918c78e1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 14:39:44 -0400 Subject: [PATCH 22/41] Keep the connector catalog declaration within the existing formatting rule --- src/agent_comms/connector.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 2ae58606fa..cfa4b161ea 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -367,7 +367,9 @@ def exposed() -> dict[str, type[ToolRequest] | type[ConnectorTool]]: tools: dict[str, type[ToolRequest] | type[ConnectorTool]] = { tool.declared_name: tool for tool in ToolRequest.members_with(ToolRequest) } - tools.update({tool.declared_name: tool for tool in ConnectorTool.members_with(ConnectorTool)}) + tools.update({ + tool.declared_name: tool for tool in ConnectorTool.members_with(ConnectorTool) + }) return tools @contextmanager From 08979733d8e9a265365440f14c015d97b913c04e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:14:01 -0400 Subject: [PATCH 23/41] P269: derive review identity and expose queue gate failures Preserve takeover checkpoint 307e5955e and merge main and P714. ReviewedHeadVerdict owns the context for both publisher and queue. Status events carry context as data; delete the repository-variable mirror. Derive audit imports and host identity, remove contradictory wrapper timeout, retain private child output and publish gate/source annotations. The existing install gate invokes the proposed revision installer, not the live installer. Queue settings and storage remain explicit operator blockers; no fake queue acceptance claimed. --- .github/workflows/debt-ratchet.yml | 9 +- docs/merge-queue.md | 132 +++++++++++++++----------- src/agent_comms/todos.py | 3 +- tools/install_gate.py | 21 +++++ tools/merge_gate_command.sh | 10 +- tools/merge_queue.py | 146 ++++++++++++++++++++++++----- tools/run_merge_runner.sh | 10 +- 7 files changed, 236 insertions(+), 95 deletions(-) diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index f6e872acd9..733232cd86 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -15,12 +15,12 @@ permissions: contents: read concurrency: - group: merge-queue-${{ github.event.merge_group.head_sha || github.event.sha || github.sha }} + group: merge-queue-${{ github.event.sha || github.sha }} cancel-in-progress: false jobs: dispatch-reviewed: - if: github.event_name == 'status' && github.event.context == vars.AGENT_COMMS_REVIEW_CONTEXT && github.event.state == 'success' + if: github.event_name == 'status' && github.event.state == 'success' runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] timeout-minutes: 5 steps: @@ -28,10 +28,13 @@ jobs: env: RUN_ID: ${{ github.run_id }} REVIEWED_HEAD: ${{ github.event.sha }} + REVIEW_CONTEXT: ${{ github.event.context }} run: | set -euo pipefail + request=$(python3 -c 'import shlex, sys; print(shlex.join(sys.argv[1:]))' \ + "$RUN_ID" dispatch "$REVIEWED_HEAD" "$REVIEW_CONTEXT") ssh -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$RUN_ID dispatch $REVIEWED_HEAD" + -i "$AGENT_COMMS_GATE_KEY" "$AGENT_COMMS_GATE_ENDPOINT" "$request" ratchet: if: github.event_name != 'status' diff --git a/docs/merge-queue.md b/docs/merge-queue.md index 2305473d3c..fb80c17941 100644 --- a/docs/merge-queue.md +++ b/docs/merge-queue.md @@ -1,59 +1,77 @@ # Automatic merge queue -Owner: pi-install-gate. Draft PR #2023. Not accepted or enabled yet. - -GitHub owns queue order, proposed merge identity and failed-entry removal. The -existing debt workflow runs `debt-src`, `debt-tools` and `install-rehearsal` on -`merge_group`; there is no second merge scheduler. - -Approval is the existing exact-head `agent-comms/review` verdict, not a new -native GitHub review requirement. All current contributors use the same GitHub -identity, so requiring them to approve their own native PRs would strand them. -A status event runs on the default branch, not its referenced PR head. The -restricted host entrypoint validates the real approved head and dispatches the -workflow on its PR branch. Successful checks then request auto-merge against -that exact head. GitHub builds the singleton merge group and reruns all checks. - -## Isolation - -The registered repository runner is `ts-gaming-agent-comms-merge` (ID 21), with -labels `self-hosted`, `Linux`, `X64`, `ts-gaming`, `agent-comms-merge`. -The runner namespace exposes only the vendor runtime, its work/log directories, -system binaries and a sealed runner home. Host homes, buses, personal tokens, -SSH agents and desktop sockets are not mounted. Its SSH key is restricted to -loopback and one fixed command. No workflow supplies a shell command, checkout, -interpreter or log location to the host. - -`tools/merge_queue.py` decodes GitHub answers with the existing FieldCodec and -ExternalRecord. Declared event/check families own eligibility and behavior. -The receiver checks the actual in-progress GitHub run and its workflow path. -An administrator can dispatch qualification checks. A merge group also requires -approval of its original PR parent. The existing install gate still owns source -coverage, private snapshotting and installer invocation. SSH returns an outcome; -all host diagnostics and bus copies stay outside the runner namespace. - -The vendor release is 2.338.0, checked against its GitHub release SHA-256 before -extraction. Owned package, runner state and qualification logs are under -`/home/ts/.cache/agent-scratch/pi-install-gate/`; together with the two existing -checkouts they remain below the current 3 GB limit. P2 remains borrowed for the -pinned read-only audit scripts. Do not retire it while a check needs it. - -## Acceptance checklist - -- [x] First checkpoint committed/pushed within ten minutes (f0dc3df0c). -- [x] Native queue ruleset accepted by GitHub, staged disabled (25439636). -- [x] Runner registered with the repository, initially offline. -- [x] Minimal runner namespace works without host home mounts. -- [ ] Isolated runner executes the three real checks. -- [ ] Review context comes from P714's existing verdict declaration. -- [ ] New host entrypoint has commander-granted source allocation and clean ratchets. -- [ ] Bootstrap changes are reviewed and merged; production entrypoints use main. -- [ ] Queue enabled, with all three checks required and no bypass. -- [ ] First genuinely approved PR merges through the queue without a commander merge. -- [ ] Runtime/storage/install candidates pass actual two-machine private rehearsals. - -The last item is blocked by the existing 3 GB worker allocation versus the -26 GiB desktop bus, independently of runner registration. Private copies are -therefore default-off in the host entrypoint. Non-install-source changes invoke -the real gate and may pass its existing outside-surface result; that is not a -successful private install rehearsal. Do not report it as one. +Owner: pi-cn-load, P269, draft PR #2023. Not enabled or accepted. +The takeover preserved pi-install-gate's newer published 307e5955e, a descendant +of the ordered f0dc3df0c checkpoint, and merged current main without rewriting +history. First takeover push: fb6074f3e. P714/#1947 is merged as the review-owner +dependency, not copied into another verdict implementation. + +GitHub owns queue order, proposed merge identity and failed-entry removal. +QueueCheck and RunEvent members own host execution and admission. The existing +workflow runs debt-src, debt-tools and install-rehearsal on merge_group. +No commander's merge scheduler is involved after bootstrap. + +## Review and installer ownership + +ReviewedHeadVerdict.context is the sole review-context declaration. Its existing +publisher and the queue reader both use it. The status event passes its context +as data; the host compares it with that declaration. No repository variable +mirrors the context. No native GitHub approval is required: the contributors all +use the same GitHub identity and cannot approve their own PRs. + +The install gate's run_machines checks out the proposed revision, then rehearse +invokes target/tools/upgrade_bus.py, including --help and the actual rehearsal. +It does not invoke the live checkout's installer. GateResult/MachineResult carry +typed failure reasons back to the queue. The wrapper forwards public GitHub +annotations, not child stdout/stderr, successful rehearsal steps or bus data. +Ratchet annotations include the measured source path and introduced line where +available; aggregate file metrics are identified as such at the start of the file. + +## Isolation and lifetime + +The existing runner ts-gaming-agent-comms-merge (ID 21) is registered but offline. +Labels: self-hosted, Linux, X64, ts-gaming, agent-comms-merge. Its bubblewrap +namespace exposes vendor runtime, work/log directories, system binaries and a +sealed runner home, not host homes, buses, personal tokens or desktop sockets. +The SSH key admits only the fixed host entrypoint. GitHub run identity and the +workflow path are verified before checks. No generic hosted runner reads a bus. + +The host derives home/user from its environment and OS identity. Audit imports +come from the repository's refactor-audit submodule, never a worker checkout. +There is no 300-second host wrapper timeout contradicting the 60-minute install +job. Job cancellation owns the SSH lifetime; local development ratchets retain +the ordered timeout 300 and faulthandler. + +Only explicit gate failure reasons and source annotations cross the runner +boundary. Full child diagnostics, copied state and successful rehearsal results +remain on the host. A storage or allocation refusal fails the gate; it never +turns into a skipped or successful practice install. + +## Settings Tristan must change (no settings changed by this worker) + +- Settings → Rules → Rulesets → Automatic merge queue (25439636): change Enforcement from Disabled to Active for main, after reviewed bootstrap code is on main. +- In that ruleset → Merge queue: Merge method Merge; maximum group/build/merge size 1; require debt-src, debt-tools and install-rehearsal from GitHub Actions. +- In that ruleset → Require a pull request before merging: set required approvals to 0; the staged rule currently incorrectly requires 1 native approval. +- Settings → Actions → Runners: reuse registered runner ts-gaming-agent-comms-merge (ID 21), with the labels above; do not register a duplicate. If it must be replaced, use New self-hosted runner → Linux → x64 with those same custom labels. + +Runner registration does not start its host process. An authorized operator must +start the reviewed isolated launcher and fixed SSH command from main, retaining +the existing private runner credentials. No runner launch, authorized_keys edit, +queue enablement, native approval, production install or restart was performed +by this worker. + +## Remaining acceptance + +A real approved PR must merge through the enabled queue after the actual +private practice install, and a deliberately failing PR must show its reason +on GitHub. Local unit checks, annotations printed locally and ratchets do not +establish that acceptance. Bootstrap is still a reviewed operator action. + +Current storage is an independent blocker: the inherited host entrypoint does +not authorize private copies under a 3 GB worker allocation, while the desktop +bus is approximately 26 GiB and this host has approximately 12 GiB free. The +operator needs a separately provisioned practice-install volume/allocation; +there is no short-storage bypass. Worker checkouts and receipts stay under +/home/ts/wt/pi-cn-load--P269 and /home/ts/.cache/agent-scratch/pi-cn-load, below +the combined 3 GB limit. The old runner state remains with its existing owner; +its credentials have not been copied or deleted. diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index c86fe652e6..5b7161304d 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2037,6 +2037,7 @@ def require_branches(cls, added: int, no_owner: str | None) -> None: @dataclass(frozen=True) class ReviewedHeadVerdict(QuotedVerdict): """The connector reviewer accepts this immutable commit, never an inferred checkout head.""" + context: ClassVar[str] = "agent-comms/review" head: str def __post_init__(self) -> None: @@ -2054,7 +2055,7 @@ def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> T recorded = self.prepare(goals, current).record(goals, problem) posted = subprocess.run( ("gh", "api", "--method", "POST", f"repos/{recorded.repo}/statuses/{self.head}", - "-f", "state=success", "-f", "context=agent-comms/review", + "-f", "state=success", "-f", f"context={self.context}", "-f", f"description=Accepted by {reviewer.name} on {problem}"), capture_output=True, text=True, timeout=60, ) diff --git a/tools/install_gate.py b/tools/install_gate.py index becbea83ca..2a7f635e19 100644 --- a/tools/install_gate.py +++ b/tools/install_gate.py @@ -599,6 +599,10 @@ class MachineResult(DeclaredFamily, affix="MachineResult"): @abstractmethod def successful(self) -> bool: ... + @property + def failures(self) -> tuple[str, ...]: + return () + @dataclass(frozen=True) class PassedMachineResult(MachineResult): @@ -624,8 +628,16 @@ class FailedMachineResult(MachineResult): def successful(self) -> bool: return False + @property + def failures(self) -> tuple[str, ...]: + return (self.error,) + class GateResult(DeclaredFamily, affix="GateResult"): + @property + def failures(self) -> tuple[str, ...]: + return (self.description,) + @property @abstractmethod def successful(self) -> bool: ... @@ -654,6 +666,11 @@ def description(self) -> str: class MachinesGateResult(GateResult): machines: dict[str, MachineResult] + @property + def failures(self) -> tuple[str, ...]: + return tuple(f"{name}: {reason}" for name, result in self.machines.items() + for reason in result.failures) + @property def successful(self) -> bool: return len(self.machines) == 2 and all( @@ -680,6 +697,10 @@ def successful(self) -> bool: class InterruptedGateResult(MachinesGateResult): error: str + @property + def failures(self) -> tuple[str, ...]: + return (self.error, *super().failures) + @property def successful(self) -> bool: return False diff --git a/tools/merge_gate_command.sh b/tools/merge_gate_command.sh index 09781cbc93..5bdbddb131 100755 --- a/tools/merge_gate_command.sh +++ b/tools/merge_gate_command.sh @@ -9,14 +9,14 @@ mkdir -p "$state/host-logs" log=$(mktemp "$state/host-logs/check-XXXXXXXX.log") exec "$log" 2>&1; then + 2>"$log"; then printf 'check passed\n' else - printf 'check failed; private diagnostics retained on ts-gaming\n' + printf 'gate failed; detailed child logs remain private on the host\n' exit 1 fi diff --git a/tools/merge_queue.py b/tools/merge_queue.py index 6cf2d272a6..4ecea440b7 100644 --- a/tools/merge_queue.py +++ b/tools/merge_queue.py @@ -12,14 +12,26 @@ import shlex import subprocess import sys +import traceback from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar, Literal, Self -from install_gate import REPO, ExternalRecord, GateError, RepositoryAnswer, Target, affected, run +from debt_ratchet import compare +from install_gate import ( + REPO, + ExternalRecord, + GateError, + RepositoryAnswer, + RunSummary, + Target, + affected, + run, +) from agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.todos import ReviewedHeadVerdict WORKFLOW = ".github/workflows/debt-ratchet.yml" @@ -27,11 +39,10 @@ @dataclass(frozen=True) class ApprovedHead(ExternalRecord): state: Literal["success"] - context: ClassVar[str] = "agent-comms/review" @classmethod def require(cls, host: Host, head: str) -> None: - query = f'.statuses | map(select(.context == "{cls.context}")) | first' + query = f'.statuses | map(select(.context == "{ReviewedHeadVerdict.context}")) | first' answer = run("gh", "api", f"repos/{host.repository}/commits/{head}/status", "--jq", query) FieldCodec.decode(cls, FieldCodec.parse(answer)) @@ -55,10 +66,15 @@ class QueuedPullRequest(ExternalRecord): def for_head(cls, host: Host, head: str) -> Self: links = FieldCodec.decode( tuple[PullRequestNumber, ...], - FieldCodec.parse(run( - "gh", "api", f"repos/{host.repository}/commits/{head}/pulls", "--jq", - f'[.[] | select(.head.sha == "{head}" and .state == "open") | {{number: .number}}]', - )), + FieldCodec.parse( + run( + "gh", + "api", + f"repos/{host.repository}/commits/{head}/pulls", + "--jq", + f'[.[] | select(.head.sha == "{head}" and .state == "open") | {{number: .number}}]', + ) + ), ) fields = ",".join(key for _, key in FieldCodec.record_fields(cls)) candidates = tuple( @@ -101,7 +117,8 @@ def target(cls, host: Host, workflow: WorkflowRun) -> Target: Administrator, FieldCodec.parse( run( - "gh", "api", + "gh", + "api", f"repos/{host.repository}/collaborators/{workflow.actor.login}/permission", ) ), @@ -169,7 +186,13 @@ def fetch(self, head: str) -> str: return run("git", "rev-parse", f"{head}^{{commit}}", cwd=self.repo) def command(self, *arguments: str | Path | int) -> None: - subprocess.run(tuple(map(str, arguments)), cwd=self.repo, check=True) + subprocess.run( + tuple(map(str, arguments)), + cwd=self.repo, + check=True, + stdout=sys.stderr, + stderr=sys.stderr, + ) @dataclass(frozen=True) @@ -193,10 +216,22 @@ class RatchetQueueCheck(QueueCheck, declared_name=None): root: ClassVar[str] def check(self, host: Host, target: Target) -> None: - host.command( - sys.executable, "-X", "faulthandler", host.repo / "tools/debt_ratchet.py", - "--root", self.root, "--base", target.base, "--head", target.head, - ) + result = compare(host.repo, target.base, target.head, self.root) + if result.failed: + for metric, increase in result.delta.items(): + if increase > 0: + _, _, path = metric.partition(":") + for line in result.introduced_lines.get(metric, [1]): + annotation( + path.partition("::")[0] or "tools/debt_ratchet.py", + line, + f"{metric}: +{increase}", + ) + for metric, lines in result.introduced_lines.items(): + _, _, path = metric.partition(":") + for line in lines: + annotation(path, line, f"Introduced {metric}") + raise GateError(f"{self.root} debt ratchet rejected {target.head}") class SrcQueueCheck(RatchetQueueCheck): @@ -215,11 +250,36 @@ def check(self, host: Host, target: Target) -> None: raise GateError( "Private-copy allocation is not authorized: worker cap 3 GB, desktop bus 26 GiB" ) - host.command( - sys.executable, "-X", "faulthandler", host.repo / "tools/install_gate.py", - "--repo", host.repo, "--revision", target.head, "--base", target.base, - "--scratch", host.scratch, + command = tuple( + map( + str, + ( + sys.executable, + "-X", + "faulthandler", + host.repo / "tools/install_gate.py", + "--repo", + host.repo, + "--revision", + target.head, + "--base", + target.base, + "--scratch", + host.scratch, + ), + ) ) + completed = subprocess.run( + command, + cwd=host.repo, + stdout=subprocess.PIPE, + stderr=sys.stderr, + text=True, + check=False, + ) + summary = FieldCodec.decode(RunSummary, FieldCodec.parse(completed.stdout)) + if completed.returncode or not summary.result.successful: + raise GateError("; ".join(summary.result.failures) or summary.result.description) class EnqueueQueueCheck(QueueCheck): @@ -227,28 +287,47 @@ def check(self, host: Host, target: Target) -> None: ApprovedHead.require(host, target.head) pr = QueuedPullRequest.for_head(host, target.head) host.command( - "gh", "pr", "merge", pr.number, "--repo", host.repository, - "--auto", "--merge", "--match-head-commit", pr.head, + "gh", + "pr", + "merge", + pr.number, + "--repo", + host.repository, + "--auto", + "--merge", + "--match-head-commit", + pr.head, ) @dataclass(frozen=True) class DispatchQueueCheck(QueueCheck): head: str + context: str @classmethod def from_tokens(cls, tokens: list[str]) -> Self: - identifier, _, head = tokens - return cls(int(identifier), head) + identifier, _, head, context = tokens + return cls(int(identifier), head, context) def execute(self, host: Host) -> None: StatusWorkflowRun.require(host, self.run_id) + if self.context != ReviewedHeadVerdict.context: + return head = host.fetch(self.head) ApprovedHead.require(host, head) pr = QueuedPullRequest.for_head(host, head) host.command( - "gh", "workflow", "run", WORKFLOW, "--repo", host.repository, - "--ref", pr.branch, "-f", "enter_queue=true", + "gh", + "workflow", + "run", + WORKFLOW, + "--repo", + host.repository, + "--ref", + pr.branch, + "-f", + "enter_queue=true", ) @@ -259,6 +338,14 @@ class HostArguments(argparse.Namespace): private_copies_authorized: bool +def annotation(path: str, line: int, reason: str) -> None: + def escape(value: str) -> str: + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + attribute = escape(path).replace(",", "%2C").replace(":", "%3A") + print(f"::error file={attribute},line={line}::{escape(reason)}", flush=True) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("request", help="SSH_ORIGINAL_COMMAND, supplied as one quoted argument") @@ -274,7 +361,16 @@ def main() -> int: RepositoryAnswer, FieldCodec.parse(run("gh", "repo", "view", "--json", "nameWithOwner", cwd=args.repo)), ) - check.execute(Host(args.repo, repository.name, args.scratch, args.private_copies_authorized)) + try: + check.execute( + Host(args.repo, repository.name, args.scratch, args.private_copies_authorized) + ) + except (GateError, subprocess.CalledProcessError, MalformedRecord) as error: + frames = traceback.extract_tb(error.__traceback__) + location = frames[-1] + path = Path(location.filename).relative_to(args.repo) + annotation(str(path), location.lineno or 1, str(error)) + return 1 return 0 diff --git a/tools/run_merge_runner.sh b/tools/run_merge_runner.sh index d392b0c745..0b096b17fb 100755 --- a/tools/run_merge_runner.sh +++ b/tools/run_merge_runner.sh @@ -3,11 +3,13 @@ set -euo pipefail state=${1:?usage: run_merge_runner.sh STATE_ROOT} mkdir -p "$state/actions-runner/_work" "$state/actions-runner/_diag" +home=${HOME:?host home required} +user=$(id -un) exec env -i \ - HOME=/home/ts USER=ts PATH=/usr/bin:/bin LANG=C.UTF-8 \ + HOME="$home" USER="$user" PATH=/usr/bin:/bin LANG=C.UTF-8 \ DOTNET_CLI_HOME=/tmp DOTNET_GCHeapHardLimit=0x10000000 \ - AGENT_COMMS_GATE_KEY=/home/ts/.ssh/merge-gate-ed25519 \ - AGENT_COMMS_GATE_ENDPOINT=ts@127.0.0.1 \ + AGENT_COMMS_GATE_KEY="$home/.ssh/merge-gate-ed25519" \ + AGENT_COMMS_GATE_ENDPOINT="$user@127.0.0.1" \ bwrap --unshare-all --share-net --die-with-parent --new-session --cap-drop ALL \ --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \ --ro-bind /etc/passwd /etc/passwd --ro-bind /etc/group /etc/group \ @@ -16,7 +18,7 @@ exec env -i \ --ro-bind /etc/ssl/certs /etc/ssl/certs \ --ro-bind /etc/ca-certificates /etc/ca-certificates \ --dev /dev --proc /proc --tmpfs /tmp \ - --ro-bind "$state/runner-home" /home/ts \ + --ro-bind "$state/runner-home" "$home" \ --ro-bind "$state/actions-runner" /runner \ --bind "$state/actions-runner/_work" /runner/_work \ --bind "$state/actions-runner/_diag" /runner/_diag \ From 3c8163caef235a3b57aaca0e0a272f8d2656bee0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:14:55 -0400 Subject: [PATCH 24/41] P269: verify failure propagation and reject unrelated status dispatches --- tests/test_merge_queue.py | 24 ++++++++++++++++++++++++ tools/merge_queue.py | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_merge_queue.py b/tests/test_merge_queue.py index a379b75cba..1c4c79e96f 100644 --- a/tests/test_merge_queue.py +++ b/tests/test_merge_queue.py @@ -1,12 +1,16 @@ """Authorization/schema checks; acceptance still requires an actual queued merge.""" +import io import unittest +from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch from agent_comms.declared_family import UnknownFamilyMember from agent_comms.field_codec import FieldCodec, MalformedRecord +from install_gate import FailedMachineResult, GateResult, MachinesGateResult from merge_queue import ( + DispatchQueueCheck, GateError, Host, InstallQueueCheck, @@ -15,6 +19,7 @@ Target, WorkflowDispatchRunEvent, WorkflowRun, + annotation, ) @@ -47,6 +52,25 @@ def test_a_workflow_cannot_supply_git_options(self): host.fetch("--upload-pack=anything") command.assert_not_called() + def test_foreign_status_does_not_dispatch_more_checks(self): + host = Host(Path("."), "owner/repo", Path("."), False) + with patch("merge_queue.StatusWorkflowRun.require"): + with patch.object(Host, "fetch") as fetch: + DispatchQueueCheck(123, "a" * 40, "unrelated").execute(host) + fetch.assert_not_called() + + def test_gate_result_carries_failure_without_successful_private_steps(self): + result = MachinesGateResult({"desktop": FailedMachineResult("Insufficient room")}) + decoded = FieldCodec.decode(GateResult, FieldCodec.encode(result)) + self.assertEqual(decoded.failures, ("desktop: Insufficient room",)) + + def test_annotation_escapes_protocol_delimiters(self): + output = io.StringIO() + with redirect_stdout(output): + annotation("tools/example.py", 17, "type mismatch\nnew line") + self.assertEqual(output.getvalue(), + "::error file=tools/example.py,line=17::type mismatch%0Anew line\n") + def test_unallocated_private_copies_fail_before_installer_execution(self): host = Host(Path("."), "owner/repo", Path("."), False) with patch("merge_queue.affected", return_value=["src/agent_comms/comms.py"]): diff --git a/tools/merge_queue.py b/tools/merge_queue.py index 4ecea440b7..b9d362d031 100644 --- a/tools/merge_queue.py +++ b/tools/merge_queue.py @@ -72,7 +72,8 @@ def for_head(cls, host: Host, head: str) -> Self: "api", f"repos/{host.repository}/commits/{head}/pulls", "--jq", - f'[.[] | select(.head.sha == "{head}" and .state == "open") | {{number: .number}}]', + f'[.[] | select(.head.sha == "{head}" and .state == "open")' + ' | {number: .number}]', ) ), ) From f7547d0a0b5b935a774bc0c362698ba732875e5b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:30:41 -0400 Subject: [PATCH 25/41] P269: command-owned deadlines and typed public check failures Apply replacement order 3476: one CheckFailed process failure; abstract QueueCheck.check and summary; CLI ratchets remain separate commands and expose exact introduced source lines. Each family owns its deadline. Host.fetch updates declared pinned submodules; delete all audit PYTHONPATH additions and the second allocation policy. Existing install gate alone owns storage refusal and target-head execution. UpgradeRefused owns its public CLI message; only that declared refusal crosses private installer stderr into the typed gate summary. Catch only CheckFailed in the queue main. No settings, authorization key, runner or install changed. --- .github/workflows/debt-ratchet.yml | 2 +- docs/merge-queue.md | 44 ++++--- tests/test_merge_queue.py | 55 +++++--- tools/cutover/backup.py | 14 ++- tools/install_gate.py | 11 +- tools/merge_gate_command.sh | 2 +- tools/merge_queue.py | 196 +++++++++++++++-------------- tools/upgrade_bus.py | 2 +- 8 files changed, 193 insertions(+), 133 deletions(-) diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 733232cd86..85d057771c 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -40,7 +40,7 @@ jobs: if: github.event_name != 'status' name: debt-${{ matrix.kind }} runs-on: [self-hosted, Linux, X64, ts-gaming, agent-comms-merge] - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/docs/merge-queue.md b/docs/merge-queue.md index fb80c17941..ec9b003393 100644 --- a/docs/merge-queue.md +++ b/docs/merge-queue.md @@ -22,10 +22,11 @@ use the same GitHub identity and cannot approve their own PRs. The install gate's run_machines checks out the proposed revision, then rehearse invokes target/tools/upgrade_bus.py, including --help and the actual rehearsal. It does not invoke the live checkout's installer. GateResult/MachineResult carry -typed failure reasons back to the queue. The wrapper forwards public GitHub -annotations, not child stdout/stderr, successful rehearsal steps or bus data. -Ratchet annotations include the measured source path and introduced line where -available; aggregate file metrics are identified as such at the start of the file. +typed failure reasons back to the queue. CheckFailed carries the actual completed command and delegates its public +summary to the QueueCheck member. Ratchet summaries decode introduced_lines +and print Measure file:line. Installer summaries return the declared +UpgradeRefused message through the existing typed gate result. Raw child +stdout/stderr, successful rehearsal steps and bus data remain private. ## Isolation and lifetime @@ -36,11 +37,13 @@ sealed runner home, not host homes, buses, personal tokens or desktop sockets. The SSH key admits only the fixed host entrypoint. GitHub run identity and the workflow path are verified before checks. No generic hosted runner reads a bus. -The host derives home/user from its environment and OS identity. Audit imports -come from the repository's refactor-audit submodule, never a worker checkout. -There is no 300-second host wrapper timeout contradicting the 60-minute install -job. Job cancellation owns the SSH lifetime; local development ratchets retain -the ordered timeout 300 and faulthandler. +The host derives home/user from its environment and OS identity. There is no audit path on the wrapper's PYTHONPATH: debt_ratchet derives it +from the repository submodule itself. Host.fetch initializes/updates the pinned +submodules. No unit checkout is a dependency of production gates. +There is no shared host wrapper timeout. Ratchet members own 600 seconds, +install owns 3000, and enqueue/dispatch share their command capability's 120. +YAML ceilings are 15, 60 and 5 minutes respectively. Local development ratchets +retain the ordered timeout 300 and faulthandler. Only explicit gate failure reasons and source annotations cross the runner boundary. Full child diagnostics, copied state and successful rehearsal results @@ -54,9 +57,13 @@ turns into a skipped or successful practice install. - In that ruleset → Require a pull request before merging: set required approvals to 0; the staged rule currently incorrectly requires 1 native approval. - Settings → Actions → Runners: reuse registered runner ts-gaming-agent-comms-merge (ID 21), with the labels above; do not register a duplicate. If it must be replaced, use New self-hosted runner → Linux → x64 with those same custom labels. -Runner registration does not start its host process. An authorized operator must -start the reviewed isolated launcher and fixed SSH command from main, retaining -the existing private runner credentials. No runner launch, authorized_keys edit, +Runner registration does not start its host process. The previous owner confirms +that its restricted key was never added to authorized_keys and no runner +process/service has started. An authorized operator must install the reviewed +fixed-command, loopback-only key restriction and start the reviewed isolated +launcher from main, retaining the existing private runner credentials. Do not +launch the previous owner's queue-qualified/ scripts; those are old qualification +evidence, not installed entrypoints. No runner launch, authorized_keys edit, queue enablement, native approval, production install or restart was performed by this worker. @@ -67,11 +74,14 @@ private practice install, and a deliberately failing PR must show its reason on GitHub. Local unit checks, annotations printed locally and ratchets do not establish that acceptance. Bootstrap is still a reviewed operator action. -Current storage is an independent blocker: the inherited host entrypoint does -not authorize private copies under a 3 GB worker allocation, while the desktop -bus is approximately 26 GiB and this host has approximately 12 GiB free. The -operator needs a separately provisioned practice-install volume/allocation; -there is no short-storage bypass. Worker checkouts and receipts stay under +Current storage is an independent blocker: the desktop bus is approximately +26 GiB and this host has approximately 12 GiB free. The operator needs a +separately provisioned practice-install volume/allocation. The queue now always +invokes the actual install gate, which owns storage admission/refusal; the +queue's second boolean allocation switch and invented capacity literals are +deleted. A verified admin/reviewed-head run authorizes a practice attempt, not +an exemption from the install gate's storage floor. No private-copy run was +started by this worker under its 3 GB allocation. Worker checkouts and receipts stay under /home/ts/wt/pi-cn-load--P269 and /home/ts/.cache/agent-scratch/pi-cn-load, below the combined 3 GB limit. The old runner state remains with its existing owner; its credentials have not been copied or deleted. diff --git a/tests/test_merge_queue.py b/tests/test_merge_queue.py index 1c4c79e96f..ed8e3312c4 100644 --- a/tests/test_merge_queue.py +++ b/tests/test_merge_queue.py @@ -1,15 +1,17 @@ """Authorization/schema checks; acceptance still requires an actual queued merge.""" -import io +import json +import subprocess import unittest -from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch from agent_comms.declared_family import UnknownFamilyMember from agent_comms.field_codec import FieldCodec, MalformedRecord +from cutover.backup import UpgradeRefused from install_gate import FailedMachineResult, GateResult, MachinesGateResult from merge_queue import ( + CheckFailed, DispatchQueueCheck, GateError, Host, @@ -19,7 +21,6 @@ Target, WorkflowDispatchRunEvent, WorkflowRun, - annotation, ) @@ -46,14 +47,14 @@ def test_github_event_decodes_to_its_behavior(self): }) def test_a_workflow_cannot_supply_git_options(self): - host = Host(Path("."), "owner/repo", Path("."), False) + host = Host(Path("."), "owner/repo", Path(".")) with patch("merge_queue.run") as command: with self.assertRaises(GateError): host.fetch("--upload-pack=anything") command.assert_not_called() def test_foreign_status_does_not_dispatch_more_checks(self): - host = Host(Path("."), "owner/repo", Path("."), False) + host = Host(Path("."), "owner/repo", Path(".")) with patch("merge_queue.StatusWorkflowRun.require"): with patch.object(Host, "fetch") as fetch: DispatchQueueCheck(123, "a" * 40, "unrelated").execute(host) @@ -64,17 +65,35 @@ def test_gate_result_carries_failure_without_successful_private_steps(self): decoded = FieldCodec.decode(GateResult, FieldCodec.encode(result)) self.assertEqual(decoded.failures, ("desktop: Insufficient room",)) - def test_annotation_escapes_protocol_delimiters(self): - output = io.StringIO() - with redirect_stdout(output): - annotation("tools/example.py", 17, "type mismatch\nnew line") - self.assertEqual(output.getvalue(), - "::error file=tools/example.py,line=17::type mismatch%0Anew line\n") + def test_ratchet_failure_preserves_measure_and_exact_source_lines(self): + completed = subprocess.CompletedProcess( + ("ratchet",), 1, + stdout=json.dumps({"introduced_lines": {"NoneAbsence:tools/example.py": [17, 21]}, + "delta": {"NoneAbsence:tools/example.py": 2}}), stderr="", + ) + error = CheckFailed(SrcQueueCheck(123), completed) + self.assertEqual(str(error), + "Ratchet: NoneAbsence tools/example.py:17; NoneAbsence tools/example.py:21") - def test_unallocated_private_copies_fail_before_installer_execution(self): - host = Host(Path("."), "owner/repo", Path("."), False) - with patch("merge_queue.affected", return_value=["src/agent_comms/comms.py"]): - with patch.object(Host, "command") as command: - with self.assertRaisesRegex(GateError, "allocation is not authorized"): - InstallQueueCheck(123).check(host, Target("a" * 40, "b" * 40)) - command.assert_not_called() + def test_member_limit_is_passed_to_the_command(self): + host = Host(Path("."), "owner/repo", Path(".")) + completed = subprocess.CompletedProcess(("true",), 0, stdout="", stderr="") + with patch("merge_queue.subprocess.run", return_value=completed) as command: + host.command(SrcQueueCheck(123), "true") + self.assertEqual(command.call_args.kwargs["timeout"], 600) + self.assertEqual(InstallQueueCheck.limit, 3000) + + def test_practice_install_is_not_skipped_by_a_second_storage_policy(self): + host = Host(Path("."), "owner/repo", Path(".")) + with patch.object(Host, "command") as command: + check = InstallQueueCheck(123) + check.check(host, Target("a" * 40, "b" * 40)) + self.assertIs(command.call_args.args[0], check) + self.assertIn("--revision", command.call_args.args) + + def test_only_declared_installer_refusal_crosses_the_log_boundary(self): + error = UpgradeRefused("Insufficient room") + self.assertEqual(UpgradeRefused.public_message("private trace\n" + error.output), + error.output) + secret = "PRIVATE_PAYLOAD_DO_NOT_LEAK" + self.assertNotIn(secret, UpgradeRefused.public_message(secret)) diff --git a/tools/cutover/backup.py b/tools/cutover/backup.py index fcf6945a27..0061937050 100644 --- a/tools/cutover/backup.py +++ b/tools/cutover/backup.py @@ -14,7 +14,7 @@ from contextlib import closing, contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Iterator, Self +from typing import ClassVar, Iterator, Self from agent_comms.coordination_database import CoordinationStore from agent_comms.external_transcript import ExternalTranscript @@ -35,6 +35,18 @@ class UpgradeRefused(Exception): """The update stopped without permission to discard its original data.""" + prefix: ClassVar[str] = "upgrade-bus:" + + @property + def output(self) -> str: + return f"{self.prefix} {self}" + + @classmethod + def public_message(cls, output: str) -> str: + for line in reversed(output.splitlines()): + if line.startswith(cls.prefix): + return line + return "Installer failed without a declared refusal; private traceback retained on host" def bus_files(root: Path) -> dict[str, str]: diff --git a/tools/install_gate.py b/tools/install_gate.py index 2a7f635e19..404e0686a9 100644 --- a/tools/install_gate.py +++ b/tools/install_gate.py @@ -30,6 +30,7 @@ from pathlib import Path from typing import Annotated, Literal, Self +from cutover.backup import UpgradeRefused from install_snapshot import SnapshotHome from agent_comms.active_route import ActiveRoute @@ -460,7 +461,8 @@ def rehearse( launch.verify() outcome = asyncio.run(process.wait()) if not outcome.successful: - raise GateError(f"Rehearsal {outcome.summary}; see {log}") + reason = UpgradeRefused.public_message(log.read_text(errors="replace")) + raise GateError(f"Rehearsal {outcome.summary}: {reason}") try: return FieldCodec.decode(InstallerAnswer, FieldCodec.parse(answer.read_text())).rehearsal except MalformedRecord as error: @@ -668,8 +670,11 @@ class MachinesGateResult(GateResult): @property def failures(self) -> tuple[str, ...]: - return tuple(f"{name}: {reason}" for name, result in self.machines.items() - for reason in result.failures) + return tuple( + f"{name}: {reason}" + for name, result in self.machines.items() + for reason in result.failures + ) @property def successful(self) -> bool: diff --git a/tools/merge_gate_command.sh b/tools/merge_gate_command.sh index 5bdbddb131..f9b979b7a2 100755 --- a/tools/merge_gate_command.sh +++ b/tools/merge_gate_command.sh @@ -11,7 +11,7 @@ exec "$log"; then diff --git a/tools/merge_queue.py b/tools/merge_queue.py index b9d362d031..add8319ec9 100644 --- a/tools/merge_queue.py +++ b/tools/merge_queue.py @@ -12,12 +12,11 @@ import shlex import subprocess import sys -import traceback +from abc import abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar, Literal, Self -from debt_ratchet import compare from install_gate import ( REPO, ExternalRecord, @@ -25,15 +24,14 @@ RepositoryAnswer, RunSummary, Target, - affected, run, ) from agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.field_codec import FieldCodec from agent_comms.todos import ReviewedHeadVerdict -WORKFLOW = ".github/workflows/debt-ratchet.yml" +WORKFLOW = Path(".github/workflows/debt-ratchet.yml") @dataclass(frozen=True) @@ -73,7 +71,7 @@ def for_head(cls, host: Host, head: str) -> Self: f"repos/{host.repository}/commits/{head}/pulls", "--jq", f'[.[] | select(.head.sha == "{head}" and .state == "open")' - ' | {number: .number}]', + " | {number: .number}]", ) ), ) @@ -107,8 +105,8 @@ class Administrator(ExternalRecord): class RunEvent(DeclaredFamily, affix="RunEvent"): @classmethod - def target(cls, host: Host, workflow: WorkflowRun) -> Target: - raise NotImplementedError + @abstractmethod + def target(cls, host: Host, workflow: WorkflowRun) -> Target: ... class WorkflowDispatchRunEvent(RunEvent, declared_name="workflow_dispatch"): @@ -158,7 +156,7 @@ def require(cls, host: Host, identifier: int) -> Self: run("gh", "api", f"repos/{host.repository}/actions/runs/{identifier}") ), ) - if result.path.partition("@")[0] != WORKFLOW: + if Path(result.path.partition("@")[0]) != WORKFLOW: raise GateError("The SSH key is restricted to the merge queue workflow") return result @@ -173,32 +171,47 @@ class StatusWorkflowRun(WorkflowIdentity): event: Literal["status"] +class CheckFailed(subprocess.SubprocessError): + def __init__(self, check: QueueCheck, completed: subprocess.CompletedProcess[str]): + self.check = check + self.completed = completed + super().__init__(f"{check.label}: {check.summary(completed)}") + + @dataclass(frozen=True) class Host: repo: Path repository: str scratch: Path - private_copies_authorized: bool def fetch(self, head: str) -> str: if re.fullmatch(r"[0-9a-f]{40}", head) is None: raise GateError("A check requires a full GitHub commit identifier") run("git", "fetch", "origin", head, cwd=self.repo) + run("git", "submodule", "update", "--init", "--recursive", cwd=self.repo) return run("git", "rev-parse", f"{head}^{{commit}}", cwd=self.repo) - def command(self, *arguments: str | Path | int) -> None: - subprocess.run( + def command(self, check: QueueCheck, *arguments: str | Path | int) -> None: + completed = subprocess.run( tuple(map(str, arguments)), cwd=self.repo, - check=True, - stdout=sys.stderr, - stderr=sys.stderr, + text=True, + capture_output=True, + timeout=check.limit, + check=False, ) + if completed.returncode: + raise CheckFailed(check, completed) @dataclass(frozen=True) class QueueCheck(DeclaredFamily, affix="QueueCheck"): run_id: int + limit: ClassVar[int] + + @property + def label(self) -> str: + return self.declared_name.title() @classmethod def from_tokens(cls, tokens: list[str]) -> Self: @@ -209,30 +222,48 @@ def execute(self, host: Host) -> None: workflow = WorkflowRun.require(host, self.run_id) self.check(host, workflow.event.target(host, workflow)) - def check(self, host: Host, target: Target) -> None: - raise NotImplementedError + @abstractmethod + def check(self, host: Host, target: Target) -> None: ... + + @abstractmethod + def summary(self, completed: subprocess.CompletedProcess[str]) -> str: ... + + +@dataclass(frozen=True) +class RatchetOutput(ExternalRecord): + introduced_lines: dict[str, tuple[int, ...]] class RatchetQueueCheck(QueueCheck, declared_name=None): root: ClassVar[str] + limit = 600 + + @property + def label(self) -> str: + return "Ratchet" def check(self, host: Host, target: Target) -> None: - result = compare(host.repo, target.base, target.head, self.root) - if result.failed: - for metric, increase in result.delta.items(): - if increase > 0: - _, _, path = metric.partition(":") - for line in result.introduced_lines.get(metric, [1]): - annotation( - path.partition("::")[0] or "tools/debt_ratchet.py", - line, - f"{metric}: +{increase}", - ) - for metric, lines in result.introduced_lines.items(): - _, _, path = metric.partition(":") - for line in lines: - annotation(path, line, f"Introduced {metric}") - raise GateError(f"{self.root} debt ratchet rejected {target.head}") + host.command( + self, + sys.executable, + "-X", + "faulthandler", + host.repo / "tools/debt_ratchet.py", + "--root", + self.root, + "--base", + target.base, + "--head", + target.head, + ) + + def summary(self, completed: subprocess.CompletedProcess[str]) -> str: + report = FieldCodec.decode(RatchetOutput, FieldCodec.parse(completed.stdout)) + sites = [] + for key, lines in report.introduced_lines.items(): + measure, _, path = key.partition(":") + sites.extend(f"{measure} {path}:{line}" for line in lines) + return "; ".join(sites) or completed.stdout.strip() class SrcQueueCheck(RatchetQueueCheck): @@ -240,54 +271,47 @@ class SrcQueueCheck(RatchetQueueCheck): class ToolsQueueCheck(RatchetQueueCheck): - root = "tools" + root = Path(__file__).parent.name class InstallQueueCheck(QueueCheck): + limit = 3000 + def check(self, host: Host, target: Target) -> None: - if not host.private_copies_authorized and not target.outside( - tuple(affected(host.repo, target.base, target.head)) - ): - raise GateError( - "Private-copy allocation is not authorized: worker cap 3 GB, desktop bus 26 GiB" - ) - command = tuple( - map( - str, - ( - sys.executable, - "-X", - "faulthandler", - host.repo / "tools/install_gate.py", - "--repo", - host.repo, - "--revision", - target.head, - "--base", - target.base, - "--scratch", - host.scratch, - ), - ) - ) - completed = subprocess.run( - command, - cwd=host.repo, - stdout=subprocess.PIPE, - stderr=sys.stderr, - text=True, - check=False, + host.command( + self, + sys.executable, + "-X", + "faulthandler", + host.repo / "tools/install_gate.py", + "--repo", + host.repo, + "--revision", + target.head, + "--base", + target.base, + "--scratch", + host.scratch, ) - summary = FieldCodec.decode(RunSummary, FieldCodec.parse(completed.stdout)) - if completed.returncode or not summary.result.successful: - raise GateError("; ".join(summary.result.failures) or summary.result.description) + + def summary(self, completed: subprocess.CompletedProcess[str]) -> str: + report = FieldCodec.decode(RunSummary, FieldCodec.parse(completed.stdout)) + return "; ".join(report.result.failures) or report.result.description + + +class CommandQueueCheck(QueueCheck, declared_name=None): + limit = 120 + + def summary(self, completed: subprocess.CompletedProcess[str]) -> str: + return completed.stderr.strip() or completed.stdout.strip() -class EnqueueQueueCheck(QueueCheck): +class EnqueueQueueCheck(CommandQueueCheck): def check(self, host: Host, target: Target) -> None: ApprovedHead.require(host, target.head) pr = QueuedPullRequest.for_head(host, target.head) host.command( + self, "gh", "pr", "merge", @@ -302,7 +326,7 @@ def check(self, host: Host, target: Target) -> None: @dataclass(frozen=True) -class DispatchQueueCheck(QueueCheck): +class DispatchQueueCheck(CommandQueueCheck): head: str context: str @@ -316,9 +340,14 @@ def execute(self, host: Host) -> None: if self.context != ReviewedHeadVerdict.context: return head = host.fetch(self.head) - ApprovedHead.require(host, head) - pr = QueuedPullRequest.for_head(host, head) + base = run("git", "merge-base", "origin/main", head, cwd=host.repo) + self.check(host, Target(head, base)) + + def check(self, host: Host, target: Target) -> None: + ApprovedHead.require(host, target.head) + pr = QueuedPullRequest.for_head(host, target.head) host.command( + self, "gh", "workflow", "run", @@ -336,15 +365,6 @@ class HostArguments(argparse.Namespace): request: str repo: Path scratch: Path - private_copies_authorized: bool - - -def annotation(path: str, line: int, reason: str) -> None: - def escape(value: str) -> str: - return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - attribute = escape(path).replace(",", "%2C").replace(":", "%3A") - print(f"::error file={attribute},line={line}::{escape(reason)}", flush=True) def main() -> int: @@ -352,7 +372,6 @@ def main() -> int: parser.add_argument("request", help="SSH_ORIGINAL_COMMAND, supplied as one quoted argument") parser.add_argument("--repo", type=Path, default=REPO) parser.add_argument("--scratch", type=Path, required=True) - parser.add_argument("--private-copies-authorized", action="store_true") args = parser.parse_args(namespace=HostArguments()) tokens = shlex.split(args.request) if len(tokens) < 2: @@ -363,14 +382,9 @@ def main() -> int: FieldCodec.parse(run("gh", "repo", "view", "--json", "nameWithOwner", cwd=args.repo)), ) try: - check.execute( - Host(args.repo, repository.name, args.scratch, args.private_copies_authorized) - ) - except (GateError, subprocess.CalledProcessError, MalformedRecord) as error: - frames = traceback.extract_tb(error.__traceback__) - location = frames[-1] - path = Path(location.filename).relative_to(args.repo) - annotation(str(path), location.lineno or 1, str(error)) + check.execute(Host(args.repo, repository.name, args.scratch)) + except CheckFailed as error: + print(str(error), flush=True) return 1 return 0 diff --git a/tools/upgrade_bus.py b/tools/upgrade_bus.py index 1c5de86077..9973ee25fa 100644 --- a/tools/upgrade_bus.py +++ b/tools/upgrade_bus.py @@ -448,7 +448,7 @@ def main() -> None: try: print(json.dumps(upgrade(args.checkout, args.root, args.to, args.installer, args.rehearse), indent=2)) except UpgradeRefused as error: - raise SystemExit(f"upgrade-bus: {error}") + raise SystemExit(error.output) if __name__ == "__main__": From b10dcb5c5ea84e2d354dfe27053813a70e180953 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:33:30 -0400 Subject: [PATCH 26/41] P269: preserve tool-call verdict typing across the review dependency Judge emits tool-call verdicts, not the newly broader head-review verdict base. Keep that owner type through CommsVerdictTool. Type the ratchet summary source list; an already abstract command capability does not need a nullable declaration marker. --- src/agent_comms/commander/judge.py | 4 ++-- tools/merge_queue.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 07affa6d45..be3a13d49d 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -20,7 +20,7 @@ from dataclasses import asdict, dataclass from ..comms import Comms -from ..todos import AcceptedVerdict, PeerAcceptedVerdict, BluntText, OrderVerdict, SendBackVerdict, StrikeReason, Todo, Slop, Derivation, SupersededVerdict +from ..todos import AcceptedVerdict, PeerAcceptedVerdict, BluntText, ToolCallVerdict, SendBackVerdict, StrikeReason, Todo, Slop, Derivation, SupersededVerdict from ..tools import CommsUnjudgedTool, CommsVerdictTool from ..field_codec import FieldCodec, option from ..tool_caller import ThreadCaller @@ -146,7 +146,7 @@ class VerdictVerb(JudgeVerb, RuledVerb): "--line", action="append", metavar="LINE", help="an exact diff line; repeat for each") @abstractmethod - def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> OrderVerdict: ... + def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> ToolCallVerdict: ... @abstractmethod def check_edits(self, edits: list[Edit]) -> None: ... diff --git a/tools/merge_queue.py b/tools/merge_queue.py index add8319ec9..32cfe20012 100644 --- a/tools/merge_queue.py +++ b/tools/merge_queue.py @@ -259,7 +259,7 @@ def check(self, host: Host, target: Target) -> None: def summary(self, completed: subprocess.CompletedProcess[str]) -> str: report = FieldCodec.decode(RatchetOutput, FieldCodec.parse(completed.stdout)) - sites = [] + sites: list[str] = [] for key, lines in report.introduced_lines.items(): measure, _, path = key.partition(":") sites.extend(f"{measure} {path}:{line}" for line in lines) @@ -299,7 +299,7 @@ def summary(self, completed: subprocess.CompletedProcess[str]) -> str: return "; ".join(report.result.failures) or report.result.description -class CommandQueueCheck(QueueCheck, declared_name=None): +class CommandQueueCheck(QueueCheck): limit = 120 def summary(self, completed: subprocess.CompletedProcess[str]) -> str: From eea6c527e4992728521f9ecc5f0074bc79dadb9f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:35:46 -0400 Subject: [PATCH 27/41] P269: distinguish public refusals from private installer tracebacks Only UpgradeRefused owns the public refusal marker. Ordinary abort diagnostics may include private exceptions; they must not match the public-output boundary merely because they also start with upgrade-bus. --- tests/test_merge_queue.py | 2 ++ tools/cutover/backup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_merge_queue.py b/tests/test_merge_queue.py index ed8e3312c4..68919cdf6c 100644 --- a/tests/test_merge_queue.py +++ b/tests/test_merge_queue.py @@ -97,3 +97,5 @@ def test_only_declared_installer_refusal_crosses_the_log_boundary(self): error.output) secret = "PRIVATE_PAYLOAD_DO_NOT_LEAK" self.assertNotIn(secret, UpgradeRefused.public_message(secret)) + private_failure = f"upgrade-bus: aborted before the backup (RuntimeError: {secret})" + self.assertNotIn(secret, UpgradeRefused.public_message(private_failure)) diff --git a/tools/cutover/backup.py b/tools/cutover/backup.py index 0061937050..0fd664fb93 100644 --- a/tools/cutover/backup.py +++ b/tools/cutover/backup.py @@ -35,7 +35,7 @@ class UpgradeRefused(Exception): """The update stopped without permission to discard its original data.""" - prefix: ClassVar[str] = "upgrade-bus:" + prefix: ClassVar[str] = "upgrade-bus: refused:" @property def output(self) -> str: From 11f496454097d9a9f3f058887601d90a55d078fc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:52:27 -0400 Subject: [PATCH 28/41] P269: practice-install bounded retained tails through CheckRoot --- .pi/APPEND_SYSTEM.md | 4 ++ docs/merge-queue.md | 47 ++++++++++-------- tests/guards/test_debt_ratchet.py | 13 ++++- tests/test_merge_queue.py | 5 +- tools/debt_ratchet.py | 18 ++++--- tools/install_gate.py | 82 +++++++++++++++++++++++++++++-- tools/merge_queue.py | 13 ++--- 7 files changed, 141 insertions(+), 41 deletions(-) diff --git a/.pi/APPEND_SYSTEM.md b/.pi/APPEND_SYSTEM.md index 05bbe72038..45dbef3629 100644 --- a/.pi/APPEND_SYSTEM.md +++ b/.pi/APPEND_SYSTEM.md @@ -56,6 +56,10 @@ You are one of several agents coordinating through agent-comms. Tristan owns pro ## Ownership, code, and delivery +- Do not merge main into feature branches. The integration owner merges each published head onto fresh main; rebase only on an explicit conflict instruction. Do not make formatting-only or re-budget-only commits. +- Line growth is a measurement, not a defect or merge gate. Do not request a line budget. Fix duplicated authorities, type filters and strings replacing declared identities; the existing measure owner decides which findings block. +- P269 practice installation uses a retained bus tail with its production indexes, bounded to 3 GiB and measured against actual free space. It does not require a 26 GiB whole-desktop copy or GitHub configuration changes. Preserve original sessions, inputs and stores; never turn short storage into a skipped success. + - Check active owners, open PRs, and claims before starting overlapping work. Coordinate with the owner of an existing feature; do not edit their worktree or start a competing implementation. Delegate genuinely independent work when it speeds delivery. - At the next safe checkpoint, when you find a concrete defect, check that a named owner is fixing it. If none is, take or assign ownership, provide the reproducer and acceptance check, and follow through to a tested fix or a precise blocker without duplicating work. - Implement working end-to-end behavior rather than a dormant path. Enable features by default only when safe and authorized; preserve opt-in or default-off safeguards for trust-sensitive features. Put tested work in a draft PR promptly and keep its full working version visible. Work only in an isolated worktree; do not reset, clean, or commit in the live main checkout or change installed packages without review. diff --git a/docs/merge-queue.md b/docs/merge-queue.md index ec9b003393..3d94b93ed5 100644 --- a/docs/merge-queue.md +++ b/docs/merge-queue.md @@ -19,9 +19,13 @@ as data; the host compares it with that declaration. No repository variable mirrors the context. No native GitHub approval is required: the contributors all use the same GitHub identity and cannot approve their own PRs. -The install gate's run_machines checks out the proposed revision, then rehearse -invokes target/tools/upgrade_bus.py, including --help and the actual rehearsal. -It does not invoke the live checkout's installer. GateResult/MachineResult carry +The queue invokes PracticeGateCommand. PracticeCopy uses the existing CheckRoot +owner to retain the bus tail, bounded saved-session tails and their production +indexes; it does not snapshot the whole desktop bus. PracticeCopy owns the 3 GiB +cap and checks measured bytes against current free space. It never admits an +empty substitute or a skipped installation. The gate checks out the proposed +revision, then rehearse invokes target/tools/upgrade_bus.py, including --help and +the actual rehearsal. It does not invoke the live checkout's installer. GateResult/MachineResult carry typed failure reasons back to the queue. CheckFailed carries the actual completed command and delegates its public summary to the QueueCheck member. Ratchet summaries decode introduced_lines and print Measure file:line. Installer summaries return the declared @@ -67,21 +71,22 @@ evidence, not installed entrypoints. No runner launch, authorized_keys edit, queue enablement, native approval, production install or restart was performed by this worker. -## Remaining acceptance - -A real approved PR must merge through the enabled queue after the actual -private practice install, and a deliberately failing PR must show its reason -on GitHub. Local unit checks, annotations printed locally and ratchets do not -establish that acceptance. Bootstrap is still a reviewed operator action. - -Current storage is an independent blocker: the desktop bus is approximately -26 GiB and this host has approximately 12 GiB free. The operator needs a -separately provisioned practice-install volume/allocation. The queue now always -invokes the actual install gate, which owns storage admission/refusal; the -queue's second boolean allocation switch and invented capacity literals are -deleted. A verified admin/reviewed-head run authorizes a practice attempt, not -an exemption from the install gate's storage floor. No private-copy run was -started by this worker under its 3 GB allocation. Worker checkouts and receipts stay under -/home/ts/wt/pi-cn-load--P269 and /home/ts/.cache/agent-scratch/pi-cn-load, below -the combined 3 GB limit. The old runner state remains with its existing owner; -its credentials have not been copied or deleted. +## Practice proof and later activation + +The latest owner ruling replaces real-queue proof with a practice installation +on the bounded retained-tail copy. Report that command's exit code and the pushed +revision; unit checks alone are not the practice-install proof. Both source and +tools ratchets must exit zero. SourceLines reports actual growth without a +budget or blocking the merge; structural findings still block through their +owning Measure implementations. + +The disabled queue, offline runner and unauthorized key are not prerequisites +for this proof. They remain untouched; later production activation requires the +operator actions above. No deliberate failing GitHub branch is claimed here. +The copy does not need room for a 26 GiB desktop snapshot. Its admission uses +actual copied bytes and actual remaining space, not a second queue policy. + +Worker checkouts and receipts stay under /home/ts/wt/pi-cn-load--P269 and +/home/ts/.cache/agent-scratch/pi-cn-load, below the combined 3 GB limit. The old +runner state remains with its existing owner; its credentials have not been +copied or deleted. diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index 42ca2462d9..b2ebd77c25 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -12,12 +12,23 @@ from tools.debt_ratchet import ( BannedVocabulary, BroadExceptDefault, BroadSwallow, ForeignFieldSpellings, FrozenFieldWrite, FunctionLocalImport, GitMeasure, NoneAbsence, UncarriedRecordFormat, - ComparedKind, DeclaredSpellings, NamedAccess, NamedDeclaration, StringIdentity, python_paths, + ComparedKind, Comparison, DeclaredSpellings, NamedAccess, NamedDeclaration, + SourceLines, StringIdentity, python_paths, ) pytestmark = pytest.mark.refactor_guard +@pytest.mark.parametrize("name,blocked", [ + (SourceLines.__name__, False), + (f"{NoneAbsence.__name__}:tools/example.py", True), +]) +def test_measure_owner_decides_whether_growth_blocks(name: str, blocked: bool) -> None: + result = Comparison("tools", "base", "head", (), {name: 0}, {name: 1}, {}, {}) + assert result.delta[name] == 1 + assert result.failed is blocked + + class Repository: def __init__(self, path: Path, root: str): self.path = path diff --git a/tests/test_merge_queue.py b/tests/test_merge_queue.py index 68919cdf6c..90e51e4f37 100644 --- a/tests/test_merge_queue.py +++ b/tests/test_merge_queue.py @@ -13,7 +13,6 @@ from merge_queue import ( CheckFailed, DispatchQueueCheck, - GateError, Host, InstallQueueCheck, QueueCheck, @@ -49,7 +48,7 @@ def test_github_event_decodes_to_its_behavior(self): def test_a_workflow_cannot_supply_git_options(self): host = Host(Path("."), "owner/repo", Path(".")) with patch("merge_queue.run") as command: - with self.assertRaises(GateError): + with self.assertRaises(MalformedRecord): host.fetch("--upload-pack=anything") command.assert_not_called() @@ -89,7 +88,7 @@ def test_practice_install_is_not_skipped_by_a_second_storage_policy(self): check = InstallQueueCheck(123) check.check(host, Target("a" * 40, "b" * 40)) self.assertIs(command.call_args.args[0], check) - self.assertIn("--revision", command.call_args.args) + self.assertIn("--practice", command.call_args.args) def test_only_declared_installer_refusal_crosses_the_log_boundary(self): error = UpgradeRefused("Insufficient room") diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 8b14189ba9..9cbc009369 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -47,6 +47,11 @@ class Measure(DeclaredFamily, affix="Measure"): + @classmethod + def blocks(cls, delta: dict[str, int]) -> bool: + return any(value > 0 for key, value in delta.items() + if key.partition(":")[0] == cls.__name__) + @staticmethod def environment(root: str) -> dict[str, str]: return dict(os.environ) @@ -243,9 +248,11 @@ def environment(root: str) -> dict[str, str]: class SourceLines(Measure): - """Budget edited lines, retaining moves across the source boundary (50% similarity).""" + """Report edited lines, retaining moves across the source boundary (50% similarity).""" - trailer: typing.ClassVar[str] = "Src-Budget" + @classmethod + def blocks(cls, delta: dict[str, int]) -> bool: + return False @classmethod def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str): @@ -258,9 +265,7 @@ def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str) if any(path.startswith(root.encode()) and path.endswith(b".py") for path in paths): added += int(increase) removed += int(decrease) - # Each commit's trailer budgets that commit's lines, so a range's budget is their sum. - declared = git(repo, "log", f"--format=%(trailers:key={cls.trailer},valueonly)", f"{base}..{head}") - return {cls.__name__: removed + sum(int(value) for value in declared.split())}, {cls.__name__: added} + return {cls.__name__: removed}, {cls.__name__: added} @dataclass(frozen=True) @@ -1537,7 +1542,8 @@ def delta(self) -> dict[str, int]: @property def failed(self) -> bool: - return any(value > 0 for value in self.delta.values()) or any(self.introduced.values()) + return (any(measure.blocks(self.delta) for measure in Measure.members_with(Measure)) + or any(self.introduced.values())) def compare(repo: Path, base: str, head: str, root: str) -> Comparison: diff --git a/tools/install_gate.py b/tools/install_gate.py index 404e0686a9..b4f67985a3 100644 --- a/tools/install_gate.py +++ b/tools/install_gate.py @@ -27,13 +27,16 @@ from collections.abc import Iterator, Mapping from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace +from itertools import islice from pathlib import Path -from typing import Annotated, Literal, Self +from typing import Annotated, ClassVar, Literal, Self from cutover.backup import UpgradeRefused from install_snapshot import SnapshotHome -from agent_comms.active_route import ActiveRoute +from agent_comms.active_route import ActiveRoute, CommsRoute +from agent_comms.check_root import CheckRoot +from agent_comms.comms import Comms from agent_comms.child_process import Platform from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import ( @@ -234,6 +237,36 @@ def affected(repo: Path, base: str, head: str) -> list[str]: ) +@dataclass(frozen=True) +class PracticeCopy: + """A real retained tail, native session tails and the production indexes they build.""" + source: Comms + into: Path + max_bytes: ClassVar[int] = 3 * 1024**3 + + @contextmanager + def open(self) -> Iterator[Comms]: + roster = self.source.registry.snapshot() + with self.source.bus.log.opened_wire_snapshot() as source: + names = tuple(dict.fromkeys( + message.sender for message in islice( + source.newest_messages(CheckRoot.max_bytes), CheckRoot.max_records) + if message.sender in roster.threads + )) + if not names: + raise GateError("The retained bus tail has no registered participants") + self.into.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if shutil.disk_usage(self.into.parent).free < storage_floor(): + raise GateError("Insufficient free space before building the retained-tail copy") + with CheckRoot(self.source, names, self.into).open() as copied: + size = sum(path.stat().st_size for path in copied.root.rglob("*") if path.is_file()) + if size > self.max_bytes: + raise GateError(f"Retained-tail copy is {size} bytes, over its {self.max_bytes} cap") + if shutil.disk_usage(self.into.parent).free < 2 * size + storage_floor(): + raise GateError(f"Insufficient free space for rehearsal of {size} copied bytes") + yield copied + + class SnapshotCopy(SnapshotHome): def place(self, home: Path, bus: Path) -> Path: original, source_home = Path(self.root), Path(self.home) @@ -351,6 +384,12 @@ def prepare_home(home: Path, bus: Path, context: SnapshotCopy) -> Path: source_route = bus.parent / "active-route.json" bus = context.place(home, bus) context.route(source_route, home, bus) + prepare_runtime_home(home) + return bus + + +def prepare_runtime_home(home: Path) -> None: + home.mkdir(mode=0o700, parents=True, exist_ok=True) pointer = home / ".local/share/agent-comms/current" pointer.parent.mkdir(mode=0o700, parents=True, exist_ok=True) pointer.unlink(missing_ok=True) @@ -362,7 +401,6 @@ def prepare_home(home: Path, bus: Path, context: SnapshotCopy) -> Path: cache = Path(run("npm", "config", "get", "cache")) if not (home / ".npm").exists() and cache.is_dir(): shutil.copytree(cache, home / ".npm", symlinks=True) - return bus @contextmanager @@ -691,6 +729,19 @@ def description(self) -> str: ) +@dataclass(frozen=True) +class PracticeGateResult(GateResult): + rehearsal: CompletedRehearsal + + @property + def successful(self) -> bool: + return True + + @property + def description(self) -> str: + return "Retained-tail practice install passed" + + @dataclass(frozen=True) class UnfinishedGateResult(MachinesGateResult): @property @@ -803,6 +854,10 @@ def execute(self, args: GateArguments, repository: str) -> int: ... class CheckGateCommand(GateCommand): + def run(self, args: GateArguments, target: Target, report: Path, + results: dict[str, MachineResult]) -> GateResult: + return MachinesGateResult(run_machines(args, target, report, results)) + @abstractmethod def target(self, args: GateArguments, repository: str) -> Target: ... @@ -819,7 +874,7 @@ def execute(self, args: GateArguments, repository: str) -> int: result = ( OutsideInstallGateResult() if target.outside(changed) - else MachinesGateResult(run_machines(args, target, report_path, results)) + else self.run(args, target, report_path, results) ) except ( GateError, @@ -884,6 +939,24 @@ def target(self, args: GateArguments, repository: str) -> Target: return Target(head, args.base.resolve(args.repo, head)) +@dataclass(frozen=True) +class PracticeGateCommand(RevisionGateCommand): + def run(self, args: GateArguments, target: Target, report: Path, + results: dict[str, MachineResult]) -> GateResult: + with tempfile.TemporaryDirectory(dir=args.scratch, prefix="practice-") as directory: + private = Path(directory) + home = private / "home" + with PracticeCopy(Comms(CommsRoute.current().root), home / ".agent-comms").open() as copied: + prepare_runtime_home(home) + with tempfile.TemporaryDirectory(dir=Path.home() / "wt", prefix="practice-source-") as source: + work = Path(source) + with (checkout(args.repo, target.base, work / "base") as previous, + checkout(args.repo, target.head, work / "target") as tree): + completed = rehearse(tree, previous, target.head, copied.root, home, + args.python, report.with_suffix(".log"), work) + return PracticeGateResult(completed) + + @dataclass(frozen=True) class ConfigureGateCommand(GateCommand): def execute(self, args: GateArguments, repository: str) -> int: @@ -955,6 +1028,7 @@ def main() -> int: mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--pr", dest="command", type=PrGateCommand.argument) mode.add_argument("--revision", dest="command", type=RevisionGateCommand) + mode.add_argument("--practice", dest="command", type=PracticeGateCommand) mode.add_argument( "--require-status", dest="command", action="store_const", const=ConfigureGateCommand() ) diff --git a/tools/merge_queue.py b/tools/merge_queue.py index 32cfe20012..0b7dc8ece7 100644 --- a/tools/merge_queue.py +++ b/tools/merge_queue.py @@ -8,7 +8,6 @@ from __future__ import annotations import argparse -import re import shlex import subprocess import sys @@ -28,7 +27,7 @@ ) from agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, GitCommitText from agent_comms.todos import ReviewedHeadVerdict WORKFLOW = Path(".github/workflows/debt-ratchet.yml") @@ -185,11 +184,13 @@ class Host: scratch: Path def fetch(self, head: str) -> str: - if re.fullmatch(r"[0-9a-f]{40}", head) is None: - raise GateError("A check requires a full GitHub commit identifier") + head = GitCommitText.decode(head) run("git", "fetch", "origin", head, cwd=self.repo) run("git", "submodule", "update", "--init", "--recursive", cwd=self.repo) - return run("git", "rev-parse", f"{head}^{{commit}}", cwd=self.repo) + resolved = run("git", "rev-parse", f"{head}^{{commit}}", cwd=self.repo) + if resolved != head: + raise GateError("A check requires the full GitHub commit identifier") + return resolved def command(self, check: QueueCheck, *arguments: str | Path | int) -> None: completed = subprocess.run( @@ -286,7 +287,7 @@ def check(self, host: Host, target: Target) -> None: host.repo / "tools/install_gate.py", "--repo", host.repo, - "--revision", + "--practice", target.head, "--base", target.base, From 3839b76d93a7dbc2b75f263f7e067108cca6f86b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 17:02:26 -0400 Subject: [PATCH 29/41] Stop check-root owners only after population succeeds --- src/agent_comms/check_root.py | 2 +- tests/test_check_root.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 7d67cfad73..70e3c6ee01 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -66,8 +66,8 @@ def open(self) -> Iterator[Comms]: root.mkdir(mode=0o700, parents=True) # Refuse an existing directory, including a symlink. try: copied = Comms(root) + self._populate(copied) try: - self._populate(copied) yield copied finally: for thread in copied.registry.snapshot().visible_threads( diff --git a/tests/test_check_root.py b/tests/test_check_root.py index bcf7860a6b..2162f70681 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -11,6 +11,8 @@ from agent_comms.cli import main from agent_comms.comms import Comms from agent_comms.coordination_errors import RecoveryBlocked +from agent_comms.envelope_claim_transitions import ClaimRelease, ClaimTransition +from agent_comms.errors import RelationViolationError from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry @@ -64,6 +66,33 @@ def test_python_failure_removes_root(source, tmp_path): assert not root.exists() +def test_failed_population_preserves_protocol_error_and_retires_root(source, tmp_path): + with source.bus.log.opened_wire_snapshot() as history: + message = next(history.newest_messages()) + owner = source.registry.require(message.sender).incarnation.require_recorded() + released = replace(message, claim_transition=ClaimTransition( + owner=message.sender, incarnation=owner.lookup, + seq=message.seq, message_id=message.message_id, + releases=(ClaimRelease(str(tmp_path / "retained.py"), "a" * 32),), + )) + + class ReleasedHistoryCheckRoot(CheckRoot): + def _populate(self, copied): + # Exercise actual protocol initialization, not a fake registry error. + copied.messaging.initialize_private_initial_protocol(history=(released,)) + + root = tmp_path / "release-only-history" + with pytest.raises(RelationViolationError, match="Committed claim transition") as failure: + with ReleasedHistoryCheckRoot(source, ("A", "B"), root).open(): + pytest.fail("A release-only historical prefix was silently accepted") + assert "without a live claim" in str(failure.value.__cause__) + assert not root.exists() + # The source remains independently readable after the failed copy. + assert source.registry.require("A").name == "A" + with source.bus.log.opened_wire_snapshot() as history: + assert next(history.newest_messages()) == message + + def test_cleanup_uses_the_registry_executor_selection_not_human_senders(source, tmp_path): root = tmp_path / "with-human" with CheckRoot(source, ("B",), root).open() as c: From 36bba6eb7ac0c5f0a89fbafc7f9dcdfbaf519575 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 17:32:08 -0400 Subject: [PATCH 30/41] Copy normalized external events without native record reprojection --- src/agent_comms/check_root.py | 10 ++-------- src/agent_comms/native_entries.py | 21 +++++++++++--------- tests/test_check_root.py | 32 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 70e3c6ee01..78647ef463 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -17,7 +17,6 @@ from .comms import Comms from .fresh_private_session import create_fresh_private_session from .goals import NoGoal -from .importing import ImportLimits from .launch_runtime import RestartEnvironment, ThreadLaunch from .native_entries import ImportedSessionWriter, NativeEntry from .read_basis import MemberActivityDisplayScope @@ -142,7 +141,6 @@ def _populate(self, copied: Comms) -> None: def _session(self, original: Thread, directory: Path, project: Path) -> str: session = create_fresh_private_session(directory, worktree=project) source = original.runtime.history_source(self.source.root, original.incarnation) - limits = ImportLimits(messages=self.max_records, characters=self.max_bytes) with source.reading() as reader, session.path.open("r+") as output: writer = ImportedSessionWriter(output, NativeEntry.read(output.readline().encode())) output.seek(0, os.SEEK_END) @@ -158,16 +156,12 @@ def _session(self, original: Thread, directory: Path, project: Path) -> str: with source.projection( source, TranscriptRoutes(self.source.root) ) as project_records: - for record, events in project_records( + for _record, events in project_records( iter(reversed(selected)), max_records=self.max_records, max_bytes=self.max_bytes, ): - record.project( - lambda current, events=events: writer.import_record( - current.entry, events, limits - ) - ) + writer.import_events(events) return session.session_file def run(self, command: Sequence[str]) -> CheckRootResult: diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 3593629a42..172db60857 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -7,14 +7,15 @@ from collections.abc import Iterable from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace -from datetime import datetime +from datetime import UTC, datetime +from itertools import chain from pathlib import Path from typing import Annotated, Any, ClassVar, Literal, TextIO from uuid import uuid4 from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import FieldCodec, MalformedRecord, RecordedTimestamp, TimestampText -from .importing import ImportBuffer, ImportedSessionMetadata, ImportLimits +from .importing import ImportedSessionMetadata from .pi_payloads import PiMessage, PiPayload from .pi_vocabulary import PendingStopReason, ThinkingLevel from .routing import TurnRouting @@ -176,13 +177,15 @@ def append(self, message: ImportedMessage, timestamp: datetime) -> None: self.stream.write(json.dumps(encoded, ensure_ascii=False) + "\n") self.parent = NativeEntry.from_wire(encoded) - def import_record( - self, entry: RecordedEntry, events: Iterable[TranscriptEvent], limits: ImportLimits - ) -> None: - buffer = ImportBuffer(limits) - buffer.add(events) - for message in buffer.messages: - self.append(message, entry.timestamp.at) + def import_events(self, events: Iterable[TranscriptEvent]) -> None: + """The source already normalized records; preserve its conversation and clock.""" + for event in events: + message = event.imported() + if message is None or not message.body.strip(): + continue + if event.timestamp is None: + raise ValueError("Copied conversation event has no original timestamp") + self.append(message, datetime.fromtimestamp(event.timestamp, UTC)) class NativeHeaderState(ABC): diff --git a/tests/test_check_root.py b/tests/test_check_root.py index 2162f70681..85924ca183 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -13,7 +13,9 @@ from agent_comms.coordination_errors import RecoveryBlocked from agent_comms.envelope_claim_transitions import ClaimRelease, ClaimTransition from agent_comms.errors import RelationViolationError +from agent_comms.external_transcript import ExternalTranscript from agent_comms.fresh_private_session import create_fresh_private_session +from agent_comms.transcript_events import AssistantTranscript, UserTranscript from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus @@ -48,6 +50,34 @@ def test_only_selected_registry_and_history_without_old_pending_input(source, tm assert not root.exists() +@pytest.mark.parametrize("timestamp", (None, 1_760_000_000.125)) +def test_external_projection_preserves_event_clocks_or_refuses_unknown(source, tmp_path, timestamp): + owner = source.registry.require("B") + external = ExternalTranscript(source.root, owner.incarnation) + events = ( + UserTranscript("source request " * 1500, position=1, timestamp=timestamp), + AssistantTranscript("source reply", position=2, + timestamp=None if timestamp is None else timestamp + 1.125), + ) + external.finish(turn_id="saved", item_id="item", events=events) + root = tmp_path / "external-window" + if timestamp is None: + with pytest.raises(ValueError, match="no original timestamp"): + with CheckRoot(source, (owner.name,), root).open(): + pytest.fail("An unknown source clock was rewritten as known time") + else: + with CheckRoot(source, (owner.name,), root).open() as copied: + session = Path(copied.registry.require(owner.name).session_file) + rows = [NativeEntry.read(line) for line in session.read_bytes().splitlines()] + assert [entry.message.text for entry in rows[1:]] == [event.text for event in events] + assert [entry.timestamp.at.timestamp() for entry in rows[1:]] == [ + event.timestamp for event in events + ] + with external.reading() as original: + assert tuple(original.reverse(original.cut().offset))[0].events == events + assert not root.exists() + + def test_wrapped_nonzero_exit_removes_root_and_is_the_cli_exit(source, tmp_path, capsys): root = tmp_path / "command" status = main(["--root", str(source.root), "check-root", "--threads", "B", "--into", str(root), @@ -164,5 +194,7 @@ def test_native_window_uses_existing_import_and_route_readers(source, tmp_path, assert [entry.message.text for entry in rows[1:]] == ["history 17", "history 18", "history 19"] assert rows[1].parent_id is None assert rows[2].parent_id == rows[1].id + source_time = NativeEntry.read(original.splitlines()[1]).timestamp.at + assert all(entry.timestamp.at == source_time for entry in rows[1:]) assert session.path.read_bytes() == original assert not root.exists() From 713c44c1dd479504c315b709fd0403f04c1a7542 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 17:36:34 -0400 Subject: [PATCH 31/41] Let transcript declarations emit portable messages through one body gate --- src/agent_comms/importing.py | 26 +++++++++++----------- src/agent_comms/native_entries.py | 12 +++++------ src/agent_comms/transcript_events.py | 32 ++++++++++++++++------------ tests/test_check_root.py | 10 +++++---- 4 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/agent_comms/importing.py b/src/agent_comms/importing.py index b41df201d6..66b12b725f 100644 --- a/src/agent_comms/importing.py +++ b/src/agent_comms/importing.py @@ -154,19 +154,19 @@ class ImportBuffer: def add(self, events: Iterable[TranscriptEvent]) -> None: for event in events: - message = event.imported() - if message is None or not message.body.strip(): - continue - self.seen += 1 - message.remember(self) - message = message.bounded(self.limits) - self.truncated += int(message.truncated) - self.messages.append(message) - self.characters += len(message.body) - while len(self.messages) > self.limits.messages or self.characters > self.limits.characters: - removed = self.messages.popleft() - self.characters -= len(removed.body) - self.truncated -= int(removed.truncated) + event.import_to(self.append) + + def append(self, message: ImportedMessage) -> None: + self.seen += 1 + message.remember(self) + message = message.bounded(self.limits) + self.truncated += int(message.truncated) + self.messages.append(message) + self.characters += len(message.body) + while len(self.messages) > self.limits.messages or self.characters > self.limits.characters: + removed = self.messages.popleft() + self.characters -= len(removed.body) + self.truncated -= int(removed.truncated) def summarize(self, text: str) -> None: """A compaction replaced everything before it; only messages after it remain context.""" diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 172db60857..e2b8367c98 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -18,6 +18,7 @@ from .importing import ImportedSessionMetadata from .pi_payloads import PiMessage, PiPayload from .pi_vocabulary import PendingStopReason, ThinkingLevel +from .recorded_time import GeneratedTimestamp from .routing import TurnRouting from .transcript_events import ImportedMessage, NoticeTranscript, TranscriptEvent from .transcript_routes import InputDisplay @@ -180,12 +181,11 @@ def append(self, message: ImportedMessage, timestamp: datetime) -> None: def import_events(self, events: Iterable[TranscriptEvent]) -> None: """The source already normalized records; preserve its conversation and clock.""" for event in events: - message = event.imported() - if message is None or not message.body.strip(): - continue - if event.timestamp is None: - raise ValueError("Copied conversation event has no original timestamp") - self.append(message, datetime.fromtimestamp(event.timestamp, UTC)) + event.import_to(lambda message: self.append_recorded(event, message)) + + def append_recorded(self, event: TranscriptEvent, message: ImportedMessage) -> None: + at = GeneratedTimestamp.decode(event.timestamp).recorded_at("Copied conversation") + self.append(message, datetime.fromtimestamp(at, UTC)) class NativeHeaderState(ABC): diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index 79fd3dbeb0..2f7bfc58c4 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -4,6 +4,7 @@ import json from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, ClassVar from pydantic import JsonValue @@ -36,6 +37,10 @@ class ImportedMessage(ABC): body: str truncated: bool = False + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + if self.body.strip(): + accept(self) + def bounded(self, limits: ImportLimits) -> ImportedMessage: cap = min(limits.per_message, limits.characters) if len(self.body) <= cap: @@ -97,9 +102,8 @@ def with_native_input(self, native_id: str | None) -> TranscriptEvent: def routed(self) -> bool: return False - def imported(self) -> ImportedMessage | None: - """This event as portable text in an imported conversation; activity is not conversation.""" - return None + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + """Non-conversation events produce no portable message.""" def record_call(self, calls: dict[str, OrderCall], since: float) -> None: """Only tool events change a unit's calls.""" @@ -246,8 +250,8 @@ def native_inputs(self) -> frozenset[str]: def routed(self) -> bool: return self.routing is not None and bool(self.routing.requests) - def imported(self) -> ImportedMessage: - return ImportedRequest(self.text) + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + ImportedRequest(self.text).import_to(accept) class AssistantTranscript(AgentTextTranscript): @@ -255,8 +259,8 @@ class AssistantTranscript(AgentTextTranscript): def category(self) -> type[MessageCategory]: return OutboundCategory if self.routed else AgentCategory - def imported(self) -> ImportedMessage: - return ImportedReply(self.text) + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + ImportedReply(self.text).import_to(accept) class NoticeTranscript(AgentTextTranscript): @@ -325,8 +329,8 @@ def starts_activity(self) -> bool: class ContextTranscript(SilentTranscript, NativeTextTranscript): category = InboundCategory - def imported(self) -> ImportedMessage: - return ImportedInput(self.text) + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + ImportedInput(self.text).import_to(accept) @dataclass(frozen=True, kw_only=True) @@ -367,9 +371,9 @@ def record_call(self, calls: dict[str, OrderCall], since: float) -> None: calls[self.tool_call_id] = OrderCall( self.tool_call_id, self.tool_name, digest, RunningCallState) - def imported(self) -> ImportedMessage: - return ImportedInput(f"[Historical tool call: {self.tool_name}]\n" - + json.dumps(self.raw_input, ensure_ascii=False)) + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: + ImportedInput(f"[Historical tool call: {self.tool_name}]\n" + + json.dumps(self.raw_input, ensure_ascii=False)).import_to(accept) @dataclass(frozen=True, kw_only=True) @@ -394,9 +398,9 @@ def record_call(self, calls: dict[str, OrderCall], since: float) -> None: calls[self.tool_call_id] = replace( calls[self.tool_call_id], state=SucceededCallState if self.ok else FailedCallState) - def imported(self) -> ImportedMessage: + def import_to(self, accept: Callable[[ImportedMessage], None]) -> None: outcome = "" if self.ok else ", failed" - return ImportedInput(f"[Historical tool result{outcome}]\n{self.text}") + ImportedInput(f"[Historical tool result{outcome}]\n{self.text}").import_to(accept) text: str = "" ok: bool = True diff --git a/tests/test_check_root.py b/tests/test_check_root.py index 85924ca183..47822476a8 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -15,7 +15,7 @@ from agent_comms.errors import RelationViolationError from agent_comms.external_transcript import ExternalTranscript from agent_comms.fresh_private_session import create_fresh_private_session -from agent_comms.transcript_events import AssistantTranscript, UserTranscript +from agent_comms.transcript_events import AssistantTranscript, NoticeTranscript, UserTranscript from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus @@ -58,20 +58,22 @@ def test_external_projection_preserves_event_clocks_or_refuses_unknown(source, t UserTranscript("source request " * 1500, position=1, timestamp=timestamp), AssistantTranscript("source reply", position=2, timestamp=None if timestamp is None else timestamp + 1.125), + NoticeTranscript("not portable conversation", position=3), + UserTranscript("", position=4), ) external.finish(turn_id="saved", item_id="item", events=events) root = tmp_path / "external-window" if timestamp is None: - with pytest.raises(ValueError, match="no original timestamp"): + with pytest.raises(ValueError, match="Copied conversation requires a recorded time"): with CheckRoot(source, (owner.name,), root).open(): pytest.fail("An unknown source clock was rewritten as known time") else: with CheckRoot(source, (owner.name,), root).open() as copied: session = Path(copied.registry.require(owner.name).session_file) rows = [NativeEntry.read(line) for line in session.read_bytes().splitlines()] - assert [entry.message.text for entry in rows[1:]] == [event.text for event in events] + assert [entry.message.text for entry in rows[1:]] == [event.text for event in events[:2]] assert [entry.timestamp.at.timestamp() for entry in rows[1:]] == [ - event.timestamp for event in events + event.timestamp for event in events[:2] ] with external.reading() as original: assert tuple(original.reverse(original.cut().offset))[0].events == events From 96db4ff63b06c26a85b92acd3fc219c4be82bb59 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 17:37:30 -0400 Subject: [PATCH 32/41] Bind each synchronous import callback to its source event --- src/agent_comms/native_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index e2b8367c98..16429587b9 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -181,7 +181,7 @@ def append(self, message: ImportedMessage, timestamp: datetime) -> None: def import_events(self, events: Iterable[TranscriptEvent]) -> None: """The source already normalized records; preserve its conversation and clock.""" for event in events: - event.import_to(lambda message: self.append_recorded(event, message)) + event.import_to(lambda message, event=event: self.append_recorded(event, message)) def append_recorded(self, event: TranscriptEvent, message: ImportedMessage) -> None: at = GeneratedTimestamp.decode(event.timestamp).recorded_at("Copied conversation") From c4d2da503598ab97f6f686cfbc93cc097ff2f43c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 17:39:10 -0400 Subject: [PATCH 33/41] Append normalized events directly through the writer lifetime --- src/agent_comms/check_root.py | 3 ++- src/agent_comms/native_entries.py | 14 +++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 78647ef463..02de2fb15a 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -161,7 +161,8 @@ def _session(self, original: Thread, directory: Path, project: Path) -> str: max_records=self.max_records, max_bytes=self.max_bytes, ): - writer.import_events(events) + for event in events: + writer.append_recorded(event) return session.session_file def run(self, command: Sequence[str]) -> CheckRootResult: diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 16429587b9..154f6ec8c6 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -4,7 +4,6 @@ import json from abc import ABC, abstractmethod -from collections.abc import Iterable from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import UTC, datetime @@ -178,14 +177,11 @@ def append(self, message: ImportedMessage, timestamp: datetime) -> None: self.stream.write(json.dumps(encoded, ensure_ascii=False) + "\n") self.parent = NativeEntry.from_wire(encoded) - def import_events(self, events: Iterable[TranscriptEvent]) -> None: - """The source already normalized records; preserve its conversation and clock.""" - for event in events: - event.import_to(lambda message, event=event: self.append_recorded(event, message)) - - def append_recorded(self, event: TranscriptEvent, message: ImportedMessage) -> None: - at = GeneratedTimestamp.decode(event.timestamp).recorded_at("Copied conversation") - self.append(message, datetime.fromtimestamp(at, UTC)) + def append_recorded(self, event: TranscriptEvent) -> None: + def append(message: ImportedMessage) -> None: + at = GeneratedTimestamp.decode(event.timestamp).recorded_at("Copied conversation") + self.append(message, datetime.fromtimestamp(at, UTC)) + event.import_to(append) class NativeHeaderState(ABC): From bd54f5e3d3fd7809852f31fe3f29fcc4cf5589d8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 20:00:30 -0400 Subject: [PATCH 34/41] P269: verify recorded-clock imports against the current storage contract --- tests/test_check_root.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/test_check_root.py b/tests/test_check_root.py index 47822476a8..75ea69be8d 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -58,23 +58,26 @@ def test_external_projection_preserves_event_clocks_or_refuses_unknown(source, t UserTranscript("source request " * 1500, position=1, timestamp=timestamp), AssistantTranscript("source reply", position=2, timestamp=None if timestamp is None else timestamp + 1.125), - NoticeTranscript("not portable conversation", position=3), - UserTranscript("", position=4), + NoticeTranscript("not portable conversation", position=3, timestamp=timestamp), + UserTranscript("", position=4, timestamp=timestamp), ) - external.finish(turn_id="saved", item_id="item", events=events) root = tmp_path / "external-window" if timestamp is None: - with pytest.raises(ValueError, match="Copied conversation requires a recorded time"): - with CheckRoot(source, (owner.name,), root).open(): - pytest.fail("An unknown source clock was rewritten as known time") - else: - with CheckRoot(source, (owner.name,), root).open() as copied: - session = Path(copied.registry.require(owner.name).session_file) - rows = [NativeEntry.read(line) for line in session.read_bytes().splitlines()] - assert [entry.message.text for entry in rows[1:]] == [event.text for event in events[:2]] - assert [entry.timestamp.at.timestamp() for entry in rows[1:]] == [ - event.timestamp for event in events[:2] - ] + # Current records require a clock at their storage boundary, before copying. + with pytest.raises(TypeError, match="received NoneType"): + external.finish(turn_id="saved", item_id="item", events=events) + with external.reading() as original: + assert not original.has_records(original.cut().offset) + assert not root.exists() + return + external.finish(turn_id="saved", item_id="item", events=events) + with CheckRoot(source, (owner.name,), root).open() as copied: + session = Path(copied.registry.require(owner.name).session_file) + rows = [NativeEntry.read(line) for line in session.read_bytes().splitlines()] + assert [entry.message.text for entry in rows[1:]] == [event.text for event in events[:2]] + assert [entry.timestamp.at.timestamp() for entry in rows[1:]] == [ + event.timestamp for event in events[:2] + ] with external.reading() as original: assert tuple(original.reverse(original.cut().offset))[0].events == events assert not root.exists() From 97256db52d7918208899fd39aef502f9043f7c7d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 19:46:16 -0400 Subject: [PATCH 35/41] Remove the source-line budget measure --- tests/guards/test_debt_ratchet.py | 29 +++++++++++++++++++++++------ tools/debt_ratchet.py | 21 --------------------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index b2ebd77c25..a3bc814b0e 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -126,6 +126,27 @@ def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_pa assert os.environ["PYTHONPATH"] == inherited +def test_clean_growth_passes_but_type_and_string_dispatch_fail(repo: Repository) -> None: + base = repo.commit({"new.py": "value = 1\n"}) + source = "value = 1\nother = 2\n" + grown = repo.commit({"new.py": source}) + assert repo.compare(base, grown)[0] == 0 + filtered = repo.commit({"new.py": source + "filtered = type(value) is int\n"}) + status, report = repo.compare(grown, filtered) + assert status == 1 + assert report["delta"]["TypeIdentity"] == 1 + dispatch = repo.commit({"new.py": source + ( + "\n\ndef select(value):\n" + ' if value == "one":\n return 1\n' + ' if value == "two":\n return 2\n' + ' if value == "three":\n return 3\n' + " return 0\n" + )}) + status, report = repo.compare(grown, dispatch) + assert status == 1 + assert report["delta"][f"StringDispatch:{repo.root}/new.py"] == 1 + + def test_added_type_check_fails(repo: Repository) -> None: base = repo.commit({"check.py": 'value = row["a"]\n'}) head = repo.commit({"check.py": "value = type(x) is int\n"}) @@ -294,8 +315,7 @@ def test_small_owners_stay_within_the_class_threshold(repo: Repository) -> None: base = repo.commit({"owners.py": owner("Small", 2)}) head = repo.commit({"owners.py": owner("Small", 500) + owner("New", 500)}) status, report = repo.compare(base, head) - assert status == 1 - assert report["delta"]["SourceLines"] > 0 + assert status == 0 assert report["delta"][f"GodClassExcess:{repo.root}/owners.py::Small"] == 0 assert report["delta"][f"GodClassExcess:{repo.root}/owners.py::New"] == 0 @@ -323,8 +343,7 @@ def test_class_move_keeps_baseline_and_new_small_owner_has_no_class_debt(repo: R base = repo.commit({"old.py": owner("Existing", 501)}) moved = repo.commit({"old.py": None, "new.py": owner("Existing", 501) + owner("NewOwner", 25)}) status, report = repo.compare(base, moved) - assert status == 1 - assert report["delta"]["SourceLines"] > 0 + assert status == 0 assert report["base"][f"GodClassExcess:{repo.root}/new.py::Existing"] == 1 assert report["delta"][f"GodClassExcess:{repo.root}/new.py::Existing"] == 0 assert report["delta"][f"GodClassExcess:{repo.root}/new.py::NewOwner"] == 0 @@ -482,7 +501,6 @@ def readable(self): """}) status, report = repo.compare(base, boolean) assert status == 1 - assert report["delta"]["SourceLines"] > 0 assert report["head"][f"ForeignAbsenceProbe:{repo.root}/connection.py"] == 0 missing = repo.commit({"missing.py": """ @@ -520,7 +538,6 @@ def test_type_expression_boundary_keeps_runtime_dictionary_access(repo: Reposito declarations = repo.commit({"boundary.py": source}) status, report = repo.compare(base, declarations) assert status == 1 - assert report["delta"]["SourceLines"] > 0 assert report["head"]["StringSubscript"] == 0 # Annotation spelling cannot conceal actual initializer/default/decorator/body reads. runtime = source + '\n'.join([ diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 9cbc009369..98f1457bfb 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -247,27 +247,6 @@ def environment(root: str) -> dict[str, str]: return {**os.environ, "MYPYPATH": os.pathsep.join((*paths, str(AUDIT_SCRIPTS)))} -class SourceLines(Measure): - """Report edited lines, retaining moves across the source boundary (50% similarity).""" - - @classmethod - def blocks(cls, delta: dict[str, int]) -> bool: - return False - - @classmethod - def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str): - added = removed = 0 - records = iter(git(repo, "diff", "--no-ext-diff", "--no-textconv", "--find-renames=50%", - "--numstat", "-z", base, head, "--").split(b"\0")[:-1]) - for record in records: - increase, decrease, path = record.split(b"\t", 2) - paths = (path,) if path else (next(records), next(records)) - if any(path.startswith(root.encode()) and path.endswith(b".py") for path in paths): - added += int(increase) - removed += int(decrease) - return {cls.__name__: removed}, {cls.__name__: added} - - @dataclass(frozen=True) class ChangedSource: """One head module and the new-side lines changed by its Git patch.""" From fc23da165577d142cdd58fae7d6cd0dde2f0b608 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 20:22:51 -0400 Subject: [PATCH 36/41] Stamp declared package identities in archived format probes --- tools/debt_ratchet.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 98f1457bfb..1d5c39591f 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1456,10 +1456,8 @@ def formats(cls, repo: Path, ref: str, root: str, changed: set[str]) -> dict[str source = git(repo, "archive", ref, "--", str(package)) subprocess.run(["tar", "-x", "-C", scratch], input=source, check=True, timeout=60) # Extracted source is stamped with its commit the way a build stamps it. - for module in {module.split(".")[0] for module in modules}: - WheelStamp(revision(repo, ref)).write( - Path(scratch) / package / module / "package_stamp.py" - ) + for stamp in Path(scratch).rglob("package_stamp.py"): + WheelStamp(revision(repo, ref)).write(stamp) result = subprocess.run([sys.executable, "-c", cls.probe, str(Path(scratch) / package), str(Path(__file__).resolve().parent), *modules], cwd=scratch, env=cls.environment(root), From 5babdb4fee9e0d410af7f433bca54c69c688ada5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 20:30:20 -0400 Subject: [PATCH 37/41] Resolve retained-tail senders through the registry namespace owner --- tools/install_gate.py | 57 ++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/tools/install_gate.py b/tools/install_gate.py index b4f67985a3..1a2156d802 100644 --- a/tools/install_gate.py +++ b/tools/install_gate.py @@ -36,8 +36,8 @@ from agent_comms.active_route import ActiveRoute, CommsRoute from agent_comms.check_root import CheckRoot -from agent_comms.comms import Comms from agent_comms.child_process import Platform +from agent_comms.comms import Comms from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import ( FieldCodec, @@ -240,6 +240,7 @@ def affected(repo: Path, base: str, head: str) -> list[str]: @dataclass(frozen=True) class PracticeCopy: """A real retained tail, native session tails and the production indexes they build.""" + source: Comms into: Path max_bytes: ClassVar[int] = 3 * 1024**3 @@ -248,11 +249,15 @@ class PracticeCopy: def open(self) -> Iterator[Comms]: roster = self.source.registry.snapshot() with self.source.bus.log.opened_wire_snapshot() as source: - names = tuple(dict.fromkeys( - message.sender for message in islice( - source.newest_messages(CheckRoot.max_bytes), CheckRoot.max_records) - if message.sender in roster.threads - )) + names = tuple( + dict.fromkeys( + sender + for message in islice( + source.newest_messages(CheckRoot.max_bytes), CheckRoot.max_records + ) + if (sender := roster.canonical_name(message.sender)) in roster.threads + ) + ) if not names: raise GateError("The retained bus tail has no registered participants") self.into.parent.mkdir(mode=0o700, parents=True, exist_ok=True) @@ -261,7 +266,9 @@ def open(self) -> Iterator[Comms]: with CheckRoot(self.source, names, self.into).open() as copied: size = sum(path.stat().st_size for path in copied.root.rglob("*") if path.is_file()) if size > self.max_bytes: - raise GateError(f"Retained-tail copy is {size} bytes, over its {self.max_bytes} cap") + raise GateError( + f"Retained-tail copy is {size} bytes, over its {self.max_bytes} cap" + ) if shutil.disk_usage(self.into.parent).free < 2 * size + storage_floor(): raise GateError(f"Insufficient free space for rehearsal of {size} copied bytes") yield copied @@ -854,8 +861,9 @@ def execute(self, args: GateArguments, repository: str) -> int: ... class CheckGateCommand(GateCommand): - def run(self, args: GateArguments, target: Target, report: Path, - results: dict[str, MachineResult]) -> GateResult: + def run( + self, args: GateArguments, target: Target, report: Path, results: dict[str, MachineResult] + ) -> GateResult: return MachinesGateResult(run_machines(args, target, report, results)) @abstractmethod @@ -941,19 +949,34 @@ def target(self, args: GateArguments, repository: str) -> Target: @dataclass(frozen=True) class PracticeGateCommand(RevisionGateCommand): - def run(self, args: GateArguments, target: Target, report: Path, - results: dict[str, MachineResult]) -> GateResult: + def run( + self, args: GateArguments, target: Target, report: Path, results: dict[str, MachineResult] + ) -> GateResult: with tempfile.TemporaryDirectory(dir=args.scratch, prefix="practice-") as directory: private = Path(directory) home = private / "home" - with PracticeCopy(Comms(CommsRoute.current().root), home / ".agent-comms").open() as copied: + with PracticeCopy( + Comms(CommsRoute.current().root), home / ".agent-comms" + ).open() as copied: prepare_runtime_home(home) - with tempfile.TemporaryDirectory(dir=Path.home() / "wt", prefix="practice-source-") as source: + with tempfile.TemporaryDirectory( + dir=Path.home() / "wt", prefix="practice-source-" + ) as source: work = Path(source) - with (checkout(args.repo, target.base, work / "base") as previous, - checkout(args.repo, target.head, work / "target") as tree): - completed = rehearse(tree, previous, target.head, copied.root, home, - args.python, report.with_suffix(".log"), work) + with ( + checkout(args.repo, target.base, work / "base") as previous, + checkout(args.repo, target.head, work / "target") as tree, + ): + completed = rehearse( + tree, + previous, + target.head, + copied.root, + home, + args.python, + report.with_suffix(".log"), + work, + ) return PracticeGateResult(completed) From 71332bfd523009df4a088387b45b5161b2c7a188 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 20:41:32 -0400 Subject: [PATCH 38/41] Finish rebase import cleanup and record worker versus merger proof duties --- .pi/APPEND_SYSTEM.md | 3 ++- src/agent_comms/native_entries.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pi/APPEND_SYSTEM.md b/.pi/APPEND_SYSTEM.md index 45dbef3629..51592dc62b 100644 --- a/.pi/APPEND_SYSTEM.md +++ b/.pi/APPEND_SYSTEM.md @@ -56,7 +56,8 @@ You are one of several agents coordinating through agent-comms. Tristan owns pro ## Ownership, code, and delivery -- Do not merge main into feature branches. The integration owner merges each published head onto fresh main; rebase only on an explicit conflict instruction. Do not make formatting-only or re-budget-only commits. +- Do not merge main into feature branches. When your own branch conflicts with main, rebase it onto origin/main that turn, keep main's side where main deleted or moved code, push the owned branch, and report its new head. Do not ask again for that routine authorization. Do not make formatting-only or re-budget-only commits. +- Fresh-environment practice installs belong to the merger, on the pushed head. Workers prove the affected behavior on a copy of the bus using existing dependencies and report precisely which installation proof the merger still owes. - Line growth is a measurement, not a defect or merge gate. Do not request a line budget. Fix duplicated authorities, type filters and strings replacing declared identities; the existing measure owner decides which findings block. - P269 practice installation uses a retained bus tail with its production indexes, bounded to 3 GiB and measured against actual free space. It does not require a 26 GiB whole-desktop copy or GitHub configuration changes. Preserve original sessions, inputs and stores; never turn short storage into a skipped success. diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 154f6ec8c6..a4c42b2785 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -7,7 +7,6 @@ from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import UTC, datetime -from itertools import chain from pathlib import Path from typing import Annotated, Any, ClassVar, Literal, TextIO from uuid import uuid4 From b0d043fa926a960ab96cf79137970f649d21ff9d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:47:09 -0400 Subject: [PATCH 39/41] Defer carry-parent decoding until an existing carry step needs it Unrelated repositories and first added steps have no historical parent module to read. Cache the target-base declarations only when an immutable step is actually examined. Remove the test consumer of the retired SourceLines measure. --- tests/guards/test_debt_ratchet.py | 21 +++++++++++++-------- tools/debt_ratchet.py | 11 +++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index a3bc814b0e..013fb3e311 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -11,22 +11,19 @@ from tools.debt_ratchet import ( BannedVocabulary, BroadExceptDefault, BroadSwallow, ForeignFieldSpellings, - FrozenFieldWrite, FunctionLocalImport, GitMeasure, NoneAbsence, UncarriedRecordFormat, + FrozenFieldWrite, FunctionLocalImport, GitMeasure, NoneAbsence, UncarriedRecordFormat, EditedCarry, ComparedKind, Comparison, DeclaredSpellings, NamedAccess, NamedDeclaration, - SourceLines, StringIdentity, python_paths, + StringIdentity, python_paths, ) pytestmark = pytest.mark.refactor_guard -@pytest.mark.parametrize("name,blocked", [ - (SourceLines.__name__, False), - (f"{NoneAbsence.__name__}:tools/example.py", True), -]) -def test_measure_owner_decides_whether_growth_blocks(name: str, blocked: bool) -> None: +def test_measure_owner_rejects_a_new_absence() -> None: + name = f"{NoneAbsence.__name__}:tools/example.py" result = Comparison("tools", "base", "head", (), {name: 0}, {name: 1}, {}, {}) assert result.delta[name] == 1 - assert result.failed is blocked + assert result.failed class Repository: @@ -126,6 +123,14 @@ def test_format_probe_keeps_parent_dependencies_and_reads_archived_source(tmp_pa assert os.environ["PYTHONPATH"] == inherited +def test_unrelated_change_does_not_require_a_core_carry_module(repo: Repository) -> None: + base = repo.commit({"plain.py": "value = 1\n"}) + head = repo.commit({"plain.py": "value = 2\n"}) + assert EditedCarry.compare( + repo.path, base, head, {f"{repo.root}/plain.py"}, repo.root, + ) == ({"EditedCarry": 0}, {"EditedCarry": 0}) + + def test_clean_growth_passes_but_type_and_string_dispatch_fail(repo: Repository) -> None: base = repo.commit({"new.py": "value = 1\n"}) source = "value = 1\nother = 2\n" diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 1d5c39591f..0ec6462daf 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -21,7 +21,7 @@ from collections import Counter from collections.abc import Iterator from dataclasses import dataclass -from functools import cached_property +from functools import cache, cached_property from pathlib import Path sys.path.insert(0, str( @@ -38,7 +38,7 @@ from agent_comms.core_stamp import WheelStamp from agent_comms.declared_family import DeclaredFamily -from cutover.carry import STEPS, Step, carry_parents +from cutover.carry import STEPS, Carry, Step, carry_parents from stored_formats import StoredForm from agent_comms.field_codec import FieldCodec, boundary_decoder, projected from agent_comms.mro_dispatch import MroDispatch, handles @@ -1397,7 +1397,10 @@ def conversion(source: bytes) -> str: def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str): fields = iter(git(repo, "diff", "--name-status", "--find-renames=50%", "-z", base, head, "--", str(STEPS)).decode().split("\0")[:-1]) - parents = carry_parents(git(repo, "show", f"{base}:{STEPS}/carry.py")) + @cache + def parents() -> dict[str, type[Carry]]: + return carry_parents(git(repo, "show", f"{base}:{STEPS}/carry.py")) + edited = 0 for status in fields: path = next(fields) @@ -1408,7 +1411,7 @@ def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str) continue if status[0] in "MRC" or status == "T": before = git(repo, "show", f"{base}:{path}") - if Step.holds_carry(before, parents): + if Step.holds_carry(before, parents()): edited += (status != "M" or cls.conversion(before) != cls.conversion(git(repo, "show", f"{head}:{path}"))) elif status == "A": From a24aeb88fa5cf2eba0ee26eb4d6adac30a922124 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:47:09 -0400 Subject: [PATCH 40/41] Keep copied initial history display-only at the protocol issuer Message owns removal of source-root claim authority. InitialWrites applies that projection once before issuing a fresh root; live claim validation and immutable source history remain unchanged. Cover positive claims and release-only retained windows. --- src/agent_comms/messages.py | 5 +++++ src/agent_comms/publisher.py | 2 +- tests/test_check_root.py | 29 +++++++++++++++-------------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 0d104b2751..35c7ceeb6d 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -373,6 +373,11 @@ def require_task_publication(self, sender, snapshot, original_source) -> None: self.task.require_target(self.target, snapshot) self.task.require_publication(sender, snapshot, original_source) + @property + def history_snapshot(self) -> Message: + """Retain display facts, never another root's resource-claim authority.""" + return replace(self, claim_transition=None) + def checkpoint_rows(self, previous_sequence: int): """The envelope declares both publication and optional claim facts.""" from .private_bus_checkpoint import ClaimCommits, PublicMessages diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 78b6286ef9..6d0871ee36 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -302,7 +302,7 @@ def _initialize_private_protocol_unlocked(self, *, history: Sequence[Message] = if message.seq <= previous: raise ValueError("Initial history must have ascending unique sequences") previous = message.seq - rows.append(json.dumps(message.to_wire(), ensure_ascii=False) + "\n") + rows.append(json.dumps(message.history_snapshot.to_wire(), ensure_ascii=False) + "\n") if rows: _atomic_write_text(self.log.path, "".join(rows), fsync_parent=True) diff --git a/tests/test_check_root.py b/tests/test_check_root.py index 75ea69be8d..04a3b05ace 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -12,7 +12,6 @@ from agent_comms.comms import Comms from agent_comms.coordination_errors import RecoveryBlocked from agent_comms.envelope_claim_transitions import ClaimRelease, ClaimTransition -from agent_comms.errors import RelationViolationError from agent_comms.external_transcript import ExternalTranscript from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.transcript_events import AssistantTranscript, NoticeTranscript, UserTranscript @@ -101,28 +100,30 @@ def test_python_failure_removes_root(source, tmp_path): assert not root.exists() -def test_failed_population_preserves_protocol_error_and_retires_root(source, tmp_path): +@pytest.mark.parametrize("release", (False, True)) +def test_initial_history_retains_display_not_source_claim_authority(source, tmp_path, release): with source.bus.log.opened_wire_snapshot() as history: message = next(history.newest_messages()) owner = source.registry.require(message.sender).incarnation.require_recorded() - released = replace(message, claim_transition=ClaimTransition( + resource = str(tmp_path / "retained.py") + retained = replace(message, claim_transition=ClaimTransition( owner=message.sender, incarnation=owner.lookup, seq=message.seq, message_id=message.message_id, - releases=(ClaimRelease(str(tmp_path / "retained.py"), "a" * 32),), + claims=() if release else (resource,), + releases=(ClaimRelease(resource, "a" * 32),) if release else (), + generation=None if release else "a" * 32, )) - class ReleasedHistoryCheckRoot(CheckRoot): + class ClaimHistoryCheckRoot(CheckRoot): def _populate(self, copied): - # Exercise actual protocol initialization, not a fake registry error. - copied.messaging.initialize_private_initial_protocol(history=(released,)) - - root = tmp_path / "release-only-history" - with pytest.raises(RelationViolationError, match="Committed claim transition") as failure: - with ReleasedHistoryCheckRoot(source, ("A", "B"), root).open(): - pytest.fail("A release-only historical prefix was silently accepted") - assert "without a live claim" in str(failure.value.__cause__) + copied.messaging.initialize_private_initial_protocol(history=(retained,)) + + root = tmp_path / "claim-history" + with ClaimHistoryCheckRoot(source, ("A", "B"), root).open() as copied: + with copied.bus.log.opened_wire_snapshot() as history: + assert next(history.newest_messages()) == message + assert retained.claim_transition is not None assert not root.exists() - # The source remains independently readable after the failed copy. assert source.registry.require("A").name == "A" with source.bus.log.opened_wire_snapshot() as history: assert next(history.newest_messages()) == message From 19e40c22994d2c8d2181b21755e1fdc1258a053d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 21:50:28 -0400 Subject: [PATCH 41/41] Package the standing rules kept in commander memory --- .../commander/rules/always-fork-subagents.md | 13 +++++++++++++ .../commander/rules/claude-usage-budget.md | 15 +++++++++++++++ .../rules/expose-full-surface-no-limits.md | 11 +++++++++++ .../commander/rules/glued-class-names.md | 10 ++++++++++ .../rules/local-patterns-not-precedent.md | 10 ++++++++++ .../rules/never-wait-on-turns-to-install.md | 11 +++++++++++ .../commander/rules/no-any-types.md | 11 +++++++++++ .../rules/no-strings-for-identity.md | 15 +++++++++++++++ .../rules/optimize-for-slop-elimination.md | 17 +++++++++++++++++ .../commander/rules/pin-only-releases.md | 11 +++++++++++ .../commander/rules/plain-names.md | 17 +++++++++++++++++ .../rules/refactor-aggressively-tests-too.md | 19 +++++++++++++++++++ .../rules/rules-serve-their-reason.md | 11 +++++++++++ .../commander/rules/slips-ok-if-caught.md | 11 +++++++++++ .../commander/rules/slow-is-a-defect.md | 11 +++++++++++ .../structural-fixes-over-pushing-through.md | 13 +++++++++++++ .../rules/tests-bounded-and-backgrounded.md | 8 ++++++++ .../rules/tooling-for-decisiveness.md | 18 ++++++++++++++++++ 18 files changed, 232 insertions(+) create mode 100644 src/agent_comms/commander/rules/always-fork-subagents.md create mode 100644 src/agent_comms/commander/rules/claude-usage-budget.md create mode 100644 src/agent_comms/commander/rules/expose-full-surface-no-limits.md create mode 100644 src/agent_comms/commander/rules/glued-class-names.md create mode 100644 src/agent_comms/commander/rules/local-patterns-not-precedent.md create mode 100644 src/agent_comms/commander/rules/never-wait-on-turns-to-install.md create mode 100644 src/agent_comms/commander/rules/no-any-types.md create mode 100644 src/agent_comms/commander/rules/no-strings-for-identity.md create mode 100644 src/agent_comms/commander/rules/optimize-for-slop-elimination.md create mode 100644 src/agent_comms/commander/rules/pin-only-releases.md create mode 100644 src/agent_comms/commander/rules/plain-names.md create mode 100644 src/agent_comms/commander/rules/refactor-aggressively-tests-too.md create mode 100644 src/agent_comms/commander/rules/rules-serve-their-reason.md create mode 100644 src/agent_comms/commander/rules/slips-ok-if-caught.md create mode 100644 src/agent_comms/commander/rules/slow-is-a-defect.md create mode 100644 src/agent_comms/commander/rules/structural-fixes-over-pushing-through.md create mode 100644 src/agent_comms/commander/rules/tooling-for-decisiveness.md diff --git a/src/agent_comms/commander/rules/always-fork-subagents.md b/src/agent_comms/commander/rules/always-fork-subagents.md new file mode 100644 index 0000000000..feb037bc33 --- /dev/null +++ b/src/agent_comms/commander/rules/always-fork-subagents.md @@ -0,0 +1,13 @@ +--- +name: always-fork-subagents +description: "Owner rule: dispatch subagents as forks (subagent_type \"fork\"), not fresh agents, unless the brief fully teaches correct maintenance and the paper" +type: feedback +--- + +Always fork (Agent with `subagent_type: "fork"`) instead of starting a fresh general-purpose agent. Owner, 2026-10-18: "always fork. stop it, fork and let the fork take over", then "unless you already schooled it on correct maintenance and the paper". + +**Why:** a fresh agent knows only its brief. My briefs carried the owner's rules (multiple inheritance, no back-compat) but not the paper's definition of slop and the results behind it, so a fresh agent judges shapes by dogma. A fork inherits the conversation, including that understanding (, ). + +**How to apply:** default to forks for refactor and design work. A fresh agent is acceptable only when its brief teaches correct maintenance itself (slop = a restated required fact a complete mechanism derives; multiple parents derive crossings; mirrors add disagreeing states; passing tests cannot reveal outside supply) and points at `.claude/verify/PAPER-RESULTS.md`. Supersedes the older habit of fresh agents with written briefs. + +**Turn limit (2026-10-18):** owner said "don't use that [turn limit] on ur subagents". Forks stop at ~200 turns and can't be given a limit; they have no definition file (docs: code.claude.com/docs/en/sub-agents.md, `maxTurns` frontmatter). For long work use the user-level agent `correct-maintainer` (`~/.claude/agents/correct-maintainer.md`, `maxTurns: 100000`, system prompt teaches the standard). Definitions load at session start only. If a fork hits the cap anyway, resume it via SendMessage right away. diff --git a/src/agent_comms/commander/rules/claude-usage-budget.md b/src/agent_comms/commander/rules/claude-usage-budget.md new file mode 100644 index 0000000000..5bac1d4b10 --- /dev/null +++ b/src/agent_comms/commander/rules/claude-usage-budget.md @@ -0,0 +1,15 @@ +--- +name: claude-usage-budget +description: Claude subagent usage is the scarce budget; keep background forks to the critical path until Pi (gpt-6.1-sol) threads work on the bus +type: feedback +--- + +2026-10-19: I had 7 background forks running at once: runtime, carry-order, strings, index-lock, hooks, fast-ratchet and fast-board. Owner: "don't launch too many background agents, I'm trying to move to Pi Codex threads to save on usage, so please be mindful. If it's going to take another 5 hours to get Pi GPT working in comms we can't just blow through usage." + +**Why:** the move to Pi threads exists to save Claude usage. Spending Claude forks freely while that move is blocked defeats it. + +**How to apply:** +- Only work on the path to working Pi threads gets a Claude fork; aim for one or two at a time. +- Everything else (perf, cleanup, slop hunts) becomes a written order queued for Pi units, not a fork. +- Briefs say "finish and report in N lines", with no exploration beyond the task. +- This tempers [[slow-is-a-defect]] and [[structural-fixes-over-pushing-through]]: dispatch still happens, but to Pi units once they work. See [[codex-units-need-dictation]]. diff --git a/src/agent_comms/commander/rules/expose-full-surface-no-limits.md b/src/agent_comms/commander/rules/expose-full-surface-no-limits.md new file mode 100644 index 0000000000..c99b6c7b75 --- /dev/null +++ b/src/agent_comms/commander/rules/expose-full-surface-no-limits.md @@ -0,0 +1,11 @@ +--- +name: expose-full-surface-no-limits +description: When OpenHCS supports an external tool, expose its whole surface, not a subset; do not scope down for budget reasons +type: feedback +--- + +When OpenHCS integrates an external algorithm or library, expose everything it offers (every capability and parameter reachable in the tool itself or in any other platform's integration of it), not a minimal subset. + +**Why:** owner, 2026-10-12, when I noted that a mature CellProfiler plugin exposes more Cellpose options than our 151-line example: "there's no reason why we aren't exposing the same amount of functions. remember there's no real limits." There is no token or effort budget reason to scope down; an integration that exposes less than competitors undercuts the claim that adding tools here is trivial. + +**How to apply:** build a capability matrix against the tool's own API and the other platforms' integrations, and close every gap or state the precise reason. Prefer deriving the surface from the tool's own signatures (as the scikit-image/CuPy/pyclesperanto registries do) over hand-transcribing, so it stays complete when the tool changes. My interpretation: "no real limits" is also a reminder not to ration scope, agents or thoroughness in general; machine limits (15 GB RAM, disk) still apply. Related: [[structural-fixes-over-pushing-through]], [[full-autonomy-openhcs]]. diff --git a/src/agent_comms/commander/rules/glued-class-names.md b/src/agent_comms/commander/rules/glued-class-names.md new file mode 100644 index 0000000000..7b93dc84eb --- /dev/null +++ b/src/agent_comms/commander/rules/glued-class-names.md @@ -0,0 +1,10 @@ +--- +name: glued-class-names +description: A class name gluing an existing domain type to a generic concept (StepField..., FunctionFieldDriver) signals a cross product flattened into names; question whether the class should exist +type: feedback +--- +When a class name is an existing domain type glued to a generic concept (`StepField...`, `FunctionFieldNavigationDriver`, `PipelineXManager`), stop and ask whether the class should exist at all, before asking whether it is well written. + +**Why:** the owner spotted `StepFieldNavigationDriver` from its name alone in an agent's status line (2026-10-12): "Step already exists. If there's a StepField, is there a PipelineField? and FunctionField? and what's Field? probably attribute, which is just on an object. So it's all kinds of wrong." The name implies a grid (domain type x mechanism), one class per cell; that is the row-column family of paper 1 Thm 4.5 flattened into names. The prediction held: `FunctionFieldNavigationDriver` already existed in pyqt-reactive, and the agent copied the pattern. I had first judged it "mostly right" because it extended an existing family; the right question was whether the family itself was the right shape. + +**How to apply:** in review and in agent briefs: a generic mechanism (navigation, validation, preview, codec) takes the object or its declaration and asks it for its fields; it does not get a subclass per domain type. If the per-type answer is real behaviour, it lives on the domain type (or a mixin it inherits), not in a parallel `` class. Scan names as *candidates* only (a glued name is a reason to look, not proof). Also: do not judge a new class only by "it uses the existing family"; check the family. See [[plain-names]], , [[optimize-for-slop-elimination]]. diff --git a/src/agent_comms/commander/rules/local-patterns-not-precedent.md b/src/agent_comms/commander/rules/local-patterns-not-precedent.md new file mode 100644 index 0000000000..80f5b63270 --- /dev/null +++ b/src/agent_comms/commander/rules/local-patterns-not-precedent.md @@ -0,0 +1,10 @@ +--- +name: local-patterns-not-precedent +description: Never copy a pattern because it is already in the codebase; judge against the standard, and treat repeated bad shapes as removal candidates +type: feedback +--- +Do not follow a local pattern just because it is local. The standard is the owner's rules and paper results, not the surrounding code. + +**Why:** Codex did this constantly: it made a mess, saw the mess, took the mess as licence, and repeated it. On 2026-10-12 a U6 agent added `StepFieldNavigationDriver` because `FunctionFieldNavigationDriver` already existed in pyqt-reactive, and I first approved it for "extending the existing family". Models (me included) copy the code around them more than they follow instructions. + +**How to apply:** when reviewing or briefing, never accept "consistent with nearby code" as a reason. An existing repeated shape that fails the standard is a candidate to report and remove, not a template. Ask what fact the thing stores and who already declares it. Extending an existing family is right only after checking the family itself is the right shape (see [[glued-class-names]]). Removing bad exemplars matters because they get copied. Keep plans and notes that outlast the current prompt (receipts, DISPATCH.md, PAPER-RESULTS.md, memory), since context is lost at compaction and the tree keeps pulling toward its own habits. See [[optimize-for-slop-elimination]]. diff --git a/src/agent_comms/commander/rules/never-wait-on-turns-to-install.md b/src/agent_comms/commander/rules/never-wait-on-turns-to-install.md new file mode 100644 index 0000000000..7c60e34bc0 --- /dev/null +++ b/src/agent_comms/commander/rules/never-wait-on-turns-to-install.md @@ -0,0 +1,11 @@ +--- +name: never-wait-on-turns-to-install +description: "Installs never wait for agent turns to finish; stop every unit at once, install (~2 min), start the same set at once" +type: feedback +--- + +Never wait for units to "finish their turn" before an install. Stop them all in parallel, install, start the same set in parallel. A stopped unit resumes its order on start; a mid-turn stop costs almost nothing. + +**Why:** 2026-10-19 the install itself took ~2 min, but waiting on idle turns (require_idle at tools/upgrade_bus.py:396) blocked installs ~2 h and left units idle ~1 h. Owner, furious: "how many times did we wait on agents to finish their turn ... I've dealt with this so many times." It recurs across sessions. + +**How to apply:** until P252 (installer owns stop/start of one set, in parallel) lands, stop/start in parallel myself; never sequential loops, never wait. After P252, just run the installer. Related: [[slow-is-a-defect]], . diff --git a/src/agent_comms/commander/rules/no-any-types.md b/src/agent_comms/commander/rules/no-any-types.md new file mode 100644 index 0000000000..38fc461eae --- /dev/null +++ b/src/agent_comms/commander/rules/no-any-types.md @@ -0,0 +1,11 @@ +--- +name: no-any-types +description: Owner rule - Any is not an allowed type annotation; declared fields and parameters must have a real type +type: feedback +--- + +`Any` is not allowed as a type annotation ("we shouldn't allow Any", owner, 2026-10-12). + +**Why:** it came up when the global config form could not be built because `SourceSpatialDomain.fill_value: Any` has no widget. In OpenHCS a declared type is what generates the form control, the MCP description and the conversion, so `Any` is a missing answer the mechanism needs. More generally it hides the family that should exist. + +**How to apply:** never write `Any` in new code; when touching code that has it, replace it with the real type (a union of the actual members, a nominal ABC, a TypeVar, or an array type). Sized on 2026-10-12: about 1,180 `Any` annotations in `openhcs/` and about 1,140 across the libraries, so removal is staged: first user-facing declarations (config dataclass fields, catalogued and custom function parameters) with a hard guard, then a ratchet so the count only falls. My interpretation: this covers `Any` hidden in `Optional[Any]`, `dict[str, Any]`, `-> Any`; `object` is acceptable only where any object truly is accepted. Related: , [[optimize-for-slop-elimination]]. diff --git a/src/agent_comms/commander/rules/no-strings-for-identity.md b/src/agent_comms/commander/rules/no-strings-for-identity.md new file mode 100644 index 0000000000..b51395a759 --- /dev/null +++ b/src/agent_comms/commander/rules/no-strings-for-identity.md @@ -0,0 +1,15 @@ +--- +name: no-strings-for-identity +description: "The owner hates strings, above all as identity (names that refer to modules, classes, steps, kinds); they are web-dev slop. Use typed objects, or derive from the owner" +type: feedback +--- + +2026-10-19: carry steps link to their predecessor by a hand-written string (`after = "carry_hunts"`). Two PRs wrote the same string, both passed their gates, and every install of main failed. Owner: "why strings and not enum or an actual object with a proper type?" and "we fucking hate strings, especially for identity, it's web dev crayon eating slop. I'm sure this pattern is present in a lot of places. Very unfortunate it wasn't caught." + +**Why:** a string standing in for an identity has no type and no owner. It drifts silently, races between PRs, and survives renames. Nominal identity is the paper's core requirement (). + +**How to apply:** +- In review, any string that names a module, class, step, kind, event or member is a blocker (). Replace it with a typed reference, or delete it if the fact is derivable. +- If a reference can't be an imported object (for example, deleted history), that is a sign the fact should be derived, not restated. +- External contracts (Claude Code hook event names, protocol fields) are honoured as declared strings in one place only. +- A ratchet measure for string identity is being built (fork, 2026-10-19). Treat that measure's findings as a work list. See . diff --git a/src/agent_comms/commander/rules/optimize-for-slop-elimination.md b/src/agent_comms/commander/rules/optimize-for-slop-elimination.md new file mode 100644 index 0000000000..b09e9add52 --- /dev/null +++ b/src/agent_comms/commander/rules/optimize-for-slop-elimination.md @@ -0,0 +1,17 @@ +--- +name: optimize-for-slop-elimination +description: Owner directive: optimize for eliminating all slop; it has a right answer, measured in restated answers, driven to zero +type: feedback +--- +Optimize for eliminating slop entirely ("slop genocide"), across OpenHCS and its libraries, tests included. Slop = a required answer supplied outside the mechanism that would derive it (paper 1). It has a right answer, so it is removable, not a style preference. + +**Why:** the owner spent nights forcing Codex to stop generating slop: without a whole-codebase view it kept adding new files and classes instead of finding the existing owner, then drowned in its own output. "it's garbage, I don't want it there, all it does is cause problems; if we know how to avoid making it then it's not justified." There is a right answer; being wrong just means a check fails and gets fixed. + +**How to apply:** +- Slop cannot be counted from the code: a count needs the required relation, i.e. the right answer (owner, 2026-10-12; P1 Cor 4.10, Prop 5.1, Thm 6.1). Scanners (ratchet, overlay, NRA, line/method counts) only produce *candidates*: where to look. Never report a scan as "slop remaining" or as a score. +- The reliable record runs the other way: after a surface is factored correctly, note in its receipt what was required, what now derives it, and which outside answers were removed (deletion certificates, "answers removed"). Report progress as concepts that now have one owner, with the receipt. +- Guards and the ratchet lock a shape in *after* it is known to be right; they are regression guards, not measures. +- Inventory before creation: no new class, file or registry without an AST search showing no existing owner of that fact. +- Tests are in scope (362k lines; expected values are a second copy of the spec). +- Still slow down for: tests-only deletions (paper 4; list them), scientific behaviour (owner decides), external contracts (exact). +See , [[review-papers-periodically]], [[refactor-aggressively-tests-too]]. diff --git a/src/agent_comms/commander/rules/pin-only-releases.md b/src/agent_comms/commander/rules/pin-only-releases.md new file mode 100644 index 0000000000..472ad59efa --- /dev/null +++ b/src/agent_comms/commander/rules/pin-only-releases.md @@ -0,0 +1,11 @@ +--- +name: pin-only-releases +description: "Owner rule: pin versions only at PyPI releases; dev stacks and our own sibling packages always track latest" +type: feedback +--- + +Pinning is limited to PyPI releases. Inside a development stack, our own packages (Toad, Textual fork, objectstate, formbridge, ui-theme, etc. from OpenHCSDev) always track the latest `main`; upgrades relock to latest (`uv lock --upgrade`) instead of trusting committed commit pins. Owner, 2026-10-18: "we should just use latest", "why would we not always use latest?", "shouldn't pinning be limited to releases on pypi?" + +**Why:** the agent-comms stack lock pinned Toad at an old commit after Core renamed a module (P312) and Toad main followed; toad-comms broke on main. A commit pin restates what the branch owns and goes stale when either side moves. + +**How to apply:** replace committed dev pins with "latest + an import/compat check" in the upgrade path and CI. Exception (my judgement, stated to the owner): byte-level patch targets like stock Pi in `stack/pi-native.sha256`, where patches apply with zero fuzz to a specific upstream release; moving those is a deliberate re-pin step. Related: [[merge-on-local-gates]] (full matrix gates PyPI releases). diff --git a/src/agent_comms/commander/rules/plain-names.md b/src/agent_comms/commander/rules/plain-names.md new file mode 100644 index 0000000000..8291401fc6 --- /dev/null +++ b/src/agent_comms/commander/rules/plain-names.md @@ -0,0 +1,17 @@ +--- +name: plain-names +description: "User rule — name things by what they do in plain terms; never extend Codex-era jargon (custody, admission, receipt, original...); rename on touch" +type: feedback +--- + +The user said on 2026-10-10: "Name things by what they do in plain terms. Do not extend the previous agent's vocabulary (custody, admission, receipt, original): when you touch code that uses it, rename to a plain name in the same change." + +**Why:** Codex-era jargon layers private names over concepts that already have plain names, so every reader has to translate. A name that disagrees with what the thing does is identity slop (IDEN-4) in . + +**How to apply:** +- In code, PR text, commit messages and plans, use plain names. +- Words to avoid: custody, admission/admitted, receipt, original, qualified, canonical, and authority/owner used as filler. +- When touching code that uses them, rename with all callers in the same change, keeping the meaning exact. For example, receipt → checksums record, admitted → validated, original path → source path. +- This is rule 1b in `docs/refactor/consolidation-20261009/00-RULES.md` (#1172) and is included in `.claude/verify/DISPATCH.md`. + +Related: the user values the OOPSLA paper as a calibration tool, because it makes "use native mechanisms" a checkable standard instead of an argument with agents. diff --git a/src/agent_comms/commander/rules/refactor-aggressively-tests-too.md b/src/agent_comms/commander/rules/refactor-aggressively-tests-too.md new file mode 100644 index 0000000000..629be21b26 --- /dev/null +++ b/src/agent_comms/commander/rules/refactor-aggressively-tests-too.md @@ -0,0 +1,19 @@ +--- +name: refactor-aggressively-tests-too +description: "User authorizes aggressive OpenHCS refactoring; when tests block it, refactor (de-slop, abstract) the tests rather than preserving them" +type: feedback +--- + +The user said on 2026-10-09: refactor aggressively. Tests are not production code. When a production refactor would mean heavy test rewriting, refactor the tests too, because they are usually badly under-abstracted. Don't port them one by one. + +**Why:** The user cares about production code quality. Tests matter only as behaviour protection, and in practice they are repetitive and under-abstracted, so preserving them blocks structural change. + +**How to apply:** +- Never let test churn veto a production refactor. +- When tests break en masse, replace them with fewer, parametrized, family-level tests that protect behaviour, not structure. +- Delete tests of deleted code. +- Never weaken a real behavioural assertion. + +**2026-10-18, agent-comms:** "no stupid test theater, refactor the slop out of tests as you go, they're fucking sloppy". Applies to every repo and every change: each touched test file is de-slopped in the same change (per-member copies become one registry-iterating family test; hand-built setup moves to one conftest fixture owner; mocks restating production, private-name/log-string tests and golden files for our formats are deleted). "N passed" is never evidence; briefs and orders require `tests -X/+Y` and the behaviour each kept test pins. #1385 touching 40 test files for one argument is the smell to watch for. + +This matches the refactor-audit rule "tests protect behaviour, not structure; one test per family". See and [[full-autonomy-openhcs]]. diff --git a/src/agent_comms/commander/rules/rules-serve-their-reason.md b/src/agent_comms/commander/rules/rules-serve-their-reason.md new file mode 100644 index 0000000000..0a81f6f4ed --- /dev/null +++ b/src/agent_comms/commander/rules/rules-serve-their-reason.md @@ -0,0 +1,11 @@ +--- +name: rules-serve-their-reason +description: "Owner's standing rule for every rule: satisfy its reason, not its letter; override a rule when breaking it leaves its reason intact" +type: feedback +--- + +Every rule the owner gives (and every safety rule in briefs and memory) exists for a reason. The aim is that the reason holds, not that the wording is obeyed. When following the letter would defeat the reason, or breaking it leaves the reason untouched, act on the reason and say plainly that I overrode the rule and why. This applies to any rule. + +**Why:** On 2026-10-19 the live bus sat down from 00:58 to 10:01. I stopped to ask before restoring the installer's own backup, because three rules ("no hand-editing registry.json", "live state only through upgrade_bus.py", "no deleting live state while the owner sleeps") looked like they covered it. Their reason is to never lose or overwrite state irreversibly. The restore kept the broken bus beside it and lost no messages, so the reason was met. The owner said: "they exist so that the reason [is] satisfied, not for the rule... you can override. this goes for any rule." + +**How to apply:** Before stopping on a rule, name its reason. If the action keeps that reason intact (it can be undone, a copy is kept, nothing is lost, nothing is exposed), do it, keep the evidence, and report. Stop only when the reason itself is at stake. Outages cost more than an undoable fix. Related: [[full-autonomy-openhcs]], [[structural-fixes-over-pushing-through]]. diff --git a/src/agent_comms/commander/rules/slips-ok-if-caught.md b/src/agent_comms/commander/rules/slips-ok-if-caught.md new file mode 100644 index 0000000000..d0064c66c4 --- /dev/null +++ b/src/agent_comms/commander/rules/slips-ok-if-caught.md @@ -0,0 +1,11 @@ +--- +name: slips-ok-if-caught +description: User tolerates mistakes as long as they are caught and fixed; report them plainly, no over-apology +type: feedback +--- + +The user said on 2026-10-11: "no worries about slips as long as u catch em. i dont expect perfection". + +**Why:** the user values verification loops that catch errors over error-free first attempts. + +**How to apply:** report slips in one plain line with the fix, and don't dwell or apologize at length. Invest in catching mistakes, through central verification, watchdogs and guards, rather than in slowing down to avoid them. Turn any repeated slip into a mechanical rule (DISPATCH.md, the scripts). Related: [[full-autonomy-openhcs]]. diff --git a/src/agent_comms/commander/rules/slow-is-a-defect.md b/src/agent_comms/commander/rules/slow-is-a-defect.md new file mode 100644 index 0000000000..4be84cea6e --- /dev/null +++ b/src/agent_comms/commander/rules/slow-is-a-defect.md @@ -0,0 +1,11 @@ +--- +name: slow-is-a-defect +description: "Treat slow tooling (ratchet, board, tests, installs) as a defect to profile and fix now, not a wait to sit through or explain" +type: feedback +--- + +2026-10-19: I explained to the owner why the debt ratchet takes 1–3 min (concurrent runs, a whole-tree report of about 850 KB) and listed fixing it as "worth an order later". Owner: "u should be impatient and ask yourself whether things are as slow as they are forced to be. make it less heavy if u can see it being less heavy." + +**Why:** every slow tool is paid on every PR by every fork, so slowness compounds into most of the fleet's wall time. + +**How to apply:** when I notice a slow step, profile it and dispatch the fix in the same tick; never only describe it. The test for any step: does it measure or do only what it must (changed files, cached blobs, batched queries)? Done 2026-10-19: forks for a fast ratchet (diff-scoped, blob cache) and a fast board (batched GraphQL) plus `threads`. See [[tooling-for-decisiveness]], [[structural-fixes-over-pushing-through]]. diff --git a/src/agent_comms/commander/rules/structural-fixes-over-pushing-through.md b/src/agent_comms/commander/rules/structural-fixes-over-pushing-through.md new file mode 100644 index 0000000000..5d1943fae3 --- /dev/null +++ b/src/agent_comms/commander/rules/structural-fixes-over-pushing-through.md @@ -0,0 +1,13 @@ +--- +name: structural-fixes-over-pushing-through +description: "When a demo or feature exposes a platform gap, fix the platform at its owner instead of working around it" +type: feedback +--- + +When work on a feature or demo exposes a gap in the platform, fix the gap structurally rather than working around it to get the feature through. + +**Why:** owner, 2026-10-12, on the Cellpose/N2V2 custom-function investigation (draft PR #1230), which listed ten platform gaps: "do all the fixes. structural fixes are more important than pushing through and ignoring them." The same session: an ObjectState defect found in passing ("shouldn't it be fixed regardless?") and the single-owner global config clean-up ("we should do that larger cleanup, we're already deep in this"). + +**How to apply:** treat workarounds found during a feature as a work list, not as acceptable cost; dispatch the fixes without asking, each at its owner, and make the feature's examples the acceptance test (no workaround left). Report a gap as "fixing" rather than offering it as an option. Relates to [[optimize-for-slop-elimination]] and [[full-autonomy-openhcs]]. + +**No narrow fixes (2026-10-19):** I scoped the Pi tracked-turn bug as a narrow fix and parked the owner's runtime refactor (#1876) behind desktop row agreement. Owner: "why would you not do the refactor along the fixes you did???" / "fuck narrow fixes have you not been listening to me?" A bug inside code a planned refactor covers is fixed *as* that refactor's step, in its target shape; anything not blocked by another owner starts now. Splitting by owner is fine; using the split as a reason to do only the minimal patch is not. diff --git a/src/agent_comms/commander/rules/tests-bounded-and-backgrounded.md b/src/agent_comms/commander/rules/tests-bounded-and-backgrounded.md index ead02454fc..35bdb8fb40 100644 --- a/src/agent_comms/commander/rules/tests-bounded-and-backgrounded.md +++ b/src/agent_comms/commander/rules/tests-bounded-and-backgrounded.md @@ -3,3 +3,11 @@ name: tests-bounded-and-backgrounded description: "every test run goes in the background under timeout 300 with -o faulthandler_timeout=60, and any test process older than 5 minutes is investigated that tick, because a hung suite burned 50 minutes and a foreground gate froze the commander loop" type: feedback --- + +On 2026-10-18 the owner said: "do not ever ever run test sina lbocking manner ever. ALWAYS run in background". I had run the P47 head-vs-base gate as a foreground Bash call with a 660 s timeout, which blocked the commander loop and left the owner without replies. + +**Why:** a foreground test run freezes the session; bus messages, the owner's questions and rotation events wait behind it. + +**How to apply:** every pytest or runp.sh invocation (gates, reruns, single-file checks) uses `run_in_background: true`, and I keep working until the completion notification arrives. Include this in fork briefs. Related: , . + +**Widened 2026-10-19 16:00:** owner, third time: "stop running long ass tool calls in the foreground." Not only tests: every command that touches the bus, order tools, gh, ssh, git fetch or an install goes in run_in_background. Foreground is only for local file reads and edits that finish in a second. diff --git a/src/agent_comms/commander/rules/tooling-for-decisiveness.md b/src/agent_comms/commander/rules/tooling-for-decisiveness.md new file mode 100644 index 0000000000..51eb3ec9e7 --- /dev/null +++ b/src/agent_comms/commander/rules/tooling-for-decisiveness.md @@ -0,0 +1,18 @@ +--- +name: tooling-for-decisiveness +description: Lean on tools (interpreter, NRA, coverage, tracing) to settle refactoring decisions with facts instead of reading and arguing +type: feedback +--- +Lean into tooling for decisiveness (owner, 2026-10-12): "AST scan is not bad, but we should lean into tooling." Also: "you're really good at working out the relation; do you ever consider using the Python interpreter?" I had been reconstructing class relations by reading text. + +**Why:** the interpreter and the owner's own tools already know answers that text only hints at; a tool is not talked into things by surrounding code, and it costs far less context than reading. + +**How to apply:** map each hesitation to a tool, and have agents run them before designing and cite the output in receipts: +- who supplies what / is there an existing owner -> `.claude/verify/supply_map.py` (live import: MRO owners, provider crossings, identical-bytecode copies, forwarders, registry contents and overwrites, structural twins, glued names; `--focus`). +- really unused? -> a reference prover (static refs + import-all + string/reflection uses across code, tests, docs, saved pipelines) issuing the "unused" certificate. +- what depends on this -> coverage with per-test contexts. +- are these the same -> bytecode equality (exact) or run both on generated inputs (test-grade, say so). +- which route actually runs -> trace a smoke/parity run recording which declaration answered. +- new case needs no edit -> a generic throwaway-member harness. +- Use the owner's NominalRefactorAdvisor for full findings on an area, not only its r1 ratchet as a gate. +Tools give candidates and facts about supply and readers; whether a behaviour should exist is still the owner's (science) and the papers' (form). Build tools under `.claude/verify/` without worktrees. See [[optimize-for-slop-elimination]], [[glued-class-names]].