diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 89716267b2..64d1086771 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -300,7 +300,7 @@ Progress means another independently reproducible user journey, not more fields These are synthetic-fixture results at the exact baseline, without live user content. F1–F4 exercise existing `ChatActionService.preview/apply` with isolated Goals. F4 injects failure immediately before the second Todo write; the other writes use the actual local Todo writer. F5–F7 are source/contract findings. -**Repair status (2026-09-17, baseline `6979d528b`).** The rows below stay as the record of what the audit found. R1 has since closed part of F1 and F3: a confirmed lane now keeps the priority it declared in its own Todo label, the settlement receipt retains each lane's acceptance beside the Todo identity it became, a partial application reports `team_plan_partially_applied` with a gap count instead of a full success, and a confirmation that staffed no lane is recorded as the typed failure `team_plan_no_staffable_lane` rather than as an applied plan with an empty Todo id. The remainder is open and owned by canonical Todos: the plan-level quota/stop classification, F2's commit precondition, F4's per-lane recovery, and rendering a partial application as partial on the confirmation card. +**Repair status (2026-09-17, baseline `6979d528b`).** The rows below stay as the record of what the audit found. R1 has since closed the priority and acceptance half of F1, all of F3, and F2: a confirmed lane now keeps the priority it declared in its own Todo label, the settlement receipt retains each lane's acceptance beside the Todo identity it became, a partial application reports `team_plan_partially_applied` with a gap count instead of a full success, a confirmation that staffed no lane is recorded as the typed failure `team_plan_no_staffable_lane` rather than as an applied plan with an empty Todo id, and a team-plan preview now binds this Goal's active-state intent and the canonical basis its lanes would advance, so rewriting the objective after the preview asks the owner to confirm again instead of applying the old plan. The remainder is open and owned by canonical Todos: the plan-level quota/stop classification, F4's per-lane recovery, and rendering a partial application as partial on the confirmation card. | ID / Priority | Trigger, result and consequence | Location and successor | | --- | --- | --- | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 874fcaa901..a9ea996afe 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -300,7 +300,7 @@ R2 的一条依赖必须通过真实 LoopX Agent 间的请求/产物交接完成 以下是精确基线上的合成 fixture 结果,不含线上用户内容。F1–F4 用现有 `ChatActionService.preview/apply` 及隔离 Goal 复核;F4 只在第二次 Todo 写入前注入失败,其余走实际本地 Todo writer。F5–F7 是源码/合同审计。 -**修复状态(2026-09-17,基线 `6979d528b`)。** 下表保留审计当时的事实。R1 已收口 F1 与 F3 的一部分:确认后的 lane 现在把声明的优先级保留在自己的 Todo 标签里;结算 receipt 会把每条 lane 的 acceptance 保留在它变成的 Todo 身份旁;部分落地返回 `team_plan_partially_applied` 并带缺口数量,而不是完整成功;没有任何 lane 可组建的确认记为 typed failure `team_plan_no_staffable_lane`,不再是一份空 Todo id 的已应用计划。剩余部分由 canonical Todo 承接:计划级 quota/stop 分类、F2 的 commit 前置条件、F4 的 per-lane 恢复,以及确认卡片把部分落地渲染为部分。 +**修复状态(2026-09-17,基线 `6979d528b`)。** 下表保留审计当时的事实。R1 已收口 F1 的优先级与验收部分、全部 F3,以及 F2:确认后的 lane 现在把声明的优先级保留在自己的 Todo 标签里;结算 receipt 会把每条 lane 的 acceptance 保留在它变成的 Todo 身份旁;部分落地返回 `team_plan_partially_applied` 并带缺口数量,而不是完整成功;没有任何 lane 可组建的确认记为 typed failure `team_plan_no_staffable_lane`,不再是一份空 Todo id 的已应用计划;团队计划预览现在绑定该 Goal 的 active-state 意图与这些 lane 将要推进的 canonical basis,因此在预览之后改写 objective 会要求业主重新确认,而不是继续应用旧计划。剩余部分由 canonical Todo 承接:计划级 quota/stop 分类、F4 的 per-lane 恢复,以及确认卡片把部分落地渲染为部分。 | ID / 优先级 | 触发、结果与影响 | 定位与修复卡 | | --- | --- | --- | diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 6236e0c6f0..0e55449717 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -260,6 +260,53 @@ def _registry_fingerprint(self) -> str: raise ValueError("the active LoopX registry is unavailable") from exc return hashlib.sha256(content).hexdigest() + def _team_plan_state_fingerprint( + self, goal_id: str, plan: Mapping[str, Any] + ) -> str: + """Bind every fact a confirmed team plan was reviewed against. + + Registry bytes are not enough. A plan is reviewed against the Goal's own + intent -- the objective its work advances -- and that intent lives in the + active-state document and in the canonical source basis the lanes would + be created against, neither of which the registry bytes cover. Changing + the objective therefore used to leave the confirmed plan applicable, + because nothing the preview bound had moved. + + An unreadable fact is bound as its own explicit absence rather than + dropped from the digest, so the precondition fails closed in both + directions: a Goal whose intent becomes readable after the preview asks + the owner to confirm again instead of silently dropping the check. + """ + + from .control_plane.work_items.governed_transition_proposal import ( + steward_team_plan_intent_basis, + ) + + goal = self._goal(goal_id) + project = Path(str(goal.get("repo") or "")).expanduser() + state_file = Path(str(goal.get("state_file") or "")) + if not state_file.is_absolute(): + state_file = project / state_file + try: + state_digest: str | None = hashlib.sha256( + state_file.read_bytes() + ).hexdigest() + except OSError: + state_digest = None + return _digest( + { + "registry": self._registry_fingerprint(), + "goal_id": goal_id, + "active_state": state_digest, + "intent_basis": steward_team_plan_intent_basis( + goal_id=goal_id, + goal=goal, + registry_path=self.registry_path, + plan=plan, + ), + } + ) + def _agent_eligibility( self, agent_id: str, @@ -977,7 +1024,15 @@ def _apply_team_plan( settle_governed_transition_proposals, ) - current_fingerprint = self._registry_fingerprint() + goal_id = str(parameters["goal_id"]) + plan = parameters.get("plan") + if not isinstance(plan, Mapping): + raise ValueError("team plan proposal is malformed") + # The preview bound this Goal's registration facts, its active-state + # intent and the canonical basis its lanes would advance; re-read them + # here so a plan confirmed against one objective cannot become work + # under another, and so a registry change still asks for confirmation. + current_fingerprint = self._team_plan_state_fingerprint(goal_id, plan) if current_fingerprint != proposal.get("expected_state_fingerprint"): stale = self.store.apply( proposal_id, @@ -985,10 +1040,6 @@ def _apply_team_plan( receipt={}, ) return {"proposal": stale, "turn": None} - goal_id = str(parameters["goal_id"]) - plan = parameters.get("plan") - if not isinstance(plan, Mapping): - raise ValueError("team plan proposal is malformed") # The governed transition owner re-validates the plan with the host's own # facts and owns the settlement phase, so this action never becomes a # second writer of lanes. @@ -1120,9 +1171,13 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: evidence = ["The recoverable Goal and Agent Chat Session is available."] permission = "scoped_correction" elif action_kind == "team.plan": - # A plan staffs registered Agents, so the registration facts it was - # validated against are the state that can make this preview stale. - fingerprint = self._registry_fingerprint() + # A plan is reviewed against this Goal's registration facts *and* + # the intent its lanes would advance, so both are bound here and + # re-read at apply. Binding only the registry let an owner objective + # change leave a confirmed plan applicable. + fingerprint = self._team_plan_state_fingerprint( + str(normalized["goal_id"]), normalized["plan"] + ) evidence = [ "The plan was validated against this Goal's registered Agents and the host's advancement action kinds.", "Applying it creates the first bounded Todo of each ready lane, through the canonical Todo owner.", diff --git a/loopx/control_plane/work_items/governed_transition_proposal.py b/loopx/control_plane/work_items/governed_transition_proposal.py index ac66169a72..c03cc17fa6 100644 --- a/loopx/control_plane/work_items/governed_transition_proposal.py +++ b/loopx/control_plane/work_items/governed_transition_proposal.py @@ -386,6 +386,30 @@ def _intent_basis_for( return str(basis) if basis else None +def steward_team_plan_intent_basis( + *, + goal_id: str, + goal: Mapping[str, Any], + registry_path: Path, + plan: Mapping[str, Any], +) -> str | None: + """The canonical source basis a team plan's lanes would be created against. + + A confirmation surface needs this fact *before* the owner confirms, not + only in the receipt afterwards: it is the intent the plan is reviewed + against, so a plan confirmed against one basis may not be applied against + another. The reader is the same one the settlement records, exposed here so + the preview can bind it instead of re-deriving a second basis. + """ + + return _intent_basis_for( + goal_id=goal_id, + goal=goal, + registry_path=Path(registry_path), + preview=plan, + ) + + def _lane_todo_text(text: str, priority: str) -> str: """Give a lane's first Todo the priority the owner confirmed. diff --git a/tests/test_chat_team_plan_action.py b/tests/test_chat_team_plan_action.py index dd5b212472..b1b8affcdb 100644 --- a/tests/test_chat_team_plan_action.py +++ b/tests/test_chat_team_plan_action.py @@ -107,6 +107,19 @@ def _todos(project: Path) -> str: ) +def _rewrite_objective(project: Path, objective: str) -> None: + """Change only the intent the plan was reviewed against, not the registry.""" + + state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + text = state_path.read_text(encoding="utf-8") + head, _, tail = text.partition("\n---") + updated = "\n".join( + f'objective: "{objective}"' if line.startswith("objective:") else line + for line in head.splitlines() + ) + state_path.write_text(f"{updated}\n---{tail}", encoding="utf-8") + + def test_a_confirmed_plan_creates_each_ready_lane_first_todo(tmp_path: Path) -> None: project, _registry_path, service = _fixture(tmp_path) @@ -175,6 +188,48 @@ def test_a_changed_registry_makes_the_confirmed_plan_stale(tmp_path: Path) -> No assert "loopx:todo " not in _todos(project) +def test_a_changed_objective_makes_the_confirmed_plan_stale(tmp_path: Path) -> None: + """The intent a plan advances is part of what the owner confirmed. + + Registry bytes do not move when the owner rewrites the Goal's objective, so + a preview that bound only the registry stayed applicable and turned a plan + reviewed against one objective into work under another. The intent basis the + lanes would be created against is a commit precondition, not a receipt + detail written afterwards. + """ + + project, registry_path, service = _fixture(tmp_path) + + preview = _preview(service) + # Same registry bytes, different intent. + _rewrite_objective(project, "Stand up a different team entirely.") + + applied = service.apply(preview["proposal_id"]) + + assert applied["proposal"]["status"] == "stale" + assert applied["proposal"]["stale"]["expected_state_fingerprint"] == ( + preview["expected_state_fingerprint"] + ) + assert _todos(project).count("loopx:todo ") == 0 + + +def test_an_unchanged_objective_still_confirms_and_records_its_basis( + tmp_path: Path, +) -> None: + """Binding the intent must not make an untouched plan unconfirmable.""" + + project, _registry_path, service = _fixture(tmp_path) + + preview = _preview(service) + applied = service.apply(preview["proposal_id"]) + + proposal = applied["proposal"] + assert proposal["status"] == "applied" + # The receipt names the same canonical basis the preview bound. + assert proposal["receipt"]["intent_basis"].startswith("sha256:") + assert _todos(project).count("loopx:todo ") == 1 + + def _validated(preview_plan: dict) -> dict: from loopx.control_plane.todos.contract import ( TODO_ACTION_KIND_ADVANCEMENT_VALUES,