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/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..6b7d846eb1 100644 --- a/loopx/control_plane/todos/legacy_mutation.py +++ b/loopx/control_plane/todos/legacy_mutation.py @@ -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, @@ -986,7 +986,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 +1186,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 +1320,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 +1388,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..de64c86626 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], *, @@ -152,6 +178,7 @@ def _field_update_plan( **{key: block.get(key) for key in ( "text", + "note", "todo_id", "status", "claimed_by", @@ -336,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) @@ -354,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, @@ -362,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 new file mode 100644 index 0000000000..8ed2185254 --- /dev/null +++ b/tests/control_plane/test_legacy_completion_actor.py @@ -0,0 +1,254 @@ +"""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 +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) + + +def test_legacy_cli_actor_survives_planning_clear_and_terminal_replay(tmp_path, monkeypatch): + from tests.control_plane.test_todo_mutation_authority import _add_agent_todo + + 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"] + planning_edit = ["--evidence", "evidence:peer-validation"] + 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", "--clear-priority") + assert detail()["last_actor_agent_id"] == REVIEW_AGENT + before = state.read_bytes() + 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, + "--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)) + 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 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": ["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"], + "--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", 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/);