diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 84b6f37d82..54a272a9c4 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -95,6 +95,34 @@ replan 持久记录已接受的 `new_runnable_successor` 后,新一轮 quota Agent、匹配确切来源义务并通过准入。方案被替换或任务失效时恢复普通选择; 显式选择、已绑定回执及现有 Gate 保持优先。纯文字 Vision 不能标识要推荐的任务。 +## Completed Todo lifecycle reentry + +An admitted lifecycle review retains its original Turn identity when its exact +completed Todos are legally closed with `todo complete --no-follow-up`. The +canonical completion identities and same-agent terminal-reentry facts must +reconstruct the admitted obligation; ambiguous or changed sources fail closed. +Lifecycle reentry updates `updated_at`, preserving the original completion time, +identity and validation evidence. Terminal rows closed before admission are +excluded. Recovery restores original +completion order before the bounded trigger selection; it does not order sources +by their later closeout times. Whole-second Todo timestamps allow a same-second +closeout candidate only when the original obligation digest still matches. +The shared typed replan owner projects settlement only, with a fresh +evidence-linked Vision/path writeback still required before one quota debit. +No original validator is rerun, no successor is executed in that Turn, and a +new frontier remains the next Turn's responsibility. Lifecycle obligation +identities now include Todo and completion identities; older ambiguous receipts +are not rewritten or automatically admitted by this recovery. + +已准入的生命周期复核中,确切 Todo 合法执行 `--no-follow-up` 后,原轮身份保持 +不变。恢复须由同一 Agent 的规范终态及原完成身份重建原义务;来源不明确或 +变化时仍拒绝。恢复只允许收尾,仍须提交带证据的 Vision/path 回写后扣额一次; +接续收尾更新 `updated_at`,保留原完成时间、身份及验证证据。准入前已收尾的 +历史记录须排除,来源按原完成顺序恢复后再截取触发项,不能按 +后来收尾的时间排序。Todo 时间戳精度为秒,同秒候选仍须匹配原义务摘要。 +不重跑原验证、不在本轮执行后续任务,也不抹除下一轮的新验收缺口。该类义务 +身份增加 Todo 与完成身份,旧的模糊回执不会被改写或自动放行。 + ## CLI Budget Per-agent vision is an executable control-plane field, so the CLI/write API must diff --git a/loopx/control_plane/coordination/todo_terminal_reentry.ts b/loopx/control_plane/coordination/todo_terminal_reentry.ts index b82272ba79..877c590967 100644 --- a/loopx/control_plane/coordination/todo_terminal_reentry.ts +++ b/loopx/control_plane/coordination/todo_terminal_reentry.ts @@ -84,5 +84,6 @@ export function prepareTerminalReentry(todo: JsonObject, input: CoordinationTodo const state = selectTodoCompletionState({schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, todo, requested_no_followup: true, has_successor: false, completion_identity_source: "lifecycle_reentry"}); return {...todo, no_followup: true, completion_continuation: state.continuation, - completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key}; + completion_recovery: state.recovery, completion_turn_key: input.requested_completion_turn_key, + updated_at: input.now.toISOString().replace(/\.\d{3}Z$/u, "Z")}; } diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 3c8ada5850..f59e3b97be 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1499,6 +1499,7 @@ def build_goal_frontier_projection_context_from_status( goal_status: str | None = None, agent_profile: dict[str, Any] | None = None, receipt_bound_replan_obligation_id: str | None = None, + receipt_bound_guard_recorded_at: str | None = None, ) -> dict[str, Any]: """Build the quota-facing goal-frontier read model. @@ -1718,6 +1719,21 @@ def build_goal_frontier_projection_context_from_status( ) frontier_transition_candidate = {"obligation": source, "ack": source_ack} + lifecycle_candidate = None + if receipt_bound_replan_obligation_id and receipt_bound_guard_recorded_at: + from ...work_items.lifecycle_recovery import receipt_bound_lifecycle_candidate + lifecycle_candidate = receipt_bound_lifecycle_candidate( + selected_obligation_id=receipt_bound_replan_obligation_id, + guard_recorded_at=receipt_bound_guard_recorded_at, agent_id=agent_id, + items=agent_todo_source_items or [], + derive=lambda summary: derive_goal_frontier_replan_obligation_from_summaries( + user_todo_summary=user_todo_summary, agent_todo_summary=summary, + work_lane_contract=work_lane_contract, agent_id=agent_id, + existing_replan_obligation=None, agent_todo_source_items=agent_todo_source_items, + _frontier_counts=frontier_counts, + ), + ) + if ( frontier_replan_obligation and autonomous_replan_ack_satisfies_obligation( @@ -1795,7 +1811,7 @@ def build_goal_frontier_projection_context_from_status( "projected_replan_ack": projected_replan_ack, "replan_transition_ack": replan_transition_ack, "run_replan_transition_ack": run_replan_transition_ack, - "replan_transition_candidates": [run_transition_candidate, frontier_transition_candidate], + "replan_transition_candidates": [run_transition_candidate, frontier_transition_candidate, *([lifecycle_candidate] if lifecycle_candidate else [])], } diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 77d0621da8..9920fe8c82 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -17,6 +17,7 @@ from .effect_program import ReceiptBoundReplayPhase from .blocked_retry import overlay_active_turn_retries from .settlement import ( + QuotaSettlementReadback, attach_settlement_progress, read_heartbeat_settlement, ) @@ -40,6 +41,23 @@ BoundedResearchFrontierProjector = Callable[..., Mapping[str, Any] | None] +def _receipt_bound_guard_fields( + readback: QuotaSettlementReadback | None, + obligation_id: str | None, +) -> dict[str, Any]: + """Adapt the recorded guard identity and time for the shared decision owner.""" + guard = getattr(readback, "semantic_replan_guard", None) + receipt = getattr(readback, "heartbeat_receipt", None) or {} + return { + "receipt_bound_replan_guard_scoped": bool( + obligation_id and isinstance(guard, Mapping) + and guard.get("scope") == "turn_guard" + and guard.get("selected_obligation_id") == obligation_id + ), + "receipt_bound_guard_recorded_at": receipt.get("recorded_at"), + } + + def _apply_retained_action_selection_reentry( payload: dict[str, Any], *, @@ -570,12 +588,6 @@ def build_live_quota_should_run_decision( receipt_bound_replay_phase = ( settlement_readback.replay_phase if settlement_readback else None ) - semantic_guard = getattr(settlement_readback, "semantic_replan_guard", None) - receipt_bound_replan_guard_scoped = bool( - receipt_bound_replan_obligation_id and isinstance(semantic_guard, Mapping) - and semantic_guard.get("scope") == "turn_guard" - and semantic_guard.get("selected_obligation_id") == receipt_bound_replan_obligation_id - ) fresh_operator_inbox_read = _fresh_operator_inbox_read_required( turn_start_hook_dispatch ) @@ -631,7 +643,9 @@ def build_live_quota_should_run_decision( receipt_bound_monitor_phase=receipt_bound_monitor_phase, receipt_bound_replay_phase=receipt_bound_replay_phase, receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, - receipt_bound_replan_guard_scoped=receipt_bound_replan_guard_scoped, + **_receipt_bound_guard_fields( + settlement_readback, receipt_bound_replan_obligation_id, + ), turn_instance_id=turn_instance_id, runtime_root=runtime_root, workspace_path=workspace_path, diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index 7a0ca71f17..d079c22db6 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -315,6 +315,7 @@ def build_quota_should_run( receipt_bound_terminal_phase: ReceiptBoundTerminalPhase | None = None, receipt_bound_replan_obligation_id: str | None = None, receipt_bound_replan_guard_scoped: bool = False, + receipt_bound_guard_recorded_at: str | None = None, turn_instance_id: str | None = None, runtime_root: str | Path | None = None, workspace_path: Path | None = None, @@ -389,6 +390,7 @@ def build_quota_should_run( receipt_bound_replay_phase=receipt_bound_replay_phase, receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, receipt_bound_replan_guard_scoped=receipt_bound_replan_guard_scoped, + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) route = _resolve_quota_should_run_route(prepared) route = _apply_selected_todo_guards(prepared, route, workspace_path=workspace_path) diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 6e19f17db1..5ae743f432 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -519,6 +519,7 @@ def _prepare_quota_should_run_item( receipt_bound_replay_phase: ReceiptBoundReplayPhase | None, receipt_bound_replan_obligation_id: str | None, receipt_bound_replan_guard_scoped: bool = False, + receipt_bound_guard_recorded_at: str | None = None, ) -> _QuotaDecisionPreparation: quota = item.get("quota") if isinstance(item.get("quota"), dict) else {} state = str(quota.get("state") or "unknown") @@ -837,6 +838,7 @@ def _prepare_quota_should_run_item( goal_status=str(registry_goal.get("status") or ""), agent_profile=_quota_agent_profile(agent_identity), receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) replan_obligation = ( None diff --git a/loopx/control_plane/work_items/autonomous_replan_obligation.py b/loopx/control_plane/work_items/autonomous_replan_obligation.py index 7749d7198f..66060310f1 100644 --- a/loopx/control_plane/work_items/autonomous_replan_obligation.py +++ b/loopx/control_plane/work_items/autonomous_replan_obligation.py @@ -228,6 +228,7 @@ def ensure_replan_novelty_policy( "monitor_target_id", "progress_fingerprint", "agent_id", + *(("todo_id", "completion_turn_key") if trigger.get("kind") == "completed_advancement_without_successor" else ()), *( ("latest_generated_at", "oldest_counted_generated_at") if trigger.get("kind") diff --git a/loopx/control_plane/work_items/lifecycle_recovery.py b/loopx/control_plane/work_items/lifecycle_recovery.py new file mode 100644 index 0000000000..7f9707521f --- /dev/null +++ b/loopx/control_plane/work_items/lifecycle_recovery.py @@ -0,0 +1,36 @@ +"""Adapt canonical lifecycle facts to the existing typed replan owner.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from ..effect_runtime import effect_runtime_result + + +def receipt_bound_lifecycle_candidate( + *, + selected_obligation_id: str, + guard_recorded_at: str, + agent_id: str | None, + items: list[dict[str, Any]], + derive: Callable[[dict[str, Any]], dict[str, Any] | None], +) -> dict[str, Any] | None: + facts = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "lifecycle_source", "guard_recorded_at": guard_recorded_at, + "agent_id": agent_id, "items": items, + }) + if not facts.get("settled"): + return None + gaps = facts["items"] + source = derive({"completed_without_successor_items": gaps, + "completed_without_successor_count": len(gaps)}) + if not source or source.get("obligation_id") != selected_obligation_id: + return None + result = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "lifecycle_obligation", "obligation": source, + "selected_obligation_id": selected_obligation_id, + "guard_recorded_at": guard_recorded_at, "agent_id": agent_id, "items": items, + }) + return {"obligation": result["obligation"], "source_obligation": source, + "guard_recorded_at": guard_recorded_at, "items": items, + "ack": None, "lifecycle_settled": True} diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 5c9f12024d..137dd36d6f 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -94,6 +94,70 @@ function projectTurnTransition(request: JsonObject): JsonObject { return {semantic_delta: delta}; } +/** Reconstruct only the exact lifecycle source which could be open at admission. + * Canonical no-follow-up reentry preserves the original completion identity. + * The selected obligation digest additionally binds Todo and completion identities. + */ +function lifecycleSource(request: JsonObject): JsonObject { + const agent = optionalNonEmptyString(request.agent_id, "agent_id"); + const admitted = typeof request.guard_recorded_at === "string" + ? parseTodoTimestampMicros(request.guard_recorded_at) : null; + if (!agent || admitted === null || !Array.isArray(request.items)) return {items: [], settled: false}; + // Canonical Todo updates use whole seconds. A closeout in the admission + // second is possible; the exact obligation digest must still prove identity. + // Older terminal rows were already absent from the admitted frontier. + const admissionSecond = admitted / 1_000_000n * 1_000_000n; + const items = request.items.map(object).filter(item => { + const completed = typeof item.completed_at === "string" ? parseTodoTimestampMicros(item.completed_at) : null; + const updated = typeof item.updated_at === "string" ? parseTodoTimestampMicros(item.updated_at) : null; + return item.status === "done" && item.done === true && + (item.archive_state ?? "active") === "active" && item.task_class === "advancement_task" && + item.claimed_by === agent && item.last_actor_agent_id === agent && + completed !== null && completed <= admitted && + strings(item.successor_todo_ids).length === 0 && !item.superseded_by && + (item.no_followup !== true || (item.completion_recovery === "lifecycle_reentry_terminal_closeout" && + updated !== null && updated >= admissionSecond)); + }).sort((left, right) => { + // Terminal reentry changes updated_at, never the original completed_at. + // Restore the original summary's ordering before its three-trigger bound. + const a = parseTodoTimestampMicros(String(left.completed_at))!; + const b = parseTodoTimestampMicros(String(right.completed_at))!; + return a === b ? Number(right.index ?? 0) - Number(left.index ?? 0) : a > b ? -1 : 1; + }); + const settled = items.length > 0 && items.slice(0, 3).every(item => + item.no_followup === true && item.completion_continuation === "no_followup" && + typeof item.todo_id === "string" && !!item.todo_id && + typeof item.completion_turn_key === "string" && !!item.completion_turn_key && + item.completion_recovery === "lifecycle_reentry_terminal_closeout"); + return {items, settled}; +} + +function lifecycleObligation(request: JsonObject): JsonObject | null { + const obligation = object(request.obligation); + if (obligation.obligation_id !== request.selected_obligation_id || + obligation.resolution_mode !== "todo_lifecycle_settlement") return null; + const source = lifecycleSource(request); + const items = source.items as JsonObject[]; + const triggers = Array.isArray(obligation.triggers) ? obligation.triggers.map(object) : []; + if (source.settled !== true || triggers.length !== Math.min(3, items.length) || + triggers.some((trigger, index) => trigger.kind !== "completed_advancement_without_successor" || + trigger.agent_id !== request.agent_id || trigger.todo_id !== items[index].todo_id || + trigger.completion_turn_key !== items[index].completion_turn_key)) return null; + const reason = "The exact completed Todo lifecycle is settled. Reassess the original acceptance with evidence, then write back and settle this Turn once; do not repeat the original work."; + return {...obligation, required: true, selection_binding: "heartbeat_receipt", + resolution_mode: "receipt_bound_replan_settlement", satisfying_semantic_outcomes: [...VISION_OUTCOMES], + recommended_action: reason, todo_actions: [], guidance_actions: [reason], + settlement_action_packet: { + schema_version: "replan_action_packet_v0", obligation_id: obligation.obligation_id, + decision: "vision_outcome_checkpoint_required", settlement_only: true, + required_outcome: "durable_writeback_receipt", semantic_delta: null, + writeback_contract: {preferred_input: "evidence_linked_vision_path", + cli_semantic_args: VISION_CLI_ARGS, vision_authoring: visionAuthoringContract(), + required_fields: ["vision_patch.acceptance_summary", "path_delta.outcome", "path_delta.evidence_refs"], + path_outcomes: [...FRESH_PATH_DISPOSITIONS], rule: reason}, + }}; +} + /** A changed frontier cannot rebind work already admitted by a Turn receipt. * Current canonical transition candidates must carry their original source * obligation. After validated writeback the settlement owner's replay phase, @@ -114,6 +178,11 @@ function preserveReceiptBoundObligation(request: JsonObject): JsonObject { for (const value of request.transition_candidates) { const candidate = object(value); const obligation = object(candidate.obligation); + if (request.guard_scoped && candidate.lifecycle_settled === true) { + const recovered = lifecycleObligation({...candidate, obligation: candidate.source_obligation, + selected_obligation_id: selected, agent_id: request.agent_id}); + if (recovered) return {obligation: recovered}; + } if (obligation.obligation_id !== selected || !requiredSemanticOutcomes(obligation).includes("new_runnable_successor")) continue; const result = projectTurnTransition({guard_scoped: request.guard_scoped, selected_obligation_id: selected, transition_acks: [candidate.ack]}); @@ -242,6 +311,12 @@ function acknowledgeVisionEvidence(request: JsonObject): JsonObject { export function projectReplanSemantics(value: unknown): JsonObject { const request = requireJsonObject(value, "work_item.replan_semantics params"); + if (request.operation === "lifecycle_source") return lifecycleSource(request); + if (request.operation === "lifecycle_obligation") { + const obligation = lifecycleObligation(request); + if (!obligation) throw new EffectRuntimeRequestError("lifecycle source does not match the admitted obligation"); + return {obligation}; + } if (request.operation === "vision_ack") return acknowledgeVisionEvidence(request); if (request.operation === "turn_transition") return projectTurnTransition(request); if (request.operation === "receipt_bound_obligation") return preserveReceiptBoundObligation(request); diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index 64ac1696b1..f80a77a5f0 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -201,6 +201,7 @@ def qualify_replan_writeback( effective_turn_cadence: dict[str, Any] | None = None, guard_scoped: bool = False, guard_semantic_replan_obligation_id: str | None = None, + receipt_bound_guard_recorded_at: str | None = None, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Return the shared open obligation and the writeback's typed delta. @@ -294,6 +295,7 @@ def qualify_replan_writeback( registered_agent_ids=list(agent_identity["registered_agents"]), goal_status=str((registry_goal or {}).get("status") or "active"), receipt_bound_replan_obligation_id=(guard_semantic_replan_obligation_id if guard_scoped else None), + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, agent_profile=( agent_identity.get("agent_profile") if isinstance(agent_identity.get("agent_profile"), dict) @@ -313,7 +315,10 @@ def qualify_replan_writeback( # This closes only the selected Turn obligation. The freshly # derived frontier obligation remains visible to the next guard. return None, transition_delta - obligation = context.get("replan_obligation") + lifecycle = next((candidate for candidate in context.get("replan_transition_candidates", []) + if candidate.get("lifecycle_settled") is True and + (candidate.get("obligation") or {}).get("obligation_id") == guard_semantic_replan_obligation_id), None) + obligation = lifecycle["obligation"] if guard_scoped and lifecycle else context.get("replan_obligation") if not obligation and guard_scoped and guard_semantic_replan_obligation_id: # A now-unclaimed successor may keep the ordinary frontier runnable, # but cannot discharge its former owner's admitted review. Retain the @@ -376,6 +381,7 @@ def enforce_open_replan_writeback( completion_turn_key: str | None = None, guard_scoped: bool = False, guard_semantic_replan_obligation_id: str | None = None, + receipt_bound_guard_recorded_at: str | None = None, todo_fields: dict[str, Any] | None = None, external_progress_review: Mapping[str, Any] | None = None, effective_turn_cadence: dict[str, Any] | None = None, @@ -404,6 +410,7 @@ def enforce_open_replan_writeback( todo_fields=todo_fields, guard_scoped=guard_scoped, guard_semantic_replan_obligation_id=guard_semantic_replan_obligation_id, + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) if not obligation: if isinstance(semantic_delta, dict) and semantic_delta.get("accepted") is True: @@ -449,6 +456,7 @@ def qualify_refresh_replan_writeback( settlement_todo_id: str | None, settlement_guard_scoped: bool, settlement_guard_semantic_replan_obligation_id: str | None, + receipt_bound_guard_recorded_at: str | None = None, newest_first_runs: list[dict[str, Any]], state_text: str, goal_id: str, @@ -528,6 +536,7 @@ def qualify_refresh_replan_writeback( guard_semantic_replan_obligation_id=( settlement_guard_semantic_replan_obligation_id ), + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, ) if semantic_delta: effective_recorded = True diff --git a/loopx/quota.py b/loopx/quota.py index dd850d250e..44d9daac51 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -924,6 +924,7 @@ def build_quota_should_run( receipt_bound_terminal_phase: ReceiptBoundTerminalPhase | None = None, receipt_bound_replan_obligation_id: str | None = None, receipt_bound_replan_guard_scoped: bool = False, + receipt_bound_guard_recorded_at: str | None = None, turn_instance_id: str | None = None, runtime_root: str | Path | None = None, workspace_path: Path | None = None, @@ -951,6 +952,7 @@ def build_quota_should_run( receipt_bound_terminal_phase=receipt_bound_terminal_phase, receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, receipt_bound_replan_guard_scoped=receipt_bound_replan_guard_scoped, + receipt_bound_guard_recorded_at=receipt_bound_guard_recorded_at, turn_instance_id=turn_instance_id, runtime_root=runtime_root, workspace_path=workspace_path, diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 4cb0251771..ba416d7b3a 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1067,6 +1067,7 @@ def refresh_state_run( settlement_guard_scoped=( settlement_replan_guard.get("scope") == "turn_guard" ), + receipt_bound_guard_recorded_at=(settlement_readback.heartbeat_receipt or {}).get("recorded_at") if settlement_readback else None, settlement_guard_semantic_replan_obligation_id=( settlement_replan_guard.get("selected_obligation_id") if isinstance( diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index df418662b5..00711d05df 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -5,6 +5,7 @@ import shlex import subprocess import sys +from hashlib import sha256 from pathlib import Path from typing import Any @@ -13,11 +14,12 @@ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.quota.settlement import read_heartbeat_settlement from loopx.control_plane.todos.markdown import render_todo_markdown -def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str): +def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, *, historical_terminals: int = 0): # Scope managed Effect discovery as well as provider data for both arms. isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry_path = cli._write_fixture(tmp_path) @@ -26,6 +28,20 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str): "required_capabilities=filesystem_write", "required_capabilities=shell%2Cfilesystem_write " f"claimed_by={cli.AGENT_ID} required_write_scopes=src/**", )) + # Initial synthetic authority, never edits to an admitted or active Goal. + # These same-Agent terminal rows are absent from the later admission source. + state.write_text(state.read_text() + "".join( + f"\n- [x] Previously accepted source {index}.\n" + f" \n" + for index in range(historical_terminals) + )) registry = json.loads(registry_path.read_text()) registry["goals"][0]["coordination"]["write_scope"] = ["src/**"] registry_path.write_text(json.dumps(registry)) @@ -46,10 +62,17 @@ def run(*args: str) -> tuple[int, dict[str, Any]]: code, listed = run("todo", "list", "--goal-id", cli.GOAL_ID) assert code == 0, listed + projection = build_todo_runtime_shadow_projection( + goal_id=cli.GOAL_ID, todos=listed["todos"], handoff_mode="hard_lease", leases=[], + ) + # Actor audit is canonical-only; legacy Markdown import cannot author it. + historical = [row for row in projection["todos"] if row["todo_id"].startswith("todo_historical_")] + assert len(historical) == historical_terminals + for row in historical: + row["last_actor_agent_id"] = cli.AGENT_ID + projection["todo_read_model"]["records_sha256"] = sha256(canonical_bytes(projection["todos"])).hexdigest() initialize_canonical_authority( - runtime, cli.GOAL_ID, build_todo_runtime_shadow_projection( - goal_id=cli.GOAL_ID, todos=listed["todos"], handoff_mode="hard_lease", leases=[], - ), state_path=state, provider=provider, + runtime, cli.GOAL_ID, projection, state_path=state, provider=provider, ) code, acquired = run( "task-lease", "acquire", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, @@ -234,3 +257,81 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( assert accepted["validation_receipt"]["validation_declaration_sha256"] == original["completion_validation_sha256"] assert marker.read_text() == "run\nrun\n", "one failed attempt, then one actual successful check" assert cli._spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("historical_terminals", [0, 1, 4]) +def test_no_followup_transition_recovers_bound_review_through_writeback_and_single_debit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, historical_terminals: int, +) -> None: + project, _, runtime, _, run, lease, marker, _, _ = _fixture( + tmp_path, monkeypatch, provider, historical_terminals=historical_terminals, + ) + def call(*args: str, expected: int = 0): + code, payload = run(*args) + assert code == expected, json.dumps(payload, ensure_ascii=False, indent=2) + return payload + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + capabilities = ("--available-capability", "shell", "--available-capability", "filesystem_write") + call("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, + "--scan-path", str(project), *binding, *capabilities) + done = call("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, + "--evidence", "Original declared source check passed.") + before = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] + call("refresh-state", "--goal-id", cli.GOAL_ID, *binding, + "--classification", "declared_deliverable_complete", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--vision-state", "vision_closed", + "--vision-summary", "The original source deliverable passed.", + "--vision-acceptance", "Its original check, writeback and accounting are independently verified.", + "--no-global-sync", "--suppress-external-sinks") + call("quota", "spend-slot", "--goal-id", cli.GOAL_ID, *binding, + "--slots", "1", "--source", "heartbeat", "--execute", "--scan-path", str(project)) + turn = "turn-original-lifecycle-review" + guard = ("quota", "should-run", "--runtime-profile", "generic_cli", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--turn-instance-id", turn, "--scan-path", str(project), *capabilities) + admitted = call(*guard) + identity = admitted["heartbeat_receipt"]["settlement_identity"] + assert admitted["autonomous_replan_obligation"]["triggers"][0]["todo_id"] == cli.TODO_ID + assert len(admitted["autonomous_replan_obligation"]["triggers"]) == 1 + call("todo", "complete", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--completion-identity-key", done["completion_identity_key"], + "--no-follow-up", "--evidence", "Original declared source check passed.") + recovered = call(*guard) + assert recovered["heartbeat_receipt"]["settlement_identity"] == identity + assert recovered.get("selected_todo") is None + assert recovered["replan_action_packet"]["settlement_only"] is True + assert recovered["replan_action_packet"]["writeback_contract"]["preferred_input"] == "evidence_linked_vision_path" + replan_binding = ("--agent-id", cli.AGENT_ID, "--replan-obligation-id", identity["replan_obligation_id"], + "--turn-instance-id", turn) + spend = ("quota", "spend-slot", "--goal-id", cli.GOAL_ID, *replan_binding, + "--slots", "1", "--source", "heartbeat", "--execute", "--scan-path", str(project)) + assert call(*spend, expected=1).get("appended") is not True + # A no-follow-up transition alone must not waive the acceptance checkpoint. + refresh = ("refresh-state", "--goal-id", cli.GOAL_ID, *replan_binding, + "--classification", "lifecycle_review", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks") + refused = call(*refresh, expected=1) + assert refused["ok"] is False + assert "vision" in str(refused).lower() + vision = {"state": "vision_closed", "summary": "Original outcome independently checked.", + "vision_patch": {"acceptance_summary": "Original source and declared check remain valid."}, + "path_delta": {"outcome": "no_change", "prior_assumption": "Original source acceptance requires validation.", + "observed_reality": "The declared validator and original settlement passed.", + "retained": ["The independently validated source outcome"], + "evidence_refs": ["original-source-validation"]}} + vision_file = tmp_path / "current-acceptance.json" + vision_file.write_text(json.dumps(vision)) + refreshed = call(*refresh, "--agent-vision-json", str(vision_file)) + assert refreshed["settlement_progress"]["state"] == "spend_required" + pending = call(*guard) + assert pending["heartbeat_receipt"]["settlement_identity"] == identity + assert pending["settlement_progress"]["state"] == "spend_required" + assert call(*spend)["appended"] is True + assert call(*spend)["appended"] is False + assert call(*guard)["effective_action"] == "heartbeat_settled_skip" + after = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] + for field in ("completed_at", "completion_turn_key", "completion_receipt_id", "completion_validation_sha256"): + assert before[field] == after[field] + assert marker.read_text() == "run\n" + assert cli._spend_run_count(runtime) == 2 # Original delivery plus the independently admitted review. diff --git a/tests/control_plane/test_goal_frontier_replan_rules.py b/tests/control_plane/test_goal_frontier_replan_rules.py index 44b8b682bc..288d7ddb74 100644 --- a/tests/control_plane/test_goal_frontier_replan_rules.py +++ b/tests/control_plane/test_goal_frontier_replan_rules.py @@ -1050,3 +1050,20 @@ def test_vision_ack_and_gap_from_same_durable_run_do_not_rearm() -> None: timestamp = "2026-08-13T09:00:00+08:00" assert _vision_gap_acknowledged(_vision_gap_with_generated_at(timestamp), _ack(timestamp)) + + +def test_lifecycle_review_identity_binds_todo_and_original_completion_not_display_text() -> None: + def identity(todo_id: str, completion: str, text: str = "Verified source") -> str: + result = derive_goal_frontier_replan_obligation_from_summaries( + user_todo_summary={"open_count": 0}, + agent_todo_summary={"open_count": 0, "completed_without_successor_items": [ + {"todo_id": todo_id, "completion_turn_key": completion, "text": text, + "task_class": "advancement_task", "claimed_by": "current-agent"}]}, + work_lane_contract=None, agent_id="current-agent", existing_replan_obligation=None, + ) + assert result is not None + return result["obligation_id"] + original = identity("todo_original", "original-completion") + assert original == identity("todo_original", "original-completion", "Changed display wording") + assert original != identity("todo_other", "original-completion") + assert original != identity("todo_original", "later-completion") diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 1c003ca848..35987a14fe 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -356,3 +356,69 @@ test("vision ACK compares strict calendar timestamps at microsecond precision", acceptance_gaps: [gap("2026-02-28T00:00:00Z")]}).acknowledged, false); } }); + + +test("canonical no-follow-up restores only its exact admitted review and still owes Vision", () => { + const id = "replan-1111111111111111", agent = "synthetic-agent"; + const row = {todo_id: "todo_original", text: "Verify independent source", status: "done", done: true, + task_class: "advancement_task", claimed_by: agent, last_actor_agent_id: agent, + completed_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-01T00:00:01Z", + completion_turn_key: "original-completion", no_followup: true, completion_continuation: "no_followup", + completion_recovery: "lifecycle_reentry_terminal_closeout"}; + const original = {required: true, obligation_id: id, resolution_mode: "todo_lifecycle_settlement", + triggers: [{kind: "completed_advancement_without_successor", todo_id: row.todo_id, + completion_turn_key: row.completion_turn_key, agent_id: agent}]}; + const candidate = {obligation: original, source_obligation: original, lifecycle_settled: true, + guard_recorded_at: "2026-08-01T00:00:01.123456Z", items: [row]}; + const request = {operation: "receipt_bound_obligation", guard_scoped: true, replay_phase: "open", + selected_obligation_id: id, agent_id: agent, current_obligation: null, transition_candidates: [candidate]}; + const result = projectReplanSemantics(request).obligation as JsonObject; + assert.equal(result.obligation_id, id); + assert.equal(result.resolution_mode, "receipt_bound_replan_settlement"); + assert.deepEqual(result.todo_actions, []); + const packet = result.settlement_action_packet as JsonObject; + assert.equal(packet.settlement_only, true); + assert.equal(packet.semantic_delta, null); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: result}).accepted, false); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: result, agent_vision: vision}).accepted, true); + const historical = {...row, todo_id: "todo_historical", completion_turn_key: "historical-completion", + completed_at: "2026-07-31T23:59:00Z", updated_at: "2026-07-31T23:59:01Z"}; + for (const items of [[historical, row], [row, historical]]) { + assert.equal((projectReplanSemantics({...request, + transition_candidates: [{...candidate, items}]}).obligation as JsonObject).obligation_id, id); + } + // A whole-second timestamp cannot prove which side of admission this row + // closed on. The exact trigger/digest check still rejects the ambiguous source. + assert.throws(() => projectReplanSemantics({...request, transition_candidates: [{...candidate, + items: [row, {...historical, updated_at: row.updated_at}]}]}), /identity conflicts/); + for (const change of [{status: "open"}, {done: false}, {claimed_by: "peer"}, {last_actor_agent_id: "peer"}, + {no_followup: false}, {completion_continuation: "active_goal"}, {completion_recovery: null}, + {completion_turn_key: "changed"}, {completed_at: "2026-08-01T00:00:02Z"}, + {successor_todo_ids: ["todo_new"]}, {archive_state: "archived"}, + {updated_at: "2026-08-01T00:00:00Z"}, {updated_at: "2026-02-30T00:00:02Z"}, {updated_at: null}]) { + assert.throws(() => projectReplanSemantics({...request, + transition_candidates: [{...candidate, items: [{...row, ...change}]}]}), /identity conflicts/); + } + assert.throws(() => projectReplanSemantics({...request, guard_scoped: false}), /identity conflicts/); + assert.throws(() => projectReplanSemantics({...request, + selected_obligation_id: "replan-2222222222222222"}), /identity conflicts/); +}); + +test("lifecycle reconstruction restores completion order before its trigger bound", () => { + const agent = "synthetic-agent"; + const rows = [1, 2, 3, 4].map(index => ({todo_id: `todo_${index}`, index, + status: "done", done: true, task_class: "advancement_task", claimed_by: agent, + last_actor_agent_id: agent, completed_at: `2026-08-01T00:00:0${index}Z`, + updated_at: "2026-08-01T00:00:10Z", completion_turn_key: `completion-${index}`, + no_followup: true, completion_continuation: "no_followup", + completion_recovery: "lifecycle_reentry_terminal_closeout"})); + const facts = projectReplanSemantics({operation: "lifecycle_source", agent_id: agent, + guard_recorded_at: "2026-08-01T00:00:05Z", items: rows}); + assert.deepEqual((facts.items as JsonObject[]).map(item => item.todo_id), + ["todo_4", "todo_3", "todo_2", "todo_1"]); + assert.equal(facts.settled, true); + const ties = rows.map(item => ({...item, completed_at: "2026-08-01T00:00:01Z"})); + assert.deepEqual((projectReplanSemantics({operation: "lifecycle_source", agent_id: agent, + guard_recorded_at: "2026-08-01T00:00:05Z", items: ties}).items as JsonObject[]) + .map(item => item.todo_id), ["todo_4", "todo_3", "todo_2", "todo_1"]); +}); diff --git a/tests/control_plane_ts/terminal_reentry_conformance.ts b/tests/control_plane_ts/terminal_reentry_conformance.ts index e78803224d..52566d662e 100644 --- a/tests/control_plane_ts/terminal_reentry_conformance.ts +++ b/tests/control_plane_ts/terminal_reentry_conformance.ts @@ -87,11 +87,13 @@ export function registerTerminalReentryConformance(provider: string, factory: Au if (before.status === "loaded" && after.status === "loaded") { const prior = (before.head.todos as JsonObject[])[0]; assert.deepEqual((after.head.todos as JsonObject[])[0], {...prior, no_followup: true, - completion_continuation: "no_followup", completion_recovery: "lifecycle_reentry_terminal_closeout"}); + completion_continuation: "no_followup", completion_recovery: "lifecycle_reentry_terminal_closeout", + updated_at: "2026-09-07T07:00:00Z"}); assert.deepEqual(after.head.leases, before.head.leases); } assert.deepEqual(await store.readReceipt("migrated"), originalReceipt); - assert.equal((await execute(contender, request, async () => {throw new Error("Historical replay needs no fresh authority");})).status, + assert.equal((await execute(contender, {...request, now: new Date("2026-09-08T07:00:00Z")}, + async () => {throw new Error("Historical replay needs no fresh authority");})).status, "replayed"); assert.equal((await execute(store, {...request, actor_agent_id: "agent-b"})).reason_code, "coordination_operation_identity_mismatch");