Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / 优先级 | 触发、结果与影响 | 定位与修复卡 |
| --- | --- | --- |
Expand Down
71 changes: 63 additions & 8 deletions loopx/chat_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -977,18 +1024,22 @@ 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,
current_state_fingerprint=current_fingerprint,
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.
Expand Down Expand Up @@ -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.",
Expand Down
24 changes: 24 additions & 0 deletions loopx/control_plane/work_items/governed_transition_proposal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
55 changes: 55 additions & 0 deletions tests/test_chat_team_plan_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
Loading