diff --git a/docs/reference/canonical-terminal-review.md b/docs/reference/canonical-terminal-review.md index e691058a1c..5f38349413 100644 --- a/docs/reference/canonical-terminal-review.md +++ b/docs/reference/canonical-terminal-review.md @@ -113,14 +113,23 @@ command or transport in this slice. A declared deliverable passing its validator is not the same event as its Turn being settled. The shared TypeScript settlement plan orders ordinary Todo -completion, durable `refresh-state`, one `quota spend-slot`, then final -`todo complete --no-follow-up`, all bound to the original Goal, Agent, Todo and -Turn. Ordinary completion retains `active_goal` continuation without creating +completion, durable `refresh-state` and one `quota spend-slot`, all bound to the +original Goal, Agent, Todo and Turn. Final `todo complete --no-follow-up` applies +only when the current contract permits final scope closeout. Ordinary completion +retains `active_goal` continuation without creating an artificial successor. Its command has the condition `todo_deliverable_complete`; required validation is never conditional or waived. Qualified `in_flight_continuation` leaves unfinished work open and omits ordinary completion rather than pretending the deliverable passed. +The shared Todo summary keeps missing-successor diagnostics and counts, but its +warning is review guidance, not an obligation to create a successor or terminate +a completed stage. Review remaining authorized Goal acceptance and the runnable +frontier; continue existing work or replan while scope remains. An empty queue +alone does not certify final scope closeout. Graph validation, empty-frontier +replan and terminal proof remain governed by their existing typed rules. This +guidance change is shared by CLI, status and quota readback. + If final closeout is attempted before writeback/accounting, CLI JSON and Markdown return that same recovery plan. Supplied registry/runtime, project/state routes and original lease proof are retained where the corresponding command accepts diff --git a/docs/reference/canonical-terminal-review.zh-CN.md b/docs/reference/canonical-terminal-review.zh-CN.md index f9411fcb1e..c358d0e567 100644 --- a/docs/reference/canonical-terminal-review.zh-CN.md +++ b/docs/reference/canonical-terminal-review.zh-CN.md @@ -91,12 +91,18 @@ legacy unscoped 回执属于该周期。三种意图共用同一个终结事务 ## 受配额约束的 CLI 完成 交付通过原 validator,不等于该 Turn 已结算。共享 TS settlement plan 依次引导普通 -Todo 完成、永久 `refresh-state` 写回、一次 `quota spend-slot` 扣额,以及最终 -`todo complete --no-follow-up`;全部绑定原 Goal、Agent、Todo 和 Turn。普通完成 +Todo 完成、永久 `refresh-state` 写回和一次 `quota spend-slot` 扣额;全部绑定原 +Goal、Agent、Todo 和 Turn。只有当前契约允许最终范围收尾时,才执行 +`todo complete --no-follow-up`。普通完成 保留 `active_goal` continuation,不制造虚假后继。该命令的执行条件是 `todo_deliverable_complete`,必需的验证不会随条件省略。符合准入的 `in_flight_continuation` 保留未交付 Todo 为 open,不会伪造通过验证的完成。 +共享 Todo 摘要仍保留缺少后继的诊断和计数,但告警是复核引导,不要求制造后继或 +终结已完成阶段。先复核剩余的授权 Goal 验收与可执行任务;范围内仍有工作时继续 +已有任务或重规划。队列为空本身不能证明最终范围可以收尾。图校验、空前沿重规划 +与终态证明仍由已有 typed 规则决定;这次提示调整覆盖 CLI、status 和 quota 读回。 + 若提前终结、缺少写回或扣额,CLI JSON 和 Markdown 返回同一恢复计划;原 registry/runtime、 project/state 路由和租约证明保留在对应命令支持的参数中。命令模板只是引导,不提供 租约、验证回执或额外执行权限。 diff --git a/loopx/control_plane/todos/succession_warning.py b/loopx/control_plane/todos/succession_warning.py index ec5096b02d..a298566162 100644 --- a/loopx/control_plane/todos/succession_warning.py +++ b/loopx/control_plane/todos/succession_warning.py @@ -103,8 +103,9 @@ def build_todo_succession_warning_lanes( "recommended_action": warning.get( "recommended_action", ( - "run loopx todo complete --no-follow-up for the completed Todo, " - "or add/link a successor Todo; do not invent a user gate" + "Review remaining authorized Goal acceptance and the runnable frontier; " + "follow the current quota settlement/replan contract. " + "A summary warning does not authorize terminal action." ), ), } diff --git a/loopx/control_plane/todos/summary_projection.ts b/loopx/control_plane/todos/summary_projection.ts index 2b85ff9c02..f0bddd2415 100644 --- a/loopx/control_plane/todos/summary_projection.ts +++ b/loopx/control_plane/todos/summary_projection.ts @@ -177,6 +177,16 @@ export function projectTodoSummary(value: unknown): SummaryProjection { const gaps = source.filter(index => rows[index].successor_gap === true).sort(byTime(false)); if (gaps.length) { fields.completed_without_successor_count = gaps.length; + // A missing lineage edge is a review prompt, not authority to terminate an + // ordinary completed stage. Replan and settlement retain their own gates. + fields.todo_succession_warning = { + schema_version: "todo_succession_warning_v0", + reason_code: "completed_advancement_without_successor", + count: gaps.length, + recommended_action: "Review remaining authorized Goal acceptance and the runnable frontier. " + + "Continue existing work or replan while scope remains; ordinary Todo completion needs no artificial successor. " + + "Use --no-follow-up only for final scope closeout allowed by the current settlement contract.", + }; lane("completed_without_successor_items", gaps, 5, "gap"); } if (selected.watch_only_monitor_items.length) { diff --git a/loopx/control_plane/todos/todo_summary.py b/loopx/control_plane/todos/todo_summary.py index 5e218855f4..5c7d8e5182 100644 --- a/loopx/control_plane/todos/todo_summary.py +++ b/loopx/control_plane/todos/todo_summary.py @@ -57,10 +57,6 @@ todo_presentation_sort_key as projection_todo_presentation_sort_key, todo_projection_sort_key as projection_todo_projection_sort_key, ) -from .succession_warning import ( - TODO_SUCCESSION_WARNING_REASON_CODE, - TODO_SUCCESSION_WARNING_SCHEMA_VERSION, -) from .resume_condition import evaluate_todo_resume_conditions from ..runtime.time import now_utc, now_utc_iso from ..work_items.project_asset import build_project_asset_todo_summary @@ -930,6 +926,10 @@ def valid_ordinals(value: Any) -> TypeGuard[list[int]]: summary = result.get("fields") if not isinstance(summary, dict) or summary.get("schema_version") != "todo_summary_v0": raise ValueError("invalid typed Todo summary fields") + warning = summary.get("todo_succession_warning") + warning_action = warning.get("recommended_action") if isinstance(warning, dict) else None + if summary.get("completed_without_successor_count") and not isinstance(warning_action, str): + raise ValueError("invalid typed Todo succession warning") for name, lane in lanes.items(): mode = lane.get("format") if mode not in {"raw", "active", "compact", "recent", "gap"}: @@ -948,11 +948,13 @@ def valid_ordinals(value: Any) -> TypeGuard[list[int]]: compact.pop(key, None) if mode == "gap": compact.update(succession_tracked=True, - recommended_action="record no_followup=true or add/link a successor todo") + recommended_action=warning_action) else: raise ValueError("invalid Todo summary display format") formatted.append(compact) summary[name] = formatted + if isinstance(warning, dict): + warning["items"] = summary["completed_without_successor_items"] return {"summary": summary, "items": [items[index] for index in selected], "succession": [succession[index] for index in selected], "orchestration": {name: [items[index] for index in indices] for name, indices in orchestration.items()}} @@ -1040,16 +1042,4 @@ def compact_evaluated_todo_group( projected["orchestration"], role=role) if handoff_gates: summary["handoff_gates"] = handoff_gates - if summary.get("completed_without_successor_count"): - summary["todo_succession_warning"] = { - "schema_version": TODO_SUCCESSION_WARNING_SCHEMA_VERSION, - "reason_code": TODO_SUCCESSION_WARNING_REASON_CODE, - "count": summary["completed_without_successor_count"], - "items": summary["completed_without_successor_items"], - "recommended_action": ( - "run loopx todo complete --no-follow-up for the completed Todo, " - "or add/link a successor Todo before closing the slice; do not " - "invent a user gate" - ), - } return summary diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 9b0f33a92b..fde30b4aca 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -124,6 +124,22 @@ def test_declared_leased_completion_writeback_spend_terminal_and_replay( assert marker.read_text() == "run\n" assert not completed.get("successor_todo_ids") assert cli._spend_run_count(runtime) == 0 + # Ordinary completion leaves a genuine lineage gap. Shared read guidance + # must not convert that diagnostic into mandatory terminal mutation. + code, ordinary = run("todo", "list", "--goal-id", cli.GOAL_ID) + assert code == 0, ordinary + warning = ordinary["agent_todos"]["todo_succession_warning"] + assert warning["count"] == 1 + assert "ordinary Todo completion needs no artificial successor" in warning["recommended_action"] + assert "terminal_closure_proof" not in ordinary["agent_todos"] + code, continuing_guard = run(*guard_args) + assert code == 0, continuing_guard + assert continuing_guard["agent_todo_summary"]["todo_succession_warning"]["recommended_action"] == warning["recommended_action"] + code, unchanged = run("todo", "list", "--goal-id", cli.GOAL_ID) + assert code == 0, unchanged + assert unchanged["todos"] == ordinary["todos"] + assert marker.read_text() == "run\n" + assert cli._spend_run_count(runtime) == 0 readback = read_heartbeat_settlement(runtime, goal_id=cli.GOAL_ID, agent_id=cli.AGENT_ID, todo_id=cli.TODO_ID, turn_instance_id=cli.TURN_ID) assert readback is not None diff --git a/tests/control_plane/test_todo_succession_read_model.py b/tests/control_plane/test_todo_succession_read_model.py index dca81dee30..883bd87756 100644 --- a/tests/control_plane/test_todo_succession_read_model.py +++ b/tests/control_plane/test_todo_succession_read_model.py @@ -86,6 +86,30 @@ def test_selection_after_warning_display_cap_keeps_exact_gap(): assert selected["completed_without_successor_count"] == 1 +def test_warning_consumers_share_typed_guidance_without_mutating_continuation(): + from copy import deepcopy + from loopx.control_plane.todos.succession_warning import build_todo_succession_warning_lanes + + records = [work("todo_stage", completion_continuation="active_goal"), + work("todo_next", status="open", done=False)] + before = deepcopy(records) + result = summary(records) + warning = result["todo_succession_warning"] + assert warning["count"] == 1 + assert "ordinary Todo completion needs no artificial successor" in warning["recommended_action"] + assert result["completed_without_successor_items"][0]["recommended_action"] == warning["recommended_action"] + assert result["first_executable_items"][0]["todo_id"] == "todo_next" + assert "terminal_closure_proof" not in result + for cap in (0, 1, 12): + projected = build_todo_succession_warning_lanes(result, item_limit=cap) + assert projected["completed_without_successor_count"] == 1 + assert projected["todo_succession_warning"]["recommended_action"] == warning["recommended_action"] + legacy = build_todo_succession_warning_lanes({"completed_without_successor_count": 1}, item_limit=0) + assert "current quota settlement/replan contract" in legacy["todo_succession_warning"]["recommended_action"] + assert "todo complete" not in legacy["todo_succession_warning"]["recommended_action"] + assert records == before + + def test_changed_item_cannot_reuse_old_graph_evaluation(): result = summary([work("todo_closed", no_followup=True)]) result["items"][0]["no_followup"] = False diff --git a/tests/control_plane_ts/todo_summary_projection.test.ts b/tests/control_plane_ts/todo_summary_projection.test.ts index f9036f718b..3ecaf244de 100644 --- a/tests/control_plane_ts/todo_summary_projection.test.ts +++ b/tests/control_plane_ts/todo_summary_projection.test.ts @@ -85,6 +85,32 @@ test("one whole-source projection computes counts, visibility and closure before } }); +test("succession diagnostics guide continuation without authorizing terminal closeout", () => { + const facts = [{todo_id: "todo_stage", status: "done", active: true, advancement: true, + no_followup: false, successors: [], superseded_by: null, unblocks: null, resumes: null, + handoff: false, context_fields: ["claimed_by"]}]; + const stage = done({todo_id: "todo_stage", no_followup: false, successor_gap: true}); + for (const limit of [null, 0, 1]) { + const carrier = fused([stage], facts); + const source = structuredClone(carrier); + const result = projectTodoSummary({...carrier, item_limit: limit}); + assert.deepEqual(result, projectTodoSummary(request([stage], {item_limit: limit}))); + assert.equal(result.fields.completed_without_successor_count, 1); + assert.equal(result.fields.terminal_closure_proof, undefined); + const warning = result.fields.todo_succession_warning as JsonObject; + assert.equal(warning.count, 1); + assert.match(String(warning.recommended_action), /Review remaining authorized Goal acceptance/); + assert.match(String(warning.recommended_action), /runnable frontier/); + assert.match(String(warning.recommended_action), /ordinary Todo completion needs no artificial successor/); + assert.match(String(warning.recommended_action), /only for final scope closeout.*current settlement contract/); + assert.deepEqual(carrier, source, "read guidance must not add successor or terminal facts"); + } + const continuing = projectTodoSummary(request([stage, row({todo_id: "todo_next"})])); + assert.equal(continuing.fields.completed_without_successor_count, 1); + assert.deepEqual(continuing.lanes.first_executable_items.indices, [1]); + assert.equal(continuing.fields.terminal_closure_proof, undefined); +}); + test("recent completion orders true microsecond instants and ignores later edits", () => { const rows = [done({completed_at: "2026-01-01T10:00:00.000001+08:00", updated_at: "2026-12-01T00:00:00Z"}), done({completed_at: "2026-01-01T02:00:00.000002Z"}), done({completed_at: "invalid"})];