Skip to content

Commit 771b18c

Browse files
committed
fix(replan): recover exact successor proof for original Turn settlement
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
1 parent 5bdae1b commit 771b18c

7 files changed

Lines changed: 258 additions & 3 deletions

File tree

‎docs/reference/protocols/goal-vision-replan-contract-v0.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,23 @@ stays on the original Turn, and in-flight continuation remains unchanged.
115115
JSON 写作契约复用 vision 校验器,不新增 ACK 仪式,也不改变既有 successor、blocker、
116116
terminal 出口。语义接受、checkpoint 满足、Turn 结算与 Goal 完成仍须分别验证。
117117

118+
An exact runnable-successor transition can settle the original Turn's selected
119+
replan without completing its still-open validation Todo. The shared frontier
120+
keeps the history-obligation receipt separate from a newly derived frontier
121+
duty; the TypeScript semantic gate recovers only the receipt matching the
122+
durable Turn guard. It reuses current canonical runnable/ownership checks, not
123+
Todo prose or an old creation response. Refresh and spend retain the original
124+
Goal/Agent/Todo/Turn identity and their existing replay behavior. Other Vision
125+
acceptance gaps remain visible; an unrelated Turn without that selected duty
126+
still requires the Todo's declared completion validation.
127+
128+
精确绑定且仍可执行的 successor 可以结算原 Turn 选定的重规划义务,但不把原先
129+
未完成的验证 Todo 标为完成。共享 frontier 分别保留历史义务的成功凭证和新派生
130+
的义务,由 TS 语义门禁仅恢复与持久 Turn guard 匹配的凭证;资格仍来自当前
131+
canonical Todo 的可执行性与归属校验,不来自描述或旧创建响应。写回和扣额保持
132+
原 Goal/Agent/Todo/Turn 身份及既有幂等行为。其他 Vision 验收缺口仍可见;未选定
133+
该义务的另一 Turn 不能复用这次成功来跳过 Todo 的完成验证。
134+
118135
Long-chain review also accepts `fresh_vision_path_outcome` and now projects this
119136
JSON route. An acceptance summary plus an evidence-linked `continue`, `no_change`
120137
or `replan` path can retain existing runnable work; no extra planning Todo or

