From 4ebf34e9d1a4447f219a3aa25bcd532f631ffe3f Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 21:13:54 +0800 Subject: [PATCH 1/4] fix(todos): preserve legacy lifecycle actor provenance Signed-off-by: huangruiteng --- loopx/control_plane/todos/contract.py | 6 + loopx/control_plane/todos/legacy_mutation.py | 9 +- loopx/control_plane/todos/line_update.py | 26 ++++ .../test_legacy_completion_actor.py | 134 ++++++++++++++++++ .../test_todo_metadata_schema.py | 2 + 5 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_legacy_completion_actor.py diff --git a/loopx/control_plane/todos/contract.py b/loopx/control_plane/todos/contract.py index 71f8def910..24e9fd269a 100644 --- a/loopx/control_plane/todos/contract.py +++ b/loopx/control_plane/todos/contract.py @@ -1000,6 +1000,11 @@ def _metadata_value_is_present(value: Any) -> bool: "claimed_by must be a public-safe agent token such as codex-main-control" ), ), + _TodoMetadataField( + "last_actor_agent_id", + normalize_todo_claimed_by, + invalid_message="last_actor_agent_id must be a public-safe agent token", + ), _TodoMetadataField( "bound_agent", normalize_todo_bound_agent, @@ -1247,6 +1252,7 @@ def format_todo_metadata_line( decision_outcome: str | None = None, decision_scope_outcomes: Any = None, claimed_by: str | None = None, + last_actor_agent_id: str | None = None, bound_agent: str | None = None, goal_bound: bool | None = None, blocks_agent: str | None = None, diff --git a/loopx/control_plane/todos/legacy_mutation.py b/loopx/control_plane/todos/legacy_mutation.py index 8feeccbe3f..97480d7329 100644 --- a/loopx/control_plane/todos/legacy_mutation.py +++ b/loopx/control_plane/todos/legacy_mutation.py @@ -682,7 +682,7 @@ def _update_goal_todo_legacy( ) -> dict[str, Any]: monitor_intent = _prepared["monitor_intent"] shadow_runtime_root = _prepared["shadow_runtime_root"] - from .line_update import apply_todo_update_to_lines + from .line_update import apply_todo_update_to_lines, record_todo_mutation_actor from ..coordination.runtime_shadow_writer_adapter import begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture resolved_project, resolved_state_file = resolve_todo_state_path( @@ -908,6 +908,7 @@ def _update_goal_todo_legacy( claim_only=claim_only, updated_at=updated_at, ) + record_todo_mutation_actor(lines, update_result, mutation_authority.get("actor_agent_id")) changed = bool(update_result["changed"]) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") if changed: @@ -986,7 +987,7 @@ def _complete_goal_todo_legacy( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: - from .line_update import apply_todo_update_to_lines, link_generated_successor_todo_ids + from .line_update import apply_todo_update_to_lines, link_generated_successor_todo_ids, record_todo_mutation_actor from ..coordination.runtime_shadow_writer_adapter import begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) @@ -1186,6 +1187,7 @@ def _complete_goal_todo_legacy( successor_todo_ids=normalized_successor_todo_ids if successor_todo_ids is not None else None, updated_at=updated_at, ) + record_todo_mutation_actor(lines, update_result, mutation_authority.get("actor_agent_id")) unblock_resume, decision_scope_resolution = ( apply_completed_user_todo_lifecycle( lines, @@ -1319,7 +1321,7 @@ def _supersede_goal_todo_legacy( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: - from .line_update import apply_todo_update_to_lines, link_superseding_todo_id + from .line_update import apply_todo_update_to_lines, link_superseding_todo_id, record_todo_mutation_actor from ..coordination.runtime_shadow_writer_adapter import begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture if successor_todo_ids: @@ -1387,6 +1389,7 @@ def _supersede_goal_todo_legacy( note="superseded", updated_at=updated_at, ) + record_todo_mutation_actor(lines, update_result, mutation_authority.get("actor_agent_id")) registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) successor_intents = build_successor_intents( next_agent_todo=next_agent_todo, diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index fe4b9389dd..aa2ab465fa 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -50,6 +50,32 @@ def upsert_todo_metadata( return True +def record_todo_mutation_actor( + lines: list[str], + update_result: dict[str, Any], + actor: str | None, +) -> None: + """Transport accepted mutation provenance inside the held writer transaction. + + Authority remains with the existing lifecycle decision. An unchanged replay + must not mint a new actor or advance the source timestamp. + """ + if not update_result.get("changed"): + return + if actor is None: + return + match = find_todo_block( + lines, todo_id=update_result["todo_id"], role=update_result["role"], + ) + if match is None: + raise RuntimeError("Authorized Todo disappeared before provenance writeback") + block = match[4] + metadata_line = metadata_line_for_todo_block(block, {"last_actor_agent_id": actor}) + updated = upsert_todo_metadata(lines, block, metadata_line) + update_result["metadata_updated"] = updated or update_result["metadata_updated"] + update_result["last_actor_agent_id"] = actor + + def link_generated_successor_todo_ids( lines: list[str], *, diff --git a/tests/control_plane/test_legacy_completion_actor.py b/tests/control_plane/test_legacy_completion_actor.py new file mode 100644 index 0000000000..5c7a17f696 --- /dev/null +++ b/tests/control_plane/test_legacy_completion_actor.py @@ -0,0 +1,134 @@ +"""The ordinary legacy writer must qualify its own terminal reentry source.""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta, timezone + +import pytest + +from loopx.cli import main +from loopx.control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result + + +@pytest.mark.parametrize("historical_source", [False, True]) +def test_legacy_completion_actor_recovers_and_settles_exact_turn(tmp_path, monkeypatch, historical_source): + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.setenv("TEMP", str(tmp_path)) + monkeypatch.setenv("TMP", str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + project = tmp_path / "project" + project.mkdir() + runtime = tmp_path / "runtime" + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text("---\nstatus: active\nobjective: Validate the source.\n---\n\n" + "# Active Goal State\n\n## Objective\n\nValidate the source.\n\n" + "## User Todo / Owner Review Reading Queue\n\n## Agent Todo\n\n") + goal, agent = "legacy-source", "source-owner" + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": goal, "domain": "synthetic-local-test", "status": "active", "repo": str(project), + "state_file": state.name, "quota": {"compute": 1}, + "adapter": {"kind": "fixture_connected_delivery_v0", "status": "connected-delivery"}, + "coordination": {"agent_model": "peer_v1", "registered_agents": [agent, "other-owner"]}, + }]})) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + + def call(*args, code=0): + output = io.StringIO() + with contextlib.redirect_stdout(output): + rc = main([*prefix, *args]) + payload = json.loads(output.getvalue()) + assert rc == code, {key: payload.get(key) for key in ("error", "error_code", "reason", "settlement_progress")} + return payload + + try: + assert call("check", "--scan-root", str(project))["ok"] + added = call("todo", "add", "--goal-id", goal, "--role", "agent", "--claimed-by", agent, + "--text", "Validate the source.", "--task-class", "advancement_task", + "--action-kind", "validate", "--target-key", "source") + todo = added["todo_id"] + completion = ["todo", "complete", "--goal-id", goal, "--todo-id", todo, "--agent-id", agent, + "--evidence", "evidence:source-validated"] + call(*completion) + detail = ["todo", "list", "--goal-id", goal, "--todo-id", todo] + original = call(*detail)["todo"] + assert original["last_actor_agent_id"] == agent + original_bytes = state.read_bytes() + call(*completion) + assert state.read_bytes() == original_bytes # Exact replay does not stamp provenance. + peer_completion = list(completion) + peer_completion[peer_completion.index("--agent-id") + 1] = "other-owner" + call(*peer_completion, code=1) + assert state.read_bytes() == original_bytes + if historical_source: + # A persisted completion from before actor transport was supported. + state.write_text(state.read_text().replace(f" last_actor_agent_id={agent}", "")) + assert call(*detail)["todo"].get("last_actor_agent_id") is None + original_bytes = state.read_bytes() + + turn = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + quota = ["quota", "should-run", "--goal-id", goal, "--agent-id", agent, + "--available-capability", "shell_command", "--codex-app", "--turn-instance-id", turn] + guard = call(*quota) + obligation = guard["autonomous_replan_obligation"] + assert obligation["resolution_mode"] == "todo_lifecycle_settlement" + receipt = guard["heartbeat_receipt"] + closeout = ["todo", "complete", "--goal-id", goal, "--todo-id", todo, "--agent-id", agent, + "--completion-identity-key", original["completion_turn_key"], "--no-follow-up", + "--note", "The source is validated."] + call(*closeout, "--dry-run") + assert state.read_bytes() == original_bytes + call(*closeout) + row = call(*detail)["todo"] + assert row["last_actor_agent_id"] == agent + assert row["completion_turn_key"] == original["completion_turn_key"] + assert row["completed_at"] == original["completed_at"] + recovered = call(*quota) + assert recovered["heartbeat_receipt"]["settlement_identity"] == receipt["settlement_identity"] + assert recovered["autonomous_replan_obligation"]["resolution_mode"] == "receipt_bound_replan_settlement" + assert recovered["autonomous_replan_obligation"]["obligation_id"] == obligation["obligation_id"] + + admitted = receipt["recorded_at"] + old_updated = (datetime.fromisoformat(admitted.replace("Z", "+00:00")) - timedelta(seconds=5)).isoformat() + for change in [{"last_actor_agent_id": None}, {"last_actor_agent_id": "other-owner"}, + {"completion_turn_key": "different-completion"}, {"updated_at": old_updated}, + {"no_followup": False}]: + candidate = {"source_obligation": obligation, "obligation": obligation, "lifecycle_settled": True, + "guard_recorded_at": admitted, "items": [{**row, **change}]} + with pytest.raises(EffectRuntimeRejected) as rejected: + effect_runtime_result("work_item.replan_semantics.project", { + "operation": "receipt_bound_obligation", "guard_scoped": True, "replay_phase": "open", + "selected_obligation_id": obligation["obligation_id"], "agent_id": agent, + "current_obligation": None, "transition_candidates": [candidate]}) + assert rejected.value.diagnostic_code == "heartbeat_receipt_identity_conflict" + + vision = tmp_path / "vision.json" + vision.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", "state": "vision_closed", + "vision_patch": {"acceptance_summary": "The authenticated source and identity fences are verified."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "replan", + "prior_assumption": "The source was unverified.", "observed_reality": "The source and contrary cases passed.", + "retained": ["Authenticated provenance and exact completion identity"], + "stopped": ["Repeat validation of the completed fixture"], "evidence_refs": ["evidence:source-validated"]}})) + call("refresh-state", "--goal-id", goal, "--agent-id", agent, + "--turn-instance-id", turn, "--replan-obligation-id", obligation["obligation_id"], + "--autonomous-replan-recorded", "--repair-delta-kind", "goal_vision_patch", + "--classification", "source_verified", "--delivery-batch-scale", "multi_surface", + "--delivery-outcome", "outcome_progress", "--agent-vision-json", str(vision), + "--progress-result-class", "advanced", "--progress-evidence-id", "evidence:source-validated", + "--no-global-sync", "--suppress-external-sinks") + spend = ["quota", "spend-slot", "--goal-id", goal, "--agent-id", agent, "--turn-instance-id", turn, + "--replan-obligation-id", obligation["obligation_id"], "--slots", "1", "--source", "heartbeat", "--execute"] + assert call(*spend)["ok"] + assert call(*spend)["ok"] + index = next(runtime.glob("goals/*/runs/index.jsonl")) + runs = [json.loads(line) for line in index.read_text().splitlines() if line.strip()] + assert sum(run.get("classification") == "quota_slot_spent" for run in runs) == 1 + finally: + subprocess.run([sys.executable, "-c", "from loopx.control_plane.effect_runtime import restart_effect_runtime; restart_effect_runtime()"], + env=dict(os.environ), check=True, capture_output=True, timeout=30) diff --git a/tests/control_plane/test_todo_metadata_schema.py b/tests/control_plane/test_todo_metadata_schema.py index 26e46703f5..a3df82d8fd 100644 --- a/tests/control_plane/test_todo_metadata_schema.py +++ b/tests/control_plane/test_todo_metadata_schema.py @@ -36,6 +36,7 @@ def test_todo_metadata_round_trip_preserves_canonical_values() -> None: } ], claimed_by="codex-quality", + last_actor_agent_id="codex-quality", bound_agent="codex-main", goal_bound=False, blocks_agent="codex-side", @@ -107,6 +108,7 @@ def test_todo_metadata_round_trip_preserves_canonical_values() -> None: } ], "claimed_by": "codex-quality", + "last_actor_agent_id": "codex-quality", "bound_agent": "codex-main", "goal_bound": False, "blocks_agent": "codex-side", From f03eb5941bb14ff37b1954e3e222a0a8054d484d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 14 Oct 2026 13:15:47 +0800 Subject: [PATCH 2/4] fix(todos): preserve planning-only actor provenance Signed-off-by: huangruiteng --- .../coordination/todo_update_intent.ts | 9 ++- loopx/control_plane/todos/legacy_mutation.py | 5 +- loopx/control_plane/todos/line_update.py | 6 +- loopx/control_plane/todos/public_update.ts | 21 +++++++ .../test_legacy_completion_actor.py | 56 +++++++++++++++++++ 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/coordination/todo_update_intent.ts b/loopx/control_plane/coordination/todo_update_intent.ts index 24025ea38d..8407462ba8 100644 --- a/loopx/control_plane/coordination/todo_update_intent.ts +++ b/loopx/control_plane/coordination/todo_update_intent.ts @@ -7,7 +7,8 @@ import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthorit requireAuthorityStoreId} from "./authority_store_codec.ts"; import {normalizeRegisteredTodoAgents, normalizeTodoAgent} from "./todo_agents.ts"; import {canonicalTodoRecord} from "./todo_presentation.ts"; -import {TODO_OWNERSHIP_INTENT_FIELDS, planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA} from "../todos/authoring_scope.ts"; +import {planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA} from "../todos/authoring_scope.ts"; +import {shouldRecordTodoMutationActor} from "../todos/public_update.ts"; import {normalizeNativePlanningIntent, planNativeTodoUpdate} from "../todos/native_update_plan.ts"; import {decodeMonitorPollObservation, type MonitorPollObservation} from "../todos/monitor_metadata.ts"; import { @@ -156,10 +157,8 @@ export function prepareUpdatedTodo( // Preserve the public planner's legacy metadata semantics. Raw copy edits // already carry actor attribution, while planning-only updates historically // leave last_actor_agent_id untouched. - const rawCopyChanged = Object.entries(input.patch).some(([field, value]) => - !Object.hasOwn(todo, field) || !canonicalAuthorityBytes(todo[field]).equals(canonicalAuthorityBytes(value))) || - input.clear_fields.some(field => Object.hasOwn(todo, field)); - if (rawCopyChanged || input.monitor_observation !== undefined || TODO_OWNERSHIP_INTENT_FIELDS.some(field => Object.hasOwn(input.planning_intent ?? {}, field))) { + if (shouldRecordTodoMutationActor(todo, input.patch, input.clear_fields, + input.planning_intent ?? {}, input.monitor_observation)) { next.last_actor_agent_id = input.actor_agent_id; } next.updated_at = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); diff --git a/loopx/control_plane/todos/legacy_mutation.py b/loopx/control_plane/todos/legacy_mutation.py index 97480d7329..6b7d846eb1 100644 --- a/loopx/control_plane/todos/legacy_mutation.py +++ b/loopx/control_plane/todos/legacy_mutation.py @@ -682,7 +682,7 @@ def _update_goal_todo_legacy( ) -> dict[str, Any]: monitor_intent = _prepared["monitor_intent"] shadow_runtime_root = _prepared["shadow_runtime_root"] - from .line_update import apply_todo_update_to_lines, record_todo_mutation_actor + from .line_update import apply_todo_update_to_lines from ..coordination.runtime_shadow_writer_adapter import begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture resolved_project, resolved_state_file = resolve_todo_state_path( @@ -899,7 +899,7 @@ def _update_goal_todo_legacy( monitor_metadata=monitor_intent["metadata"], public_context={ "goal_id": goal_id, "role": target_role, - "actor_agent_id": effective_agent_id, + "actor_agent_id": mutation_authority.get("actor_agent_id"), "registered_agents": registered_agent_ids_from_registry(registry_path, goal_id), "monitor_observation": monitor_intent["observation"], "enforce_monitor_boundedness": enforce_monitor_boundedness, @@ -908,7 +908,6 @@ def _update_goal_todo_legacy( claim_only=claim_only, updated_at=updated_at, ) - record_todo_mutation_actor(lines, update_result, mutation_authority.get("actor_agent_id")) changed = bool(update_result["changed"]) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") if changed: diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index aa2ab465fa..de64c86626 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -178,6 +178,7 @@ def _field_update_plan( **{key: block.get(key) for key in ( "text", + "note", "todo_id", "status", "claimed_by", @@ -362,6 +363,7 @@ def apply_todo_update_to_lines( normalized_status = plan["normalized_status"] target_status = plan["target_status"] updates = plan["metadata_updates"] + accepted_actor = updates.pop("last_actor_agent_id", None) text = updates.pop("text", text) updated_priority = updates.pop("priority", todo_priority_label(block)) updates.pop("title", None) @@ -380,7 +382,7 @@ def apply_todo_update_to_lines( metadata_line = metadata_line_for_todo_block(block, updates) metadata_updated = upsert_todo_metadata(lines, block, metadata_line) effective_metadata = parse_todo_metadata_line(metadata_line or "") or {} - return serialize_todo_update_result( + result = serialize_todo_update_result( role=resolved_role, section=section, todo=block.get("text"), todo_id=normalized_todo_id, status=target_status, priority=updated_priority, status_changed=status_changed, text_changed=text_changed, @@ -388,3 +390,5 @@ def apply_todo_update_to_lines( monitor_poll_transition=plan.get("monitor_poll_transition"), external_wait_transition=plan.get("external_wait_transition"), ) + record_todo_mutation_actor(lines, result, accepted_actor) + return result diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 7a569aea07..51491bb54b 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -15,9 +15,22 @@ import { } from "./decision_metadata.ts"; import { normalizeMonitorConfiguration, validateMonitorConfigurationTarget } from "./monitor_metadata.ts"; +import {canonicalAuthorityBytes} from "../coordination/authority_store_codec.ts"; export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0"; +/** Shared provenance intent; callers still suppress unchanged replay and own admission. */ +export function shouldRecordTodoMutationActor( + todo: JsonObject, patch: JsonObject, clearFields: readonly string[], + planningIntent: JsonObject, monitorObservation: unknown, +): boolean { + const rawCopyChanged = Object.entries(patch).some(([field, value]) => + !Object.hasOwn(todo, field) || !canonicalAuthorityBytes(todo[field]).equals(canonicalAuthorityBytes(value))) || + clearFields.some(field => Object.hasOwn(todo, field)); + return rawCopyChanged || monitorObservation !== undefined || + TODO_OWNERSHIP_INTENT_FIELDS.some(field => Object.hasOwn(planningIntent, field)); +} + const SCOPE_INTENT_FIELDS = ["task_class", "status", "claimed_by", "bound_agent", "goal_bound", "blocks_agent", "clear_blocks_agent", "global_gate", "clear_global_gate", "excluded_agents", "task_repository", "task_domain", "resume_when", "clear_resume_when", "clear_claim"] as const; @@ -100,5 +113,13 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { monitor_context: {metadata: intent.monitor_metadata ?? null, observation: context.monitor_observation ?? null, role, task_class: scope.task_class, resume_when: scope.effective_resume_when, enforce_boundedness: context.enforce_monitor_boundedness}}); + const copyPatch = Object.fromEntries(["text", "note"].flatMap(field => + Object.hasOwn(intent, field) ? [[field, intent[field]]] : [])); + if (context.actor_agent_id != null && shouldRecordTodoMutationActor( + todo, copyPatch, [], intent, context.monitor_observation ?? undefined)) { + // The legacy writer transports this after its material-change check; actor + // provenance alone must never turn an unchanged replay into a mutation. + plan.metadata_updates.last_actor_agent_id = context.actor_agent_id; + } return {...plan, ...(transition ? {external_wait_transition: transition} : {})}; } diff --git a/tests/control_plane/test_legacy_completion_actor.py b/tests/control_plane/test_legacy_completion_actor.py index 5c7a17f696..b96c40e295 100644 --- a/tests/control_plane/test_legacy_completion_actor.py +++ b/tests/control_plane/test_legacy_completion_actor.py @@ -14,6 +14,62 @@ from loopx.cli import main from loopx.control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result +from tests.control_plane.test_todo_mutation_authority import ( + AUTHOR_AGENT, GOAL_ID, REVIEW_AGENT, _write_fixture, +) + + +@pytest.mark.parametrize("edit,expected_actor", [ + (["--evidence", "evidence:planning-only"], AUTHOR_AGENT), + (["--priority", "P2"], AUTHOR_AGENT), + (["--clear-priority"], AUTHOR_AGENT), + (["--note", "Peer context.", "--evidence", "evidence:mixed"], REVIEW_AGENT), + (["--text", "Clarify source.", "--evidence", "evidence:mixed"], REVIEW_AGENT), + (["--claimed-by", REVIEW_AGENT], REVIEW_AGENT), +]) +def test_legacy_cli_update_preserves_planning_actor(tmp_path, edit, expected_actor): + registry, state = _write_fixture(tmp_path, lifecycle_authority=[{ + "agent_id": REVIEW_AGENT, "actions": ["update", "reassign"], "requires_reason": True, + }]) + prefix = ["--registry", str(registry), "--runtime-root", str(tmp_path / "runtime"), + "--format", "json"] + + def call(*args, code=0): + output = io.StringIO() + with contextlib.redirect_stdout(output): + actual = main([*prefix, *args]) + payload = json.loads(output.getvalue()) + assert actual == code, {key: payload.get(key) for key in ("error", "error_code")} + return payload + + try: + todo = call("todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--text", "Verify source.", "--priority", "P1", + "--claimed-by", AUTHOR_AGENT, "--task-class", "advancement_task")["todo_id"] + common = ["todo", "update", "--goal-id", GOAL_ID, "--todo-id", todo] + call(*common, "--agent-id", AUTHOR_AGENT, "--note", "Owner context.", + "--evidence", "evidence:original") + detail = ["todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo] + assert call(*detail)["todo"]["last_actor_agent_id"] == AUTHOR_AGENT + delegated = [*common, "--agent-id", REVIEW_AGENT, *edit] + before = state.read_bytes() + call(*delegated, code=1) # A grant still requires its actual reason. + assert state.read_bytes() == before + delegated += ["--authority-reason", "Maintain the source."] + call(*delegated, "--dry-run") + assert state.read_bytes() == before + changed = call(*delegated) + assert changed["changed"] + row = call(*detail)["todo"] + assert row["last_actor_agent_id"] == expected_actor + if "--claimed-by" not in edit: + assert row["claimed_by"] == AUTHOR_AGENT + after = state.read_bytes() + assert not call(*delegated)["changed"] + assert state.read_bytes() == after + finally: + subprocess.run([sys.executable, "-c", "from loopx.control_plane.effect_runtime import restart_effect_runtime; restart_effect_runtime()"], + env=dict(os.environ), check=True, capture_output=True, timeout=30) @pytest.mark.parametrize("historical_source", [False, True]) From b09543ebd93b254e8357ce9ba50479820038021e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:24:53 +0800 Subject: [PATCH 3/4] fix(todos): preserve lifecycle actor during planning updates Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/todo_update_intent.ts | 8 +- loopx/control_plane/todos/legacy_mutation.py | 5 +- loopx/control_plane/todos/line_update.py | 1 + loopx/control_plane/todos/public_update.ts | 23 ++++++ .../test_legacy_completion_actor.py | 74 ++++++++++++++++++- .../public_todo_update.test.ts | 14 ++++ 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/loopx/control_plane/coordination/todo_update_intent.ts b/loopx/control_plane/coordination/todo_update_intent.ts index 24025ea38d..bb64952b77 100644 --- a/loopx/control_plane/coordination/todo_update_intent.ts +++ b/loopx/control_plane/coordination/todo_update_intent.ts @@ -7,7 +7,8 @@ import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthorit requireAuthorityStoreId} from "./authority_store_codec.ts"; import {normalizeRegisteredTodoAgents, normalizeTodoAgent} from "./todo_agents.ts"; import {canonicalTodoRecord} from "./todo_presentation.ts"; -import {TODO_OWNERSHIP_INTENT_FIELDS, planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA} from "../todos/authoring_scope.ts"; +import {planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA} from "../todos/authoring_scope.ts"; +import {recordsTodoUpdateActor} from "../todos/public_update.ts"; import {normalizeNativePlanningIntent, planNativeTodoUpdate} from "../todos/native_update_plan.ts"; import {decodeMonitorPollObservation, type MonitorPollObservation} from "../todos/monitor_metadata.ts"; import { @@ -156,10 +157,7 @@ export function prepareUpdatedTodo( // Preserve the public planner's legacy metadata semantics. Raw copy edits // already carry actor attribution, while planning-only updates historically // leave last_actor_agent_id untouched. - const rawCopyChanged = Object.entries(input.patch).some(([field, value]) => - !Object.hasOwn(todo, field) || !canonicalAuthorityBytes(todo[field]).equals(canonicalAuthorityBytes(value))) || - input.clear_fields.some(field => Object.hasOwn(todo, field)); - if (rawCopyChanged || input.monitor_observation !== undefined || TODO_OWNERSHIP_INTENT_FIELDS.some(field => Object.hasOwn(input.planning_intent ?? {}, field))) { + if (recordsTodoUpdateActor(todo, input)) { next.last_actor_agent_id = input.actor_agent_id; } next.updated_at = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); diff --git a/loopx/control_plane/todos/legacy_mutation.py b/loopx/control_plane/todos/legacy_mutation.py index 97480d7329..6b7d846eb1 100644 --- a/loopx/control_plane/todos/legacy_mutation.py +++ b/loopx/control_plane/todos/legacy_mutation.py @@ -682,7 +682,7 @@ def _update_goal_todo_legacy( ) -> dict[str, Any]: monitor_intent = _prepared["monitor_intent"] shadow_runtime_root = _prepared["shadow_runtime_root"] - from .line_update import apply_todo_update_to_lines, record_todo_mutation_actor + from .line_update import apply_todo_update_to_lines from ..coordination.runtime_shadow_writer_adapter import begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture resolved_project, resolved_state_file = resolve_todo_state_path( @@ -899,7 +899,7 @@ def _update_goal_todo_legacy( monitor_metadata=monitor_intent["metadata"], public_context={ "goal_id": goal_id, "role": target_role, - "actor_agent_id": effective_agent_id, + "actor_agent_id": mutation_authority.get("actor_agent_id"), "registered_agents": registered_agent_ids_from_registry(registry_path, goal_id), "monitor_observation": monitor_intent["observation"], "enforce_monitor_boundedness": enforce_monitor_boundedness, @@ -908,7 +908,6 @@ def _update_goal_todo_legacy( claim_only=claim_only, updated_at=updated_at, ) - record_todo_mutation_actor(lines, update_result, mutation_authority.get("actor_agent_id")) changed = bool(update_result["changed"]) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") if changed: diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index aa2ab465fa..4812b95d74 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -178,6 +178,7 @@ def _field_update_plan( **{key: block.get(key) for key in ( "text", + "note", "todo_id", "status", "claimed_by", diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 7a569aea07..d207696817 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -15,6 +15,7 @@ import { } from "./decision_metadata.ts"; import { normalizeMonitorConfiguration, validateMonitorConfigurationTarget } from "./monitor_metadata.ts"; +import {canonicalAuthorityBytes} from "../coordination/authority_store_codec.ts"; export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0"; @@ -22,6 +23,19 @@ const SCOPE_INTENT_FIELDS = ["task_class", "status", "claimed_by", "bound_agent" "blocks_agent", "clear_blocks_agent", "global_gate", "clear_global_gate", "excluded_agents", "task_repository", "task_domain", "resume_when", "clear_resume_when", "clear_claim"] as const; +/** Attribution is shared by the canonical materializer and compatibility plan. + * Planning changes alone preserve the actor that produced the lifecycle state. */ +export function recordsTodoUpdateActor(todo: JsonObject, input: { + patch: JsonObject; clear_fields: readonly string[]; + planning_intent?: JsonObject; monitor_observation?: unknown; +}): boolean { + const copyChanged = Object.entries(input.patch).some(([field, value]) => + !Object.hasOwn(todo, field) || !canonicalAuthorityBytes(todo[field]).equals(canonicalAuthorityBytes(value))) || + input.clear_fields.some(field => Object.hasOwn(todo, field)); + return copyChanged || input.monitor_observation !== undefined || + TODO_OWNERSHIP_INTENT_FIELDS.some(field => Object.hasOwn(input.planning_intent ?? {}, field)); +} + function externalWait(todo: JsonObject, intent: JsonObject, scope: JsonObject, context: JsonObject): JsonObject | null { // Copy-only edits do not re-arm a satisfied wait or demand a fresh successor. @@ -100,5 +114,14 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { monitor_context: {metadata: intent.monitor_metadata ?? null, observation: context.monitor_observation ?? null, role, task_class: scope.task_class, resume_when: scope.effective_resume_when, enforce_boundedness: context.enforce_monitor_boundedness}}); + const updates = plan.metadata_updates; + const copyPatch: JsonObject = { + ...(Object.hasOwn(intent, "text") && Object.hasOwn(updates, "text") ? {text: updates.text} : {}), + ...(Object.hasOwn(intent, "note") && Object.hasOwn(updates, "note") ? {note: updates.note} : {}), + }; + if (context.actor_agent_id != null && recordsTodoUpdateActor(todo, { + patch: copyPatch, clear_fields: [], planning_intent: intent, + ...(context.monitor_observation == null ? {} : {monitor_observation: context.monitor_observation}), + })) updates.last_actor_agent_id = context.actor_agent_id; return {...plan, ...(transition ? {external_wait_transition: transition} : {})}; } diff --git a/tests/control_plane/test_legacy_completion_actor.py b/tests/control_plane/test_legacy_completion_actor.py index 5c7a17f696..aefbc017e0 100644 --- a/tests/control_plane/test_legacy_completion_actor.py +++ b/tests/control_plane/test_legacy_completion_actor.py @@ -16,6 +16,76 @@ from loopx.control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result +@pytest.mark.parametrize("planning_edit", [ + ["--evidence", "evidence:peer-validation"], + ["--clear-priority"], + ["--priority", "P1"], +]) +def test_legacy_planning_preserves_actor_and_mixed_copy_records_it(tmp_path, monkeypatch, planning_edit): + from tests.control_plane.test_todo_mutation_authority import ( + AUTHOR_AGENT, GOAL_ID, REVIEW_AGENT, _add_agent_todo, _write_fixture, + ) + + for variable in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(variable, str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + registry, state = _write_fixture(tmp_path, lifecycle_authority=[{ + "agent_id": REVIEW_AGENT, "actions": ["update"], "requires_reason": True, + }]) + todo = _add_agent_todo(registry, claimed_by=None)["todo_id"] + prefix = ["--registry", str(registry), "--runtime-root", str(tmp_path / "runtime"), "--format", "json"] + + def call(*args, code=0): + output = io.StringIO() + with contextlib.redirect_stdout(output): + rc = main([*prefix, *args]) + payload = json.loads(output.getvalue()) + assert rc == code, {key: payload.get(key) for key in ("error", "error_code", "reason", "mutation_authority")} + return payload + + def detail(): + return call("todo", "list", "--goal-id", GOAL_ID, "--todo-id", todo)["todo"] + + update = ["todo", "update", "--goal-id", GOAL_ID, "--todo-id", todo] + call("todo", "claim", "--goal-id", GOAL_ID, "--todo-id", todo, + "--agent-id", AUTHOR_AGENT, "--claimed-by", AUTHOR_AGENT) + assert detail()["last_actor_agent_id"] == AUTHOR_AGENT + call(*update, "--agent-id", AUTHOR_AGENT, "--note", "Owner context", "--evidence", "evidence:owner-validation", "--priority", "P2") + assert detail()["last_actor_agent_id"] == AUTHOR_AGENT + peer = [*update, "--agent-id", REVIEW_AGENT, "--authority-reason", "Maintain validated planning evidence"] + before = state.read_bytes() + call(*update, "--agent-id", REVIEW_AGENT, "--evidence", "evidence:unadmitted", code=1) + assert state.read_bytes() == before + call(*peer, *planning_edit, "--dry-run") + assert state.read_bytes() == before + call(*peer, *planning_edit) + assert detail()["last_actor_agent_id"] == AUTHOR_AGENT + assert detail()["claimed_by"] == AUTHOR_AGENT + before = state.read_bytes() + call(*peer, *planning_edit) + assert state.read_bytes() == before + + call(*peer, "--note", "Peer copy correction", "--evidence", "evidence:mixed-validation") + assert detail()["last_actor_agent_id"] == REVIEW_AGENT + assert detail()["claimed_by"] == AUTHOR_AGENT + # A supplied but unchanged copy field must not turn planning into a new actor. + call(*update, "--agent-id", AUTHOR_AGENT, "--note", "Peer copy correction", "--evidence", "") + assert detail()["last_actor_agent_id"] == REVIEW_AGENT + before = state.read_bytes() + call(*peer, "--note", "Peer copy correction", "--evidence", "", "--dry-run") + assert state.read_bytes() == before + + retire = ["todo", "supersede", "--goal-id", GOAL_ID, "--todo-id", todo, + "--agent-id", AUTHOR_AGENT, "--reason", "Retire the validated approach."] + call(*retire, "--dry-run") + assert state.read_bytes() == before + call(*retire) + assert detail()["last_actor_agent_id"] == AUTHOR_AGENT + before = state.read_bytes() + call(*retire) + assert state.read_bytes() == before + + @pytest.mark.parametrize("historical_source", [False, True]) def test_legacy_completion_actor_recovers_and_settles_exact_turn(tmp_path, monkeypatch, historical_source): monkeypatch.setenv("TMPDIR", str(tmp_path)) @@ -110,10 +180,10 @@ def call(*args, code=0): vision = tmp_path / "vision.json" vision.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", "state": "vision_closed", - "vision_patch": {"acceptance_summary": "The authenticated source and identity fences are verified."}, + "vision_patch": {"acceptance_summary": "The accepted source actor and identity fences are verified."}, "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "replan", "prior_assumption": "The source was unverified.", "observed_reality": "The source and contrary cases passed.", - "retained": ["Authenticated provenance and exact completion identity"], + "retained": ["Accepted actor provenance and exact completion identity"], "stopped": ["Repeat validation of the completed fixture"], "evidence_refs": ["evidence:source-validated"]}})) call("refresh-state", "--goal-id", goal, "--agent-id", agent, "--turn-instance-id", turn, "--replan-obligation-id", obligation["obligation_id"], diff --git a/tests/control_plane_ts/public_todo_update.test.ts b/tests/control_plane_ts/public_todo_update.test.ts index b534c26ef5..6eb5dcbf25 100644 --- a/tests/control_plane_ts/public_todo_update.test.ts +++ b/tests/control_plane_ts/public_todo_update.test.ts @@ -17,6 +17,20 @@ function plan(intent: JsonObject = {}, source: JsonObject = todo, extra: JsonObj enforce_monitor_boundedness: true, items: [source, monitor, successor], ...extra}}); } +test("planning keeps lifecycle attribution, including priority display and unchanged copy", () => { + const source = {todo_id: "todo_attribution", role: "agent", status: "open", + task_class: "advancement_task", claimed_by: "agent-a", last_actor_agent_id: "agent-a", + text: "[P2] Retained work", note: "Retained note", evidence: "old"}; + for (const intent of [{evidence: "new"}, {evidence: ""}, {priority: "P1"}, + {clear_priority: true}, {evidence: "new", note: source.note}]) { + assert.equal((plan(intent, source, {actor_agent_id: "agent-b"}).metadata_updates as JsonObject) + .last_actor_agent_id, undefined); + } + assert.equal((plan({evidence: "new", note: "Corrected note"}, source, + {actor_agent_id: "agent-b"}).metadata_updates as JsonObject).last_actor_agent_id, "agent-b"); + assert.equal(source.last_actor_agent_id, "agent-a"); +}); + test("partial topology edits validate the effective retained wait", () => { for (const edit of [{successor_todo_ids: []}, {status: "blocked"}, {task_class: "continuous_monitor"}]) { assert.throws(() => plan(edit), /external-wait/); From 317770ba8f8ad7b2cf8204eca565a6ee5de86d0a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:33:06 +0800 Subject: [PATCH 4/4] test(todos): exercise an explicit planning clear with unchanged copy Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/control_plane/test_legacy_completion_actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/control_plane/test_legacy_completion_actor.py b/tests/control_plane/test_legacy_completion_actor.py index fc95e0f862..8ed2185254 100644 --- a/tests/control_plane/test_legacy_completion_actor.py +++ b/tests/control_plane/test_legacy_completion_actor.py @@ -119,10 +119,10 @@ def detail(): assert detail()["last_actor_agent_id"] == REVIEW_AGENT assert detail()["claimed_by"] == AUTHOR_AGENT # A supplied but unchanged copy field must not turn planning into a new actor. - call(*update, "--agent-id", AUTHOR_AGENT, "--note", "Peer copy correction", "--evidence", "") + call(*update, "--agent-id", AUTHOR_AGENT, "--note", "Peer copy correction", "--clear-priority") assert detail()["last_actor_agent_id"] == REVIEW_AGENT before = state.read_bytes() - call(*peer, "--note", "Peer copy correction", "--evidence", "", "--dry-run") + call(*peer, "--note", "Peer copy correction", "--clear-priority", "--dry-run") assert state.read_bytes() == before retire = ["todo", "supersede", "--goal-id", GOAL_ID, "--todo-id", todo,