Skip to content
Merged
9 changes: 4 additions & 5 deletions loopx/control_plane/coordination/todo_update_intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions loopx/control_plane/todos/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions loopx/control_plane/todos/legacy_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion loopx/control_plane/todos/line_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
*,
Expand Down Expand Up @@ -152,6 +178,7 @@ def _field_update_plan(
**{key: block.get(key)
for key in (
"text",
"note",
"todo_id",
"status",
"claimed_by",
Expand Down Expand Up @@ -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)
Expand All @@ -354,11 +382,13 @@ 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,
metadata_updated=metadata_updated, metadata=effective_metadata,
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
21 changes: 21 additions & 0 deletions loopx/control_plane/todos/public_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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} : {})};
}
Loading
Loading