‎loopx/control_plane/goals/goal_frontier/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,10 @@ def build_goal_frontier_projection_context_from_status(
16481648
replan_obligation=replan_obligation,
16491649
agent_todo_items=agent_todo_source_items,
16501650
)
1651+
# Keep the validated history-obligation transition distinct from any
1652+
# successor frontier obligation derived below. Exact Turn settlement may
1653+
# still owe this receipt even when the next decision has another duty.
1654+
run_replan_transition_ack = replan_transition_ack
16511655
obligation_ack = replan_transition_ack or effective_replan_ack
16521656
if (
16531657
autonomous_replan_is_required(replan_obligation)
@@ -1766,6 +1770,7 @@ def build_goal_frontier_projection_context_from_status(
17661770
"latest_replan_ack": latest_agent_replan_ack,
17671771
"projected_replan_ack": projected_replan_ack,
17681772
"replan_transition_ack": replan_transition_ack,
1773+
"run_replan_transition_ack": run_replan_transition_ack,
17691774
}
17701775

17711776

‎loopx/control_plane/work_items/progress_observation.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,27 @@ def required_semantic_outcomes(obligation: Mapping[str, Any]) -> list[str]:
342342
return list(replan_writeback_requirements(obligation)["required_any_of"])
343343

344344

345+
def guarded_replan_transition_delta(
346+
*, guard_scoped: bool, selected_obligation_id: str | None,
347+
transition_acks: list[dict[str, Any] | None],
348+
) -> dict[str, Any] | None:
349+
"""Adapt revalidated canonical receipts to the TS-owned exact Turn gate."""
350+
try:
351+
result = effect_runtime_result("work_item.replan_semantics.project", {
352+
"operation": "turn_transition", "guard_scoped": guard_scoped,
353+
"selected_obligation_id": selected_obligation_id,
354+
"transition_acks": transition_acks,
355+
})
356+
except EffectRuntimeRejected as exc:
357+
raise ValueError(str(exc)) from None
358+
if not isinstance(result, Mapping) or "semantic_delta" not in result:
359+
raise RuntimeError("TypeScript guarded replan transition shape mismatch")
360+
delta = result["semantic_delta"]
361+
if delta is not None and not isinstance(delta, Mapping):
362+
raise RuntimeError("TypeScript guarded replan delta must be an object or null")
363+
return dict(delta) if delta is not None else None
364+
365+
345366
def replan_obligation_trigger_kinds(
346367
obligation: Mapping[str, Any],
347368
) -> list[str]:

‎loopx/control_plane/work_items/replan_semantics.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { JsonObject } from "../effect_program.ts";
22
import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
3-
import { requireJsonObject } from "../runtime_decode.ts";
3+
import { optionalNonEmptyString, requireJsonObject } from "../runtime_decode.ts";
44
import { visionAuthoringContract } from "../goals/vision_checkpoint.ts";
55

66
const PROGRESS_OUTCOMES = [
@@ -49,6 +49,38 @@ function isExternalReview(obligation: JsonObject): boolean {
4949
return triggerKinds(obligation).some(kind => EXTERNAL_REVIEW_TRIGGERS.has(kind));
5050
}
5151

52+
/** Route only a revalidated canonical transition to its original Turn guard.
53+
* A successor frontier duty stays with the next decision; this receipt cannot
54+
* discharge an unrelated duty or certify completion of the settlement Todo.
55+
*/
56+
function projectTurnTransition(request: JsonObject): JsonObject {
57+
if (typeof request.guard_scoped !== "boolean" || !Array.isArray(request.transition_acks)) {
58+
throw new EffectRuntimeRequestError("turn transition requires a scoped guard and transition receipts");
59+
}
60+
const selected = optionalNonEmptyString(request.selected_obligation_id, "selected_obligation_id");
61+
if (selected !== null && !/^replan-[a-f0-9]{16}$/.test(selected)) {
62+
throw new EffectRuntimeRequestError("selected replan obligation id is malformed");
63+
}
64+
let delta: JsonObject | null = null;
65+
if (request.guard_scoped && selected !== null) {
66+
for (const value of request.transition_acks) {
67+
const ack = object(value);
68+
const candidate = object(ack.semantic_delta);
69+
if (ack.schema_version === "autonomous_replan_ack_v0" &&
70+
ack.recorded === true && ack.source === "todo_replan_successor_transition" &&
71+
candidate.schema_version === "replan_semantic_delta_v0" &&
72+
candidate.accepted === true && candidate.obligation_id === selected &&
73+
strings(candidate.outcomes).includes("new_runnable_successor") &&
74+
strings(candidate.satisfying_outcomes).includes("new_runnable_successor") &&
75+
String(candidate.successor_todo_id ?? "").trim()) {
76+
delta = candidate;
77+
break;
78+
}
79+
}
80+
}
81+
return {semantic_delta: delta};
82+
}
83+
5284
/** One outcome policy for host projection and write-time discharge. */
5385
export function requiredSemanticOutcomes(obligation: JsonObject): SemanticOutcome[] {
5486
const kinds = triggerKinds(obligation);
@@ -108,6 +140,7 @@ function writebackProjection(required: SemanticOutcome[], externalReview: boolea
108140

109141
export function projectReplanSemantics(value: unknown): JsonObject {
110142
const request = requireJsonObject(value, "work_item.replan_semantics params");
143+
if (request.operation === "turn_transition") return projectTurnTransition(request);
111144
const obligation = requireJsonObject(request.obligation, "obligation");
112145
const required = requiredSemanticOutcomes(obligation);
113146
const externalReview = isExternalReview(obligation);

‎loopx/control_plane/work_items/semantic_replan_writeback.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
build_autonomous_replan_cli_actions,
3232
project_todo_lifecycle_settlement_reentry,
3333
)
34-
from .progress_observation import semantic_delta_from_writeback
34+
from .progress_observation import guarded_replan_transition_delta, semantic_delta_from_writeback
3535
from .repair_delta import (
3636
build_repair_delta_contract,
3737
repair_delta_kinds_have_accountable_progress,
@@ -198,6 +198,8 @@ def qualify_replan_writeback(
198198
completion_turn_key: str | None = None,
199199
todo_fields: dict[str, Any] | None = None,
200200
external_progress_review: Mapping[str, Any] | None = None,
201+
guard_scoped: bool = False,
202+
guard_semantic_replan_obligation_id: str | None = None,
201203
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
202204
"""Return the shared open obligation and the writeback's typed delta.
203205
@@ -295,6 +297,17 @@ def qualify_replan_writeback(
295297
else None
296298
),
297299
)
300+
if guard_scoped and guard_semantic_replan_obligation_id:
301+
transition_delta = guarded_replan_transition_delta(
302+
guard_scoped=guard_scoped,
303+
selected_obligation_id=guard_semantic_replan_obligation_id,
304+
transition_acks=[context.get("run_replan_transition_ack"),
305+
context.get("replan_transition_ack")],
306+
)
307+
if transition_delta is not None:
308+
# This closes only the selected Turn obligation. The freshly
309+
# derived frontier obligation remains visible to the next guard.
310+
return None, transition_delta
298311
obligation = context.get("replan_obligation")
299312
if not obligation:
300313
# A validated Todo transition can discharge the read-model obligation
@@ -373,6 +386,8 @@ def enforce_open_replan_writeback(
373386
completion_todo_id=completion_todo_id,
374387
completion_turn_key=completion_turn_key,
375388
todo_fields=todo_fields,
389+
guard_scoped=guard_scoped,
390+
guard_semantic_replan_obligation_id=guard_semantic_replan_obligation_id,
376391
)
377392
if not obligation:
378393
if isinstance(semantic_delta, dict) and semantic_delta.get("accepted") is True:

‎tests/control_plane/test_replan_successor_durable_ack.py‎

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def test_cli_successor_refresh_resets_periodic_window(tmp_path: Path, capsys) ->
8383
[*reversed(new_runs), compact, *runs], agent_todos={}, agent_id=AGENT) is not None
8484

8585

86-
@pytest.mark.parametrize("guard", [None, "replan-different"])
86+
@pytest.mark.parametrize("guard", [None, "replan-0000000000000000"])
8787
def test_transition_cannot_settle_a_different_turn_guard(guard) -> None:
8888
runs = history()
8989
obligation = autonomous_replan_obligation_from_runs(runs, agent_todos={}, agent_id=AGENT)
@@ -106,6 +106,145 @@ def test_transition_settles_only_exact_guard_and_owner() -> None:
106106
state_text=successor_state(obligation["obligation_id"], owner="another-agent"), **kwargs)
107107

108108

109+
@pytest.mark.parametrize("invalid", ["done", "deferred", "missing_target"])
110+
def test_non_runnable_successor_cannot_settle_original_guard(invalid: str) -> None:
111+
runs = history()
112+
obligation = autonomous_replan_obligation_from_runs(runs, agent_todos={}, agent_id=AGENT)
113+
state = successor_state(obligation["obligation_id"])
114+
state = (state.replace("target_key=source-audit ", "") if invalid == "missing_target"
115+
else state.replace("status=open", f"status={invalid}"))
116+
with pytest.raises(ReplanWritebackRejected):
117+
enforce_open_replan_writeback(newest_first_runs=runs, state_text=state,
118+
agent_id=AGENT, goal_id=GOAL, guard_scoped=True,
119+
guard_semantic_replan_obligation_id=obligation["obligation_id"])
120+
121+
122+
@pytest.mark.parametrize("provider", ["file", "sqlite"])
123+
def test_canonical_periodic_successor_settles_original_open_validation_todo(
124+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str,
125+
) -> None:
126+
"""A new task changes the path, not the original task's completion."""
127+
import subprocess
128+
import sys
129+
130+
from canonical_authority_fixture import (
131+
initialize_canonical_authority, isolate_sqlite_runtime,
132+
)
133+
from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection
134+
from loopx.control_plane.goals.goal_vision import compact_goal_vision_packet, normalize_goal_vision_packet
135+
from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos
136+
137+
if provider == "sqlite":
138+
isolate_sqlite_runtime(tmp_path, monkeypatch)
139+
project = tmp_path / "project"
140+
project.mkdir()
141+
state = project / "ACTIVE_GOAL_STATE.md"
142+
original_todo = "todo_original_validation"
143+
state.write_text(
144+
"---\nstatus: active\n---\n\n# Goal\n\n## Agent Todo\n\n"
145+
"- [ ] [P1] Validate the original artifact.\n"
146+
f" <!-- loopx:todo todo_id={original_todo} status=open "
147+
f"task_class=advancement_task claimed_by={AGENT} action_kind=validate "
148+
"validation_command=pytest -->\n"
149+
)
150+
runtime = tmp_path / "runtime"
151+
index = runtime / "goals" / GOAL / "runs" / "index.jsonl"
152+
index.parent.mkdir(parents=True)
153+
runs = history()
154+
baseline_json = index.parent / "synthetic-baseline.json"
155+
baseline_markdown = index.parent / "synthetic-baseline.md"
156+
baseline_json.write_text(json.dumps({"ok": True, "fixture": "synthetic-replan"}))
157+
baseline_markdown.write_text("# Synthetic prior delivery\n")
158+
for row in runs:
159+
row.update(json_path=str(baseline_json), markdown_path=str(baseline_markdown))
160+
runs[0]["agent_vision"] = compact_goal_vision_packet(normalize_goal_vision_packet({
161+
"goal_id": GOAL, "agent_id": AGENT, "state": "vision_drift_detected",
162+
"todo_delta": [f"retain:{original_todo}"],
163+
"vision_patch": {
164+
"acceptance_summary": "Independently validate the source artifact.",
165+
"replan_trigger_summary": "The source acceptance remains open.",
166+
"advancement_policy": "repeat_until_closed",
167+
},
168+
}, goal_id=GOAL, agent_id=AGENT))
169+
runs[0]["vision_checkpoint"] = {
170+
"agent_id": AGENT, "required": True, "satisfied": False,
171+
"triggers": [{"kind": "material_delivery_outcome", "delivery_outcome": "outcome_progress"}],
172+
}
173+
index.write_text("".join(json.dumps(row) + "\n" for row in reversed(runs)))
174+
registry = tmp_path / "registry.json"
175+
registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{
176+
"id": GOAL, "status": "active", "repo": str(project), "state_file": state.name,
177+
"domain": "synthetic-replan",
178+
"adapter": {"kind": "fixture_connected_delivery_v0", "status": "connected-delivery"},
179+
"quota": {"compute": 1.0, "window_hours": 24},
180+
"coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT]},
181+
}]}))
182+
todos = parse_active_state_todos(state.read_text(), item_limit=None)["agent_todos"]["items"]
183+
initialize_canonical_authority(runtime, GOAL, build_todo_runtime_shadow_projection(
184+
goal_id=GOAL, todos=todos, handoff_mode="soft_claim", leases=[],
185+
), state_path=state, provider=provider)
186+
187+
def call(*args: str, expected_error: str | None = None) -> dict:
188+
result = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry),
189+
"--runtime-root", str(runtime), "--format", "json", *args], cwd=project,
190+
capture_output=True, text=True, timeout=60)
191+
payload = json.loads(result.stdout)
192+
if expected_error is not None:
193+
assert result.returncode == 1, payload
194+
assert expected_error in payload["error"], payload
195+
else:
196+
assert result.returncode == 0, (payload.get("error"), payload.get("reason"), payload.get("status"))
197+
return payload
198+
199+
binding = ["--goal-id", GOAL, "--agent-id", AGENT, "--todo-id", original_todo,
200+
"--turn-instance-id", "turn-original-periodic-review"]
201+
guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL,
202+
"--agent-id", AGENT, "--turn-instance-id", "turn-original-periodic-review")
203+
assert guard["selected_todo"]["todo_id"] == original_todo
204+
obligation = guard["autonomous_replan_obligation"]
205+
added = call("todo", "add", "--goal-id", GOAL, "--role", "agent", "--claimed-by", AGENT,
206+
"--text", "Verify an independent source artifact",
207+
"--task-class", "advancement_task", "--action-kind", "validate",
208+
"--target-key", "independent-source-artifact", "--operation-id", "periodic-successor",
209+
"--replan-obligation-id", obligation["obligation_id"])
210+
assert added["replan_transition"]["recorded"] is True
211+
refresh_args = ("refresh-state", *binding, "--classification", "bounded_replan_progress",
212+
"--delivery-batch-scale", "single_surface", "--delivery-outcome", "outcome_progress",
213+
"--vision-unchanged-reason", "The original validation remains open; the independent successor changes the path.",
214+
"--no-global-sync", "--suppress-external-sinks")
215+
refreshed = call(*refresh_args)
216+
persisted = json.loads(Path(refreshed["json_path"]).read_text())
217+
delta = persisted["autonomous_replan_ack"]["semantic_delta"]
218+
assert delta["obligation_id"] == obligation["obligation_id"]
219+
assert delta["successor_todo_id"] == added["todo_id"]
220+
spend_args = ("quota", "spend-slot", *binding, "--slots", "1",
221+
"--source", "heartbeat", "--execute")
222+
spent = call(*spend_args)
223+
assert spent["appended"] is True
224+
assert spent["settlement_progress"]["state"] == "settled"
225+
assert call(*refresh_args)["appended"] is False
226+
assert call(*spend_args)["appended"] is False
227+
settlement_runs = [json.loads(line) for line in index.read_text().splitlines()
228+
if json.loads(line).get("turn_instance_id") == "turn-original-periodic-review"]
229+
assert len(settlement_runs) == 2
230+
assert sum(row.get("classification") == "quota_slot_spent" for row in settlement_runs) == 1
231+
original = call("todo", "list", "--goal-id", GOAL, "--todo-id", original_todo)["todo"]
232+
assert original["status"] == "open"
233+
# Review settlement does not establish Vision acceptance or Todo completion.
234+
next_guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL,
235+
"--agent-id", AGENT, "--turn-instance-id", "turn-next-vision-review")
236+
frontier = next_guard["goal_frontier_projection"]
237+
assert "vision_outcome_checkpoint_required" in [gap["kind"] for gap in frontier["acceptance_gaps"]]
238+
assert frontier["vision_continuation_audit"]["decision"] == "acceptance_gap_open"
239+
call("quota", "should-run", "--codex-app", "--goal-id", GOAL,
240+
"--agent-id", AGENT, "--todo-id", original_todo,
241+
"--turn-instance-id", "turn-next-vision-review")
242+
call("refresh-state", "--goal-id", GOAL, "--agent-id", AGENT, "--todo-id", original_todo,
243+
"--turn-instance-id", "turn-next-vision-review", "--classification", "evidence_validated",
244+
"--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks",
245+
expected_error="controller-declared completion validation")
246+
247+
109248
@pytest.mark.parametrize("route", ["writeback", "successor", "canonical-successor"])
110249
def test_long_chain_ack_survives_real_cli_history_and_peer_claim(tmp_path: Path, route: str) -> None:
111250
import subprocess

0 commit comments

Comments
 (0)