From e56b9aaa250efb652f47e4f8dbb83bec5bbd0e27 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 01:46:07 +0800 Subject: [PATCH 1/2] refactor(todos): retire Python lifecycle field planning rules Signed-off-by: huangruiteng --- .../control_plane/effect_runtime_handlers.ts | 4 +- loopx/control_plane/todos/completion_state.py | 49 ---- loopx/control_plane/todos/field_update.ts | 199 +++++++++++++ loopx/control_plane/todos/line_update.py | 269 ++++++------------ .../test_todo_field_update_runtime.py | 252 ++++++++++++++++ .../todo_field_update.test.ts | 92 ++++++ 6 files changed, 632 insertions(+), 233 deletions(-) create mode 100644 loopx/control_plane/todos/field_update.ts create mode 100644 tests/control_plane/test_todo_field_update_runtime.py create mode 100644 tests/control_plane_ts/todo_field_update.test.ts diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8cef4b2c98..056688bff0 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -58,13 +58,13 @@ import { evaluateTodoCompletionFence, } from "./todos/completion_fence.ts"; import { - buildTodoCompletionMetadataUpdates, normalizeTodoCompletionValue, requireTodoCompletionMetadataValue, selectTodoCompletionContinuation, } from "./todos/completion_state.ts"; import { reduceTodoCompletionTransaction } from "./todos/completion_transaction.ts"; import { transitionTodoNextAction } from "./todos/next_action.ts"; +import { planTodoFieldUpdate } from "./todos/field_update.ts"; import { evaluateTodoResumeConditions, normalizeTodoResumeWhen, @@ -364,7 +364,7 @@ export function createEffectRuntimeHandlers( ["todo.completion_state.normalize", normalizeTodoCompletionValue], ["todo.completion_state.require_metadata", requireTodoCompletionMetadataValue], ["todo.completion_state.continuation_for_write", selectTodoCompletionContinuation], - ["todo.completion_state.metadata_updates", buildTodoCompletionMetadataUpdates], + ["todo.field_update.plan", planTodoFieldUpdate], [ "todo.claim.decide", (params) => evaluateCoordinationTodoClaimDecision( diff --git a/loopx/control_plane/todos/completion_state.py b/loopx/control_plane/todos/completion_state.py index 63530e7b3c..5e8ca1bd21 100644 --- a/loopx/control_plane/todos/completion_state.py +++ b/loopx/control_plane/todos/completion_state.py @@ -36,14 +36,6 @@ def _no_followup_value(value: Any) -> bool | str: return value if isinstance(value, bool) else _string_value(value) -def _successor_values(value: Any) -> Any: - if not value: - return [] - if isinstance(value, (list, tuple, set, str, Mapping)): - return list(value) - return value - - def _result(method: str, params: dict[str, Any]) -> Mapping[str, Any]: try: result = effect_runtime_result(method, params) @@ -135,44 +127,3 @@ def completion_continuation_for_write(*, no_followup: bool, has_successor: bool) if continuation not in {item.value for item in TodoCompletionContinuation}: raise RuntimeError("TypeScript completion continuation result shape mismatch") return str(continuation) - - -def completion_metadata_updates( - block: Mapping[str, Any], - *, - target_status: str, - normalized_status: str | None, - completion_continuation: str | None, - completion_recovery: str | None, - no_followup: bool | None, - successor_todo_ids: list[str] | None, -) -> dict[str, Any]: - result = _result( - "todo.completion_state.metadata_updates", - { - "schema_version": TODO_COMPLETION_STATE_REQUEST_SCHEMA, - "block": { - "no_followup": _no_followup_value(block.get("no_followup")), - "completion_continuation": _string_value( - block.get("completion_continuation") - ), - "successor_todo_ids": _successor_values( - block.get("successor_todo_ids") - ), - }, - "target_status": target_status, - "normalized_status": normalized_status, - "completion_continuation": completion_continuation, - "completion_recovery": completion_recovery, - "no_followup": no_followup, - "successor_todo_ids": successor_todo_ids, - }, - ) - updates = result.get("updates") - if not isinstance(updates, Mapping) or any( - key not in {"completion_continuation", "completion_recovery"} - or not isinstance(value, str) - for key, value in updates.items() - ): - raise RuntimeError("TypeScript completion metadata updates shape mismatch") - return dict(updates) diff --git a/loopx/control_plane/todos/field_update.ts b/loopx/control_plane/todos/field_update.ts new file mode 100644 index 0000000000..8ba4e2098e --- /dev/null +++ b/loopx/control_plane/todos/field_update.ts @@ -0,0 +1,199 @@ +/** Pure field intent planning. Admission, leases, validation and commit stay + * with the calling lifecycle transaction; this result grants no write right. */ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; +import { normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_agents.ts"; +import { AuthorityStoreProtocolError } from "../coordination/authority_store_codec.ts"; +import { + buildTodoCompletionMetadataUpdates, + TODO_COMPLETION_STATE_REQUEST_SCHEMA, +} from "./completion_state.ts"; +import { normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION } from "./resume_condition.ts"; + +export const TODO_FIELD_UPDATE_REQUEST_SCHEMA = "loopx_todo_field_update_request_v0"; +export const TODO_FIELD_UPDATE_RESULT_SCHEMA = "loopx_todo_field_update_result_v0"; + +const STATUS = ["open", "done", "blocked", "deferred"] as const; +type Status = typeof STATUS[number]; +export interface TodoFieldUpdatePlan extends JsonObject { + schema_version: typeof TODO_FIELD_UPDATE_RESULT_SCHEMA; + normalized_status: Status | null; + target_status: Status; + metadata_updates: JsonObject; +} +const STRING_FIELDS = ["note", "evidence", "completion_turn_key", "reason", "task_class", + "action_kind", "task_domain", "task_repository", "continuation_policy"] as const; +const PRESENT_FIELDS = ["required_write_scopes", "required_capabilities", "target_capabilities", + "explore_result_node_refs", "decision_scope", "required_decision_scopes", "decision_outcome", + "decision_scope_outcomes"] as const; +const MONITOR_FIELDS = ["target_key", "monitor_effect_id", "cadence", "next_due_at", "expires_at", + "last_checked_at", "result_hash", "consecutive_no_change", "material_change", + "material_change_generation", "max_no_change_before_replan", "watch_only"] as const; +const FLAGS = ["clear_claim", "claim_only", "clear_user_binding", "clear_blocks_agent", + "clear_global_gate", "clear_resume_when"] as const; +const INTENT_FIELDS = new Set([...STRING_FIELDS, ...PRESENT_FIELDS, ...FLAGS, + "status", "claimed_by", "bound_agent", "goal_bound", "blocks_agent", "excluded_agents", + "global_gate", "unblocks_todo_id", "successor_todo_ids", "completion_continuation", + "completion_recovery", "completion_metadata_updates_override", "resume_when", + "resume_monitor_generation", "no_followup", "monitor_metadata"]); + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "string") throw new EffectRuntimeRequestError(`${label} must be a string or null`); + return value; +} + +function present(value: unknown): boolean { + return value !== null && value !== undefined; +} + +// Imported Markdown may carry an invalid historical claim. Match the existing +// codec's nullable normalization instead of treating that token as ownership. +function existingAgent(value: unknown): string | null { + try { + return normalizeTodoAgent(value, "agent_id"); + } catch (error) { + if (error instanceof AuthorityStoreProtocolError) return null; + throw error; + } +} + +function validateIntent(value: unknown): JsonObject { + const intent = requireJsonObject(value, "Todo field intent"); + for (const key of Object.keys(intent)) { + if (!INTENT_FIELDS.has(key)) throw new EffectRuntimeRequestError(`Todo field plan does not own ${key}`); + } + for (const field of [...FLAGS, "goal_bound", "global_gate", "no_followup"]) { + if (present(intent[field]) && typeof intent[field] !== "boolean") { + throw new EffectRuntimeRequestError(`${field} must be a boolean`); + } + } + for (const field of [...STRING_FIELDS, "status", "claimed_by", "bound_agent", "blocks_agent", + "unblocks_todo_id", "resume_when", "completion_continuation", "completion_recovery"]) { + optionalString(intent[field], field); + } + return intent; +} + +function validateRepair(block: JsonObject, intent: JsonObject, todoId: string): void { + const removed = stripPythonWhitespace(String(block.removed_continuation_policy ?? "")).toLowerCase(); + if (removed !== "primary_review" && removed !== "review_handoff") return; + const prefix = `todo_id '${todoId}' uses removed continuation_policy=${removed}; `; + if (intent.claim_only) throw new EffectRuntimeRequestError(prefix + "repair it before claiming"); + const repair = stripPythonWhitespace(String(intent.continuation_policy ?? "")).toLowerCase(); + const exclusions = Array.isArray(intent.excluded_agents) ? intent.excluded_agents : []; + if (repair !== "independent_handoff" || !exclusions.some(agent => existingAgent(agent) !== null)) { + throw new EffectRuntimeRequestError(prefix + + "repair it explicitly with continuation_policy=independent_handoff and excluded_agents="); + } +} + +function bindingUpdates(block: JsonObject, intent: JsonObject, todoId: string): JsonObject { + const updates: JsonObject = {}; + if (intent.clear_claim) updates.claimed_by = null; + else if (intent.claimed_by) { + const existing = existingAgent(block.claimed_by); + if (intent.claim_only && existing && existing !== intent.claimed_by) { + throw new EffectRuntimeRequestError(`todo_id '${todoId}' is already claimed_by='${existing}'; ` + + "clear or transfer the claim explicitly before claiming it"); + } + updates.claimed_by = intent.claimed_by; + } + if (intent.clear_user_binding) { + updates.bound_agent = null; + updates.goal_bound = null; + } else if (intent.bound_agent) { + updates.bound_agent = intent.bound_agent; + updates.goal_bound = null; + } else if (present(intent.goal_bound)) { + updates.bound_agent = null; + updates.goal_bound = intent.goal_bound; + } + if (intent.blocks_agent) updates.blocks_agent = intent.blocks_agent; + else if (intent.clear_blocks_agent) updates.blocks_agent = null; + if (present(intent.excluded_agents)) updates.excluded_agents = intent.excluded_agents; + if (intent.clear_global_gate) updates.global_gate = null; + else if (present(intent.global_gate)) updates.global_gate = intent.global_gate; + return updates; +} + +function completionUpdates(block: JsonObject, intent: JsonObject, targetStatus: Status, + normalizedStatus: Status | null): JsonObject { + if (present(intent.completion_metadata_updates_override)) { + const updates = requireJsonObject(intent.completion_metadata_updates_override, "completion metadata override"); + if (Object.entries(updates).some(([key, value]) => + !["completion_continuation", "completion_recovery"].includes(key) || typeof value !== "string")) { + throw new EffectRuntimeRequestError("TypeScript Todo completion metadata updates shape mismatch"); + } + return {...updates}; + } + const result = buildTodoCompletionMetadataUpdates({ + schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, + block: {no_followup: block.no_followup ?? "", completion_continuation: block.completion_continuation ?? "", + successor_todo_ids: block.successor_todo_ids ?? []}, + target_status: targetStatus, normalized_status: normalizedStatus, + completion_continuation: intent.completion_continuation ?? null, + completion_recovery: intent.completion_recovery ?? null, + no_followup: intent.no_followup ?? null, successor_todo_ids: intent.successor_todo_ids ?? null, + }); + return requireJsonObject(result.updates, "completion metadata updates"); +} + +export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan { + const request = requireJsonObject(value, "Todo field update request"); + if (request.schema_version !== TODO_FIELD_UPDATE_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("Todo field update request schema mismatch"); + } + const block = requireJsonObject(request.todo, "Todo field update source"); + const todoId = requireNonEmptyString(block.todo_id, "todo_id"); + const updatedAt = requireNonEmptyString(request.updated_at, "updated_at"); + const intent = validateIntent(request.intent); + const resumeWhen = intent.resume_when ? normalizeTodoResumeWhen({ + schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: intent.resume_when, + }) : null; + if (intent.resume_when && !resumeWhen) throw new EffectRuntimeRequestError("unsupported Todo resume condition"); + if (resumeWhen && intent.clear_resume_when) { + throw new EffectRuntimeRequestError("todo update accepts either resume_when or clear_resume_when, not both"); + } + validateRepair(block, intent, todoId); + const status = intent.status ? stripPythonWhitespace(String(intent.status)).toLowerCase() : null; + if (status !== null && !STATUS.includes(status as Status)) { + throw new EffectRuntimeRequestError("todo status must be one of: open, done, blocked, deferred"); + } + const normalizedStatus = status as Status | null; + const targetStatus = normalizedStatus ?? (block.status || "open") as Status; + if (!STATUS.includes(targetStatus)) throw new EffectRuntimeRequestError("invalid source Todo status"); + if (targetStatus === "deferred" && intent.clear_resume_when) { + throw new EffectRuntimeRequestError("cannot clear resume_when while todo status remains deferred"); + } + if (intent.claim_only && targetStatus !== "open") { + throw new EffectRuntimeRequestError(`todo claim requires status=open; todo_id '${todoId}' is status='${targetStatus}'`); + } + const updates: JsonObject = {todo_id: todoId, status: targetStatus}; + if (normalizedStatus === "done" && !block.completed_at) updates.completed_at = updatedAt; + else if (normalizedStatus && normalizedStatus !== "done") updates.completed_at = null; + // The public editing contract distinguishes omitted/empty text metadata from + // present collections and booleans. Never turn [] or false into omission. + for (const field of STRING_FIELDS) if (intent[field]) updates[field] = intent[field]; + for (const field of PRESENT_FIELDS) if (present(intent[field])) updates[field] = intent[field]; + Object.assign(updates, bindingUpdates(block, intent, todoId)); + if (intent.unblocks_todo_id) updates.unblocks_todo_id = intent.unblocks_todo_id; + if (present(intent.successor_todo_ids)) updates.successor_todo_ids = intent.successor_todo_ids; + if (intent.clear_resume_when) { + updates.resume_when = null; + updates.resume_monitor_generation = null; + } else if (resumeWhen) { + updates.resume_when = resumeWhen; + updates.resume_monitor_generation = resumeWhen.startsWith("monitor_changed:") + ? intent.resume_monitor_generation ?? null : null; + } + if (present(intent.no_followup)) updates.no_followup = intent.no_followup; + Object.assign(updates, completionUpdates(block, intent, targetStatus, normalizedStatus)); + if (present(intent.monitor_metadata)) { + const monitor = requireJsonObject(intent.monitor_metadata, "monitor metadata"); + for (const field of MONITOR_FIELDS) if (Object.hasOwn(monitor, field)) updates[field] = monitor[field]; + } + return {schema_version: TODO_FIELD_UPDATE_RESULT_SCHEMA, normalized_status: normalizedStatus, + target_status: targetStatus, metadata_updates: updates}; +} diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index da5b0794bc..54a399c777 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -3,6 +3,8 @@ from collections.abc import Mapping from typing import Any +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + from .active_state_editing import ( TODO_SECTION_HEADINGS, find_todo_block, @@ -11,15 +13,10 @@ todo_metadata_would_change, ) from .contract import ( - TODO_MONITOR_METADATA_FIELDS, - TODO_STATUS_DONE, - TODO_STATUS_OPEN, - TodoContinuationPolicy, merge_todo_id_lists, metadata_line_for_todo_block, normalize_explore_result_node_refs, normalize_required_capabilities, - normalize_removed_todo_continuation_policy, normalize_target_capabilities, normalize_todo_blocks_agent, normalize_todo_bound_agent, @@ -35,7 +32,6 @@ normalize_todo_no_followup, normalize_todo_required_decision_scopes, normalize_todo_resume_when, - normalize_todo_status, normalize_todo_task_domain, normalize_todo_task_repository, parse_todo_metadata_line, @@ -43,7 +39,6 @@ ) from .completion_state import ( TodoCompletionContinuation, - completion_metadata_updates, normalize_todo_completion_continuation, normalize_todo_completion_recovery, ) @@ -99,9 +94,7 @@ def link_generated_successor_todo_ids( block, { "successor_todo_ids": merged_successor_ids, - "completion_continuation": ( - TodoCompletionContinuation.SUCCESSOR.value - ), + "completion_continuation": (TodoCompletionContinuation.SUCCESSOR.value), }, ), ) @@ -113,37 +106,6 @@ def link_generated_successor_todo_ids( return metadata_updated -def _completion_updates_for_write( - block: Mapping[str, Any], - *, - target_status: str, - normalized_status: str | None, - completion_continuation: str | None, - completion_recovery: str | None, - completion_metadata_updates_override: Mapping[str, Any] | None, - no_followup: bool | None, - successor_todo_ids: list[str] | None, -) -> dict[str, Any]: - if completion_metadata_updates_override is None: - return completion_metadata_updates( - block, - target_status=target_status, - normalized_status=normalized_status, - completion_continuation=completion_continuation, - completion_recovery=completion_recovery, - no_followup=no_followup, - successor_todo_ids=successor_todo_ids, - ) - updates = dict(completion_metadata_updates_override) - if any( - key not in {"completion_continuation", "completion_recovery"} - or not isinstance(value, str) - for key, value in updates.items() - ): - raise RuntimeError("TypeScript Todo completion metadata updates shape mismatch") - return updates - - def link_superseding_todo_id( lines: list[str], *, @@ -187,24 +149,44 @@ def link_superseding_todo_id( return metadata_updated -def _resume_metadata_updates( - *, - normalized_resume_when: str | None, - resume_monitor_generation: int | None, - clear_resume_when: bool, +def _field_update_plan( + block: Mapping[str, Any], intent: dict[str, Any], updated_at: str ) -> dict[str, Any]: - if clear_resume_when: - return {"resume_when": None, "resume_monitor_generation": None} - if not normalized_resume_when: - return {} - return { - "resume_when": normalized_resume_when, - "resume_monitor_generation": ( - resume_monitor_generation - if normalized_resume_when.startswith("monitor_changed:") - else None - ), - } + """Adapt source facts only; the TS planner owns omission/clear/state rules.""" + try: + result = effect_runtime_result( + "todo.field_update.plan", + { + "schema_version": "loopx_todo_field_update_request_v0", + "todo": { + key: block.get(key) + for key in ( + "todo_id", + "status", + "claimed_by", + "completed_at", + "removed_continuation_policy", + "no_followup", + "completion_continuation", + "successor_todo_ids", + ) + }, + "intent": intent, + "updated_at": updated_at, + }, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + if ( + not isinstance(result, dict) + or result.get("schema_version") != "loopx_todo_field_update_result_v0" + or result.get("target_status") not in {"open", "done", "blocked", "deferred"} + or result.get("normalized_status") + not in {None, "open", "done", "blocked", "deferred"} + or not isinstance(result.get("metadata_updates"), dict) + ): + raise RuntimeError("TypeScript Todo field update result shape mismatch") + return result def apply_todo_update_to_lines( @@ -273,37 +255,54 @@ def apply_todo_update_to_lines( f"todo_id {normalized_todo_id!r} was not found in active user or agent todos" ) resolved_role, section, _start, _end, block = block_match - removed_continuation_policy = normalize_removed_todo_continuation_policy( - block.get("removed_continuation_policy") + plan = _field_update_plan( + block, + { + "status": status, + "note": note, + "evidence": evidence, + "completion_turn_key": completion_turn_key, + "reason": reason, + "task_class": task_class, + "action_kind": action_kind, + "task_domain": task_domain, + "task_repository": task_repository, + "continuation_policy": continuation_policy, + "required_write_scopes": required_write_scopes, + "required_capabilities": required_capabilities, + "target_capabilities": target_capabilities, + "explore_result_node_refs": explore_result_node_refs, + "decision_scope": decision_scope, + "required_decision_scopes": required_decision_scopes, + "decision_outcome": decision_outcome, + "decision_scope_outcomes": decision_scope_outcomes, + "claimed_by": claimed_by, + "bound_agent": bound_agent, + "goal_bound": goal_bound, + "clear_user_binding": clear_user_binding, + "blocks_agent": blocks_agent, + "clear_blocks_agent": clear_blocks_agent, + "excluded_agents": excluded_agents, + "global_gate": global_gate, + "clear_global_gate": clear_global_gate, + "unblocks_todo_id": unblocks_todo_id, + "successor_todo_ids": successor_todo_ids, + "completion_continuation": completion_continuation, + "completion_recovery": completion_recovery, + "completion_metadata_updates_override": completion_metadata_updates_override, + "resume_when": normalized_resume_when, + "resume_monitor_generation": resume_monitor_generation, + "clear_resume_when": clear_resume_when, + "no_followup": no_followup, + "monitor_metadata": monitor_metadata, + "clear_claim": clear_claim, + "claim_only": claim_only, + }, + updated_at, ) - if removed_continuation_policy: - if claim_only: - raise ValueError( - f"todo_id {normalized_todo_id!r} uses removed continuation_policy=" - f"{removed_continuation_policy}; repair it before claiming" - ) - repair_policy = normalize_todo_continuation_policy(continuation_policy) - repair_exclusions = normalize_todo_excluded_agents(excluded_agents) - if ( - repair_policy != TodoContinuationPolicy.INDEPENDENT_HANDOFF.value - or not repair_exclusions - ): - raise ValueError( - f"todo_id {normalized_todo_id!r} uses removed continuation_policy=" - f"{removed_continuation_policy}; repair it explicitly with " - "continuation_policy=independent_handoff and excluded_agents=" - ) - normalized_status = normalize_todo_status(status) if status else None - if status and not normalized_status: - raise ValueError("todo status must be one of: open, done, blocked, deferred") - target_status = normalized_status or str(block.get("status") or TODO_STATUS_OPEN) - if target_status == "deferred" and clear_resume_when: - raise ValueError("cannot clear resume_when while todo status remains deferred") - if claim_only and target_status != TODO_STATUS_OPEN: - raise ValueError( - f"todo claim requires status=open; todo_id {normalized_todo_id!r} " - f"is status={target_status!r}" - ) + normalized_status = plan["normalized_status"] + target_status = plan["target_status"] + updates = plan["metadata_updates"] status_changed = ( set_todo_marker(lines, block, normalized_status) if normalized_status else False ) @@ -312,100 +311,6 @@ def apply_todo_update_to_lines( if text is not None else False ) - - updates: dict[str, Any] = { - "todo_id": normalized_todo_id, - "status": target_status, - } - if normalized_status == TODO_STATUS_DONE and not block.get("completed_at"): - updates["completed_at"] = updated_at - elif normalized_status and normalized_status != TODO_STATUS_DONE: - updates["completed_at"] = None - for key, value in ( - ("note", note), - ("evidence", evidence), - ("completion_turn_key", completion_turn_key), - ("reason", reason), - ("task_class", task_class), - ("action_kind", action_kind), - ("task_domain", task_domain), - ("task_repository", task_repository), - ("continuation_policy", continuation_policy), - ): - if value: - updates[key] = value - for key, value in ( - ("required_write_scopes", required_write_scopes), - ("required_capabilities", required_capabilities), - ("target_capabilities", target_capabilities), - ("explore_result_node_refs", explore_result_node_refs), - ("decision_scope", decision_scope), - ("required_decision_scopes", required_decision_scopes), - ("decision_outcome", decision_outcome), - ("decision_scope_outcomes", decision_scope_outcomes), - ): - if value is not None: - updates[key] = value - if clear_claim: - updates["claimed_by"] = None - elif claimed_by: - existing_claim = normalize_todo_claimed_by(block.get("claimed_by")) - if claim_only and existing_claim and existing_claim != claimed_by: - raise ValueError( - f"todo_id {normalized_todo_id!r} is already claimed_by=" - f"{existing_claim!r}; clear or transfer the claim explicitly before " - "claiming it" - ) - updates["claimed_by"] = claimed_by - if clear_user_binding: - updates["bound_agent"] = None - updates["goal_bound"] = None - elif bound_agent: - updates["bound_agent"] = bound_agent - updates["goal_bound"] = None - elif goal_bound is not None: - updates["bound_agent"] = None - updates["goal_bound"] = goal_bound - if blocks_agent: - updates["blocks_agent"] = blocks_agent - elif clear_blocks_agent: - updates["blocks_agent"] = None - if excluded_agents is not None: - updates["excluded_agents"] = excluded_agents - if clear_global_gate: - updates["global_gate"] = None - elif global_gate is not None: - updates["global_gate"] = global_gate - if unblocks_todo_id: - updates["unblocks_todo_id"] = unblocks_todo_id - if successor_todo_ids is not None: - updates["successor_todo_ids"] = successor_todo_ids - updates.update( - _resume_metadata_updates( - normalized_resume_when=normalized_resume_when, - resume_monitor_generation=resume_monitor_generation, - clear_resume_when=clear_resume_when, - ) - ) - if no_followup is not None: - updates["no_followup"] = no_followup - updates.update( - _completion_updates_for_write( - block, - target_status=target_status, - normalized_status=normalized_status, - completion_continuation=completion_continuation, - completion_recovery=completion_recovery, - completion_metadata_updates_override=( - completion_metadata_updates_override - ), - no_followup=no_followup, - successor_todo_ids=successor_todo_ids, - ) - ) - for key, value in (monitor_metadata or {}).items(): - if key in TODO_MONITOR_METADATA_FIELDS: - updates[key] = value metadata_line = metadata_line_for_todo_block(block, updates) semantic_metadata_changed = todo_metadata_would_change(lines, block, metadata_line) if status_changed or text_changed or semantic_metadata_changed: diff --git a/tests/control_plane/test_todo_field_update_runtime.py b/tests/control_plane/test_todo_field_update_runtime.py new file mode 100644 index 0000000000..d53043928a --- /dev/null +++ b/tests/control_plane/test_todo_field_update_runtime.py @@ -0,0 +1,252 @@ +"""Observable update semantics shared by the legacy lifecycle writers.""" + +from copy import deepcopy + +import pytest + +from loopx.control_plane.todos.active_state_editing import find_todo_block +from loopx.control_plane.todos.line_update import apply_todo_update_to_lines +from loopx.control_plane.todos.contract import normalize_todo_watch_only + +AT = "2026-09-01T00:00:00Z" + + +def _lines(metadata=""): + return [ + "## Agent Todo", + "", + "- [ ] Preserve this task", + " Detail stays unless text changes.", + " ", + "", + ] + + +@pytest.mark.parametrize( + "intent, initial, expected", + [ + ({"clear_claim": True}, "claimed_by=agent-a", {"claimed_by": None}), + ({"claimed_by": "agent-b"}, "claimed_by=agent-a", {"claimed_by": "agent-b"}), + ( + {"clear_user_binding": True}, + "bound_agent=agent-a", + {"bound_agent": None, "goal_bound": None}, + ), + ( + {"goal_bound": True}, + "bound_agent=agent-a", + {"bound_agent": None, "goal_bound": True}, + ), + ({"excluded_agents": []}, "excluded_agents=agent-a", {"excluded_agents": []}), + ({"clear_blocks_agent": True}, "blocks_agent=agent-a", {"blocks_agent": None}), + ({"clear_global_gate": True}, "global_gate=true", {"global_gate": None}), + ( + {"clear_resume_when": True}, + "resume_when=monitor_changed:todo_monitor resume_monitor_generation=4", + {"resume_when": None, "resume_monitor_generation": None}, + ), + ( + {"resume_when": "todo_done:todo_dependency"}, + "resume_monitor_generation=4", + { + "resume_when": "todo_done:todo_dependency", + "resume_monitor_generation": None, + }, + ), + ( + { + "resume_when": "monitor_changed:todo_monitor", + "resume_monitor_generation": 0, + }, + "", + { + "resume_when": "monitor_changed:todo_monitor", + "resume_monitor_generation": 0, + }, + ), + ( + {"status": "done", "no_followup": True}, + "", + { + "status": "done", + "no_followup": True, + "completion_continuation": "no_followup", + }, + ), + ( + {"status": "done", "successor_todo_ids": ["todo_successor"]}, + "", + {"completion_continuation": "successor"}, + ), + ( + {"status": "open"}, + "completed_at=2026-08-01T00%3A00%3A00Z", + {"status": "open"}, + ), + ( + {"note": "", "required_capabilities": []}, + "note=keep required_capabilities=network", + {"required_capabilities": []}, + ), + ( + {"monitor_metadata": {"consecutive_no_change": 0, "watch_only": False}}, + "watch_only=true", + {"watch_only": False}, + ), + ], +) +def test_field_omission_clear_and_completion_semantics(intent, initial, expected): + lines = _lines(initial) + result = apply_todo_update_to_lines( + lines, todo_id="todo_field_test", updated_at=AT, **intent + ) + for key, value in expected.items(): + # Legacy result fields retain the Markdown codec's scalar representation. + actual = ( + normalize_todo_watch_only(result[key]) + if key == "watch_only" + else result[key] + ) + assert actual == value + block = find_todo_block(lines, todo_id="todo_field_test")[4] + assert block["todo_id"] == "todo_field_test" + if intent.get("status") == "done": + assert block["completed_at"] == AT + if intent.get("status") == "open": + assert not block.get("completed_at") + if intent.get("note") == "": + assert block["note"] == "keep" # Empty note historically means omit here. + + +def test_noop_does_not_advance_updated_at_or_touch_unrelated_lines(): + lines = _lines("updated_at=2026-08-01T00%3A00%3A00Z") + # Stabilize representation once; the next call must be an actual no-op. + apply_todo_update_to_lines(lines, todo_id="todo_field_test", updated_at=AT) + before = deepcopy(lines) + result = apply_todo_update_to_lines( + lines, todo_id="todo_field_test", updated_at="2026-09-02T00:00:00Z" + ) + assert not result["changed"] + assert lines == before + + +@pytest.mark.parametrize( + "intent, initial, error", + [ + ({"status": "invalid"}, "", "todo status"), + ({"claim_only": True}, "claimed_by=agent-a", None), + ( + {"claim_only": True, "claimed_by": "agent-b"}, + "claimed_by=agent-a", + "already claimed_by", + ), + ( + {"status": "deferred", "clear_resume_when": True}, + "", + "cannot clear resume_when", + ), + ( + { + "status": "done", + "no_followup": True, + "successor_todo_ids": ["todo_successor"], + }, + "", + "both no_followup", + ), + ], +) +def test_invalid_lifecycle_field_combinations_are_rejected(intent, initial, error): + lines = _lines(initial) + if error is None: + apply_todo_update_to_lines( + lines, todo_id="todo_field_test", updated_at=AT, **intent + ) + else: + before = deepcopy(lines) + with pytest.raises(ValueError, match=error): + apply_todo_update_to_lines( + lines, todo_id="todo_field_test", updated_at=AT, **intent + ) + assert lines == before + + +def test_completion_field_plan_has_one_crossing_and_retires_metadata_rpc(monkeypatch): + from loopx.control_plane import effect_runtime + from loopx.control_plane.todos import line_update + + actual = effect_runtime.effect_runtime_result + calls = [] + + def traced(method, params): + calls.append(method) + return actual(method, params) + + monkeypatch.setattr(line_update, "effect_runtime_result", traced) + result = apply_todo_update_to_lines( + _lines(), + todo_id="todo_field_test", + updated_at=AT, + status="done", + no_followup=True, + ) + assert result["completion_continuation"] == "no_followup" + assert calls == ["todo.field_update.plan"] + with pytest.raises( + effect_runtime.EffectRuntimeRejected, match="[Uu]nknown|[Uu]nsupported" + ): + actual("todo.completion_state.metadata_updates", {}) + + +def test_rejected_plan_does_not_partially_change_text_or_checkbox(): + lines = _lines() + before = deepcopy(lines) + with pytest.raises(ValueError, match="both no_followup"): + apply_todo_update_to_lines( + lines, + todo_id="todo_field_test", + updated_at=AT, + text="Do not commit", + status="done", + no_followup=True, + successor_todo_ids=["todo_next"], + ) + assert lines == before + + +@pytest.mark.parametrize("exclusions", [[], ["!!!"]]) +def test_removed_policy_repair_requires_a_valid_exclusion(exclusions): + lines = _lines("continuation_policy=primary_review") + before = deepcopy(lines) + with pytest.raises(ValueError, match="repair it explicitly"): + apply_todo_update_to_lines( + lines, + todo_id="todo_field_test", + updated_at=AT, + continuation_policy="independent_handoff", + excluded_agents=exclusions, + ) + assert lines == before + + +def test_field_planner_covers_the_existing_monitor_codec_fields(): + from loopx.control_plane.effect_runtime import effect_runtime_result + from loopx.control_plane.todos.contract import TODO_MONITOR_METADATA_FIELDS + + clears = dict.fromkeys(TODO_MONITOR_METADATA_FIELDS) + plan = effect_runtime_result( + "todo.field_update.plan", + { + "schema_version": "loopx_todo_field_update_request_v0", + "todo": {"todo_id": "todo_field_test", "status": "open"}, + "intent": {"monitor_metadata": clears}, + "updated_at": AT, + }, + ) + assert plan["metadata_updates"] == { + "todo_id": "todo_field_test", + "status": "open", + **clears, + } diff --git a/tests/control_plane_ts/todo_field_update.test.ts b/tests/control_plane_ts/todo_field_update.test.ts new file mode 100644 index 0000000000..cc656fd0f7 --- /dev/null +++ b/tests/control_plane_ts/todo_field_update.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + planTodoFieldUpdate, TODO_FIELD_UPDATE_REQUEST_SCHEMA, +} from "../../loopx/control_plane/todos/field_update.ts"; +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; + +const source = Object.freeze({todo_id: "todo_field_test", status: "open", claimed_by: "agent-a", + completion_continuation: null, successor_todo_ids: [], completed_at: null}); +function plan(intent: JsonObject = {}, todo: JsonObject = source): JsonObject { + return planTodoFieldUpdate({schema_version: TODO_FIELD_UPDATE_REQUEST_SCHEMA, + todo, intent: Object.freeze(intent), updated_at: "2026-09-01T00:00:00Z"}); +} + +test("omission, explicit empty collections and false remain distinct", () => { + const {metadata_updates: updates} = plan({note: "", evidence: "Evidence", required_capabilities: [], + no_followup: false, successor_todo_ids: [], monitor_metadata: {consecutive_no_change: 0, watch_only: false}}); + assert.deepEqual(updates, {todo_id: "todo_field_test", status: "open", evidence: "Evidence", + required_capabilities: [], no_followup: false, successor_todo_ids: [], consecutive_no_change: 0, watch_only: false}); + assert.equal(source.claimed_by, "agent-a"); +}); + +test("explicit clears and binding precedence are a single plan", () => { + assert.deepEqual(plan({clear_claim: true, claimed_by: "agent-b", clear_user_binding: true, + bound_agent: "agent-b", goal_bound: true, blocks_agent: "agent-a", clear_blocks_agent: true, + global_gate: true, clear_global_gate: true, clear_resume_when: true}).metadata_updates, { + todo_id: "todo_field_test", status: "open", claimed_by: null, bound_agent: null, goal_bound: null, + blocks_agent: "agent-a", global_gate: null, resume_when: null, resume_monitor_generation: null, + }); +}); + +test("all lifecycle statuses have explicit timestamp behavior", () => { + for (const status of ["open", "blocked", "deferred", "done"]) { + const updates = plan({status, no_followup: true}).metadata_updates as JsonObject; + assert.equal(updates.completed_at, status === "done" ? "2026-09-01T00:00:00Z" : null); + assert.equal(updates.completion_continuation, status === "done" ? "no_followup" : undefined); + } + const completed = {...source, status: "done", completed_at: "2026-08-01T00:00:00Z"}; + assert.equal((plan({status: "done"}, completed).metadata_updates as JsonObject).completed_at, undefined); +}); + +test("resume generation belongs only to the monitor condition", () => { + for (const [condition, generation] of [["monitor_changed:todo_monitor", 0], ["todo_done:todo_dependency", null], + ["pr_merged:owner/repo#12", null], ["capacity_available:network", null]] as const) { + const updates = plan({resume_when: condition, resume_monitor_generation: 0}).metadata_updates as JsonObject; + assert.equal(updates.resume_when, condition); + assert.equal(updates.resume_monitor_generation, generation); + } + assert.throws(() => plan({resume_when: "free text"}), /unsupported Todo resume/); + assert.throws(() => plan({resume_when: "todo_done:todo_dependency", clear_resume_when: true}), /not both/); + assert.throws(() => plan({status: "deferred", clear_resume_when: true}), /cannot clear/); +}); + +test("completion composes existing state rules; finalization overrides are closed", () => { + assert.equal((plan({status: "done", successor_todo_ids: ["todo_successor"]}).metadata_updates as JsonObject) + .completion_continuation, "successor"); + assert.throws(() => plan({status: "done", successor_todo_ids: ["todo_successor"], no_followup: true}), /both/); + const override = {completion_continuation: "no_followup", completion_recovery: "same_turn_terminal_closeout"}; + const updates = plan({status: "done", completion_metadata_updates_override: override}).metadata_updates as JsonObject; + assert.equal(updates.completion_recovery, override.completion_recovery); + assert.throws(() => plan({completion_metadata_updates_override: {claimed_by: "agent-b"}}), /shape mismatch/); +}); + +test("claim and removed-policy repair cannot evade the existing rules", () => { + assert.throws(() => plan({claim_only: true, claimed_by: "agent-b"}), /already claimed_by/); + assert.throws(() => plan({claim_only: true, status: "blocked"}), /requires status=open/); + for (const removed of ["primary_review", "review_handoff"]) { + const todo = {...source, removed_continuation_policy: removed}; + assert.throws(() => plan({claim_only: true}, todo), /repair it before claiming/); + assert.throws(() => plan({continuation_policy: "independent_handoff", excluded_agents: []}, todo), /repair it explicitly/); + assert.throws(() => plan({continuation_policy: "independent_handoff", excluded_agents: ["!!!"]}, todo), /repair it explicitly/); + const repaired = plan({continuation_policy: "independent_handoff", excluded_agents: ["agent-a"]}, todo); + assert.equal((repaired.metadata_updates as JsonObject).continuation_policy, "independent_handoff"); + } +}); + +test("imported claims retain Python whitespace normalization and invalid-token tolerance", () => { + const intent = {claim_only: true, claimed_by: "agent-a"}; + assert.doesNotThrow(() => plan(intent, {...source, claimed_by: "\u0085Agent\u001cA\u0085"})); + assert.doesNotThrow(() => plan(intent, {...source, claimed_by: "!!!"})); + assert.throws(() => plan(intent, {...source, claimed_by: "Agent B"}), /already claimed_by='agent-b'/); +}); + +test("unknown intent fields, wrong flag types and monitor authority injection are rejected or excluded", () => { + for (const field of ["role", "todo_id", "lease_epoch", "last_actor_agent_id", "updated_at"]) { + assert.throws(() => plan({[field]: "injected"}), /does not own/); + } + assert.throws(() => plan({clear_claim: "false"}), /must be a boolean/); + assert.throws(() => plan({status: "superseded"}), /todo status/); + assert.deepEqual(plan({monitor_metadata: {claimed_by: "agent-b", material_change: false}}).metadata_updates, + {todo_id: "todo_field_test", status: "open", material_change: false}); +}); From e4d09643829a0193ece87f2614e28b715fd8e06e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 01:46:48 +0800 Subject: [PATCH 2/2] docs(rfcs): record field-rule retirement and remaining writer boundaries Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 9 ++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 8 ++++++ .../typescript-control-plane-migration-v0.md | 28 ++++++++++++++++++- ...script-control-plane-migration-v0.zh-CN.md | 24 +++++++++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index db1e310ed0..926b622b7d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2461,6 +2461,15 @@ Every pull request that claims progress against this RFC follows the It declares fixture impact, exercises every affected provider arm, and keeps the read-only three-arm rehearsal as a separate promotion gate. +Legacy lifecycle field assembly now calls the single TS field planner described +in the [TS retirement checkpoint](typescript-control-plane-migration-v0.md#legacy-field-rule-retirement-checkpoint). +This removes Python decisions without changing the per-goal authority phase: +unpromoted goals still commit through the locked Markdown writer, while promoted +goals retain their existing provider transactions and unsupported-field fences. +The planner neither reads a provider nor grants a lease, CAS receipt, or write +permission. This checkpoint closes one rule owner, not the remaining mutation +inventory or local-store/promotion qualification. + ### Next delivery and parallel provider work Markdown is a **permanent first-class readable projection**. Retire its database diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 02a83e38a3..10982563af 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -1952,6 +1952,14 @@ fence。它不能替代只读三臂演练,因为所有 provider 共享新的 s 声明 fixture 影响、覆盖所有受影响的 provider arm,并把只读三臂演练保留为独立的 promotion gate。 +Legacy lifecycle 的字段组装现在调用唯一 TS field planner,详见 +[TS 退役检查点](typescript-control-plane-migration-v0.zh-CN.md#legacy-字段规则退役检查点)。 +它删除 Python decision,但不改变逐 goal 的 authority 阶段:未 promotion 的 goal +仍由持锁 Markdown writer 提交,promoted goal 仍使用既有 provider transaction 与 +unsupported-field fence。planner 不读取 provider,也不授予 lease、CAS receipt 或 +写权限。该检查点闭合的是一个规则 owner,不是剩余 mutation inventory 或本地 +store/promotion 资格化。 + ### 下一步交付与并行 provider 工作 Markdown 是**长期保留的一等可读投影**。退役的是它的数据库及业务 writer 权威, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index d057cb7784..cd75d72646 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -3,7 +3,7 @@ - Status: Accepted, transaction-payoff phase in progress - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Last revised: 2026-09-07 +- Last revised: 2026-09-09 - Scope: an incremental, replacement-first migration of the LoopX control-plane core from Python to TypeScript without maintaining two semantic implementations @@ -195,6 +195,32 @@ marker/hint configuration with an explicit compatibility plan. Exact legacy lifecycle classification codes and unrelated cadence policies are outside this slice; they must not be reported as migrated or globally free of prose rules. +### Legacy field-rule retirement checkpoint + +`todos/field_update.ts` now owns the complete metadata intent assembly used by +the legacy `update`, `claim`, `complete`, and `supersede` line writer: status and +completion timestamps, omission versus explicit clears, binding precedence, +removed-policy repair, resume-generation pairing, and completion metadata. +It composes the existing TS completion rule directly. The replaced Python +decision branches and the last-caller `todo.completion_state.metadata_updates` +RPC/facade are removed, not kept as a fallback. + +This is a pure plan, not admission or a provider commit. Python still owns +Markdown lookup/encoding, byte-level no-op detection, locking and external +effects. Public role/binding admission and the event writer are not declared +migrated by this slice. Promoted update remains text/note-only; no unsupported +field gains authority, no goal is promoted, and no third storage path appears. +Rejected plans now leave even the caller's in-memory line buffer unchanged; +public rejected transactions were already non-committing. + +There is one field-plan crossing per legacy line write. It replaces the former +metadata RPC on ordinary edits; already-finalized completions with an override +gain one planning crossing. Cached codec normalization calls remain. This is +semantic deletion, not a claim of fewer crossings on every command. Retire the +adapter with its final legacy lifecycle caller after full-goal cutover, or fold +it into that caller's coarse transaction when migrating the caller; do not grow +a series of field-level RPCs. Retain Markdown rendering permanently. + ### Next delivery sequence The destination retains Markdown as a **permanent readable projection**, not a diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index c4d8055549..063db9dfcf 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -3,7 +3,7 @@ - Status:Accepted,transaction-payoff 阶段进行中 - Proposed by:LoopX maintainers - Date:2026-08-15 -- Last revised:2026-09-07 +- Last revised:2026-09-09 - Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first 迁移;不长期维护两份语义实现 - Tracking issue:[#3225](https://github.com/huangruiteng/loopx/issues/3225) @@ -152,6 +152,28 @@ Python decision,保留独立审阅的 typed case,并通过真实 CLI 验证 字段的 writer,并用明确兼容计划退役旧 marker/hint 配置。本批不迁移精确的旧 lifecycle classification code 或其他 cadence policy,不能宣称全局已无文本规则。 +### Legacy 字段规则退役检查点 + +`todos/field_update.ts` 现在持有 legacy `update`、`claim`、`complete`、`supersede` +line writer 共用的完整 metadata intent 组装:status 与 completion 时间、未传与显式 +清空、binding 优先级、已移除 policy 的修复、resume-generation 配对及 completion +metadata。它直接组合已有 TS completion rule。被替代的 Python decision 分支,以及 +失去最后调用者的 `todo.completion_state.metadata_updates` RPC/facade 一起删除, +不保留为 fallback。 + +这是一份纯 plan,不是 admission 或 provider commit。Python 仍保留 Markdown 定位/ +编码、字节级 no-op 检查、锁与外部 effect;本批不宣称迁完公共 role/binding admission +或 event writer。Promoted update 仍只支持 text/note:不扩权、不 promotion goal、 +不增加第三条存储路径。plan 拒绝时,现在连调用方的内存行缓冲也保持不变;公共 +事务在拒绝时原本就不会提交。 + +每次 legacy line write 有一次 field-plan crossing:普通编辑替代原 metadata RPC; +已经 finalization、携带 override 的 completion 会增加一次 planning crossing。 +带缓存的 codec normalization 调用仍在。这兑现的是语义代码删除,不宣称每个命令 +都减少 round trip。完整 goal cutover 后随最后 legacy lifecycle caller 删除 adapter, +或者迁移该 caller 时将 plan 折叠进其粗粒度事务;不得继续扩张逐字段 RPC。 +Markdown renderer 长期保留。 + ### 下一步交付顺序 终态长期保留 Markdown 作为**可读投影**,不是第二个数据库。本 RFC 负责唯一 typed