From c83f659ad9999833450b71e7979ef3a20ce3e38e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 21 Aug 2026 20:00:35 +0800 Subject: [PATCH] fix(todos): settle durable next action on completion Signed-off-by: huangruiteng --- loopx/bootstrap.py | 12 +- loopx/control_plane/effect_runtime.py | 1 + .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/todos/line_update.py | 43 ++ loopx/control_plane/todos/next_action.ts | 444 ++++++++++++++++++ .../todos/next_action_runtime.py | 146 ++++++ loopx/state_projection.py | 2 + loopx/state_refresh.py | 7 +- loopx/todos.py | 53 +-- pyproject.toml | 1 + .../test_todo_next_action_settlement.py | 338 +++++++++++++ .../control_plane_ts/todo_next_action.test.ts | 268 +++++++++++ tsconfig.control-plane.json | 2 + 13 files changed, 1285 insertions(+), 34 deletions(-) create mode 100644 loopx/control_plane/todos/next_action.ts create mode 100644 loopx/control_plane/todos/next_action_runtime.py create mode 100644 tests/control_plane/test_todo_next_action_settlement.py create mode 100644 tests/control_plane_ts/todo_next_action.test.ts diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 591c68e95f..2c7790e38f 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -7,15 +7,16 @@ from typing import Any from .control_plane.runtime.time import now_local_iso -from .control_plane.todos.handoff_mode import ( - HANDOFF_MODE_LEGACY, - goal_handoff_mode, -) from .control_plane.todos.active_state_editing import ( TODO_SECTION_HEADINGS, insertion_anchor, section_bounds, ) +from .control_plane.todos.next_action_runtime import bind_next_action_to_todo +from .control_plane.todos.handoff_mode import ( + HANDOFF_MODE_LEGACY, + goal_handoff_mode, +) from .execution_profile import ( build_execution_profile, compact_execution_profile, @@ -376,7 +377,7 @@ def apply_onboarding_todos_to_state( else None ) if action: - add_todo_to_lines( + added = add_todo_to_lines( lines, role="agent", text=action["text"], @@ -384,6 +385,7 @@ def apply_onboarding_todos_to_state( action_kind=action["action_kind"], updated_at=updated_at, ) + bind_next_action_to_todo(lines, todo_id=str(added["todo_id"])) return "\n".join(lines) + "\n" lines = text.splitlines() if accept_onboarding_agent_todos: diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index ec31ec567e..67931d6e4b 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -31,6 +31,7 @@ "effect_runtime_handlers.ts", "effect_runtime_io.ts", "effect_runtime_server.ts", + "todos/next_action.ts", "turn_driver/turn_journal.ts", "turn_driver/turn_journal_effects.ts", "turn_transaction_contract.json", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index c5b93f60ee..1dbc968153 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -33,6 +33,7 @@ import { type TurnJournalInspectionRequest, } from "./turn_driver/turn_journal.ts"; import { commitTurnJournal } from "./turn_driver/turn_journal_effects.ts"; +import { transitionTodoNextAction } from "./todos/next_action.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -261,6 +262,7 @@ export function createEffectRuntimeHandlers( (params) => interpretTurnJournal(turnJournalInspectionRequest(params)), ], ["turn_journal.write", commitTurnJournal], + ["todo.next_action.transition", transitionTodoNextAction], [ "effect.program_from_ordered_steps", (params) => effectProgramFromOrderedSteps( diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index aef665914b..266f1eadd6 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -112,6 +112,49 @@ def link_generated_successor_todo_ids( return metadata_updated +def link_superseding_todo_id( + lines: list[str], + *, + update_result: dict[str, Any], + role: str | None, + successor_todo_ids: list[str], +) -> bool: + """Persist supersede lineage after generated successors are materialized.""" + + if not successor_todo_ids: + return False + block_match = find_todo_block( + lines, + todo_id=str(update_result.get("todo_id") or ""), + role=role, + ) + if not block_match: + return False + _resolved_role, _section, _start, _end, block = block_match + merged_successor_ids = merge_todo_id_lists( + update_result.get("successor_todo_ids"), + successor_todo_ids, + ) + metadata_updated = upsert_todo_metadata( + lines, + block, + metadata_line_for_todo_block( + block, + { + "superseded_by": successor_todo_ids[0], + "successor_todo_ids": merged_successor_ids, + }, + ), + ) + update_result["metadata_updated"] = bool( + update_result.get("metadata_updated") or metadata_updated + ) + update_result["superseded_by"] = successor_todo_ids[0] + update_result["successor_todo_ids"] = merged_successor_ids + update_result["changed"] = True + return metadata_updated + + def apply_todo_update_to_lines( lines: list[str], *, diff --git a/loopx/control_plane/todos/next_action.ts b/loopx/control_plane/todos/next_action.ts new file mode 100644 index 0000000000..3a3a4652a8 --- /dev/null +++ b/loopx/control_plane/todos/next_action.ts @@ -0,0 +1,444 @@ +import type { JsonObject } from "../effect_program.ts"; + +export const TODO_NEXT_ACTION_REQUEST_SCHEMA = + "loopx_todo_next_action_transition_v0"; +export const TODO_NEXT_ACTION_RESULT_SCHEMA = + "loopx_todo_next_action_result_v0"; +export const NEXT_ACTION_BINDING_SCHEMA = "loopx_next_action_binding_v0"; + +const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; +const NEXT_ACTION_BINDING_PATTERN = + /^\s*\s*$/; +const TODO_STATUSES = new Set(["open", "done", "blocked", "deferred"]); +const CONTROL_TASK_CLASSES = new Set([ + "continuous_monitor", + "user_gate", + "blocker", +]); + +export interface TodoNextActionSnapshot { + todo_id: string; + status: string; + task_class: string | null; + text: string; + index: number; + completion_continuation: string | null; + successor_todo_ids: readonly string[]; +} + +export type TodoNextActionRequest = + | { + schema_version: typeof TODO_NEXT_ACTION_REQUEST_SCHEMA; + operation: "bind"; + lines: readonly string[]; + todo_id: string; + } + | { + schema_version: typeof TODO_NEXT_ACTION_REQUEST_SCHEMA; + operation: "settle_completion"; + lines: readonly string[]; + todo_id: string; + agent_todos: readonly TodoNextActionSnapshot[]; + materialized_todo_ids: readonly string[]; + }; + +export interface TodoNextActionResult extends JsonObject { + schema_version: typeof TODO_NEXT_ACTION_RESULT_SCHEMA; + operation: TodoNextActionRequest["operation"]; + outcome: + | "bound" + | "unchanged" + | "not_terminal" + | "awaiting_successor" + | "route_unmatched" + | "settled"; + changed: boolean; + matched: boolean; + lines: string[]; + completed_todo_id?: string; + match_source?: "typed_todo_binding" | "legacy_exact_text"; + next_todo_id?: string | null; + next_action?: string | null; +} + +function requiredObject(value: unknown, label: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + return requiredString(value, label); +} + +function normalizedTodoId(value: unknown, label: string): string { + const candidate = requiredString(value, label).trim().toLowerCase(); + if (!TODO_ID_PATTERN.test(candidate)) { + throw new Error(`${label} must be a valid todo_id`); + } + return candidate; +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`${label} must be an array of strings`); + } + return [...value]; +} + +function todoSnapshot(value: unknown, index: number): TodoNextActionSnapshot { + const label = `agent_todos[${index}]`; + const item = requiredObject(value, label); + const status = requiredString(item.status, `${label}.status`).trim().toLowerCase(); + if (!TODO_STATUSES.has(status)) { + throw new Error(`${label}.status is unsupported`); + } + if (!Number.isSafeInteger(item.index) || Number(item.index) < 1) { + throw new Error(`${label}.index must be a positive integer`); + } + return { + todo_id: normalizedTodoId(item.todo_id, `${label}.todo_id`), + status, + task_class: optionalString(item.task_class, `${label}.task_class`), + text: normalizeText(requiredString(item.text, `${label}.text`)), + index: Number(item.index), + completion_continuation: optionalString( + item.completion_continuation, + `${label}.completion_continuation`, + ), + successor_todo_ids: stringArray( + item.successor_todo_ids, + `${label}.successor_todo_ids`, + ).map((todoId, successorIndex) => + normalizedTodoId(todoId, `${label}.successor_todo_ids[${successorIndex}]`) + ), + }; +} + +export function decodeTodoNextActionRequest( + value: unknown, +): TodoNextActionRequest { + const request = requiredObject(value, "todo.next_action params"); + if (request.schema_version !== TODO_NEXT_ACTION_REQUEST_SCHEMA) { + throw new Error("Todo Next Action request schema mismatch"); + } + const operation = requiredString( + request.operation, + "todo.next_action operation", + ); + const lines = stringArray(request.lines, "todo.next_action lines"); + const todoId = normalizedTodoId(request.todo_id, "todo.next_action todo_id"); + if (operation === "bind") { + return { + schema_version: TODO_NEXT_ACTION_REQUEST_SCHEMA, + operation, + lines, + todo_id: todoId, + }; + } + if (operation !== "settle_completion") { + throw new Error("Todo Next Action operation is unsupported"); + } + if (!Array.isArray(request.agent_todos)) { + throw new Error("todo.next_action agent_todos must be an array"); + } + const materializedTodoIds = stringArray( + request.materialized_todo_ids, + "todo.next_action materialized_todo_ids", + ).map((todoId, index) => + normalizedTodoId(todoId, `materialized_todo_ids[${index}]`) + ); + return { + schema_version: TODO_NEXT_ACTION_REQUEST_SCHEMA, + operation, + lines, + todo_id: todoId, + agent_todos: request.agent_todos.map(todoSnapshot), + materialized_todo_ids: materializedTodoIds, + }; +} + +function normalizeText(value: string): string { + return value.trim().replace(/\s+/g, " "); +} + +function headingBounds( + lines: readonly string[], + heading: string, +): [number, number] | null { + const needle = `## ${heading}`; + const start = lines.findIndex((line) => line.trim() === needle); + if (start < 0) return null; + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + if (lines[index].startsWith("## ")) { + end = index; + break; + } + } + return [start, end]; +} + +function visibleNextActionEntries(lines: readonly string[]): string[] { + const bounds = headingBounds(lines, "Next Action"); + if (!bounds) return []; + const [start, end] = bounds; + const entries: string[] = []; + const current: string[] = []; + const flush = (): void => { + if (!current.length) return; + const normalized = normalizeText(current.join(" ")); + if (normalized) entries.push(normalized); + current.length = 0; + }; + for (const line of lines.slice(start + 1, end)) { + const stripped = line.trim(); + if (stripped.startsWith("`; +} + +function unchangedResult( + operation: TodoNextActionRequest["operation"], + outcome: TodoNextActionResult["outcome"], + lines: readonly string[], + matched = false, +): TodoNextActionResult { + return { + schema_version: TODO_NEXT_ACTION_RESULT_SCHEMA, + operation, + outcome, + changed: false, + matched, + lines: [...lines], + }; +} + +function bindNextAction( + lines: readonly string[], + todoId: string, +): TodoNextActionResult { + const updated = [...lines]; + const bounds = headingBounds(updated, "Next Action"); + if (!bounds || visibleNextActionEntries(updated).length !== 1) { + return unchangedResult("bind", "unchanged", updated); + } + const marker = bindingLine(todoId); + const matches = bindingMatches(updated); + if (matches.length === 1 && updated[matches[0].index] === marker) { + return unchangedResult("bind", "unchanged", updated, true); + } + if (matches.length > 0 || hasBindingDirective(updated)) { + return unchangedResult("bind", "unchanged", updated); + } + const refreshed = headingBounds(updated, "Next Action"); + if (!refreshed) return unchangedResult("bind", "unchanged", updated); + const [start, end] = refreshed; + let insertAt = end; + while (insertAt > start + 1 && !updated[insertAt - 1].trim()) insertAt -= 1; + updated.splice(insertAt, 0, marker); + return { + schema_version: TODO_NEXT_ACTION_RESULT_SCHEMA, + operation: "bind", + outcome: "bound", + changed: true, + matched: true, + lines: updated, + }; +} + +function priorityRank(text: string): number { + const match = /\bP([0-4])\b/i.exec(text); + return match ? Number(match[1]) : 50; +} + +function nextOpenAgentTodo( + todos: readonly TodoNextActionSnapshot[], +): TodoNextActionSnapshot | null { + const candidates = todos.filter((todo) => + todo.status === "open" && + !CONTROL_TASK_CLASSES.has(todo.task_class ?? "") + ); + candidates.sort((left, right) => { + const leftClass = left.task_class === "advancement_task" ? 0 : 1; + const rightClass = right.task_class === "advancement_task" ? 0 : 1; + return leftClass - rightClass || + priorityRank(left.text) - priorityRank(right.text) || + left.index - right.index; + }); + return candidates[0] ?? null; +} + +function settleCompletedTodo( + request: Extract, +): TodoNextActionResult { + const completed = request.agent_todos.find( + (todo) => todo.todo_id === request.todo_id, + ); + if (!completed) { + throw new Error("completed Todo is absent from the Agent Todo projection"); + } + if (completed.status !== "done") { + return unchangedResult( + "settle_completion", + "not_terminal", + request.lines, + ); + } + if ( + completed.completion_continuation === "successor" && + ( + completed.successor_todo_ids.length === 0 || + completed.successor_todo_ids.some( + (todoId) => !request.materialized_todo_ids.includes(todoId), + ) + ) + ) { + return unchangedResult( + "settle_completion", + "awaiting_successor", + request.lines, + ); + } + + const matches = bindingMatches(request.lines); + const boundTodoId = matches.length === 1 && + matches[0].schema === NEXT_ACTION_BINDING_SCHEMA + ? matches[0].todoId + : null; + let matchSource: TodoNextActionResult["match_source"]; + if (boundTodoId === completed.todo_id) { + matchSource = "typed_todo_binding"; + } else if ( + !hasBindingDirective(request.lines) && + visibleNextActionEntries(request.lines).length === 1 && + visibleNextActionEntries(request.lines)[0] === completed.text + ) { + matchSource = "legacy_exact_text"; + } else { + return { + ...unchangedResult( + "settle_completion", + "route_unmatched", + request.lines, + ), + completed_todo_id: completed.todo_id, + }; + } + + const bounds = headingBounds(request.lines, "Next Action"); + if (!bounds) { + return { + ...unchangedResult( + "settle_completion", + "route_unmatched", + request.lines, + true, + ), + completed_todo_id: completed.todo_id, + match_source: matchSource, + }; + } + const [start, end] = bounds; + const nextTodo = nextOpenAgentTodo(request.agent_todos); + const replacement = ["## Next Action", ""]; + if (nextTodo) { + replacement.push( + `- ${nextTodo.text}`, + bindingLine(nextTodo.todo_id), + "", + ); + } + const updated = [ + ...request.lines.slice(0, start), + ...replacement, + ...request.lines.slice(end), + ]; + const changed = updated.some((line, index) => line !== request.lines[index]) || + updated.length !== request.lines.length; + return { + schema_version: TODO_NEXT_ACTION_RESULT_SCHEMA, + operation: "settle_completion", + outcome: changed ? "settled" : "unchanged", + changed, + matched: true, + lines: updated, + completed_todo_id: completed.todo_id, + match_source: matchSource, + next_todo_id: nextTodo?.todo_id ?? null, + next_action: nextTodo?.text ?? null, + }; +} + +export function transitionTodoNextAction( + value: unknown, +): TodoNextActionResult { + const request = decodeTodoNextActionRequest(value); + return request.operation === "bind" + ? bindNextAction(request.lines, request.todo_id) + : settleCompletedTodo(request); +} diff --git a/loopx/control_plane/todos/next_action_runtime.py b/loopx/control_plane/todos/next_action_runtime.py new file mode 100644 index 0000000000..4623c2290a --- /dev/null +++ b/loopx/control_plane/todos/next_action_runtime.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ..effect_runtime import effect_runtime_result +from .active_state_editing import section_bounds, todo_blocks +from .contract import ( + TODO_STATUS_OPEN, + normalize_todo_id, + normalize_todo_id_list, + normalize_todo_status, +) + + +TODO_NEXT_ACTION_REQUEST_SCHEMA = "loopx_todo_next_action_transition_v0" +TODO_NEXT_ACTION_RESULT_SCHEMA = "loopx_todo_next_action_result_v0" + + +def _agent_todo_snapshots(lines: list[str]) -> list[dict[str, Any]]: + bounds = section_bounds(lines, "agent") + if bounds is None: + return [] + start, end, section = bounds + snapshots: list[dict[str, Any]] = [] + for block in todo_blocks( + lines, + start, + end, + role="agent", + source_section=section, + ): + todo_id = normalize_todo_id(block.get("todo_id")) + if not todo_id: + raise RuntimeError("Agent Todo projection produced an invalid todo_id") + snapshots.append( + { + "todo_id": todo_id, + "status": normalize_todo_status(block.get("status")) + or TODO_STATUS_OPEN, + "task_class": str(block.get("task_class") or "").strip() or None, + "text": str(block.get("text") or "").strip(), + "index": int(block.get("index") or 0), + "completion_continuation": ( + str(block.get("completion_continuation") or "").strip() or None + ), + "successor_todo_ids": normalize_todo_id_list( + block.get("successor_todo_ids") + ), + } + ) + return snapshots + + +def _materialized_todo_ids(lines: list[str]) -> list[str]: + todo_ids: list[str] = [] + for role in ("user", "agent"): + bounds = section_bounds(lines, role) + if bounds is None: + continue + start, end, section = bounds + for block in todo_blocks( + lines, + start, + end, + role=role, + source_section=section, + ): + todo_id = normalize_todo_id(block.get("todo_id")) + if todo_id and todo_id not in todo_ids: + todo_ids.append(todo_id) + return todo_ids + + +def _apply_transition( + lines: list[str], + *, + operation: str, + params: Mapping[str, Any], +) -> dict[str, Any]: + result = effect_runtime_result( + "todo.next_action.transition", + { + "schema_version": TODO_NEXT_ACTION_REQUEST_SCHEMA, + "operation": operation, + "lines": list(lines), + **dict(params), + }, + ) + if not isinstance(result, Mapping): + raise RuntimeError("TypeScript Todo Next Action result must be an object") + if ( + result.get("schema_version") != TODO_NEXT_ACTION_RESULT_SCHEMA + or result.get("operation") != operation + or not isinstance(result.get("changed"), bool) + or not isinstance(result.get("matched"), bool) + or not isinstance(result.get("outcome"), str) + ): + raise RuntimeError("TypeScript Todo Next Action result shape mismatch") + updated_lines = result.get("lines") + if not isinstance(updated_lines, list) or not all( + isinstance(line, str) for line in updated_lines + ): + raise RuntimeError("TypeScript Todo Next Action lines shape mismatch") + changed = updated_lines != lines + if changed is not result["changed"]: + raise RuntimeError("TypeScript Todo Next Action changed flag mismatch") + if changed: + lines[:] = updated_lines + return dict(result) + + +def bind_next_action_to_todo(lines: list[str], *, todo_id: str) -> bool: + """Bind one generated durable Next Action to its stable Todo identity.""" + + normalized_todo_id = normalize_todo_id(todo_id) + if not normalized_todo_id: + raise ValueError("next-action binding requires a valid todo_id") + result = _apply_transition( + lines, + operation="bind", + params={"todo_id": normalized_todo_id}, + ) + return bool(result["changed"]) + + +def settle_completed_todo_next_action( + lines: list[str], + *, + completed_todo_id: str, +) -> bool: + """Reconcile the generated Next Action from the final Todo transaction state.""" + + normalized_todo_id = normalize_todo_id(completed_todo_id) + if not normalized_todo_id: + raise ValueError("Next Action settlement requires a valid completed todo_id") + result = _apply_transition( + lines, + operation="settle_completion", + params={ + "todo_id": normalized_todo_id, + "agent_todos": _agent_todo_snapshots(lines), + "materialized_todo_ids": _materialized_todo_ids(lines), + }, + ) + return bool(result["changed"]) diff --git a/loopx/state_projection.py b/loopx/state_projection.py index 80581b7dc1..03010834b9 100644 --- a/loopx/state_projection.py +++ b/loopx/state_projection.py @@ -394,6 +394,8 @@ def _section_entries(lines: list[str]) -> list[str]: entries: list[str] = [] current: list[str] = [] for line in lines: + if line.strip().startswith("\n", + 1, + ), + encoding="utf-8", + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(completed["todo_id"]), + successor_todo_ids=[str(successor["todo_id"])], + agent_id=AGENT_ID, + evidence="connection preflight passed", + ) + + assert result["changed"] is True + assert active_state_next_action_entries(state.read_text(encoding="utf-8")) == [ + "[P0] Implement and validate the requested behavior." + ] + + +def test_supersede_reprojects_after_generated_successor_is_materialized( + tmp_path: Path, +) -> None: + completed_text = "[P1] Replace the obsolete implementation." + registry, state = _write_fixture(tmp_path, next_action=completed_text) + completed = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text=completed_text, + task_class="advancement_task", + action_kind="implementation", + claimed_by=AGENT_ID, + ) + state.write_text( + state.read_text(encoding="utf-8").replace( + f"- {completed_text}\n", + f"- {completed_text}\n" + "\n", + 1, + ), + encoding="utf-8", + ) + + result = supersede_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(completed["todo_id"]), + reason="replace the obsolete route", + next_agent_todo="[P1] Implement the corrected route.", + next_action_kind="implementation", + next_claimed_by=AGENT_ID, + agent_id=AGENT_ID, + ) + + successor_id = result["next_todos"][0]["todo_id"] + state_text = state.read_text(encoding="utf-8") + assert active_state_next_action_entries(state_text) == [ + "[P1] Implement the corrected route." + ] + assert f"todo_id={successor_id} -->" in state_text + + +def test_complete_preserves_unrelated_owner_next_action(tmp_path: Path) -> None: + registry, state = _write_fixture( + tmp_path, + next_action="Keep the owner-approved release route unchanged.", + ) + todo = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="[P1] Validate the project connection.", + task_class="advancement_task", + action_kind="onboarding_connection_validation", + claimed_by=AGENT_ID, + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(todo["todo_id"]), + no_followup=True, + agent_id=AGENT_ID, + evidence="connection preflight passed", + ) + + assert result["changed"] is True + assert active_state_next_action_entries(state.read_text(encoding="utf-8")) == [ + "Keep the owner-approved release route unchanged." + ] + + +def test_complete_waits_for_generated_successor_before_reprojecting( + tmp_path: Path, +) -> None: + completed_text = "[P0] Finish the bounded implementation." + registry, state = _write_fixture(tmp_path, next_action=completed_text) + completed = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text=completed_text, + task_class="advancement_task", + action_kind="implementation", + claimed_by=AGENT_ID, + ) + state.write_text( + state.read_text(encoding="utf-8").replace( + f"- {completed_text}\n", + f"- {completed_text}\n" + "\n", + 1, + ), + encoding="utf-8", + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(completed["todo_id"]), + next_agent_todo="[P1] Review and validate the implementation.", + next_action_kind="validation_review", + next_claimed_by=AGENT_ID, + agent_id=AGENT_ID, + evidence="implementation completed", + ) + + successor_id = result["next_todos"][0]["todo_id"] + state_text = state.read_text(encoding="utf-8") + assert active_state_next_action_entries(state_text) == [ + "[P1] Review and validate the implementation." + ] + assert f"todo_id={successor_id} -->" in state_text + + +def test_complete_migrates_legacy_exact_text_next_action(tmp_path: Path) -> None: + completed_text = "[P1] Validate the legacy project connection." + registry, state = _write_fixture(tmp_path, next_action=completed_text) + completed = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text=completed_text, + task_class="advancement_task", + action_kind="onboarding_connection_validation", + claimed_by=AGENT_ID, + ) + + result = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(completed["todo_id"]), + no_followup=True, + agent_id=AGENT_ID, + evidence="legacy connection preflight passed", + ) + + assert result["changed"] is True + assert active_state_next_action_entries(state.read_text(encoding="utf-8")) == [] + + +def test_complete_upgrades_legacy_open_todo_to_typed_binding( + tmp_path: Path, +) -> None: + completed_text = "[P0] Finish the bound implementation." + registry, state = _write_fixture(tmp_path, next_action=completed_text) + completed = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text=completed_text, + task_class="advancement_task", + action_kind="implementation", + claimed_by=AGENT_ID, + ) + state_text = state.read_text(encoding="utf-8") + state_text = state_text.replace( + "## Next Action", + "- [ ] [P1] Legacy open work without metadata.\n\n## Next Action", + 1, + ).replace( + f"- {completed_text}\n", + f"- {completed_text}\n" + "\n", + 1, + ) + state.write_text(state_text, encoding="utf-8") + + complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(completed["todo_id"]), + no_followup=True, + agent_id=AGENT_ID, + evidence="implementation completed", + ) + + final_text = state.read_text(encoding="utf-8") + assert active_state_next_action_entries(final_text) == [ + "[P1] Legacy open work without metadata." + ] + assert ( + "`, + ), + true, + ); + assert.deepEqual(lines, before); +}); + +test("binding never overwrites an existing or malformed ownership directive", () => { + const directives = [ + ``, + "", + ``, + ]; + for (const directive of directives) { + const lines = [ + "## Next Action", + "", + "- [P1] Validate the project connection.", + directive, + "", + ]; + const result = transitionTodoNextAction({ + schema_version: TODO_NEXT_ACTION_REQUEST_SCHEMA, + operation: "bind", + lines, + todo_id: "todo_connection", + }); + + assert.equal(result.changed, false); + assert.deepEqual(result.lines, lines); + } +}); + +test("completion settles from the final Todo snapshot and skips control work", () => { + const completed = todo("todo_completed", { + status: "done", + text: "[P1] Validate the project connection.", + completion_continuation: "successor", + successor_todo_ids: ["todo_successor"], + }); + const monitor = todo("todo_monitor", { + text: "[P0] Poll an external dependency.", + task_class: "continuous_monitor", + index: 2, + }); + const blocker = todo("todo_blocker", { + text: "[P0] Wait for owner input.", + task_class: "blocker", + index: 3, + }); + const successor = todo("todo_successor", { + text: "[P2] Implement and validate the requested behavior.", + index: 4, + }); + const lines = [ + "## Next Action", + "", + `- ${completed.text}`, + ``, + "", + "## Progress Ledger", + "", + ]; + + const result = transitionTodoNextAction( + settleRequest(lines, [completed, monitor, blocker, successor]), + ); + + assert.equal(result.outcome, "settled"); + assert.equal(result.match_source, "typed_todo_binding"); + assert.equal(result.next_todo_id, successor.todo_id); + assert.equal(result.next_action, successor.text); + assert.deepEqual(result.lines.slice(0, 5), [ + "## Next Action", + "", + `- ${successor.text}`, + ``, + "", + ]); +}); + +test("successor completion fence waits until every declared successor exists", () => { + const completed = todo("todo_completed", { + status: "done", + text: "[P1] Complete the bounded implementation.", + completion_continuation: "successor", + successor_todo_ids: ["todo_landed", "todo_not_landed"], + }); + const landed = todo("todo_landed", { index: 2 }); + const lines = [ + "## Next Action", + "", + `- ${completed.text}`, + ``, + "", + ]; + + const result = transitionTodoNextAction( + settleRequest(lines, [completed, landed]), + ); + + assert.equal(result.outcome, "awaiting_successor"); + assert.equal(result.changed, false); + assert.deepEqual(result.lines, lines); +}); + +test("legacy exact text is migrated once, including wrapped Markdown", () => { + const completed = todo("todo_completed", { + status: "done", + text: "[P1] Validate the legacy project connection and record the result.", + completion_continuation: "no_followup", + }); + const lines = [ + "## Next Action", + "", + "- [P1] Validate the legacy project connection", + " and record the result.", + "", + "## Progress Ledger", + "", + ]; + + const result = transitionTodoNextAction(settleRequest(lines, [completed])); + + assert.equal(result.outcome, "settled"); + assert.equal(result.match_source, "legacy_exact_text"); + assert.equal(result.next_todo_id, null); + assert.deepEqual(result.lines.slice(0, 3), [ + "## Next Action", + "", + "## Progress Ledger", + ]); +}); + +test("owner-authored, multiply-bound, and unknown-schema routes fail closed", () => { + const completed = todo("todo_completed", { + status: "done", + text: "[P1] Validate the project connection.", + completion_continuation: "no_followup", + }); + const cases = [ + ["## Next Action", "", "- Keep the owner-approved route.", ""], + [ + "## Next Action", + "", + `- ${completed.text}`, + ``, + ``, + "", + ], + [ + "## Next Action", + "", + `- ${completed.text}`, + ``, + "", + ], + [ + "## Next Action", + "", + `- ${completed.text}`, + "", + "", + ], + ]; + + for (const lines of cases) { + const result = transitionTodoNextAction(settleRequest(lines, [completed])); + assert.equal(result.outcome, "route_unmatched"); + assert.equal(result.changed, false); + assert.deepEqual(result.lines, lines); + } +}); + +test("runtime decoder rejects malformed authority input before transition", () => { + assert.throws( + () => + transitionTodoNextAction({ + schema_version: TODO_NEXT_ACTION_REQUEST_SCHEMA, + operation: "settle_completion", + lines: ["## Next Action"], + todo_id: "todo_completed", + agent_todos: [ + { + ...todo("todo_completed"), + status: "completed", + }, + ], + materialized_todo_ids: ["todo_completed"], + }), + /status is unsupported/, + ); + assert.throws( + () => + transitionTodoNextAction({ + schema_version: "unsupported", + operation: "bind", + lines: ["## Next Action"], + todo_id: "todo_completed", + }), + /request schema mismatch/, + ); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index a64af80e18..2cefe348f9 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -16,9 +16,11 @@ "loopx/control_plane/effect_runtime_handlers.ts", "loopx/control_plane/effect_runtime_io.ts", "loopx/control_plane/effect_runtime_server.ts", + "loopx/control_plane/todos/next_action.ts", "loopx/control_plane/turn_driver/turn_journal.ts", "loopx/control_plane/turn_driver/turn_journal_effects.ts", "tests/control_plane_ts/effect_program.test.ts", + "tests/control_plane_ts/todo_next_action.test.ts", "tests/control_plane_ts/turn_journal.test.ts", "tests/control_plane_ts/turn_journal_effects.test.ts" ]