diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index de827923e5..e3f403d741 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -2363,6 +2363,17 @@ promotion also requires deleting characterization machinery that serves only that implementation comparison; durable regression fixtures may remain when they express a public or persisted compatibility contract. +#### Canonical Todo recovery qualification (2026-10-21) + +Canonical Todo readback does not make a separately declared Goal source optional. +Quota still refuses automatic work when that declared state file is missing. +Recovery fixtures therefore retain the original Goal prose and remove only its +legacy Todo display after capture. File and SQLite CLI checks exercise monitor +repair, generation waits and capacity recovery without recreating Todo display. +Separate missing-source and restore checks retain the Goal health guard. These +checks qualify the bounded Todo recovery paths; full Goal-source migration +remains separate. + ### Performance Measure cold startup separately from steady-state execution. Every transaction 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 5e57bb70d8..c540b5b32b 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 @@ -1743,6 +1743,15 @@ invariant 冲突,PR 必须披露,并把行为变更单独批准。旧 author 还要求删除只服务这次实现对比的 characterization machinery;当 fixture 表达 public 或 persisted compatibility contract 时,可以保留为持久 regression test。 +#### Canonical Todo 恢复资格验证(2026-10-21) + +Canonical Todo 读回不会使单独声明的 Goal 来源变成可选项。 +该状态文件缺失时,quota 仍拒绝自动执行。恢复 fixture 因此保留原始 Goal 正文, +只在 capture 后移除旧 Todo 展示。File 与 SQLite CLI 检查覆盖 monitor 修复、 +generation 等待和 capacity 恢复,且不会重建 Todo 展示。 +独立的来源缺失与恢复检查保留 Goal 健康门禁。这里验证有界 Todo 恢复路径; +完整 Goal 来源迁移仍是独立工作。 + ### 性能 Cold startup 与 steady-state 分开测量。每笔 transaction cutover 必须报告: diff --git a/tests/control_plane/test_resume_planning_projection.py b/tests/control_plane/test_resume_planning_projection.py index 0de3230a46..1d2c385267 100644 --- a/tests/control_plane/test_resume_planning_projection.py +++ b/tests/control_plane/test_resume_planning_projection.py @@ -15,6 +15,29 @@ from loopx.control_plane.todos.quota_summary import select_quota_todo_summary +def _quota_goal(tmp_path, monkeypatch, todos, provider): + """Todo cutover retains the separately declared Goal definition source.""" + runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" + prose = "---\nstatus: active\n---\n# Goal\n## Objective\nBuild a checked change.\n\n" + state.write_text(prose + "## Agent Todo\n" + todos, encoding="utf-8") + write_fixture_registry( + project=tmp_path, runtime_root=runtime, registry_path=registry, + goal_id="goal-a", domain="resume-planning", adapter_kind="generic_project_goal_v0", + state_file=str(state), registered_agents=["agent-a", "agent-b"], quota_allowed_slots=None, + ) + if provider is not None: + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + goal = json.loads(registry.read_text())["goals"][0] + fields = parse_active_state_todos(state.read_text(), goal=goal, item_limit=None) + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", todos=fields["agent_todos"]["items"], handoff_mode="soft_claim", + ) + initialize_canonical_authority(runtime, "goal-a", projection, state_path=state, provider=provider) + state.write_text(prose, encoding="utf-8") + return runtime, registry, state + + def waiting(todo_id: str, **fields: object) -> dict: return { "todo_id": todo_id, "index": 1, "text": "[P1] Wait for dependency", @@ -100,28 +123,21 @@ def test_invalid_deferred_condition_cannot_reuse_a_stale_ready_flag() -> None: assert row["resume_ready"] is True # Diagnosis is a projection, not an edit. -@pytest.mark.parametrize("promoted", [False, True]) +@pytest.mark.parametrize("provider", [None, "file", "sqlite"]) @pytest.mark.parametrize("kind", ["todo_done", "monitor_changed"]) -def test_public_quota_distinguishes_monitor_repair_from_generation_wait(tmp_path: Path, promoted: bool, kind: str) -> None: - runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" - state.write_text( - "---\nstatus: active\n---\n# Goal\n## Objective\nBuild a checked change.\n\n## Agent Todo\n" +def test_public_quota_distinguishes_monitor_repair_from_generation_wait( + tmp_path: Path, monkeypatch, provider: str | None, kind: str, +) -> None: + runtime, registry, state = _quota_goal( + tmp_path, monkeypatch, "- [ ] [P1] Continue after observation.\n" f" \n" "- [ ] [P2] Observe dependency.\n" " \n", - encoding="utf-8", + provider, ) - write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, - goal_id="goal-a", domain="resume-planning", adapter_kind="generic_project_goal_v0", - state_file=str(state), registered_agents=["agent-a", "agent-b"], quota_allowed_slots=None) - if promoted: - goal = json.loads(registry.read_text())["goals"][0] - fields = parse_active_state_todos(state.read_text(), goal=goal, item_limit=None) - projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=fields["agent_todos"]["items"], handoff_mode="soft_claim") - initialize_canonical_authority(runtime, "goal-a", projection, state_path=state) - state.unlink() - before = state.read_bytes() if state.exists() else None + before = state.read_bytes() + assert (b"## Agent Todo" in before) == (provider is None) code, packet = run_json_cli_result("quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", "--include-detail", "agent-todos", "--scan-path", str(tmp_path), registry_path=registry, runtime_root=runtime) assert code == 0, packet @@ -136,7 +152,7 @@ def test_public_quota_distinguishes_monitor_repair_from_generation_wait(tmp_path assert not summary.get("monitor_blocked_resume_count") assert condition["baseline_generation"] == 3 assert condition["material_change_generation"] == 3 - assert (state.read_bytes() if state.exists() else None) == before + assert state.read_bytes() == before def test_ready_deferred_lanes_keep_full_counts_before_truncation() -> None: @@ -237,32 +253,19 @@ def unavailable(*_args, **_kwargs): project_todo_resume_planning(source) -@pytest.mark.parametrize("promoted", [False, True]) +@pytest.mark.parametrize("provider", [None, "file", "sqlite"]) def test_public_quota_capacity_wait_uses_provider_after_cutover_without_display_write( - tmp_path: Path, promoted: bool, + tmp_path: Path, monkeypatch, provider: str | None, ) -> None: - runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" - state.write_text( - "---\nstatus: active\n---\n# Goal\n## Objective\nBuild a checked change.\n\n" - "## Agent Todo\n- [ ] [P1] Continue when compiler capacity returns.\n" + runtime, registry, state = _quota_goal( + tmp_path, monkeypatch, + "- [ ] [P1] Continue when compiler capacity returns.\n" " \n", - encoding="utf-8", - ) - write_fixture_registry( - project=tmp_path, runtime_root=runtime, registry_path=registry, - goal_id="goal-a", domain="resume-planning", adapter_kind="generic_project_goal_v0", - state_file=str(state), registered_agents=["agent-a", "agent-b"], quota_allowed_slots=None, + provider, ) - if promoted: - goal = json.loads(registry.read_text())["goals"][0] - fields = parse_active_state_todos(state.read_text(), goal=goal, item_limit=None) - projection = build_todo_runtime_shadow_projection( - goal_id="goal-a", todos=fields["agent_todos"]["items"], handoff_mode="soft_claim", - ) - initialize_canonical_authority(runtime, "goal-a", projection, state_path=state) - state.unlink() - before = state.read_bytes() if state.exists() else None + before = state.read_bytes() + assert (b"## Agent Todo" in before) == (provider is None) for capabilities, count in [((), 0), (("--available-capability", "compiler"), 1)]: code, packet = run_json_cli_result( "quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", @@ -272,4 +275,39 @@ def test_public_quota_capacity_wait_uses_provider_after_cutover_without_display_ assert packet["agent_todo_summary"]["current_agent_deferred_resume_count"] == count if count: assert packet["effective_action"] == "successor_replan_required" - assert (state.read_bytes() if state.exists() else None) == before + assert state.read_bytes() == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_canonical_todos_do_not_replace_a_missing_declared_goal_source(tmp_path, monkeypatch, provider): + runtime, registry, state = _quota_goal( + tmp_path, monkeypatch, + "- [ ] [P1] Advance the checked work.\n" + " \n", + provider, + ) + prose = state.read_bytes() + assert b"## Agent Todo" not in prose + state.unlink() + + code, packet = run_json_cli_result( + "quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", + "--scan-path", str(tmp_path), registry_path=registry, runtime_root=runtime, + ) + + assert code == 1 + assert packet["status"] == "state_file_missing" + assert packet["should_run"] is False + assert packet["normal_delivery_allowed"] is False + assert packet["agent_todo_summary"]["open_count"] == 1 + assert not state.exists() + + state.write_bytes(prose) + code, packet = run_json_cli_result( + "quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", + "--scan-path", str(tmp_path), registry_path=registry, runtime_root=runtime, + ) + assert code == 0, packet + assert packet["status_health_ok"] is True + assert packet["agent_todo_summary"]["open_count"] == 1 + assert state.read_bytes() == prose