From fe803853dff3af78b6a0f8f0fd2b3221348d9343 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:05:17 +0800 Subject: [PATCH 01/10] fix(replan): review structured state before captured writeback guidance Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota.py | 14 ++ loopx/cli_commands/quota_capture.py | 13 + loopx/cli_commands/quota_registration.py | 6 + loopx/control_plane/quota/turn_envelope.ts | 138 ++++++++++- .../work_items/replan_context.ts | 22 +- .../work_items/replan_context_codec.py | 4 +- tests/control_plane/replan_packet_fixture.py | 145 ++++++++++++ .../test_replan_context_order.py | 4 +- .../test_replan_host_context_projection.py | 43 +--- .../control_plane/test_replan_staged_read.py | 222 ++++++++++++++++++ tests/control_plane_ts/replan_context.test.ts | 35 ++- 11 files changed, 592 insertions(+), 54 deletions(-) create mode 100644 tests/control_plane/replan_packet_fixture.py create mode 100644 tests/control_plane/test_replan_staged_read.py diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index d84984529f..ef7f0b316a 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import time from collections.abc import Callable, Mapping from pathlib import Path @@ -411,6 +412,9 @@ def _project_quota_cli_payload( if args.quota_command in {"status", "plan"}: return compact_quota_plan_cli_payload(payload, detail_sections=detail_sections) if args.quota_command == "should-run": + if captured_decision_path and "vision" not in detail_sections: + from ..control_plane.quota.turn_envelope import _turn_envelope_result + payload = _turn_envelope_result("replan_cli_view", payload=payload) return compact_quota_should_run_cli_payload( payload, include_todo_summary_detail="agent-todos" in detail_sections, @@ -464,6 +468,8 @@ def _emit_quota_result( started_at=usage_quota_started, ) bind_capture_selection_transport(payload, args) + if capture_directory is not None and payload.get("replan_action_packet"): + payload = {**payload, "replan_read_path": str(capture_directory / "decision.json")} try: capture_decision(capture_directory, payload) if payload.get("ok") is True: @@ -504,6 +510,14 @@ def handle_quota_command( append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, ) -> int: + if args.quota_command == "replan-view": + from .quota_capture import read_replan_view + try: + view = read_replan_view(args) + except (OSError, ValueError, EffectRuntimeRejected) as exc: + view = {"ok": False, "reason": str(exc)} + print_payload(view, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 0 if view["ok"] else 1 usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None heartbeat_receipt_existing: dict[str, object] | None = None diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index f19c3f03a4..90b3783c81 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -123,3 +123,16 @@ def handoff_decision(directory: Path | None, socket_path: str | None) -> None: "Decision handoff failed after guard evaluation; inspect the original " f"Turn receipt and {path}. Do not blindly rerun the guard." ) from exc + + +def read_replan_view(args: argparse.Namespace) -> dict: + """Read a captured observation only; no registry, provider, guard or receipts.""" + from ..control_plane.quota.turn_envelope import _turn_envelope_result + + if not args.decision_file or not args.source_hash: + raise ValueError("replan-view requires --decision-file and --source-hash") + path = Path(args.decision_file).expanduser() + with path.open(encoding="utf-8") as stream: + payload = json.load(stream) + return _turn_envelope_result("replan_view", payload=payload, source_hash=args.source_hash, + stage=args.replan_stage, outcome_ref=args.outcome_ref) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 7eed4e01a2..0ee83315c7 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -27,6 +27,7 @@ def register_quota_command( "status", "plan", "should-run", + "replan-view", "monitor-poll", "scheduler-ack", "scheduler-ack-current", @@ -37,6 +38,11 @@ def register_quota_command( default="status", help="Use status for all groups, plan for next-turn groups, should-run for one goal, monitor-poll for no-spend quiet poll evidence, scheduler-ack for successful Codex App RRULE state, scheduler-fail-current to suppress a repeated failed host update pair, spend-slot for accounting, or void-slot for a non-destructive accounting correction.", ) + quota_parser.add_argument("--decision-file", help="Original captured decision for read-only replan-view.") + quota_parser.add_argument("--outcome-ref", help="Exact captured acceptance outcome reference for replan-view.") + quota_parser.add_argument("--source-hash", help="Original source_decision_hash for replan-view.") + quota_parser.add_argument("--replan-stage", choices=["review", "writeback", "acceptance", "explore"], default="review", + help="Read decision context before opening its writeback format.") quota_parser.add_argument( "--goal-id", help="Goal id to check. Optional for status/plan (omit for all goals); required for should-run, scheduler ACK/failure, spend, and void. A scoped status/plan ranks only the selected goal, not the global next turn.", diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 4978401a9b..c3f40e9df8 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -291,9 +291,129 @@ function selectedTodo(payload: JsonObject, recommendedAction: string | null): Js return Object.keys(compact).length > 0 ? compact : null; } +/** These reads stay on one captured observation; they never re-enter the guard. */ +function replanReadCommands(payload: JsonObject): JsonObject | null { + const path = payload.replan_read_path; + const packet = object(payload.replan_action_packet); + if (typeof path !== "string" || !path.trim() || path.includes("\0") || payload.ok !== true || + !packet.obligation_id || packet.settlement_only === true) return null; + const command = `loopx --format json quota replan-view --decision-file ${shellQuote(path)}` + + ` --source-hash ${canonicalHash(payload)}`; + return {review: `${command} --replan-stage review`, writeback: `${command} --replan-stage writeback`, + acceptance: `${command} --replan-stage acceptance`, explore: `${command} --replan-stage explore`}; +} + +function replanView(request: JsonObject): JsonObject { + const payload = requireJsonObject(request.payload, "captured replan decision"); + if (request.source_hash !== canonicalHash(payload) || payload.ok !== true) { + throw new EffectRuntimeRequestError("Replan read requires the original successful decision and matching source hash"); + } + const packet = object(payload.replan_action_packet); + const obligation = object(payload.autonomous_replan_obligation); + const context = object(obligation.replan_context); + if (!packet.obligation_id || packet.settlement_only === true || packet.obligation_id !== context.obligation_id) { + throw new EffectRuntimeRequestError("Replan read requires a matching decision context"); + } + const common = {ok: true, goal_id: payload.goal_id, agent_id: payload.agent_id ?? object(payload.agent_identity).agent_id, + obligation_id: packet.obligation_id, context_id: context.context_id, source_hash: request.source_hash, + stage: request.stage}; + const reads = replanReadCommands(payload); + const outcomes = new Map(); + function indexedGoal(value: unknown): JsonObject { + const core = {...object(value)}; + const acceptance = {...object(core.acceptance_contract)}; + const requirements = object(acceptance.outcome_requirements); + if (!Array.isArray(requirements.outcomes) || !requirements.outcomes.length || !reads) return core; + const outcomeRefs = requirements.outcomes.map(value => { + const outcome = requireJsonObject(value, "captured acceptance outcome"); + const ref = canonicalHash(outcome); + outcomes.set(ref, outcome); + return ref; + }); + const {outcomes: _outcomes, ...metadata} = requirements; + delete acceptance.outcome_requirements; + return {...core, acceptance_contract: acceptance, + outcome_requirements_index: {...metadata, outcome_refs: outcomeRefs}}; + } + const core = indexedGoal(context.core_goal); + const horizon = {...object(payload.planning_horizon)}; + if (horizon.core_goal) { + if (canonicalHash(horizon.core_goal) === canonicalHash(context.core_goal)) { + delete horizon.core_goal; + horizon.core_goal_ref = "$.core_goal"; + } else horizon.core_goal = indexedGoal(horizon.core_goal); + } + if (request.stage === "acceptance") { + const outcome = outcomes.get(String(request.outcome_ref)); + if (!outcome) throw new EffectRuntimeRequestError("acceptance read requires an exact captured outcome reference"); + return {...common, outcome_ref: request.outcome_ref, outcome}; + } + if (request.stage === "explore") return {...common, explore_basis: context.explore_basis ?? null}; + if (request.stage === "writeback") { + // Preserve executable shape and all settlement rules; decision advice and + // duplicate context are available in the unchanged full captured source. + const stride = {...object(packet.stride_control)}; + for (const key of ["decision_followthrough", "explore_basis", "decision_value_review"]) delete stride[key]; + return {...common, writeback_contract: packet.writeback_contract, + required_outcome: packet.required_outcome, allowed_terminal: packet.allowed_terminal, + ...(packet.stride_control ? {stride_control: stride} : {}), + writeback: actionProjection(payload).writeback, + context_read: replanReadCommands(payload)?.review ?? null}; + } + if (request.stage !== "review") throw new EffectRuntimeRequestError("unsupported replan read stage"); + // Index every captured outcome, including differing context/horizon revisions. + // Full-content equality alone shares a row; identical IDs do not erase edits. + const acceptanceIndex = outcomes.size ? { + read_action_template: `${reads!.acceptance} --outcome-ref {outcome_ref}`, + outcomes: [...outcomes].map(([ref, outcome]) => ({outcome_ref: ref, id: outcome.id, + objective: outcome.objective, revision: outcome.revision, + requirement_count: Array.isArray(outcome.requirements) ? outcome.requirements.length : null})), + } : null; + const coverage = Array.isArray(context.coverage_ledger) ? context.coverage_ledger.map(object) : []; + const follow = {...object(context.decision_followthrough)}; + delete follow.guidance; + if (Object.hasOwn(follow, "recorded_progress_count")) { + follow.observation_count = follow.recorded_progress_count; + delete follow.recorded_progress_count; + } + return {...common, + core_goal: core, + ...(acceptanceIndex ? {acceptance_index: acceptanceIndex} : {}), + triggers: obligation.triggers, + decision_followthrough: Object.keys(follow).length ? follow : null, + planning_horizon: payload.planning_horizon == null ? null : horizon, + action_portfolio: payload.action_portfolio ?? null, + ...(context.explore_basis ? {capabilities: {explore: { + availability: object(context.explore_basis).availability, revision: object(context.explore_basis).revision, + read_action: reads?.explore ?? null, + }}} : {}), + evidence: (Array.isArray(context.evidence) ? context.evidence : []).map(value => { + const row = object(value); + const observation = coverage.find(item => item.fingerprint === row.coverage_ref); + return {...compactFields(row, ["evidence_ref", "generated_at", "delivery_outcome", "coverage_ref", + "occurrences", "first_observed_at", "read_action"]), + ...(observation ? {result_class: observation.result_class} : {})}; + }), + evidence_count: context.evidence_count, + coverage_count: context.coverage_count, + coverage_truncated: context.coverage_truncated, + omitted_evidence: context.omitted_evidence ?? null, + evidence_read_action_template: context.evidence_read_action_template ?? null, + uncovered_frontier: context.uncovered_frontier, + guidance: "Compare the goal, prior decision and actual results; distinguish scoped dependencies from independent feasible work. " + + "Read applicable acceptance and evidence by their source references; choose useful work within existing authority and budget. " + + "Missing evidence stays unknown; no pivot is required.", + next: {writeback: replanReadCommands(payload)?.writeback ?? null}, + }; +} + function replanActionPacket(payload: JsonObject): JsonObject | null { const source = object(payload.replan_action_packet); if (Object.keys(source).length === 0) return null; + const reads = replanReadCommands(payload); + if (reads) return {...compactFields(source, ["schema_version", "decision", "obligation_id", "required_outcome", "allowed_terminal"]), + review: reads.review, writeback: reads.writeback, + guidance: "Read review state, choose the next work, then read its writeback format. These reads reuse this decision; they grant no new authority."}; const compact = compactFields(source, [ "schema_version", "decision", "obligation_id", "uncovered_frontier", "required_outcome", "allowed_terminal", "bounded_frontier", "settlement_only", "successor_todo_id", "stride_control", @@ -602,6 +722,7 @@ function actionProjection(payload: JsonObject): JsonObject { const recommendedAction = replanPacket ? replanSettlementOnly ? scalarString(object(object(payload.replan_action_packet).writeback_contract).rule, "replan settlement rule") + : replanReadCommands(payload) ? "read replan_action_packet.review, decide from state, then read writeback" : "apply replan_action_packet and emit one required semantic outcome" : text(turn.observation.recommended_action || payload.recommended_action, 480); // The signed host action is executable authority, not a display summary. @@ -610,7 +731,7 @@ function actionProjection(payload: JsonObject): JsonObject { const action: JsonObject = { recommended_action: recommendedAction, primary_action: replanPacket && !replanSettlementOnly - ? "produce one required semantic outcome" + ? replanReadCommands(payload)?.review ?? "produce one required semantic outcome" : primaryAction || null, must_attempt: Boolean(agentChannel.must_attempt), delivery_allowed: Boolean(agentChannel.delivery_allowed), @@ -622,7 +743,9 @@ function actionProjection(payload: JsonObject): JsonObject { action.action_portfolio = { ...turn.observation.action_portfolio }; } if (turn.observation.planning_horizon !== null) { - action.planning_horizon = { ...turn.observation.planning_horizon }; + action.planning_horizon = replanReadCommands(payload) + ? {context_read: replanReadCommands(payload)!.review} + : { ...turn.observation.planning_horizon }; } const user = userChannel(interaction, payload); const schedulerValue = scheduler(payload, turn); @@ -997,6 +1120,17 @@ function bindTurnDecisionCapture(request: JsonObject): JsonObject { export function evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; + if (operation === "replan_view") return replanView(request); + if (operation === "replan_cli_view") { + const payload = requireJsonObject(request.payload, "replan CLI payload"); + if (!replanReadCommands(payload)) return payload; + const obligation = {...object(payload.autonomous_replan_obligation)}; + delete obligation.replan_context; + return {...payload, + ...(payload.planning_horizon ? {planning_horizon: {context_read: replanReadCommands(payload)!.review}} : {}), + autonomous_replan_obligation: {...obligation, context_read: replanReadCommands(payload)!.review}, + replan_action_packet: replanActionPacket(payload)}; + } if (operation === "build") return buildTurnEnvelope(request); if (operation === "bind_capture") return bindTurnDecisionCapture(request); if (operation === "replan_settlement_identity") return {identity: turnEnvelopeReplanSettlementIdentity( diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index e3daad87ce..bd47dfd568 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -9,6 +9,7 @@ import {FRESH_PATH_DISPOSITIONS, requiredSemanticOutcomes} from "./replan_semant import {readReplanSnapshot} from "./replan_history_snapshot.ts"; import {normalizeGoalPathDelta} from "../goals/vision_checkpoint.ts"; import {normalizeRecordedStrideDecision, normalizeStrideStrategy, projectStrideExploreBasis} from "./stride_policy.ts"; +import {PROGRESS_RESULT_CLASSES} from "./replan_history.ts"; import {MATERIAL_DELIVERY_OUTCOMES} from "./delivery_outcome.ts"; const CONTEXT_SCHEMA = "replan_context_v0"; @@ -353,11 +354,16 @@ function decisionFollowthrough(rows: JsonObject[], goalFacts: unknown, fromFullI source_history_complete: fromFullIndex, unsequenced_progress_count: rows.filter(row => row.progress_observation && !Number.isSafeInteger(objectOrNull(row._source_facts)?.history_position)).length, + // Compatibility count of observations, not a count of successful work. recorded_progress_count: observations.length, + result_counts: Object.fromEntries(PROGRESS_RESULT_CLASSES.map(result => [result, + observations.filter(row => (row.progress_observation as JsonObject).result_class === result).length])), observations: visible.map(row => ({ evidence_ref: "replan-evidence-" + digest(readableEvidenceRow(row)), generated_at: row.generated_at, todo_id: row.todo_id, coverage_ref: (row.progress_observation as JsonObject).fingerprint, + result_class: (row.progress_observation as JsonObject).result_class, + delivery_outcome: row.delivery_outcome ?? null, })), omitted_observation_count: Math.max(0, observations.length - 6), // Ordinary writebacks are source evidence, never fabricated typed progress. @@ -455,8 +461,8 @@ export function validateReplanContext(value: unknown): JsonObject { if (context.stride_strategy !== undefined) normalizeStrideStrategy(context.stride_strategy); if (context.decision_followthrough !== undefined) { const follow = requireJsonObject(context.decision_followthrough, "decision_followthrough"); - if (context.stride_strategy !== "evidence_conditioned_v1" || follow.execution_and_goal_gain !== "unknown") { - throw new EffectRuntimeRequestError("decision followthrough requires the enabled strategy and unknown utility"); + if (follow.execution_and_goal_gain !== "unknown") { + throw new EffectRuntimeRequestError("decision followthrough requires unknown utility"); } const observations = objects(follow.observations, "decision_followthrough.observations"); validateWorkResultUse(follow); @@ -467,6 +473,16 @@ export function validateReplanContext(value: unknown): JsonObject { Number(follow.recorded_progress_count) !== observations.length + Number(follow.omitted_observation_count)) { throw new EffectRuntimeRequestError("decision followthrough requires bounded detail and explicit coverage counts"); } + if (follow.result_counts !== undefined) { + const counts = requireJsonObject(follow.result_counts, "followthrough result counts"); + if (Object.keys(counts).length !== PROGRESS_RESULT_CLASSES.length || + PROGRESS_RESULT_CLASSES.some(result => !Number.isSafeInteger(counts[result]) || Number(counts[result]) < 0) || + Object.values(counts).reduce((sum, count) => sum + Number(count), 0) !== follow.recorded_progress_count || + observations.some(row => !PROGRESS_RESULT_CLASSES.some(result => result === row.result_class)) || + PROGRESS_RESULT_CLASSES.some(result => observations.filter(row => row.result_class === result).length > Number(counts[result]))) { + throw new EffectRuntimeRequestError("invalid followthrough result counts"); + } + } const previous = requireJsonObject(follow.previous_decision, "decision_followthrough.previous_decision"); identifier(previous.evidence_ref, "previous_decision.evidence_ref"); identifier(previous.history_evidence_ref, "previous_decision.history_evidence_ref"); @@ -648,7 +664,7 @@ export function projectReplanContext(value: unknown): JsonObject { if (evidenceReadTemplate) for (const row of shown) delete row.read_action; const core = projectCoreGoal(request.goal_facts); const exploreBasis = strideStrategy !== "off" ? projectStrideExploreBasis(request.explore_context, goal, agent) : null; - const followthrough = agent && strideStrategy !== "off" + const followthrough = agent ? decisionFollowthrough(rows, request.goal_facts, request.from_full_index === true, request.todo_items) : null; const contextId = "replan-context-" + digest({goal, agent, obligationId, core, ledger, shown, frontier, ...(evidenceReadTemplate ? {evidence_read_action_template: evidenceReadTemplate} : {}), diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py index 798d83973e..aa8e7d48bc 100644 --- a/loopx/control_plane/work_items/replan_context_codec.py +++ b/loopx/control_plane/work_items/replan_context_codec.py @@ -187,14 +187,14 @@ def project_replan_context( "goal_id": goal_id, "agent_id": agent_id, "obligation": dict(obligation) if obligation is not None else None, "rows": replan_evidence_rows(runs, goal_id=goal_id, agent_id=agent_id, - include_decisions=strategy != "off" or bool(evidence_ref and evidence_ref.startswith("replan-decision-")), + include_decisions=bool(agent_id), history_newest_first=history_newest_first), "stride_strategy": strategy, # Canonical caller-owned facts only. TypeScript joins the declared # work with the accepted decision and causally later observations. **({"todo_items": [{key: item[key] for key in ( "todo_id", "goal_id", "role", "status", "claimed_by", "task_class", "action_kind", - ) if key in item} for item in todo_items]} if strategy != "off" and todo_items is not None else {}), + ) if key in item} for item in todo_items]} if agent_id and todo_items is not None else {}), **({"explore_context": explore_context} if explore_context is not None else {}), "evidence_ref": evidence_ref, "read_prefix": shlex.join(prefix), diff --git a/tests/control_plane/replan_packet_fixture.py b/tests/control_plane/replan_packet_fixture.py new file mode 100644 index 0000000000..2f2f5bbdf9 --- /dev/null +++ b/tests/control_plane/replan_packet_fixture.py @@ -0,0 +1,145 @@ +"""Public synthetic workloads for long-lived Goal packet budgets. + +Match production dimensions (large append history, capped review windows, +multilingual cumulative requirements, real Explore logs), not private content. +No snapshots or copied outputs are checked in. +""" +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import json + +from loopx.control_plane.work_items.progress_observation import build_replan_action_packet +from loopx.control_plane.work_items.replan_context_codec import project_replan_context +from test_replan_context_order import _rows +from test_replan_host_context_projection import AGENT_ID, GOAL_ID, _quota_payload + + +def populated_replan_source(root, *, acceptance=False, explore_context=None): + source = _quota_payload(stride_strategy="evidence_conditioned_v1") + source["replan_read_path"] = "/synthetic/decision.json" + obligation = deepcopy(source["autonomous_replan_obligation"]) + _, decision, work = _rows() + + def scoped(value): + if isinstance(value, dict): + return {key: scoped(item) for key, item in value.items()} + if isinstance(value, list): + return [scoped(item) for item in value] + if value == "evidence-goal": + return GOAL_ID + if value == "agent-a": + return AGENT_ID + return value + + work, decision = scoped(work), scoped(decision) + decision["agent_vision"]["todo_delta"] = ["retain:todo-a"] + decision["agent_vision"]["stride_decision"] = deepcopy(source["replan_action_packet"]["stride_control"]["minimal_example"]) + decision["agent_vision"]["path_delta"].update( + prior_assumption="The retained implementation should resolve the scoped failure under the accepted conditions. " * 2, + observed_reality="The observed failure persists, while independent implementation work remains feasible. " * 2, + retained=["Keep all original requirements, authority limits and the strongest qualified artifact."], + changed=["Implement and validate the independent path while the scoped measurement remains unavailable."]) + start = datetime(2026, 1, 1, tzinfo=timezone.utc) + rows = [] + # 1,500 records, one accepted review, then 21 observations with mixed results. + for index in range(1500): + if index == 1478: + row = deepcopy(decision) + else: + row = deepcopy(work) + row.pop("quota_spend_commit", None) + row["health_check"] = "Synthetic scoped counterexample; the alternative remains independently runnable. " * 7 + row["recommended_action"] = "Use applicable evidence to select the next implementation or discriminating check. " * 3 + result = "advanced" if index % 7 == 0 else "blocked" if index % 3 else "unchanged" + row["progress_observation"].update(result_class=result, + surface_id=f"surface-{index % 630}", hypothesis_id=f"hypothesis-{index % 17}", + probe_kind=f"probe-{index % 5}", evidence_ids=[f"evidence:synthetic-{index}"]) + row["delivery_outcome"] = "outcome_progress" if result == "advanced" else "outcome_gap" + row["generated_at"] = (start + timedelta(minutes=index)).isoformat() + row["turn_instance_id"] = f"turn-synthetic-{index}" + row["settlement_identity"]["turn_instance_id"] = row["turn_instance_id"] + rows.append(row) + contract = None + if acceptance: + outcomes = [] + for i in range(24): + requirements = [{"id": f"requirement-{j}", + "description": f"Verify synthetic outcome {i}, condition {j}, including recovery and readback.", + "status": "unverified", "planning_satisfied": False, + "agent_review": {"conclusion": "unmet", "basis_current": True, + "independently_verified": False, "evidence_refs": [f"probe:negative-{i}-{j}"], + "note": "合成观察保留未通过的条件;成功的局部检查不能替代完整验收。 " * 18}} + for j in range(4 + i % 17)] + outcomes.append({"id": f"outcome-{i}", "owner_agent_id": AGENT_ID, + "objective": f"Deliver independently verifiable synthetic outcome {i} with recovery and current evidence.", + "revision": 2, "requirements": requirements, + "todo_ids": [f"todo-outcome-{i}"], "sources": [], "basis_digest": f"{i:064x}", + "work": [{"todo_id": f"todo-outcome-{i}", "status": "open"}]}) + contract = {"outcome_requirements": {"schema_version": "loopx_goal_outcomes_v0", "revision": 24, + "current_lifetime": True, "execution_authority_granted": False, "goal_completion_assessed": False, + "acceptance_gaps": [], "outcomes": outcomes}} + root.mkdir(parents=True, exist_ok=True) + runtime = root / "runtime" + registry = root / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": GOAL_ID, "repo": str(root), + "objective": "Deliver a complete, independently verifiable artifact under the retained constraints.", + "coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT_ID]}}]})) + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("".join(json.dumps(row) + "\n" for row in rows)) + context = project_replan_context(goal_id=GOAL_ID, agent_id=AGENT_ID, obligation=obligation, + runs=(), source_status={"registry": str(registry), "runtime_root": str(runtime), "run_history": {"runs": []}}, stride_strategy="evidence_conditioned_v1", + goal_acceptance_contract=contract, explore_context=explore_context, + todo_items=[{"todo_id": "todo-a", "goal_id": GOAL_ID, "role": "agent", "status": "waiting", + "claimed_by": AGENT_ID, "task_class": "advancement_task", "action_kind": "validate"}]) + obligation["replan_context"] = context + source["autonomous_replan_obligation"] = obligation + source["replan_action_packet"] = build_replan_action_packet(obligation, goal_id=GOAL_ID, agent_id=AGENT_ID) + from loopx.control_plane.effect_runtime import effect_runtime_result + selected = {"todo_id": "todo_independent", "text": "Implement the independent path.", "index": 1, + "priority": "P1", "status": "open", "task_class": "advancement_task", "action_kind": "implement", + "claimed_by": AGENT_ID} + waiting = {**selected, "todo_id": "todo_waiting", "text": "Compare the remote measurements.", "index": 2, + "status": "deferred", "resume_when": "todo_done:todo_measurement"} + extra = [{**selected, "todo_id": f"todo_alternative_{i}", "index": i + 3, + "text": (f"Deliver alternative {i} with independent behavior, failure recovery and complete acceptance readback. " * 2), + "successor_todo_ids": ["todo_waiting"]} for i in range(3)] + from loopx.control_plane.work_items.action_portfolio import QUOTA_PLANNING_PACKET_REQUEST_SCHEMA_VERSION + projected = effect_runtime_result("work_item.action_portfolio.project", { + "schema_version": QUOTA_PLANNING_PACKET_REQUEST_SCHEMA_VERSION, + "planning_inventory_request": { + "schema_version": "todo_planning_inventory_request_v0", "goal_id": GOAL_ID, "agent_id": AGENT_ID, + "selected_todo": selected, "source_items": [selected, waiting, *extra], "runnable_candidates": [selected, *extra], + "unavailable_higher_priority": [], "source_context_todo_count": 5}, + "projection_enabled": True, "include_detail": False, "acceptance_gaps": [], + "goal_facts": {"registry_objective": context["core_goal"]["objective"], + "goal_read_action": context["core_goal"].get("read_action"), "acceptance_contract": contract}}) + source.update({key: projected[key] for key in ("planning_horizon", "action_portfolio")}) + return source + + +def populated_explore_context(root): + from loopx.capabilities.explore.result_log import ( + append_explore_result_event, build_explore_node_event, build_explore_finding_event, + explore_result_log_path, + ) + from loopx.capabilities.explore.turn_context import read_replan_explore_context + + registry = root / "registry.json" + runtime = root / "runtime" + registry.write_text(json.dumps({"goals": [{"id": GOAL_ID, "repo": str(root), + "explore_graph": {"enabled": True}, + "coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT_ID]}}]})) + log = explore_result_log_path(runtime, GOAL_ID) + for i in range(12): + append_explore_result_event(log, build_explore_node_event(goal_id=GOAL_ID, agent_id=AGENT_ID, + node_id=f"route-{i}", title=f"Synthetic alternative mechanism {i}", status="open")) + for j, status in enumerate(["confirmed", "refuted", "tentative"]): + append_explore_result_event(log, build_explore_finding_event(goal_id=GOAL_ID, agent_id=AGENT_ID, + node_id=f"route-{i}", finding_id=f"finding-{i}-{j}", title=f"Scoped comparison {i}-{j}", status=status, + summary="Independent synthetic evidence with retained counterexamples. " * 12, + evidence_refs=[f"probe:route-{i}-{j}"], tags=["writeback-result"])) + return read_replan_explore_context(source_status={"registry": str(registry), "runtime_root": str(runtime)}, + goal_id=GOAL_ID, agent_id=AGENT_ID) diff --git a/tests/control_plane/test_replan_context_order.py b/tests/control_plane/test_replan_context_order.py index 513e7eb0d5..6bc9e657d6 100644 --- a/tests/control_plane/test_replan_context_order.py +++ b/tests/control_plane/test_replan_context_order.py @@ -84,7 +84,9 @@ def test_carrier_metadata_preserves_feature_off_parity_and_rejects_non_boolean_o ordinary = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows) declared = project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows, history_newest_first=True, stride_strategy="off") - assert declared == ordinary + assert "decision_followthrough" not in ordinary + _assert_followthrough(declared) + assert "stride_strategy" not in declared for invalid in [0, 1, "newest_first"]: with pytest.raises(ValueError, match="boolean or unknown"): project_replan_context(goal_id="evidence-goal", agent_id="agent-a", runs=rows, diff --git a/tests/control_plane/test_replan_host_context_projection.py b/tests/control_plane/test_replan_host_context_projection.py index cd436e0430..02c8aebb21 100644 --- a/tests/control_plane/test_replan_host_context_projection.py +++ b/tests/control_plane/test_replan_host_context_projection.py @@ -218,44 +218,9 @@ def test_replan_guidance_survives_cli_and_host_compaction_without_new_authority( source = (_required_vision_replan_source(goal_id=GOAL_ID, agent_id=AGENT_ID) if vision_gap else _quota_payload()) guidance = source["replan_action_packet"]["planning_guidance"] - # These are delivery assertions, not a claim that a model follows the advice. - assert len(guidance) == 5 - assert "Never shrink requested goals" in guidance[0] - assert "honor user scope, authority, budget and stops" in guidance[0] - assert "operating objective and cumulative requirements" in guidance[0] - assert "delivered core_goal/acceptance context" in guidance[0] - assert "scope conflict, missing evidence or stale display text" in guidance[0] - assert "justified no-change decision" in guidance[0] - assert "goal-acceptance prepare-objective" in guidance[0] - assert "owner apply, then each peer reconciles its own work and adopts" in guidance[0] - assert "Documents/peers grant no authority" in guidance[0] - assert "without polling or a per-Turn revision/check receipt" in guidance[0] - assert "current authoritative evidence for every requirement" in guidance[1] - assert "unproven" in guidance[1] - assert "blocked/exhausted/superseded is not achieved" in guidance[1] - assert "review unmet acceptance against the original authorized goal and evidence" in guidance[2] - assert "continue or replan without waiting for an assigned successor" in guidance[2] - assert "An empty Todo queue is not a scope limit" in guidance[2] - assert "do not invent work, exceed authority or consume budget merely to stay active" in guidance[2] - assert "requested priorities and optimization direction" in guidance[3] - assert "user requirements and hard constraints" in guidance[3] - assert "provisional agent-created candidate rules" in guidance[3] - assert "Do not relax frozen acceptance, authority, budget or stops" in guidance[3] - assert "or rewrite criteria after failure" in guidance[3] - assert "Review unmet requirements even with open Todos" in guidance[4] - assert "Strongest first delivery is the objective" in guidance[4] - assert "diagnostic validation and available feedback can strengthen it" in guidance[4] - assert "Flat metrics alone prove neither progress nor exhaustion" in guidance[4] - assert "Compare expected goal/information gain with full marginal cost" in guidance[4] - assert "executed path and current artifact" in guidance[4] - assert "probe that distinguishes remaining explanations" in guidance[4] - assert "scope negative results to tested conditions and local proxies to their coverage" in guidance[4] - assert "Reuse still-applicable evidence" in guidance[4] - assert "Correct within the affected work first" in guidance[4] - assert "goal, path, dependency or acceptance evidence" in guidance[4] - assert "A due review can retain the path" in guidance[4] - assert "subsequent work and authoritative outcomes" in guidance[4] - assert "neither adoption nor improvement" in guidance[4] + # Legacy callers without a capture keep their complete source guidance. + # Transport equality, not dated wording, is the invariant here. + assert guidance and all(isinstance(item, str) and item for item in guidance) compact = compact_quota_should_run_cli_payload(source) envelope = build_turn_envelope(source) for packet in (compact, envelope): @@ -277,7 +242,7 @@ def test_replan_guidance_survives_cli_and_host_compaction_without_new_authority( # the envelope/transport/compute budget. assert envelope["compaction"]["within_budget"] == old_envelope["compaction"]["within_budget"] assert (envelope["compaction"]["envelope_json_bytes"] - - old_envelope["compaction"]["envelope_json_bytes"]) <= 3000 + - old_envelope["compaction"]["envelope_json_bytes"]) <= 3072 envelope["replan_action_packet"].pop("planning_guidance") assert envelope["replan_action_packet"] == old_envelope["replan_action_packet"] diff --git a/tests/control_plane/test_replan_staged_read.py b/tests/control_plane/test_replan_staged_read.py new file mode 100644 index 0000000000..9d4ad7ec57 --- /dev/null +++ b/tests/control_plane/test_replan_staged_read.py @@ -0,0 +1,222 @@ +"""Captured replan reads separate decision facts from authoring without re-admission.""" +from __future__ import annotations + +import copy +import json +import shlex + +import pytest + +from canonical_authority_fixture import isolate_sqlite_runtime +from loopx.control_plane.effect_runtime import EffectRuntimeRejected, restart_effect_runtime +from loopx.control_plane.quota.turn_envelope import ( + _turn_envelope_result, build_turn_envelope, quota_action_signature_document, + turn_envelope_action_signature_document, +) +from test_replan_host_context_projection import _quota_payload +from test_replan_progress_native_cli import ( + AGENT, GOAL, TODO, TURN, call_progress_cli, make_progress_cli_fixture, +) + + +def json_bytes(value): + return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + + +@pytest.mark.parametrize("stride", ["off", "evidence_conditioned_v1"]) +def test_staged_packet_budget_and_lossless_authority(stride): + source = _quota_payload(stride_strategy=stride) + legacy = build_turn_envelope(source) + source["replan_read_path"] = "/synthetic/decision.json" + original = copy.deepcopy(source) + envelope = build_turn_envelope(source, captured_decision_path=source["replan_read_path"]) + packet = envelope["replan_action_packet"] + assert "--replan-stage review" in packet["review"] + assert "--replan-stage writeback" in packet["writeback"] + assert "planning_guidance" not in packet and "stride_control" not in packet + assert json_bytes(packet) <= 2048 + assert json_bytes(packet) < json_bytes(legacy["replan_action_packet"]) / 2 + assert envelope["writeback"] == legacy["writeback"] + for field in ("boundary", "effective_action", "user", "scheduler"): + assert envelope.get(field) == legacy.get(field) + assert turn_envelope_action_signature_document(envelope) == quota_action_signature_document(source) + params = {"payload": source, "source_hash": envelope["action_signature"]["source_decision_hash"]} + review = _turn_envelope_result("replan_view", **params, stage="review") + writer = _turn_envelope_result("replan_view", **params, stage="writeback") + context = source["autonomous_replan_obligation"]["replan_context"] + assert review["core_goal"] == context["core_goal"] + assert review["planning_horizon"] == source.get("planning_horizon") + assert review["uncovered_frontier"] == context["uncovered_frontier"] + assert review["coverage_count"] == context["coverage_count"] + assert review["coverage_truncated"] == context["coverage_truncated"] + assert [row["evidence_ref"] for row in review["evidence"]] == [row["evidence_ref"] for row in context["evidence"]] + assert writer["writeback_contract"] == source["replan_action_packet"]["writeback_contract"] + assert writer["allowed_terminal"] == source["replan_action_packet"]["allowed_terminal"] + assert review["context_id"] == writer["context_id"] == context["context_id"] + assert json_bytes(review) <= 16384 + assert len(review["guidance"].encode()) <= 512 + assert json_bytes(writer) <= 16384 + if stride != "off": + control = writer["stride_control"] + assert control["previous_decision_ref"] == source["replan_action_packet"]["stride_control"]["previous_decision_ref"] + assert "decision_followthrough" not in control and "decision_value_review" not in control + assert source == original + + +def test_review_retains_scoped_dependencies_and_unknown_outcomes(): + source = _quota_payload() + source["replan_read_path"] = "/synthetic/decision.json" + # These are canonical typed planning facts, never text classified as blockers. + source["planning_horizon"] = {"items": [ + {"todo_id": "todo-report", "state": "waiting"}, + {"todo_id": "todo-implementation", "state": "runnable"}, + ], "relations": [{"from_todo_id": "todo-report", "to_todo_id": "todo-measurement", "kind": "depends_on"}]} + context = source["autonomous_replan_obligation"]["replan_context"] + context["decision_followthrough"] = {"recorded_progress_count": 21, + "result_counts": {"advanced": 1, "blocked": 20, "unchanged": 0, "no_followup": 0, "exploration_exhausted": 0}, + "observations": [{"todo_id": "todo-report", "result_class": "blocked", "delivery_outcome": None}], + "execution_and_goal_gain": "unknown"} + envelope = build_turn_envelope(source) + review = _turn_envelope_result("replan_view", payload=source, stage="review", + source_hash=envelope["action_signature"]["source_decision_hash"]) + assert review["planning_horizon"] == source["planning_horizon"] + assert review["decision_followthrough"]["observation_count"] == 21 + assert review["decision_followthrough"]["result_counts"]["advanced"] == 1 + assert review["decision_followthrough"]["execution_and_goal_gain"] == "unknown" + assert "recommended_action" not in review # No hardcoded pivot or automatic wait. + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("envelope", [False, True]) +def test_real_guard_capture_read_and_writeback_without_another_guard(tmp_path, monkeypatch, provider, envelope): + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = make_progress_cli_fixture(tmp_path, provider, "advanced") + capture = tmp_path / "capture" + try: + initial = call_progress_cli(fixture, "quota", "should-run", "--codex-app", + "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", TURN) + assert initial["decision"] == "autonomous_replan_required" + guard = call_progress_cli(fixture, "quota", "should-run", "--codex-app", + "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", TURN, + "--todo-id", TODO, "--decision-output-dir", str(capture), + *(["--turn-envelope"] if envelope else [])) + packet = guard["replan_action_packet"] + saved = (capture / "decision.json").read_bytes() + source = json.loads(saved) + original_files = {key: fixture[key].read_bytes() for key in ("registry", "state", "index")} + # Executable commands come from the production packet. Deliberately use + # an absent registry: the reader must never collect state or re-admit. + reader_fixture = {**fixture, "registry": tmp_path / "missing-registry.json"} + def read(stage, expected_code=0): + args = shlex.split(packet[stage]) + assert args[:3] == ["loopx", "--format", "json"] + return call_progress_cli(reader_fixture, *args[3:], expected_code=expected_code) + review, writer = read("review"), read("writeback") + assert review["context_id"] == writer["context_id"] + assert review["obligation_id"] == source["replan_action_packet"]["obligation_id"] + assert writer["writeback_contract"] == source["replan_action_packet"]["writeback_contract"] + assert (capture / "decision.json").read_bytes() == saved + assert {key: fixture[key].read_bytes() for key in original_files} == original_files + assert not reader_fixture["registry"].exists() + # Changed bytes cannot masquerade as the admitted observation. + source["goal_id"] = "another-goal" + (capture / "decision.json").write_text(json.dumps(source)) + rejected = read("review", expected_code=1) + assert "source hash" in rejected["reason"] + (capture / "decision.json").write_bytes(saved) + # A valid next operation still reaches the existing writer and semantic gate. + result = call_progress_cli(fixture, *fixture["refresh"]) + assert result["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True + finally: + restart_effect_runtime(wait_for_exit=True) + + +def _views(source): + envelope = build_turn_envelope(source) + params = {"payload": source, "source_hash": envelope["action_signature"]["source_decision_hash"]} + return envelope, _turn_envelope_result("replan_view", **params, stage="review"), params + + +def test_populated_history_and_cumulative_acceptance_have_separate_budgets(tmp_path): + from replan_packet_fixture import populated_replan_source + + base = populated_replan_source(tmp_path / "base") + envelope, review, _ = _views(base) + context = base["autonomous_replan_obligation"]["replan_context"] + assert context["evidence_count"] == 1500 and len(context["evidence"]) == 24 + assert context["from_full_index"] is True + assert {row["planning_state"] for row in review["planning_horizon"]["work_items"]} == {"selected", "waiting", "runnable"} + assert context["coverage_truncated"] and context["evidence_truncated"] + assert review["decision_followthrough"]["observation_count"] == 21 + assert review["decision_followthrough"]["omitted_observation_count"] == 15 + assert sum(review["decision_followthrough"]["result_counts"].values()) == 21 + assert review["decision_followthrough"]["result_counts"]["advanced"] < 21 + no_planning = copy.deepcopy(base) + no_planning.pop("planning_horizon") + no_planning.pop("action_portfolio") + _, history_review, _ = _views(no_planning) + assert json_bytes(history_review) <= 16 * 1024 + assert 0 < json_bytes(review) - json_bytes(history_review) <= 8 * 1024 + assert json_bytes(envelope["replan_action_packet"]) <= 2 * 1024 + + large = populated_replan_source(tmp_path / "acceptance", acceptance=True) + large_envelope, expanded, params = _views(large) + # Assert the complete emitted envelope too: moving the payload into a second + # hot carrier would satisfy a packet-only budget while defeating the fix. + assert abs(json_bytes(large_envelope) - json_bytes(envelope)) <= 512 + assert "context_read" in large_envelope["action"]["planning_horizon"] + original = large["autonomous_replan_obligation"]["replan_context"]["core_goal"] + assert json_bytes(original) > 200 * 1024 + assert expanded["planning_horizon"]["core_goal_ref"] == "$.core_goal" + index = expanded["acceptance_index"] + assert len(index["outcomes"]) == 24 + # Cumulative acceptance earns its own index allocation; it cannot spend the + # generic review allowance by duplicating entire historical reviews. + assert 0 < json_bytes(expanded) - json_bytes(review) <= 12 * 1024 + originals = original["acceptance_contract"]["outcome_requirements"]["outcomes"] + for row, outcome in zip(index["outcomes"], originals, strict=True): + read = _turn_envelope_result("replan_view", **params, stage="acceptance", outcome_ref=row["outcome_ref"]) + assert read["outcome"] == outcome + assert row["requirement_count"] == len(outcome["requirements"]) + # A different source revision with the same outcome id must remain visible. + large["planning_horizon"]["core_goal"]["acceptance_contract"]["outcome_requirements"]["outcomes"][0]["revision"] = 3 + _, revised, params = _views(large) + same_id = [row for row in revised["acceptance_index"]["outcomes"] if row["id"] == "outcome-0"] + assert len(same_id) == 2 and same_id[0]["outcome_ref"] != same_id[1]["outcome_ref"] + with pytest.raises(EffectRuntimeRejected, match="exact captured outcome reference"): + _turn_envelope_result("replan_view", **params, stage="acceptance", outcome_ref="missing") + + +def test_explore_uses_its_own_incremental_budget_and_real_result_log(tmp_path): + from replan_packet_fixture import populated_explore_context, populated_replan_source + + explore = populated_explore_context(tmp_path) + assert explore["ok"] and explore["graph_enabled"] + base = populated_replan_source(tmp_path / "base") + enabled = populated_replan_source(tmp_path / "enabled", explore_context=explore) + base_envelope, base_review, _ = _views(base) + envelope, review, params = _views(enabled) + assert "capabilities" not in base_review + # Optional capability context must not inflate the shared hot packet. + assert json_bytes(envelope["replan_action_packet"]) - json_bytes(base_envelope["replan_action_packet"]) <= 128 + assert 0 < json_bytes(review) - json_bytes(base_review) <= 768 + detail = _turn_envelope_result("replan_view", **params, stage="explore") + basis = enabled["autonomous_replan_obligation"]["replan_context"]["explore_basis"] + assert detail["explore_basis"] == basis + assert basis["availability"] == "available" and basis["finding_refs"] + assert basis["result_page"]["total"] > len(basis["finding_refs"]) + assert json_bytes(detail) <= 8 * 1024 + # Execute the capability reader through the shipped CLI as well as its TS owner. + captured = tmp_path / "explore-decision.json" + enabled["replan_read_path"] = str(captured) + captured.write_text(json.dumps(enabled)) + _, cli_review, _ = _views(enabled) + command = shlex.split(cli_review["capabilities"]["explore"]["read_action"]) + reader_fixture = {"registry": tmp_path / "absent.json", "runtime": tmp_path / "absent-runtime", "project": tmp_path} + cli_detail = call_progress_cli(reader_fixture, *command[3:]) + assert cli_detail["explore_basis"] == basis + assert {row["status"] for row in basis["finding_refs"]} >= {"confirmed", "refuted"} + _, _, params = _views(enabled) + writer = _turn_envelope_result("replan_view", **params, stage="writeback") + assert writer["stride_control"]["reference_context_id"] == review["context_id"] + assert json_bytes(writer) <= 16 * 1024 diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts index c321a350f9..5f4365e590 100644 --- a/tests/control_plane_ts/replan_context.test.ts +++ b/tests/control_plane_ts/replan_context.test.ts @@ -259,7 +259,7 @@ test("ordinary declared-work evidence reaches followthrough without becoming typ assert.throws(() => validateReplanContext({...context, decision_followthrough: {...follow, ...patch}}), /ordinary work/); } - assert.equal(projectReplanContext({...request, rows: [decision, actual]}).decision_followthrough, undefined); + assert.deepEqual(projectReplanContext({...request, rows: [decision, actual]}).decision_followthrough.ordinary_work.evidence, follow.ordinary_work.evidence); }); test("ordinary followthrough retains corrections and bounded coverage under source scope", () => { @@ -371,8 +371,7 @@ test("declared work returns canonical state and only its subsequent observations // Live work state changes the review basis, never the historical decision identity. assert.equal(withoutSource.decision_followthrough.previous_decision.evidence_ref, follow.previous_decision.evidence_ref); - assert.deepEqual(projectReplanContext({...request, rows: [decision], todo_items: [{invalid: true}]}), - projectReplanContext({...request, rows: [decision]})); + assert.equal(projectReplanContext({...request, rows: [decision], todo_items: [{invalid: true}]}).decision_followthrough.planned_work[0].current, null); for (const patch of [{todo_source_available: false}, {planned_work: [{...work, observation_refs: ["evidence:invented"]}]}, {planned_work: [work, work]}, {omitted_planned_work_count: -1}]) { @@ -414,7 +413,7 @@ test("autonomous decisions return through their own binding and resolve without assert.throws(() => projectReplanContext({...resolve, rows: [autonomousReviewed(2, "replan-other")]}), /unavailable/); const ordinary = projectReplanContext({...request, rows}); assert.deepEqual(projectReplanContext({...request, rows, stride_strategy: "off"}), ordinary); - assert.equal(ordinary.decision_followthrough, undefined); + assert.equal(ordinary.decision_followthrough.previous_decision.evidence_ref, context.decision_followthrough.previous_decision.evidence_ref); }); test("autonomous followthrough rejects conflicting identities, bindings and acknowledgements", () => { @@ -539,12 +538,13 @@ test("stall frontier projects its current strategy through the shared outcome ow } }); -test("strategy off preserves context identity and selection despite decision transport facts", () => { +test("generic followthrough is available with strategy off without enabling stride", () => { const rows = [reviewed(), subsequent(1)]; const ordinary = projectReplanContext({...request, rows}); const off = projectReplanContext({...request, rows, stride_strategy: "off"}); assert.deepEqual(off, ordinary); - assert.equal(off.decision_followthrough, undefined); + assert.deepEqual(off.decision_followthrough, projectReplanContext({...strideRequest, rows}).decision_followthrough); + assert.deepEqual(validateReplanContext(off), off); const enabled = projectReplanContext({...strideRequest, rows}); assert.equal(enabled.stride_strategy, "evidence_conditioned_v1"); assert.deepEqual(validateReplanContext(enabled), enabled); @@ -553,7 +553,7 @@ test("strategy off preserves context identity and selection despite decision tra assert.throws(() => validateReplanContext({...enabled, decision_followthrough: { ...enabled.decision_followthrough as JsonObject, ...patch}}), /followthrough/); } - assert.throws(() => validateReplanContext({...enabled, stride_strategy: "off"}), /enabled strategy/); + assert.deepEqual(validateReplanContext({...enabled, stride_strategy: "off"}).decision_followthrough, enabled.decision_followthrough); assert.notEqual(enabled.context_id, off.context_id); for (const field of ["evidence", "coverage_ledger", "uncovered_frontier", "evidence_count"]) { @@ -628,3 +628,24 @@ test("invalid source digest shapes cannot authorize accounting reduction", () => } } }); + +test("blocked and unchanged observations never count as advanced work; caps preserve result totals", () => { + const observations = Array.from({length: 13}, (_, i) => ({...subsequent(i + 3), + delivery_outcome: i === 0 ? "outcome_progress" : "outcome_gap", + progress_observation: {...progress, fingerprint: "mixed-" + i, + result_class: i === 0 ? "advanced" : i < 11 ? "blocked" : "unchanged"}})); + const context = projectReplanContext({...request, rows: [reviewed(2), ...observations]}); + const follow = context.decision_followthrough; + assert.equal(follow.recorded_progress_count, 13); + assert.deepEqual(follow.result_counts, {advanced: 1, blocked: 10, unchanged: 2, + exploration_exhausted: 0, no_followup: 0}); + assert.equal(follow.observations.length, 6); + assert.equal(follow.omitted_observation_count, 7); + assert.equal(follow.execution_and_goal_gain, "unknown"); + assert.ok(follow.observations.every((row: JsonObject) => typeof row.result_class === "string" && row.delivery_outcome)); + assert.deepEqual(validateReplanContext(context), context); + assert.throws(() => validateReplanContext({...context, decision_followthrough: {...follow, + result_counts: {...follow.result_counts, advanced: 13}}}), /result counts/); + assert.throws(() => validateReplanContext({...context, decision_followthrough: {...follow, + observations: [{...follow.observations[0], result_class: "invented"}]}}), /followthrough/); +}); From 2035dfcdf97d0659c8b4a2f28f58d551050f989d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:05:17 +0800 Subject: [PATCH 02/10] docs(replan): qualify staged reads and incremental context budgets Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../hierarchical-agent-stride-control-v0.md | 20 ++++-- ...rarchical-agent-stride-control-v0.zh-CN.md | 16 +++-- docs/reference/protocols/turn-envelope-v0.md | 64 +++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index 01f6499484..fab49194de 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -762,7 +762,7 @@ these facts do not change the admitted context/basis identity. Changed source requires rereading within the original Turn. Page coordinates persist with the decision into subsequent review; node-filtered/planner detail routes remain outside this bounded citation path. No separate receipt store is introduced. -The enabled review also carries the prior decision's existing `todo_delta` +The replan context also carries the prior decision's existing `todo_delta` targets in `decision_followthrough.planned_work`: current canonical status and claim, plus references to causally later observations of that exact work item. Legacy descriptive Todo deltas remain readable with unresolved linkage; an @@ -770,9 +770,21 @@ overlong reference is never truncated into a different Todo identity. Missing Todo sources and omitted work remain explicit; a missing observation does not prove non-execution, and Todo completion does not prove objective gain. Related observations survive unrelated recent activity in the bounded window. -The same bounded followthrough is included in `stride_control` so compact Host -envelopes expose it without requiring another full-context read. Historical -decision identities and off-mode output remain unchanged. +Generic followthrough is available with stride off as well: `result_counts` +separates advanced, unchanged, blocked and terminal observations, while visible +rows retain their result class and delivery outcome. The compatibility +`recorded_progress_count` is an observation count, never successful work. +Historical decision identities and stride admission/settlement gates remain +unchanged; off-mode context now includes this feedback without enabling stride. + +The [captured replan reader](../../reference/protocols/turn-envelope-v0.md#captured-replan-reads--捕获后的重规划读取) +separates state review from authoring on one immutable decision snapshot. +Cumulative acceptance uses lossless content-bound detail reads, and Explore +receives a separate context increment. Existing uncaptured callers retain their +full delivery. File/SQLite source tests and populated packet budgets qualify +transport and writer continuity; installed adoption, real model decisions and +matched outcome/cost benefit remain unqualified. This introduces no new planning +receipt, model checkpoint or requirement to change direction. The next-review input also exposes causally later ordinary writebacks for the declared work in `ordinary_work`, with per-Todo `ordinary_evidence_refs`. Its diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index 99feb6d376..45d90affe9 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -632,14 +632,22 @@ revision、考虑的 finding 与适用/暂缓等理由,不建新图或第二 拒绝缺少复盘、错误引用以及过期或不可用依据的错误声明,校验提案结构和来源, 不能证明研究已执行或有收益。 -启用策略后,`decision_followthrough.planned_work` 将上次决定中既有 `todo_delta` +replan context 中的 `decision_followthrough.planned_work` 将上次决定中既有 `todo_delta` 指向的 Todo 与当前规范状态、认领人和该工作项在决定之后的观测引用一起返回。 Todo 来源缺失和省略数量明确保留;缺少观测不等于没有执行,Todo 完成也不等于 目标收益。历史描述性 Todo delta 保持可读并注明关联未解析;过长引用不会截断成 另一个 Todo 的身份。相关观测不会被大量较新的无关活动挤出有限窗口。 -同一份有界反馈进入 -`stride_control`,紧凑 Host envelope 可直接消费,避免为这些信息再读完整上下文。 -历史决定的引用身份及 off 模式输出不变。 +通用执行回顾在 stride 关闭时也可读取:`result_counts` 区分推进、未变、阻塞和 +终态观测,明细保留结果分类及交付结果。兼容字段 `recorded_progress_count` 表示 +观测数量,不表示成功工作。历史决定引用及 stride 准入/结算门禁不变;off 模式 +新增反馈上下文,但不启用 stride。 + +[捕获后的重规划读取](../../reference/protocols/turn-envelope-v0.md#captured-replan-reads--捕获后的重规划读取) +把状态审视和写回格式分开,复用同一不可变决策快照。累计验收通过内容绑定的明细 +读取完整保留,Explore 使用独立的上下文增量预算;未捕获快照的调用仍完整投递。 +File/SQLite 源码测试和真实规模 packet 预算验证传输与写回连续性;已安装采用、真实 +模型决策及可比成果/成本收益仍待资格化。不新增规划回执、模型 checkpoint 或强制 +改变方向的要求。 下一轮输入还通过 `ordinary_work` 与各 Todo 的 `ordinary_evidence_refs` 返回已 声明工作在决定之后的普通回写。详情最多六项,保留总数、省略数和稳定 history diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 7e01dd65b9..6314a7520a 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -77,6 +77,70 @@ CLI from becoming another reader of the legacy guidance field. 事实仍在当前包内,不需再次执行 guard。不同副本和既有详细视图保留;原始决定与 capture 不变。动作维度等价不意味着不同渲染可以共用原始 source hash。 +### Captured replan reads / 捕获后的重规划读取 + +For a successful replan with `--decision-output-dir` or `--decision-output-root`, +the CLI and TurnEnvelope expose `replan_action_packet.review` followed by +`writeback`. Both are executable `quota replan-view` commands bound to the same +captured JSON value and source hash. The first read supplies goal context, +previous decision/results, planning dependencies and evidence references; the +second supplies the existing authoring shape and settlement commands. No read +runs another guard, writes a receipt or authorizes work. There is no additional +read acknowledgement, mandatory model call or prescribed route change. + +Cumulative outcome requirements are indexed by exact content hash. Every +objective, revision and requirement count remains visible; the acceptance read +returns the complete original outcome. Identical IDs with different revisions +or evidence remain distinct. Equal context/horizon copies share references; +differing goal scopes remain separate. The full capture retains all evidence, +legacy guidance and authoring examples. Explore has an optional, separately +readable basis; unavailable evidence remains unavailable. These views do not +assess acceptance, infer execution from counts or make a dependency global. + +`quota replan-view --decision-file --source-hash ` defaults to +`--replan-stage review`; `writeback`, `acceptance --outcome-ref ` and +`explore` read the corresponding original-source detail. Missing/mutated sources +fail without guard replay. Existing uncaptured calls and native journal captures +without the CLI read route retain their prior delivery. The existing generic +followthrough is now available with stride off; its result-class counts and +observations remain evidence, not successful-work or goal-gain verdicts. + +对使用 `--decision-output-dir` 或 `--decision-output-root` 的成功 replan,CLI 和 +TurnEnvelope 先给出 `review` 状态读取,再给出 `writeback` 格式读取。两步绑定同一 +快照和来源哈希,不重新准入、不新增回执或授权,也不强制换路线。累计验收按完整 +内容哈希建立索引:每个目标、版本及要求数量可见,明细完整读回;相同 ID 的不同 +版本不会覆盖。相同的目标上下文副本共享引用,不同范围保留。Explore 明细单独按需 +读取;不可用保持不可用。完整快照仍保存原指导、示例和证据。没有捕获或没有 CLI +读取路径的原生 journal 保持原交付方式。通用执行回顾不再依赖 stride 开关;分类 +计数只表示观测,不等于工作成功或目标收益。 + +Regression budgets use compact UTF-8 JSON, separately from compute quota and +provider tokens. The synthetic workload uses a real append index with 1,500 +records, 21 later observations, 24 cumulative outcomes and a populated Explore +log. Limits protect default input while exact details remain readable: + +| View | Regression allocation | +| --- | --- | +| Replan entry packet | 2 KiB | +| Populated history review | 16 KiB | +| Planning inventory increment | 8 KiB | +| Cumulative acceptance index increment | 12 KiB | +| Explore increment in entry / review | 128 B / 768 B | +| Explore detail / writeback | 8 KiB / 16 KiB | + +The whole-envelope differential also prevents a large acceptance copy moving to +another hot carrier. These are fixture regression allocations, not runtime +truncation or refusal limits; the existing 8 KiB envelope warning remains. +File/SQLite CLI tests cover capture → reads → existing writeback, hash rejection +and unchanged state during reads. They do not establish model choice quality, +installed adoption, token savings or matched long-horizon benefit. + +大小回归使用紧凑 UTF-8 JSON,与算力 quota 和 provider token 分开。通用 review、 +累计验收索引和 Explore 分别约束增量,同时检查整个 envelope,防止把大副本挪到 +另一处热输入。预算不在运行时截断或拒绝合法内容,原 8 KiB envelope 警告保留。 +File/SQLite CLI 验证快照、分步读取、既有写回和哈希拒绝,不据此宣称模型决策、 +已安装采用、token 节省或长程收益改善。 + ### Native Turn Host capture Executing `turn run-once --execute` now saves the original full decision before From c18abc4d9ebf8fb38a00279bee3c58f828e82256 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:02:49 +0800 Subject: [PATCH 03/10] fix(replan): preserve reader routes and remove prefabricated choices Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/protocols/turn-envelope-v0.md | 16 ++++++++ loopx/cli_commands/quota.py | 5 ++- loopx/control_plane/quota/turn_envelope.ts | 26 +++++++++---- .../control_plane/work_items/stride_policy.ts | 3 ++ .../control_plane/test_replan_staged_read.py | 37 ++++++++++++++++--- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 6314a7520a..38fc70965f 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -88,6 +88,16 @@ second supplies the existing authoring shape and settlement commands. No read runs another guard, writes a receipt or authorizes work. There is no additional read acknowledgement, mandatory model call or prescribed route change. +Captured reads retain the caller's explicit `--cli-bin` route, including later +acceptance and Explore reads; an ambient executable cannot silently replace it. +The review stage provides structured facts without a prescribed diagnostic +method. Writeback omits filled-in Vision/stride examples while retaining field +limits, legal options, source bindings and settlement rules. Stride exposes its +required field names and basis options independently of examples. The unchanged +full capture remains available for optional examples and source inspection. +This intentionally reduces advisory prompting; it does not remove acceptance, +authority or stop obligations, nor demonstrate better autonomous decisions. + Cumulative outcome requirements are indexed by exact content hash. Every objective, revision and requirement count remains visible; the acceptance read returns the complete original outcome. Identical IDs with different revisions @@ -114,6 +124,12 @@ TurnEnvelope 先给出 `review` 状态读取,再给出 `writeback` 格式读 读取路径的原生 journal 保持原交付方式。通用执行回顾不再依赖 stride 开关;分类 计数只表示观测,不等于工作成功或目标收益。 +捕获后的读取沿用调用方显式选择的 `--cli-bin`,后续验收和 Explore 读取也不会 +悄悄切换到环境中的其他版本。复盘视图提供结构化事实,不预设诊断方法;写回视图 +移除预填的 Vision/stride 答案示例,保留字段限制、合法选项、来源绑定和结算规则。 +stride 的必需字段与 basis 选项独立展示,完整快照仍可用于按需查看示例与源材料。 +这是有意减少建议性提示,不删除验收、权限或停止义务,也不证明自主决策质量提升。 + Regression budgets use compact UTF-8 JSON, separately from compute quota and provider tokens. The synthetic workload uses a real append index with 1,500 records, 21 later observations, 24 cumulative outcomes and a populated Explore diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index ef7f0b316a..da4791a6b4 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -469,7 +469,10 @@ def _emit_quota_result( ) bind_capture_selection_transport(payload, args) if capture_directory is not None and payload.get("replan_action_packet"): - payload = {**payload, "replan_read_path": str(capture_directory / "decision.json")} + from ..command_invocation import loopx_cli_argv + + payload = {**payload, "replan_read_path": str(capture_directory / "decision.json"), + "replan_read_argv": loopx_cli_argv()} try: capture_decision(capture_directory, payload) if payload.get("ok") is True: diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index c3f40e9df8..b20972de1e 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -297,7 +297,13 @@ function replanReadCommands(payload: JsonObject): JsonObject | null { const packet = object(payload.replan_action_packet); if (typeof path !== "string" || !path.trim() || path.includes("\0") || payload.ok !== true || !packet.obligation_id || packet.settlement_only === true) return null; - const command = `loopx --format json quota replan-view --decision-file ${shellQuote(path)}` + + // Retain the renderer's executable across reads. A different ambient LoopX + // may not understand this captured contract. Legacy captures keep their route. + const argv = payload.replan_read_argv ?? ["loopx"]; + if (!Array.isArray(argv) || !argv.length || argv.some(arg => typeof arg !== "string" || !arg || /[\x00-\x1f]/.test(arg))) { + throw new EffectRuntimeRequestError("captured replan CLI route is malformed"); + } + const command = `${argv.map(arg => shellQuote(arg as string)).join(" ")} --format json quota replan-view --decision-file ${shellQuote(path)}` + ` --source-hash ${canonicalHash(payload)}`; return {review: `${command} --replan-stage review`, writeback: `${command} --replan-stage writeback`, acceptance: `${command} --replan-stage acceptance`, explore: `${command} --replan-stage explore`}; @@ -318,6 +324,7 @@ function replanView(request: JsonObject): JsonObject { obligation_id: packet.obligation_id, context_id: context.context_id, source_hash: request.source_hash, stage: request.stage}; const reads = replanReadCommands(payload); + const sourceRead = reads ? {source_read: `cat -- ${shellQuote(payload.replan_read_path as string)}`} : {}; const outcomes = new Map(); function indexedGoal(value: unknown): JsonObject { const core = {...object(value)}; @@ -353,8 +360,14 @@ function replanView(request: JsonObject): JsonObject { // Preserve executable shape and all settlement rules; decision advice and // duplicate context are available in the unchanged full captured source. const stride = {...object(packet.stride_control)}; - for (const key of ["decision_followthrough", "explore_basis", "decision_value_review"]) delete stride[key]; - return {...common, writeback_contract: packet.writeback_contract, + for (const key of ["decision_followthrough", "explore_basis", "decision_value_review", "minimal_example"]) delete stride[key]; + const contract = {...object(packet.writeback_contract)}; + if (contract.vision_authoring) { + const authoring = {...object(contract.vision_authoring)}; + delete authoring.minimal_example; + contract.vision_authoring = authoring; + } + return {...common, ...sourceRead, writeback_contract: contract, required_outcome: packet.required_outcome, allowed_terminal: packet.allowed_terminal, ...(packet.stride_control ? {stride_control: stride} : {}), writeback: actionProjection(payload).writeback, @@ -400,9 +413,8 @@ function replanView(request: JsonObject): JsonObject { omitted_evidence: context.omitted_evidence ?? null, evidence_read_action_template: context.evidence_read_action_template ?? null, uncovered_frontier: context.uncovered_frontier, - guidance: "Compare the goal, prior decision and actual results; distinguish scoped dependencies from independent feasible work. " + - "Read applicable acceptance and evidence by their source references; choose useful work within existing authority and budget. " + - "Missing evidence stays unknown; no pivot is required.", + // Facts and their provenance are the review input. Do not preselect a + // diagnostic method, candidate route or judgment from observation counts. next: {writeback: replanReadCommands(payload)?.writeback ?? null}, }; } @@ -413,7 +425,7 @@ function replanActionPacket(payload: JsonObject): JsonObject | null { const reads = replanReadCommands(payload); if (reads) return {...compactFields(source, ["schema_version", "decision", "obligation_id", "required_outcome", "allowed_terminal"]), review: reads.review, writeback: reads.writeback, - guidance: "Read review state, choose the next work, then read its writeback format. These reads reuse this decision; they grant no new authority."}; + guidance: "Read review state and applicable acceptance/evidence before deciding; writeback supplies the format. Reads grant no new authority and require no acknowledgement."}; const compact = compactFields(source, [ "schema_version", "decision", "obligation_id", "uncovered_frontier", "required_outcome", "allowed_terminal", "bounded_frontier", "settlement_only", "successor_todo_id", "stride_control", diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index c5113511d4..53bdc6d507 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -414,6 +414,9 @@ export function strideControlContract(context: JsonObject, obligationId: unknown return {schema_version: decisionSchema.schema_version, strategy_version: context.stride_strategy, reference_context_id: context.context_id ?? null, reference_obligation_id: obligationId, axis_names: decisionSchema.axis_names, actions: decisionSchema.actions, + basis_options: decisionSchema.basis_options, + required_fields: ["schema_version", "strategy_version", "reference_context_id", "reference_obligation_id", + "basis", "action", "axes", "next_probe", ...(previous ? ["previous_review"] : []), ...(basis ? ["explore_review"] : [])], axis_plan_limit: decisionSchema.axis_plan_limit, axis_reason_limit: decisionSchema.axis_reason_limit, probe_field_limits: STRIDE_PROBE_LIMITS, decision_value_review: {...decisionValueReview}, diff --git a/tests/control_plane/test_replan_staged_read.py b/tests/control_plane/test_replan_staged_read.py index 9d4ad7ec57..80540a2932 100644 --- a/tests/control_plane/test_replan_staged_read.py +++ b/tests/control_plane/test_replan_staged_read.py @@ -4,6 +4,10 @@ import copy import json import shlex +import os +import subprocess +import sys +from pathlib import Path import pytest @@ -23,6 +27,13 @@ def json_bytes(value): return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) +def without_example(contract): + expected = copy.deepcopy(contract) + if "vision_authoring" in expected: + expected["vision_authoring"].pop("minimal_example") + return expected + + @pytest.mark.parametrize("stride", ["off", "evidence_conditioned_v1"]) def test_staged_packet_budget_and_lossless_authority(stride): source = _quota_payload(stride_strategy=stride) @@ -50,16 +61,19 @@ def test_staged_packet_budget_and_lossless_authority(stride): assert review["coverage_count"] == context["coverage_count"] assert review["coverage_truncated"] == context["coverage_truncated"] assert [row["evidence_ref"] for row in review["evidence"]] == [row["evidence_ref"] for row in context["evidence"]] - assert writer["writeback_contract"] == source["replan_action_packet"]["writeback_contract"] + assert writer["writeback_contract"] == without_example(source["replan_action_packet"]["writeback_contract"]) assert writer["allowed_terminal"] == source["replan_action_packet"]["allowed_terminal"] assert review["context_id"] == writer["context_id"] == context["context_id"] assert json_bytes(review) <= 16384 - assert len(review["guidance"].encode()) <= 512 + assert "guidance" not in review assert json_bytes(writer) <= 16384 if stride != "off": control = writer["stride_control"] assert control["previous_decision_ref"] == source["replan_action_packet"]["stride_control"]["previous_decision_ref"] assert "decision_followthrough" not in control and "decision_value_review" not in control + assert "minimal_example" not in control + assert control["basis_options"] == ["supported_progress", "unexplained_gap", "uncertain_evidence", "useful_new_route", "decision_failure"] + assert {"basis", "action", "axes", "next_probe"} <= set(control["required_fields"]) assert source == original @@ -96,7 +110,10 @@ def test_real_guard_capture_read_and_writeback_without_another_guard(tmp_path, m initial = call_progress_cli(fixture, "quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", TURN) assert initial["decision"] == "autonomous_replan_required" - guard = call_progress_cli(fixture, "quota", "should-run", "--codex-app", + # Exercise the emitted command verbatim even with a hostile ambient + # PATH entry. The captured reader belongs to the originating checkout. + pinned_cli = str(Path(sys.executable).with_name("loopx")) + guard = call_progress_cli(fixture, "--cli-bin", pinned_cli, "quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", TURN, "--todo-id", TODO, "--decision-output-dir", str(capture), *(["--turn-envelope"] if envelope else [])) @@ -106,15 +123,23 @@ def test_real_guard_capture_read_and_writeback_without_another_guard(tmp_path, m original_files = {key: fixture[key].read_bytes() for key in ("registry", "state", "index")} # Executable commands come from the production packet. Deliberately use # an absent registry: the reader must never collect state or re-admit. + trap = tmp_path / "bin" + trap.mkdir() + (trap / "loopx").write_text("#!/bin/sh\nexit 73\n") + (trap / "loopx").chmod(0o700) reader_fixture = {**fixture, "registry": tmp_path / "missing-registry.json"} def read(stage, expected_code=0): args = shlex.split(packet[stage]) - assert args[:3] == ["loopx", "--format", "json"] - return call_progress_cli(reader_fixture, *args[3:], expected_code=expected_code) + assert args[:3] == [pinned_cli, "--cli-bin", pinned_cli] + result = subprocess.run([args[0], "--registry", str(reader_fixture["registry"]), *args[1:]], + cwd=fixture["project"], env={**os.environ, "PATH": str(trap) + os.pathsep + os.environ["PATH"], + "LOOPX_USAGE_PING": "0"}, capture_output=True, text=True, timeout=60) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return json.loads(result.stdout) review, writer = read("review"), read("writeback") assert review["context_id"] == writer["context_id"] assert review["obligation_id"] == source["replan_action_packet"]["obligation_id"] - assert writer["writeback_contract"] == source["replan_action_packet"]["writeback_contract"] + assert writer["writeback_contract"] == without_example(source["replan_action_packet"]["writeback_contract"]) assert (capture / "decision.json").read_bytes() == saved assert {key: fixture[key].read_bytes() for key in original_files} == original_files assert not reader_fixture["registry"].exists() From 7e6459411729d3c6ea2e9cd4505f649398b765c9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:30 +0800 Subject: [PATCH 04/10] refactor(quota): keep captured replan IO out of live dispatch Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota.py | 18 +++--------------- loopx/cli_commands/quota_capture.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index da4791a6b4..15e86efc49 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from pathlib import Path -from .quota_capture import bind_capture_selection_transport, capture_decision, handoff_decision, prepare_decision_capture +from .quota_capture import bind_capture_selection_transport, capture_decision, handoff_decision, handle_replan_view, prepare_quota_capture from ..usage_goal import observe_quota_result from ..capabilities.explore.composition_frontier import ( @@ -514,13 +514,7 @@ def handle_quota_command( capture_directory: Path | None = None, ) -> int: if args.quota_command == "replan-view": - from .quota_capture import read_replan_view - try: - view = read_replan_view(args) - except (OSError, ValueError, EffectRuntimeRejected) as exc: - view = {"ok": False, "reason": str(exc)} - print_payload(view, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) - return 0 if view["ok"] else 1 + return handle_replan_view(args, print_payload) usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None heartbeat_receipt_existing: dict[str, object] | None = None @@ -535,13 +529,7 @@ def handle_quota_command( context: QuotaCommandContext | None = None goal_ref: dict[str, str] | None = None try: - if capture_directory is None and ( - getattr(args, "decision_output_dir", None) is not None - or getattr(args, "decision_output_root", None) is not None - or getattr(args, "decision_handoff_socket", None) is not None - ): - validate_quota_command_context_request(args) - capture_directory = prepare_decision_capture(args) + capture_directory = prepare_quota_capture(args, capture_directory) goal_ref, turn_start_hook_dispatch, context = ( _prepare_quota_command_execution( args, diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 90b3783c81..c0f71f5044 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -8,12 +8,28 @@ import shlex import socket import tempfile +from collections.abc import Callable from pathlib import Path +from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.quota.error_codes import QuotaCommandValidationError, QuotaDecisionTransportError from ..control_plane.turn_driver.decision_capture import validate_handoff_socket +def prepare_quota_capture(args: argparse.Namespace, directory: Path | None) -> Path | None: + """Validate transport options before allocating a new guard capture.""" + if directory is None and ( + getattr(args, "decision_output_dir", None) is not None + or getattr(args, "decision_output_root", None) is not None + or getattr(args, "decision_handoff_socket", None) is not None + ): + from .quota_context import validate_quota_command_context_request + + validate_quota_command_context_request(args) + return prepare_decision_capture(args) + return directory + + def prepare_decision_capture(args: argparse.Namespace) -> Path | None: requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) @@ -136,3 +152,16 @@ def read_replan_view(args: argparse.Namespace) -> dict: payload = json.load(stream) return _turn_envelope_result("replan_view", payload=payload, source_hash=args.source_hash, stage=args.replan_stage, outcome_ref=args.outcome_ref) + + +def handle_replan_view( + args: argparse.Namespace, + print_payload: Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None], +) -> int: + """Render the captured read without entering live quota dispatch or recovery.""" + try: + view = read_replan_view(args) + except (OSError, ValueError, EffectRuntimeRejected) as exc: + view = {"ok": False, "reason": str(exc)} + print_payload(view, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 0 if view["ok"] else 1 From ed5c2bf4e8bcd0ce00a381bc99f3816ed6e3b07c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:54 +0800 Subject: [PATCH 05/10] fix(checkpoint): recheck captured session without writing launch locks Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/checkpoint.py | 13 +-- benchmark/runtime/checkpoint_wake.py | 12 +-- .../test_checkpoint_binding_observation.py | 79 +++++++++++++++++++ benchmark/tests/test_checkpoint_handoff.py | 8 +- loopx/hosts/research_checkpoint.py | 37 +++++++-- 5 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 benchmark/tests/test_checkpoint_binding_observation.py diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py index 4fa3a93299..ebfbe0c41d 100644 --- a/benchmark/runtime/checkpoint.py +++ b/benchmark/runtime/checkpoint.py @@ -18,7 +18,8 @@ from loopx.control_plane.effect_runtime import effect_runtime_result from loopx.control_plane.quota.settlement import read_heartbeat_settlement from loopx.hosts.research_checkpoint import ( - _participant_id, native_binding_participant, research_participants, verify_research_participants, + _participant_id, native_binding_participant, verify_native_binding_participant, + research_participants, verify_research_participants, ) from .checkpoint_handoff import CHECKPOINT_CAPTURE_SECONDS @@ -335,11 +336,9 @@ def capture_after_wake(env: dict[str, str], receipt: dict[str, Any], *, if request.get("native_session"): from loopx.hosts.codex_checkpoint import native_checkpoint_reader, native_session_checkpoint from .checkpoint_continuation import restored_capture_history - from .codex import Execution - from .sessions import BenchmarkSessionWake session = receipt.get("session") or {} - components, source_wake = native_binding_participant(env, components, metadata) + components, source_wake, registry_bytes = native_binding_participant(env, components, metadata) native_options = restored_capture_history(env, restore_gate, session_id=session.get("session_id", "")) reader = readers.enter_context(native_checkpoint_reader(env, timeout=max(.1, deadline - time.time()))) @@ -374,11 +373,7 @@ def capture_after_wake(env: dict[str, str], receipt: dict[str, Any], *, **native_options) if confirmed != native: raise ValueError("native checkpoint cursor or participant basis moved before sealing") - confirmed_wake = BenchmarkSessionWake(env, Execution(context="resume", - sandbox=env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {}) - if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) != - (source_wake.session_id, source_wake.goal_ref, source_wake.digest)): - raise ValueError("native checkpoint source session binding moved before sealing") + verify_native_binding_participant(env, source_wake, registry_bytes) if request.get("loopx_state"): verify_research_participants(env, components, metadata, research, probe["source_witness"], deadline) if opportunity is not None and opportunity["native_incumbent"] is not None: diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py index 966d8913f4..3548c2ba01 100644 --- a/benchmark/runtime/checkpoint_wake.py +++ b/benchmark/runtime/checkpoint_wake.py @@ -125,8 +125,8 @@ def _capture(self, held, deadline): if self.request.get("native_session"): from loopx.hosts.codex_checkpoint import native_checkpoint_reader, native_session_checkpoint from .checkpoint_continuation import restored_capture_history - from .checkpoint import native_binding_participant - components, source_wake = native_binding_participant(self.env, components, metadata) + from .checkpoint import native_binding_participant, verify_native_binding_participant + components, source_wake, registry_bytes = native_binding_participant(self.env, components, metadata) native_options = restored_capture_history(self.env, self.receipt.get("checkpoint_restore"), session_id=self.receipt["session"]["session_id"]) guard = {"cli": self.env["LOOPX_CLI"], "workspace": self.env["LOOPX_PROJECT"], @@ -160,13 +160,7 @@ def _capture(self, held, deadline): confirmed = native_session_checkpoint(self.env, self.receipt["session"]["session_id"], timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader, **native_options) - from .sessions import BenchmarkSessionWake - from .codex import Execution - confirmed_wake = BenchmarkSessionWake(self.env, Execution(context="resume", - sandbox=self.env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")), {}) - if ((confirmed_wake.session_id, confirmed_wake.goal_ref, confirmed_wake.digest) != - (source_wake.session_id, source_wake.goal_ref, source_wake.digest)): - raise ValueError("interrupted source session binding moved before sealing") + verify_native_binding_participant(self.env, source_wake, registry_bytes) if confirmed != native: raise ValueError("interrupted native cursor moved before sealing") if self.request.get("loopx_state"): diff --git a/benchmark/tests/test_checkpoint_binding_observation.py b/benchmark/tests/test_checkpoint_binding_observation.py new file mode 100644 index 0000000000..47ec1ce7d1 --- /dev/null +++ b/benchmark/tests/test_checkpoint_binding_observation.py @@ -0,0 +1,79 @@ +"""A capture recheck must not mutate its source, or overlook a real change.""" +import json +import sys + +import pytest + + +@pytest.fixture +def source(tmp_path, capsys): + from loopx.cli import main + from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession + from loopx.control_plane.turn_driver.codex_sessions import _store_codex_cli_session + from loopx.hosts.research_checkpoint import native_binding_participant + + project, runtime, home = (tmp_path / name for name in ("project", "runtime", "home")) + for path in (project, runtime, home): + path.mkdir() + registry = project / ".loopx/registry.json" + (home / "config.toml").write_text("[features]\ngoals=false\n") + prefix = ["--format", "json", "--registry", str(registry), "--runtime-root", str(runtime)] + for argv in (["bootstrap", "--project", str(project), "--goal-id", "source", + "--objective", "Preserve a synthetic candidate.", "--no-global-sync"], + ["configure-goal", "--goal-id", "source", "--registered-agent", "agent", "--execute"]): + code = main([*prefix, *argv]) + assert code == 0, capsys.readouterr().out + env = {"LOOPX_REGISTRY": str(registry), "LOOPX_RUNTIME_ROOT": str(runtime), + "LOOPX_PROJECT": str(project), "LOOPX_GOAL_ID": "source", "LOOPX_AGENT_ID": "agent", + "CODEX_BIN": sys.executable, "CODEX_HOME": str(home), "MODEL_NAME": "synthetic", + "REASONING_EFFORT": "high", "LOOPX_CODEX_SANDBOX": "read-only"} + session = CodexAgentSession(env, context="resume", sandbox="read-only", receipt={}) + _store_codex_cli_session(runtime, lineage=session.lineage, session_scope="agent", + session_id="source-session", goal_ref=session.goal_ref, session_profile_digest=session.digest) + components, session, raw = native_binding_participant(env, + [{"id": "project", "source_path": str(project)}], {"session": {"session_id": "source-session"}}) + return env, components, session, raw + + +@pytest.mark.parametrize("fault", [None, "registry", "session", "profile", "provenance", "config", "missing"]) +def test_binding_observation_preserves_witness_and_rejects_changes(source, tmp_path, fault): + from pathlib import Path + from loopx.control_plane.effect_runtime import effect_runtime_result + from loopx.control_plane.turn_driver.codex_sessions import _session_path + from loopx.hosts.research_checkpoint import verify_native_binding_participant + + env, components, session, raw = source + before = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "probe", "components": components}) + binding_path = _session_path(session.root, session.lineage, "agent") + if fault == "registry": + registry = Path(env["LOOPX_REGISTRY"]) + registry.write_bytes(registry.read_bytes() + b"\n") + elif fault == "config": + (Path(env["CODEX_HOME"]) / "config.toml").write_text("model = 'changed'\n") + elif fault == "missing": + binding_path.unlink() + elif fault: + value = json.loads(binding_path.read_bytes()) + value[{"session": "session_id", "profile": "session_profile_digest", + "provenance": "checkpoint_artifact_sha256"}[fault]] = "changed" + binding_path.write_text(json.dumps(value)) + if fault: + with pytest.raises(ValueError, match="registry moved|binding or profile moved"): + verify_native_binding_participant(env, session, raw) + else: + verify_native_binding_participant(env, session, raw) + verify_native_binding_participant(env, session, raw) + after = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "probe", "components": components}) + assert before == after + sealed = effect_runtime_result("runtime.checkpoint_artifacts", {"action": "capture", + "components": components, "destination": str(tmp_path / "point"), "point_metadata": {}, + "expected_source_sha256": before["source_sha256"]}) + assert sealed["sha256"] + # Even advisory lock bytes remain part of the artifact fence. This fix + # must not introduce a wildcard exclusion for lock-named task files. + lock = Path(env["LOOPX_REGISTRY"] + ".lock") + lock.write_text("changed after preparation") + with pytest.raises(Exception, match="changed|moved|witness"): + effect_runtime_result("runtime.checkpoint_artifacts", {"action": "capture", + "components": components, "destination": str(tmp_path / "later"), "point_metadata": {}, + "expected_source_sha256": before["source_sha256"]}) diff --git a/benchmark/tests/test_checkpoint_handoff.py b/benchmark/tests/test_checkpoint_handoff.py index 40ffb5cae7..7a8bf5fb25 100644 --- a/benchmark/tests/test_checkpoint_handoff.py +++ b/benchmark/tests/test_checkpoint_handoff.py @@ -130,8 +130,8 @@ def test_capture_preparation_uses_one_window_without_renewing_phase(tmp_path, mo binding = SimpleNamespace(session_id="native-session", goal_ref="source", digest="profile") monkeypatch.setattr(checkpoint, "research_participants", lambda env, components, *a: (components, {})) monkeypatch.setattr(checkpoint, "verify_research_participants", lambda *a: None) - monkeypatch.setattr(checkpoint, "native_binding_participant", lambda env, components, *a: (components, binding)) - monkeypatch.setattr("benchmark.runtime.sessions.BenchmarkSessionWake", lambda *a, **k: binding) + monkeypatch.setattr(checkpoint, "native_binding_participant", lambda env, components, *a: (components, binding, b"registry")) + monkeypatch.setattr(checkpoint, "verify_native_binding_participant", lambda *a: None) monkeypatch.setattr("benchmark.runtime.checkpoint_continuation.restored_capture_history", lambda *a, **k: {}) @contextmanager def reader(*a, **k): @@ -361,8 +361,8 @@ def test_interrupted_capture_keeps_reader_through_publication_and_cleans_up(tmp_ held["path"].write_text("original guard") binding = SimpleNamespace(session_id="session", goal_ref="goal", digest="profile") monkeypatch.setattr("benchmark.runtime.checkpoint.native_binding_participant", - lambda env, components, metadata: (components, binding)) - monkeypatch.setattr("benchmark.runtime.sessions.BenchmarkSessionWake", lambda *a, **k: binding) + lambda env, components, metadata: (components, binding, b"registry")) + monkeypatch.setattr("benchmark.runtime.checkpoint.verify_native_binding_participant", lambda *a: None) events = [] token = object() native_options = {"retained_history": {"turn_ids": ["source"], "visible_history_sha256": "source-digest"}, diff --git a/loopx/hosts/research_checkpoint.py b/loopx/hosts/research_checkpoint.py index 62e3332eea..d1fb7a7b62 100644 --- a/loopx/hosts/research_checkpoint.py +++ b/loopx/hosts/research_checkpoint.py @@ -412,8 +412,11 @@ def _loopx_state_components(env, components, output_dir): def native_binding_participant(env, components, metadata): from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession + registry_bytes = Path(env["LOOPX_REGISTRY"]).read_bytes() source_wake = CodexAgentSession(env, context="resume", sandbox=env.get("LOOPX_CODEX_SANDBOX", "danger-full-access"), receipt={}) + if Path(env["LOOPX_REGISTRY"]).read_bytes() != registry_bytes: + raise ValueError("checkpoint registry moved during session observation") if source_wake.session_id != metadata["session"].get("session_id"): raise ValueError("native checkpoint differs from the current source session binding") # This sideband record belongs to the existing session owner. It @@ -427,7 +430,32 @@ def native_binding_participant(env, components, metadata): metadata["session"] = {**metadata["session"], "session_profile_digest": source_wake.digest} if source_wake.binding.get("checkpoint_context_refresh") is not None: metadata["session"]["checkpoint_context_refresh"] = source_wake.binding["checkpoint_context_refresh"] - return components, source_wake + return components, source_wake, registry_bytes + + +def verify_native_binding_participant(env, source_wake, registry_bytes): + """Recheck the accepted binding without creating another launch admission. + + The initial session owner selects the binding and Goal lifetime. After the + byte probe, constructing that owner again would write its advisory locks + and invalidate the snapshot. Compare the complete binding, profile and + unchanged registry instead; this observation grants no launch authority. + """ + import shutil + from loopx.control_plane.turn_driver.codex_sessions import ( + codex_session_profile_digest, select_codex_cli_session, + ) + if Path(env["LOOPX_REGISTRY"]).read_bytes() != registry_bytes: + raise ValueError("checkpoint registry moved before sealing") + binding = select_codex_cli_session(source_wake.root, lineage=source_wake.lineage, session_scope="agent") + binary = shutil.which(env["CODEX_BIN"]) + if binary is None: + raise ValueError("checkpoint native binary is unavailable") + digest = codex_session_profile_digest(project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, + home=Path(env["CODEX_HOME"]), model=env["MODEL_NAME"], reasoning_effort=env["REASONING_EFFORT"], + sandbox=env.get("LOOPX_CODEX_SANDBOX", "danger-full-access")) + if binding != source_wake.binding or digest != source_wake.digest: + raise ValueError("checkpoint source session binding or profile moved before sealing") def research_participants(env, components, metadata, destination, evidence_path, deadline): @@ -517,7 +545,7 @@ def settled(): destination, Path(temporary) / "research-context.json", deadline) if research is None: raise ValueError("research capture requires canonical source coordination") - components, observed_session = native_binding_participant(env, components, metadata) + components, observed_session, registry_bytes = native_binding_participant(env, components, metadata) # Reopening the native reader after probing can checkpoint its history # database and invalidate our own byte witness. Keep one inspection # process open; the original logical reread and byte fence both remain. @@ -535,10 +563,7 @@ def settled(): timeout=max(.1, deadline-time.time()), retry_safe=False, large_local_snapshot=True) if native_session_checkpoint(env, session.session_id, timeout=max(.1, deadline-time.time()), transport=reader) != native: raise ValueError("native checkpoint source moved before sealing") - _, confirmed = native_binding_participant(env, components, metadata) - if ((confirmed.session_id, confirmed.goal_ref, confirmed.digest) != - (observed_session.session_id, observed_session.goal_ref, observed_session.digest)): - raise ValueError("research checkpoint session binding moved before sealing") + verify_native_binding_participant(env, observed_session, registry_bytes) verify_research_participants(env, components, metadata, research, probe["source_witness"], deadline) if settled().identity.value != original.identity.value: raise ValueError("research checkpoint source settlement moved") From e98020e80349290ca30f18dc1caf917051b6e768 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:30:15 +0800 Subject: [PATCH 06/10] fix(worker): retain original replan across bounded host retry Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/shell-worker.md | 12 ++- scripts/external_scheduler_worker.py | 42 +++++++-- .../test_external_worker_replan_cli.py | 28 +++++- tests/test_external_scheduler_replan_retry.py | 87 +++++++++++++++++++ 4 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 tests/test_external_scheduler_replan_retry.py diff --git a/docs/reference/shell-worker.md b/docs/reference/shell-worker.md index a44ceb25ab..8fb80b5c83 100644 --- a/docs/reference/shell-worker.md +++ b/docs/reference/shell-worker.md @@ -56,9 +56,15 @@ model invocation. A retry reads that original binding even when normal wakes use fresh context. A confirmed successful response ends the failure streak; an exhausted policy remains held. Provider hold/backoff is checked before recording a new semantic replan attempt; a rejected dispatch cannot consume one. -An unresolved semantic replan still requires its original settlement recovery, -even when the provider would otherwise permit a transport retry. Host state is -local transport evidence, not Goal/Todo/quota authority or proof of completion. +For a replan, dispatch also binds the Host attempt to its original obligation. +A permitted transport retry may continue that one unknown semantic attempt only +while current admission retains the exact Goal/agent scope and obligation. It +reuses the recorded attempt and session, preserves its evidence, and does not +acknowledge or settle the obligation. Changed admission, multiple unresolved +attempts, interrupted dispatch, or legacy state without this association still +requires original recovery. After transport success, an unchanged unresolved +obligation remains paused; it cannot authorize another semantic attempt. Host +state is local transport evidence, not Goal/Todo/quota authority or completion. Inspect the existing state file, the original wake's `receipt.json`, and the content-free `host_failure_hold` / `host_failure_backoff` liveness output. Use diff --git a/scripts/external_scheduler_worker.py b/scripts/external_scheduler_worker.py index 5b833fd6ff..fa6f4f0cfc 100644 --- a/scripts/external_scheduler_worker.py +++ b/scripts/external_scheduler_worker.py @@ -380,6 +380,28 @@ def _pending_replan_attempt(state: dict[str, Any], payload: dict[str, Any], args return any(_same_replan_scope(attempt, scope) for attempt in unresolved) +def _host_retry_replan(state: dict[str, Any], payload: dict[str, Any], + args: argparse.Namespace, prior: dict[str, Any]) -> dict[str, Any] | None: + """Reuse a dispatch-bound replan only after the Host retry gate admits it. + + A native session alone cannot associate a transport failure with an older + semantic attempt. Legacy/unbound or ambiguous attempts retain their hold. + This does not acknowledge the obligation or settle its original effects. + """ + obligation = prior.get("replan_obligation_id") + current = _replan_attempt(payload, args) + if not obligation or current is None or current["obligation_id"] != obligation: + return None + pending = [attempt for attempt in state.get("replan_attempts", []) + if attempt["status"] in {"started", "unknown"} + and _same_replan_scope(attempt, current)] + if (len(pending) != 1 or pending[0]["status"] != "unknown" + or pending[0]["obligation_id"] != obligation + or _attempt_scope(pending[0]) != _attempt_scope(current)): + return None + return pending[0] + + def _read_resolved_replan_attempts(state: dict[str, Any], payload: dict[str, Any], args: argparse.Namespace) -> bool: """Only the original settlement owner can resolve a known lost response. @@ -834,7 +856,12 @@ def save(fields: dict[str, Any]) -> None: break if _read_resolved_replan_attempts(state, payload, args): _save_state(state_path, state) - if _pending_replan_attempt(state, payload, args): + retry_replan = _host_retry_replan(state, payload, args, prior) if host_guard and prior else None + if prior.get("replan_obligation_id") and retry_replan is None: + _log("status=host_failure_hold reason=original_replan_not_retryable " + "next=original_host_recovery goal_complete=false new_wake=false") + return 2 + if _pending_replan_attempt(state, payload, args) and retry_replan is None: replan_scope = _admission_scope(payload, args) original = next(previous for previous in state["replan_attempts"] if previous["status"] in {"started", "unknown"} @@ -843,13 +870,13 @@ def save(fields: dict[str, Any]) -> None: _log("status=replan_outcome_unknown next=use_original_host_recovery " "goal_complete=false new_wake=false") return 2 - attempt = _replan_attempt(payload, args) + attempt = retry_replan if retry_replan is not None else _replan_attempt(payload, args) if attempt is not None: attempts = state.setdefault("replan_attempts", []) original = next((previous for previous in attempts if _same_replan_scope(previous, attempt) and previous["obligation_id"] == attempt["obligation_id"]), None) - if original is not None: + if original is not None and original is not retry_replan: _observe_worker_replan(payload, args, original, "replan_paused") _log("status=replan_paused reason=unresolved_original_obligation " f"obligation={attempt['obligation_id']} " @@ -857,8 +884,9 @@ def save(fields: dict[str, Any]) -> None: "goal_complete=false new_wake=false") return 0 attempt["status"] = "started" - attempt.update(_worker_goal_observation_digests(args)) - attempts.append(attempt) + if retry_replan is None: + attempt.update(_worker_goal_observation_digests(args)) + attempts.append(attempt) _save_state(state_path, {**state, "reset_token": decision.reset_token, "unchanged_count": unchanged_count}) _observe_worker_replan(payload, args, None, None) @@ -868,7 +896,9 @@ def save(fields: dict[str, Any]) -> None: _save_host_pending(state_path, True) state["host_attempt"] = {"scope": scope, "status": "started", "attempt": prior.get("attempt", 0) + 1, - "session_id": prior.get("session_id")} + "session_id": prior.get("session_id"), + **({"replan_obligation_id": attempt["obligation_id"]} + if attempt is not None else {})} save({}) environment = {**os.environ, "LOOPX_HOST_RETRY_SESSION_ID": prior.get("session_id") or ""} wake_result = _run_wake( diff --git a/tests/control_plane/test_external_worker_replan_cli.py b/tests/control_plane/test_external_worker_replan_cli.py index 064a8e90fb..a40d0e7253 100644 --- a/tests/control_plane/test_external_worker_replan_cli.py +++ b/tests/control_plane/test_external_worker_replan_cli.py @@ -11,7 +11,7 @@ import pytest from test_replan_successor_guard_reentry import AGENT, GOAL, TURN, _add, _fixture, _guard -from test_external_scheduler_worker import _args, worker +from test_external_scheduler_worker import _args, _guarded_host, worker from loopx.extensions.process_runtime import CappedProcessResult from loopx.history import collect_history @@ -306,3 +306,29 @@ def read(registry): assert Path(args.state_file).read_bytes() == original_attempt assert requests == [1] assert index.read_bytes() == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_quota_retry_preserves_original_obligation_without_settlement(tmp_path, monkeypatch, provider): + _, runtime, history = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) + args, count = _guarded_host(tmp_path, kind="provider_capacity", recover_at=2) + args.cli_bin = str(Path(sys.executable).parent / "loopx") + args.registry, args.runtime_root = str(tmp_path / "registry.json"), str(runtime) + args.goal_id, args.agent_id, args.quota_timeout_seconds = GOAL, AGENT, 90 + now, delays = [1000.0], [] + def sleep(seconds): + delays.append(seconds) + now[0] += seconds + before = history.read_bytes() + # Real CLI admission and authority stores; only the model process is synthetic. + # Its second response succeeds without semantically settling the replan. + assert worker.run_worker(args, sleep=sleep, clock=lambda: now[0]) == 0 + assert count.read_text() == "2" + assert delays == [30.0] + attempts = json.loads(Path(args.state_file).read_text())["replan_attempts"] + assert len(attempts) == 1 and attempts[0]["status"] == "returned" + assert history.read_bytes() == before + args.once = True + assert worker.run_worker(args, clock=lambda: now[0]) == 0 + assert count.read_text() == "2" + assert history.read_bytes() == before diff --git a/tests/test_external_scheduler_replan_retry.py b/tests/test_external_scheduler_replan_retry.py new file mode 100644 index 0000000000..b6ae5fbe27 --- /dev/null +++ b/tests/test_external_scheduler_replan_retry.py @@ -0,0 +1,87 @@ +"""Transport retry must continue one replan, never authorize a replacement.""" +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from tests.test_external_scheduler_worker import _guarded_host, _hint_payload, worker + + +def _replan(tmp_path): + return {**_hint_payload(should_run=True), + "effective_action": "autonomous_replan_required", "goal_id": "goal", + "agent_identity": {"agent_id": "agent"}, "runtime_root": str(tmp_path), + "autonomous_replan_obligation": {"required": True, "obligation_id": "replan-1111111111111111"}} + + +@pytest.mark.parametrize("recover_at,expected_calls,rc", [(2, 2, 0), (None, 3, 2)]) +def test_bound_replan_retries_original_host_with_one_semantic_attempt(tmp_path, monkeypatch, recover_at, expected_calls, rc): + args, count = _guarded_host(tmp_path, kind="provider_capacity", recover_at=recover_at) + now, delays = [1000.0], [] + def sleep(seconds): + delays.append(seconds) + now[0] += seconds + def quota(*a, **kw): + if recover_at and count.exists() and int(count.read_text()) >= recover_at: + return _hint_payload(should_run=False, local_scheduler_directive="stop") + return _replan(tmp_path) + monkeypatch.setattr(worker, "run_quota_should_run", quota) + assert worker.run_worker(args, sleep=sleep, clock=lambda: now[0]) == rc + assert count.read_text() == str(expected_calls) + assert delays == ([30.0] if recover_at else [30.0, 60.0]) + state = json.loads(Path(args.state_file).read_text()) + assert len(state["replan_attempts"]) == 1 + attempt = state["replan_attempts"][0] + assert attempt["obligation_id"] == "replan-1111111111111111" + assert attempt["status"] == ("returned" if recover_at else "unknown") + if recover_at: + assert "host_attempt" not in state + # Transport success is not semantic acknowledgement. The unchanged + # obligation must still pause instead of starting a third conversation. + monkeypatch.setattr(worker, "run_quota_should_run", lambda *a, **kw: _replan(tmp_path)) + assert worker.run_worker(args, sleep=sleep, clock=lambda: now[0]) == 0 + else: + assert state["host_attempt"]["attempt"] == 3 + assert worker.run_worker(args, sleep=sleep, clock=lambda: now[0]) == 2 + assert count.read_text() == str(expected_calls) + + +@pytest.mark.parametrize("change", ["unchanged", "legacy", "other_obligation", "normal_work", "other_lifetime", + "ambiguous", "started", "disabled", "stopped"]) +def test_restart_keeps_replan_binding_and_existing_holds(tmp_path, monkeypatch, change): + args, count = _guarded_host(tmp_path, kind="provider_capacity", recover_at=2) + args.once = True + payload = _replan(tmp_path) + monkeypatch.setattr(worker, "run_quota_should_run", lambda *a, **kw: payload) + assert worker.run_worker(args, clock=lambda: 1000.0) == 1 + state_path = Path(args.state_file) + state = json.loads(state_path.read_text()) + assert state["host_attempt"]["replan_obligation_id"] == "replan-1111111111111111" + if change == "legacy": + state["host_attempt"].pop("replan_obligation_id") + elif change == "other_obligation": + payload["autonomous_replan_obligation"]["obligation_id"] = "replan-2222222222222222" + elif change == "normal_work": + payload["effective_action"] = "normal_run" + elif change == "other_lifetime": + payload["goal_ref"] = {"goal_id": "goal", "goal_instance_id": "new-lifetime"} + elif change == "ambiguous": + another = deepcopy(state["replan_attempts"][0]) + another["obligation_id"] = "replan-3333333333333333" + state["replan_attempts"].append(another) + elif change == "started": + state["replan_attempts"][0]["status"] = "started" + elif change == "disabled": + args.wake_host_failure = False + elif change == "stopped": + payload = _hint_payload(should_run=False, local_scheduler_directive="stop") + state_path.write_text(json.dumps(state)) + rc = worker.run_worker(args, clock=lambda: 1031.0) + assert rc == (0 if change in {"unchanged", "stopped"} else 2) + assert count.read_text() == ("2" if change == "unchanged" else "1") + after = json.loads(state_path.read_text()) + assert len(after["replan_attempts"]) == len(state["replan_attempts"]) + if change != "unchanged": + assert after["host_attempt"]["attempt"] == 1 + assert after["replan_attempts"] == state["replan_attempts"] From 4a53fb7c37366da7310cf1019dd5cd132fc31f81 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:54:03 +0800 Subject: [PATCH 07/10] fix(replan): replace repeated guidance and deliver captured checkpoint views Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/checkpoint.py | 28 ++++- benchmark/tests/test_checkpoint_handoff.py | 8 +- .../hierarchical-agent-stride-control-v0.md | 4 + ...rarchical-agent-stride-control-v0.zh-CN.md | 3 + docs/reference/protocols/turn-envelope-v0.md | 29 ++++- loopx/control_plane/effect_runtime.py | 1 + loopx/control_plane/effect_runtime_server.ts | 1 + loopx/control_plane/quota/turn_envelope.py | 16 ++- loopx/control_plane/quota/turn_envelope.ts | 36 ++++-- .../work_items/replan_semantics.ts | 56 ++++------ .../control_plane/work_items/stride_policy.ts | 22 ---- tests/control_plane/test_cli_output_budget.py | 6 +- .../control_plane/test_open_outcome_replan.py | 4 +- .../test_progress_observation.py | 2 +- .../test_replan_host_context_projection.py | 9 +- .../control_plane/test_replan_staged_read.py | 104 ++++++++++++++++++ .../test_replan_successor_guard_reentry.py | 8 +- .../control_plane_ts/replan_semantics.test.ts | 28 ++--- 18 files changed, 250 insertions(+), 115 deletions(-) diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py index 4fa3a93299..e7d213fc6d 100644 --- a/benchmark/runtime/checkpoint.py +++ b/benchmark/runtime/checkpoint.py @@ -10,6 +10,7 @@ import json import math import subprocess +import tempfile import time from contextlib import ExitStack, contextmanager from pathlib import Path, PurePosixPath @@ -137,24 +138,39 @@ def capture_before_replan(env: dict[str, str], receipt: dict[str, Any], body: st raise ValueError("fresh_replan checkpoint trigger requires a heartbeat execute wake") started = time.monotonic() guard_path = Path(env["LOOPX_WAKE_LOG_DIR"]) / receipt["turn_id"] / "checkpoint-start-guard.json" - command = [env["LOOPX_CLI"], "--format", "json", "--registry", env["LOOPX_REGISTRY"], + # Use the CLI's original-decision capture, including with TurnEnvelope off. + # The checkpoint selector needs the full source; the model consumes the + # ordinary CLI projection of those same bytes, never a second guard. + capture_root = Path(tempfile.mkdtemp(prefix="checkpoint-start-", dir=guard_path.parent)) + command = [env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"], "--format", "json", "--registry", env["LOOPX_REGISTRY"], "--runtime-root", env["LOOPX_RUNTIME_ROOT"], "quota", "should-run", "--goal-id", env["LOOPX_GOAL_ID"], "--agent-id", env["LOOPX_AGENT_ID"], "--runtime-profile", "generic_cli", "--turn-instance-id", receipt["turn_id"], - "--available-capability", "shell", "--available-capability", "filesystem_write"] + "--available-capability", "shell", "--available-capability", "filesystem_write", + "--decision-output-root", str(capture_root)] guard_run = subprocess.run(command, cwd=env["LOOPX_PROJECT"], env=env, capture_output=True, text=True, timeout=120, check=True) - guard = json.loads(guard_run.stdout) - if not isinstance(guard, dict) or guard.get("ok") is not True: + projected = json.loads(guard_run.stdout) + captures = list(capture_root.glob("decision-*/decision.json")) + if len(captures) != 1: + raise ValueError("checkpoint requires exactly one captured original quota decision; do not replay the guard") + raw_guard = captures[0].read_bytes() + guard = json.loads(raw_guard) + if (not isinstance(guard, dict) or guard.get("ok") is not True or + not isinstance(projected, dict) or projected.get("ok") is not True): raise ValueError("checkpoint requires a successful original quota guard") # A previous preflight on this wake may already have allowed model work, # even if its optional attempt marker (or final receipt) could not be saved. # Such reentry can verify an existing point, but cannot capture a later one. preflight_seen = guard_path.exists() - guard_path.write_text(guard_run.stdout, encoding="utf-8") + guard_path.write_bytes(raw_guard) guard_path.chmod(0o600) + view_path = guard_path.with_name("checkpoint-start-view.json") + view_path.write_text(guard_run.stdout, encoding="utf-8") + view_path.chmod(0o600) preloaded_body = (f"Host checkpoint preflight has already executed this wake's original quota guard once. " - f"Read the complete packet at {guard_path} before work, then follow its interaction_contract. " + f"Read the complete CLI view at {view_path} before work, then follow its interaction_contract " + "and replan review/writeback reads when present. " "Do not repeat the initial guard below; use its selection or recovery command when required.\n\n" + body) receipt["checkpoint"] = {"captured": False, "research_point_qualified": False, "phase": "before_replan"} try: diff --git a/benchmark/tests/test_checkpoint_handoff.py b/benchmark/tests/test_checkpoint_handoff.py index 40ffb5cae7..af7b31065b 100644 --- a/benchmark/tests/test_checkpoint_handoff.py +++ b/benchmark/tests/test_checkpoint_handoff.py @@ -473,7 +473,13 @@ def test_preflight_defers_history_only_when_capture_will_verify_it(tmp_path, mon request.write_text(json.dumps({"destination": str(tmp_path / "checkpoints"), "trigger": "fresh_replan", "native_session": native_participant, "components": [{"id": "code", "source_path": str(tmp_path / "code")}], "point_metadata": {}})) - monkeypatch.setattr(checkpoint.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout=json.dumps({"ok": True}))) + def guard(command, **kwargs): + from pathlib import Path + captured = Path(command[command.index("--decision-output-root") + 1]) / "decision-original" + captured.mkdir() + (captured / "decision.json").write_text(json.dumps({"ok": True})) + return SimpleNamespace(stdout=json.dumps({"ok": True})) + monkeypatch.setattr(checkpoint.subprocess, "run", guard) monkeypatch.setattr(checkpoint, "effect_runtime_result", lambda *a, **k: {"capture": False, "reason": "no_fresh_replan"}) observed = [] def gate(*args, full_history): diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index fab49194de..183198d03e 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -785,6 +785,10 @@ full delivery. File/SQLite source tests and populated packet budgets qualify transport and writer continuity; installed adoption, real model decisions and matched outcome/cost benefit remain unqualified. This introduces no new planning receipt, model checkpoint or requirement to change direction. +The reader replaces repeated method guidance with compact semantic boundaries +and the existing stride format. Benchmark preflight captures the original +decision through the CLI and delivers its staged view even with TurnEnvelope +off; retaining a full checkpoint packet alone does not qualify model exposure. The next-review input also exposes causally later ordinary writebacks for the declared work in `ordinary_work`, with per-Todo `ordinary_evidence_refs`. Its diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index 45d90affe9..d310e8bf66 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -648,6 +648,9 @@ Todo 来源缺失和省略数量明确保留;缺少观测不等于没有执行 File/SQLite 源码测试和真实规模 packet 预算验证传输与写回连续性;已安装采用、真实 模型决策及可比成果/成本收益仍待资格化。不新增规划回执、模型 checkpoint 或强制 改变方向的要求。 +读取入口用精简的语义边界和既有 stride 格式替换重复方法指导。benchmark 前置检查 +通过 CLI 捕获原决定,在 TurnEnvelope 关闭时也交付分段视图;仅保留完整 checkpoint +源包不能证明模型实际收到了实验处理。 下一轮输入还通过 `ordinary_work` 与各 Todo 的 `ordinary_evidence_refs` 返回已 声明工作在决定之后的普通回写。详情最多六项,保留总数、省略数和稳定 history diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 38fc70965f..613e427315 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -103,7 +103,7 @@ objective, revision and requirement count remains visible; the acceptance read returns the complete original outcome. Identical IDs with different revisions or evidence remain distinct. Equal context/horizon copies share references; differing goal scopes remain separate. The full capture retains all evidence, -legacy guidance and authoring examples. Explore has an optional, separately +original guidance and authoring examples. Explore has an optional, separately readable basis; unavailable evidence remains unavailable. These views do not assess acceptance, infer execution from counts or make a dependency global. @@ -115,6 +115,23 @@ without the CLI read route retain their prior delivery. The existing generic followthrough is now available with stride off; its result-class counts and observations remain evidence, not successful-work or goal-gain verdicts. +Live decisions replace the previous method checklist and duplicate stride +planning advice with compact goal, evidence and authority boundaries. Review +delivers those boundaries once, with the facts; writeback delivers the existing +format. Eligible Todo order and agent-proposed candidate rules remain revisable +planning choices, while original priorities, constraints, budgets and stops +remain binding. Old captures retain their original text. This is an intentional +change to advisory prompting, not a lossless claim about model behavior. + +The benchmark checkpoint preflight uses the same CLI decision capture with +TurnEnvelope off. It retains the complete captured source for checkpoint +selection and verification, and sends the projected CLI view to the model. +The view's review/writeback commands read that exact observation; capture does +not rerun admission, change evaluation opportunities or select a strategy. +Identical work-context acceptance copies use that same read; other revisions +stay inline. A source exceeding the RPC request bound uses the existing bounded +local-snapshot transport for pure projection, preserving both transport limits. + 对使用 `--decision-output-dir` 或 `--decision-output-root` 的成功 replan,CLI 和 TurnEnvelope 先给出 `review` 状态读取,再给出 `writeback` 格式读取。两步绑定同一 快照和来源哈希,不重新准入、不新增回执或授权,也不强制换路线。累计验收按完整 @@ -130,6 +147,16 @@ TurnEnvelope 先给出 `review` 状态读取,再给出 `writeback` 格式读 stride 的必需字段与 basis 选项独立展示,完整快照仍可用于按需查看示例与源材料。 这是有意减少建议性提示,不删除验收、权限或停止义务,也不证明自主决策质量提升。 +新决定用精简的目标、证据和权限边界替换原方法清单及重复的 stride 规划建议:复盘 +一次交付这些边界与事实,写回提供既有格式。可执行 Todo 的次序和 agent 自拟候选 +规则可以调整,原有优先目标、硬约束、预算与停止仍须保留。旧快照保留原文;这是 +有意改变建议性提示,不能称为对模型行为无损。benchmark checkpoint 前置检查也 +使用同一 CLI 捕获入口,TurnEnvelope 关闭仍生效:完整源包供 checkpoint 选择和 +校验,模型读取投影视图,再从同一观察分段读取。不重复准入、不改变评测机会, +也不替 agent 选择策略。 +work context 中完全相同的验收副本复用该读取,不同版本保留。超过 RPC 请求限额的 +完整源包用既有有界本地快照传输做纯投影;两种传输限额均不放宽。 + Regression budgets use compact UTF-8 JSON, separately from compute quota and provider tokens. The synthetic workload uses a real append index with 1,500 records, 21 later observations, 24 cumulative outcomes and a populated Explore diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 5e1681db98..da16d1f48a 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -55,6 +55,7 @@ "work_item.task_graph.goal_topology", "work_item.replan_semantics.project", "quota.settlement.read", + "quota.turn_envelope.evaluate", "goal.checkpoint_read_context.resolve", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 54f2e767fa..32602b4975 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -41,6 +41,7 @@ const LOCAL_SNAPSHOT_METHODS = new Set([ "work_item.task_graph.goal_topology", "work_item.replan_semantics.project", "quota.settlement.read", + "quota.turn_envelope.evaluate", "goal.checkpoint_read_context.resolve", "goal.checkpoint_read_context.commit", "goal.checkpoint_read_context.inspect_replay", diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 02aa223b5a..197570c20e 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import Any -from ..effect_runtime import effect_runtime_result +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..scheduler.execution_context import ( SchedulerExecutionContextResolution, render_scheduler_execution_args, @@ -32,10 +32,16 @@ def _turn_envelope_result( ) -> dict[str, Any]: """Call the coarse TS owner and fail closed on transport shape drift.""" - result = effect_runtime_result( - "quota.turn_envelope.evaluate", - {"operation": operation, **params}, - ) + request = {"operation": operation, **params} + try: + result = effect_runtime_result("quota.turn_envelope.evaluate", request) + except EffectRuntimeRejected as error: + if error.diagnostic_code != "request_too_large": + raise + # The client refused before sending this pure projection. Reuse the + # existing bounded local-snapshot transport, never truncate the source + # or re-enter quota admission. Small packets retain their normal path. + result = effect_runtime_result("quota.turn_envelope.evaluate", request, large_local_snapshot=True) if not isinstance(result, Mapping): raise RuntimeError("TypeScript Turn envelope result must be an object") return dict(result) diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index b20972de1e..df5ee3ebf8 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -309,6 +309,23 @@ function replanReadCommands(payload: JsonObject): JsonObject | null { acceptance: `${command} --replan-stage acceptance`, explore: `${command} --replan-stage explore`}; } +/** Move only an identical acceptance copy behind the existing captured read. + * Different revisions, other source fields and unavailable context stay intact. + */ +function replanWorkContext(payload: JsonObject, context: JsonObject): JsonObject { + const reads = replanReadCommands(payload); + const core = object(object(object(payload.autonomous_replan_obligation).replan_context).core_goal); + const acceptance = object(core.acceptance_contract); + if (!reads || !Object.keys(acceptance).length || !Array.isArray(context.sources)) return context; + return {...context, sources: context.sources.map(value => { + const source = object(value), content = object(source.content); + if (source.source !== "goal_acceptance" || content.status != null && content.status !== "loaded" || + canonicalHash(content.goal_acceptance_contract) !== canonicalHash(acceptance)) return value; + const {goal_acceptance_contract: _contract, ...metadata} = content; + return {...source, content: {...metadata, context_read: reads.review}}; + })}; +} + function replanView(request: JsonObject): JsonObject { const payload = requireJsonObject(request.payload, "captured replan decision"); if (request.source_hash !== canonicalHash(payload) || payload.ok !== true) { @@ -371,7 +388,7 @@ function replanView(request: JsonObject): JsonObject { required_outcome: packet.required_outcome, allowed_terminal: packet.allowed_terminal, ...(packet.stride_control ? {stride_control: stride} : {}), writeback: actionProjection(payload).writeback, - context_read: replanReadCommands(payload)?.review ?? null}; + context_read: reads?.review ?? null}; } if (request.stage !== "review") throw new EffectRuntimeRequestError("unsupported replan read stage"); // Index every captured outcome, including differing context/horizon revisions. @@ -391,6 +408,7 @@ function replanView(request: JsonObject): JsonObject { } return {...common, core_goal: core, + planning_guidance: packet.planning_guidance ?? [], ...(acceptanceIndex ? {acceptance_index: acceptanceIndex} : {}), triggers: obligation.triggers, decision_followthrough: Object.keys(follow).length ? follow : null, @@ -415,7 +433,7 @@ function replanView(request: JsonObject): JsonObject { uncovered_frontier: context.uncovered_frontier, // Facts and their provenance are the review input. Do not preselect a // diagnostic method, candidate route or judgment from observation counts. - next: {writeback: replanReadCommands(payload)?.writeback ?? null}, + next: {writeback: reads?.writeback ?? null}, }; } @@ -425,7 +443,7 @@ function replanActionPacket(payload: JsonObject): JsonObject | null { const reads = replanReadCommands(payload); if (reads) return {...compactFields(source, ["schema_version", "decision", "obligation_id", "required_outcome", "allowed_terminal"]), review: reads.review, writeback: reads.writeback, - guidance: "Read review state and applicable acceptance/evidence before deciding; writeback supplies the format. Reads grant no new authority and require no acknowledgement."}; + guidance: "Read review state and applicable acceptance/evidence; choose the next work and route before reading writeback for its format. Reads grant no authority and require no acknowledgement."}; const compact = compactFields(source, [ "schema_version", "decision", "obligation_id", "uncovered_frontier", "required_outcome", "allowed_terminal", "bounded_frontier", "settlement_only", "successor_todo_id", "stride_control", @@ -848,7 +866,7 @@ function actionProjection(payload: JsonObject): JsonObject { && (typeof object(action.selected_todo).text === "string" || object(action.selected_todo).text_ref === "action.recommended_action"); if (Object.keys(workContext).length > 0 && !selectedTodoContextIsAlreadyInAction) { - projection.work_context = workContext; + projection.work_context = replanWorkContext(payload, workContext); } const orchestration = object(payload.task_orchestration_contract); if (Object.keys(orchestration).length > 0) projection.task_orchestration_contract = orchestration; @@ -1135,12 +1153,16 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { if (operation === "replan_view") return replanView(request); if (operation === "replan_cli_view") { const payload = requireJsonObject(request.payload, "replan CLI payload"); - if (!replanReadCommands(payload)) return payload; + const reads = replanReadCommands(payload); + if (!reads) return payload; const obligation = {...object(payload.autonomous_replan_obligation)}; delete obligation.replan_context; + const interaction = object(payload.interaction_contract), agent = object(interaction.agent_channel); return {...payload, - ...(payload.planning_horizon ? {planning_horizon: {context_read: replanReadCommands(payload)!.review}} : {}), - autonomous_replan_obligation: {...obligation, context_read: replanReadCommands(payload)!.review}, + ...(payload.planning_horizon ? {planning_horizon: {context_read: reads.review}} : {}), + interaction_contract: {...interaction, agent_channel: {...agent, + work_context: replanWorkContext(payload, object(agent.work_context))}}, + autonomous_replan_obligation: {...obligation, context_read: reads.review}, replan_action_packet: replanActionPacket(payload)}; } if (operation === "build") return buildTurnEnvelope(request); diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 13037aaf74..ebc4e0b13f 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -4,7 +4,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; import { visionAuthoringContract } from "../goals/vision_checkpoint.ts"; import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; -import {normalizeStrideDecision, normalizeStrideStrategy, STRIDE_DECISION_SCHEMA, strideControlContract, stridePlanningGuidance, strideReviewMismatch} from "./stride_policy.ts"; +import {normalizeStrideDecision, normalizeStrideStrategy, STRIDE_DECISION_SCHEMA, strideControlContract, strideReviewMismatch} from "./stride_policy.ts"; import { projectProgressDelta } from "./progress_delta.ts"; const PROGRESS_OUTCOMES = [ @@ -34,42 +34,29 @@ export const FRESH_PATH_DISPOSITIONS = new Set(["continue", "no_change", "replan const PROGRESS_CLI_ARGS = "--progress-result-class --progress-surface-id --progress-hypothesis-id --progress-probe-kind --progress-evidence-id "; const VISION_CLI_ARGS = "--agent-vision-json ''"; -// Agent guidance only: the typed outcome/authority gates below remain the owner. +// Retain semantic boundaries, not a prescribed diagnosis or search strategy. +// The review supplies facts; the Agent chooses work, route and effort. This is +// guidance, not a second outcome qualifier or an executable priority gate. const REPLAN_PLANNING_GUIDANCE = [ - "Never shrink requested goals for easier tests. Retain unmet requirements; " + - "honor user scope, authority, budget and stops. During replanning, compare the current " + - "operating objective and cumulative requirements with original intent, explicit user direction " + - "and actual work using delivered core_goal/acceptance context. Distinguish a misleading objective " + - "from scope conflict, missing evidence or stale display text; record the evidence and judgment " + - "in the existing Vision/path_delta, including a justified no-change decision. Only a material " + - "user direction change that makes the objective misleading warrants goal-acceptance prepare-objective " + - "(native: prepare_goal_objective); " + - "reuse a pending proposal; surfacing never blocks authorized work or forces a question. " + - "Use goal-acceptance --help for CLI preparation, owner apply and each peer's own adoption. " + - "Progress, Todo/route changes, diagnostics, polish or age alone need no revision. Documents/peers " + - "grant no authority. Reuse inline facts; inspect missing context only, without polling or " + - "a per-Turn revision/check receipt. Retain root commitments; a proposal or adoption grants no permissions.", + "Review original intent, the operating objective and cumulative requirements, explicit user direction, " + + "actual work and evidence, even with open Todos. Choose useful eligible work and its full marginal cost; " + + "Todo order and provisional agent-created candidate rules are revisable planning choices. " + + "A due review may retain the path or reorder/replace unfinished work; it never forces a pivot. " + + "Do not shrink requested goals or relax frozen acceptance, priorities, authority, budget or stops; " + + "do not rewrite criteria after failure. Documents, peers and planning grant no authority.", "Claim achieved only with current authoritative evidence for every requirement. " + - "Empty Todos/replan closure is not proof; unproven/blocked/exhausted/superseded is not achieved.", - "Before no_followup, review unmet acceptance against the original authorized goal and evidence. " + + "Unproven, blocked, exhausted or superseded is not achieved; decisions and counts prove neither adoption nor improvement. " + + "Keep negative results and local proxies scoped to their tested conditions. Before no_followup, review unmet acceptance. " + "If a reasonable in-scope next step remains, continue or replan without waiting for an assigned successor. " + "An empty Todo queue is not a scope limit. Otherwise explain why no such step remains; " + - "do not invent work, exceed authority or consume budget merely to stay active.", - "Preserve the requested priorities and optimization direction. Distinguish user requirements " + - "and hard constraints from provisional agent-created candidate rules; revisit the latter " + - "when they obstruct the goal. Do not relax frozen acceptance, authority, budget or stops, " + - "or rewrite criteria after failure.", - "Review unmet requirements even with open Todos. Strongest first delivery is the objective; " + - "diagnostic validation and available feedback can strengthen it. Flat metrics alone prove neither progress nor exhaustion. " + - "Compare expected goal/information gain with full marginal cost. " + - "Before widening repeated validation, check that the changed intervention reaches the " + - "executed path and current artifact. Prefer a probe that distinguishes remaining explanations; " + - "scope negative results to tested conditions and local proxies to their coverage. " + - "Reuse still-applicable evidence instead of repeating checks without a new uncertainty. " + - "Correct within the affected work first; widen replanning only when current goal, path, dependency " + - "or acceptance evidence warrants it. A due review can retain the path; it does not force a pivot. " + - "At the next review, check whether the correction reached subsequent work and authoritative outcomes; " + - "a recorded decision alone proves neither adoption nor improvement.", + "do not invent work or consume budget merely to stay active.", + "Record the evidence and judgment in existing Vision/path_delta, including justified no-change. " + + "Only material user direction making the objective misleading warrants goal-acceptance prepare-objective " + + "(native: prepare_goal_objective); distinguish scope conflict, missing evidence and stale display text. " + + "Reuse a pending proposal and inline facts; inspect missing context only, without polling or per-Turn revision receipts. " + + "Surfacing a proposal neither blocks authorized work nor forces a question. Use goal-acceptance --help for " + + "owner apply and each peer's own adoption; neither proposal nor adoption grants permissions. " + + "Progress, Todo/route changes, diagnostics, polish or age alone need no objective revision.", ]; function object(value: unknown): JsonObject { @@ -440,8 +427,7 @@ export function projectReplanSemantics(value: unknown): JsonObject { rule: "This cadence or observed-stall review must record a current evidence-linked stride_decision before any typed progress, successor, blocker or terminal outcome can settle it. " + String(contract.rule ?? "")}; } - return {required_any_of: required, planning_guidance: [...REPLAN_PLANNING_GUIDANCE, - ...stridePlanningGuidance(object(obligation.replan_context).stride_strategy)], + return {required_any_of: required, planning_guidance: REPLAN_PLANNING_GUIDANCE, ...(strideControl ? {stride_control: strideControl} : {}), ...writeback}; } diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index 53bdc6d507..6695551851 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -43,28 +43,6 @@ const decisionValueReview = { "Distinguish local or synthetic gains from authoritative transfer, and repeated identical outcomes from new information. " + "Choose decisive coverage before broadening a sweep; retain uncertainty and relevant negative evidence.", }; -export function stridePlanningGuidance(value: unknown): string[] { - if (normalizeStrideStrategy(value) === "off") return []; - return ["Review actual adoption of the previous decision, new information and authoritative goal outcomes separately. " + - "Choose grow to expand useful completed scope on a supported route, switch to test a different mechanism, or verify to resolve a route decision. " + - "Describe work size, route distance and validation breadth independently as concrete plans, with a reason for each. " + - "Name the reference method and the assumption or mechanism that changes; more tuning alone is not a route switch. " + - "Use the live decision_value_review questions to compare goal headroom and full cost through the three existing axes. " + - "Work size must cover a coherent accepted deliverable or an experiment capable of an interpretable conclusion: a new route may need a larger " + - "implementation and uncertain evidence may need broader validation. Expand useful work when evidence supports it; " + - "do not respond to a persistent unexplained gap by repeatedly making the same work smaller. " + - "For engineering, organizing or maintenance, grow can complete related implementation, integration, validation and readback together to reduce repeated planning and source reads. Work size is accepted scope, not Todo text, file count or tool calls. Do not bundle independent commitments or cross dependency, risk or authority boundaries to make work larger. Do not restart unfinished coherent work or repeat small probes unless changed inputs or a new uncertainty justify it. " + - "Where relevant, compare continuation, an alternative mechanism and replication using applicable Explore findings and scoped counterevidence. " + - "Do not force a pivot from elapsed time, a progress count or one noisy result. " + - "Missing or refuted evidence is not a universal route ban. Preserve the best qualified artifact and original priorities. " + - "Declare minimum evidence, a testable prediction, adoption criterion and full marginal cost including control and validation; review actual adoption, quality and cost next. Verify must resolve a named grow-or-switch question, not repeat an unchanged check. " + - "Carry the next_probe into existing executable Todo work, reusing an unfinished experiment when it still answers the question. " + - "Use agent_vision.todo_delta entries such as retain:todo_ to identify that work after its normal authoring/readback; do not invent IDs or add a planning-only Todo. " + - "Read planned_work and subsequent observations before crediting the previous decision. A negative experiment may finish its Todo while the broader goal remains open. " + - "Keep a candidate experiment separate from the best retained artifact: authorized evaluation may be informative before replacement is justified. " + - "Use the live stride_control contract for agent_vision.stride_decision. This controls proposal completeness, not execution, " + - "spawn, acceptance or a promise of utility; existing Todo/path and authority owners still govern work."]; -} export const STRIDE_DECISION_SCHEMA = decisionSchema.schema_version; export const STRIDE_PROBE_LIMITS = decisionSchema.probe_field_limits; diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 8f2c86a3e8..2a3377e7fe 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -1290,11 +1290,9 @@ def test_crowded_quota_preserves_long_chain_decision_clauses( guidance = " ".join(payload["replan_action_packet"]["planning_guidance"]) assert "a reasonable in-scope next step" in guidance assert "Otherwise explain why no such step remains" in guidance - assert "do not invent work, exceed authority or consume budget merely to stay active" in guidance + assert "do not invent work or consume budget merely to stay active" in guidance assert "provisional agent-created candidate rules" in guidance - assert "or rewrite criteria after failure" in guidance - assert "executed path and current artifact" in guidance - assert "Reuse still-applicable evidence" in guidance + assert "do not rewrite criteria after failure" in guidance def test_quota_cli_keeps_full_user_todo_diagnostics_on_explicit_cold_path( diff --git a/tests/control_plane/test_open_outcome_replan.py b/tests/control_plane/test_open_outcome_replan.py index 54676d302e..2879fa46d0 100644 --- a/tests/control_plane/test_open_outcome_replan.py +++ b/tests/control_plane/test_open_outcome_replan.py @@ -73,9 +73,9 @@ def guard(turn): assert outcomes["outcomes"][0]["work"] == [{"todo_id": TODO, "status": "open"}] assert not all(r["planning_satisfied"] for r in outcomes["outcomes"][0]["requirements"]) assert any("even with open Todos" in s for s in admitted["replan_action_packet"]["planning_guidance"]) - review = admitted["replan_action_packet"]["planning_guidance"][0] + review = " ".join(admitted["replan_action_packet"]["planning_guidance"]) assert "operating objective and cumulative requirements" in review - assert "justified no-change decision" in review + assert "justified no-change" in review assert "goal-acceptance prepare-objective" in review envelope = call("quota", "should-run", "--codex-app", "--turn-envelope", "--goal-id", GOAL, "--agent-id", AGENT, "--todo-id", TODO, "--turn-instance-id", TURN, diff --git a/tests/control_plane/test_progress_observation.py b/tests/control_plane/test_progress_observation.py index 7d0cf7b2b7..f1833a5d8e 100644 --- a/tests/control_plane/test_progress_observation.py +++ b/tests/control_plane/test_progress_observation.py @@ -309,7 +309,7 @@ def test_host_projects_evidence_context_and_minimal_action_packet() -> None: "allowed_terminal", "planning_guidance", } - assert len(packet["planning_guidance"]) == 5 + assert 0 < len(packet["planning_guidance"]) <= 3 assert all( isinstance(instruction, str) and instruction for instruction in packet["planning_guidance"] diff --git a/tests/control_plane/test_replan_host_context_projection.py b/tests/control_plane/test_replan_host_context_projection.py index 02c8aebb21..84593771de 100644 --- a/tests/control_plane/test_replan_host_context_projection.py +++ b/tests/control_plane/test_replan_host_context_projection.py @@ -132,7 +132,7 @@ def test_quota_delivers_coverage_context_and_minimal_replan_action() -> None: "delivered_by": "quota_host_projection", } assert context["coverage_ledger"][0]["surface_id"] == "surface-existing" - assert len(action["planning_guidance"]) == 5 + assert 0 < len(action["planning_guidance"]) <= 3 assert {key: value for key, value in action.items() if key != "planning_guidance"} == { "schema_version": "replan_action_packet_v0", "decision": "replan_required", @@ -235,11 +235,8 @@ def test_replan_guidance_survives_cli_and_host_compaction_without_new_authority( assert turn_envelope_action_signature_document(envelope) == signature signature["replan_action_packet"].pop("planning_guidance") assert signature == quota_action_signature_document(baseline) - # Preserve the existing 8 KiB envelope warning, including the already-large - # vision fixture. Objective review extends the existing five advice clauses; - # retain the earlier advice rather than shortening its obligations. This is a - # bounded advice allocation, not permission to truncate authority or raise - # the envelope/transport/compute budget. + # Semantic boundaries have a bounded allocation; the existing envelope + # warning and executable authority remain independent of that advice. assert envelope["compaction"]["within_budget"] == old_envelope["compaction"]["within_budget"] assert (envelope["compaction"]["envelope_json_bytes"] - old_envelope["compaction"]["envelope_json_bytes"]) <= 3072 diff --git a/tests/control_plane/test_replan_staged_read.py b/tests/control_plane/test_replan_staged_read.py index 80540a2932..87b655ae90 100644 --- a/tests/control_plane/test_replan_staged_read.py +++ b/tests/control_plane/test_replan_staged_read.py @@ -56,6 +56,7 @@ def test_staged_packet_budget_and_lossless_authority(stride): writer = _turn_envelope_result("replan_view", **params, stage="writeback") context = source["autonomous_replan_obligation"]["replan_context"] assert review["core_goal"] == context["core_goal"] + assert review["planning_guidance"] == source["replan_action_packet"]["planning_guidance"] assert review["planning_horizon"] == source.get("planning_horizon") assert review["uncovered_frontier"] == context["uncovered_frontier"] assert review["coverage_count"] == context["coverage_count"] @@ -77,6 +78,82 @@ def test_staged_packet_budget_and_lossless_authority(stride): assert source == original +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("replan", [True, False]) +def test_checkpoint_preflight_delivers_the_real_cli_view_and_retains_full_source(tmp_path, monkeypatch, provider, replan): + """Exercise the production preflight without a model or evaluator. + + A full-source fixture alone missed the original bug: the model must be sent + the captured reader, while checkpoint selection still sees full context. + """ + from benchmark.runtime import checkpoint + + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = make_progress_cli_fixture(tmp_path, provider, "advanced") + if not replan: + fixture["index"].write_text("") + wake = tmp_path / "wakes" / TURN + wake.mkdir(parents=True) + request = tmp_path / "request.json" + request.write_text(json.dumps({"trigger": "fresh_replan", "destination": str(tmp_path / "checkpoints"), + "components": [{"id": "code", "source_path": str(tmp_path / "code")}], "point_metadata": {}})) + executable = Path(sys.executable).with_name("loopx") + env = {**os.environ, "LOOPX_USAGE_PING": "0", "LOOPX_CLI": str(executable), + "LOOPX_CHECKPOINT_REQUEST": str(request), "LOOPX_WAKE_LOG_DIR": str(wake.parent), + "LOOPX_PROJECT": str(fixture["project"]), "LOOPX_RUNTIME_ROOT": str(fixture["runtime"]), + "LOOPX_REGISTRY": str(fixture["registry"]), "LOOPX_GOAL_ID": GOAL, "LOOPX_AGENT_ID": AGENT, + "LOOPX_TURN_ENVELOPE": "0"} + # This boundary has no native session to capture. Stop only the optional + # artifact operation; real quota, selector and reader remain unmocked. + monkeypatch.setattr("benchmark.runtime.checkpoint_continuation.restored_capture_gate", + lambda *a, **k: {"capture_allowed": False, "reason": "synthetic_no_native_capture"}) + run, commands = checkpoint.subprocess.run, [] + def observe(command, **kwargs): + commands.append(command) + return run(command, **kwargs) + monkeypatch.setattr(checkpoint.subprocess, "run", observe) + receipt = {"turn_id": TURN, "mode": "heartbeat", "stage": "execute"} + try: + body = checkpoint.capture_before_replan(env, receipt, "original heartbeat instructions") + assert len(commands) == 1 and "--turn-envelope" not in commands[0] + assert "--decision-output-root" in commands[0] + source_path, view_path = wake / "checkpoint-start-guard.json", wake / "checkpoint-start-view.json" + source, view = json.loads(source_path.read_bytes()), json.loads(view_path.read_bytes()) + assert str(view_path) in body and str(source_path) not in body + assert body.endswith("original heartbeat instructions") + assert source["heartbeat_receipt"]["turn_instance_id"] == TURN + assert source["heartbeat_receipt"] == view["heartbeat_receipt"] + assert receipt["checkpoint"]["reason"] == "synthetic_no_native_capture" + selection = view["interaction_contract"]["cli_channel"].get("selection_command") + if selection: + assert "--decision-output-root" in selection["command_args_template"] + selector = checkpoint.effect_runtime_result("runtime.checkpoint_artifacts", { + "action": "select_replan", "quota": source, "goal_id": GOAL, "agent_id": AGENT, + "turn_instance_id": TURN}, large_local_snapshot=True) + assert selector["capture"] is replan + if not replan: + assert "replan_action_packet" not in view + return + assert source["autonomous_replan_obligation"]["replan_context"]["context_id"] + assert "replan_context" not in view["autonomous_replan_obligation"] + packet = view["replan_action_packet"] + assert "planning_guidance" not in packet + capture = Path(source["replan_read_path"]) + assert capture.read_bytes() == source_path.read_bytes() + before = {key: fixture[key].read_bytes() for key in ("registry", "state", "index")} + for stage in ("review", "writeback"): + result = run(shlex.split(packet[stage]), cwd=fixture["project"], env=env, + capture_output=True, text=True, timeout=60, check=True) + read = json.loads(result.stdout) + assert read["obligation_id"] == selector["obligation_id"] + assert read["stage"] == stage + assert {key: fixture[key].read_bytes() for key in before} == before + assert capture.read_bytes() == source_path.read_bytes() + assert len(commands) == 1 # Reads did not re-enter checkpoint preflight. + finally: + restart_effect_runtime(wait_for_exit=True) + + def test_review_retains_scoped_dependencies_and_unknown_outcomes(): source = _quota_payload() source["replan_read_path"] = "/synthetic/decision.json" @@ -212,6 +289,33 @@ def test_populated_history_and_cumulative_acceptance_have_separate_budgets(tmp_p _turn_envelope_result("replan_view", **params, stage="acceptance", outcome_ref="missing") +def test_large_acceptance_is_not_repeated_in_cli_or_envelope_work_context(tmp_path): + from replan_packet_fixture import populated_replan_source + + source = populated_replan_source(tmp_path, acceptance=True) + contract = source["autonomous_replan_obligation"]["replan_context"]["core_goal"]["acceptance_contract"] + other = copy.deepcopy(contract) + other["outcome_requirements"]["revision"] += 1 + sources = [{"source": "goal_acceptance", "content": {"status": "loaded", "provider_revision": "fixture:1", + "goal_acceptance_contract": value}} for value in (contract, other)] + work = {"complete": True, "sources": sources} + source["interaction_contract"]["agent_channel"]["work_context"] = work + original = copy.deepcopy(source) + cli = _turn_envelope_result("replan_cli_view", payload=source) + envelope = build_turn_envelope(source) + for delivered in (cli["interaction_contract"]["agent_channel"]["work_context"], envelope["work_context"]): + matched, different = delivered["sources"] + assert matched["content"] == {"status": "loaded", "provider_revision": "fixture:1", + "context_read": cli["replan_action_packet"]["review"]} + assert different == sources[1] # Another revision is not covered by this reader. + assert json_bytes(work) - json_bytes(delivered) > 200 * 1024 + assert source == original + _, review, params = _views(source) + rows = review["acceptance_index"]["outcomes"] + for row, outcome in zip(rows, contract["outcome_requirements"]["outcomes"], strict=True): + assert _turn_envelope_result("replan_view", **params, stage="acceptance", outcome_ref=row["outcome_ref"])["outcome"] == outcome + + def test_explore_uses_its_own_incremental_budget_and_real_result_log(tmp_path): from replan_packet_fixture import populated_explore_context, populated_replan_source diff --git a/tests/control_plane/test_replan_successor_guard_reentry.py b/tests/control_plane/test_replan_successor_guard_reentry.py index fc64a76a31..52dbc22aa5 100644 --- a/tests/control_plane/test_replan_successor_guard_reentry.py +++ b/tests/control_plane/test_replan_successor_guard_reentry.py @@ -193,9 +193,7 @@ def test_real_mcp_replan_delivers_objective_and_evidence_guidance( assert packet["decision"] == "autonomous_replan_required" guidance = " ".join(packet["replan_action_packet"]["planning_guidance"]) assert "provisional agent-created candidate rules" in guidance - assert "Do not relax frozen acceptance" in guidance - assert "executed path and current artifact" in guidance - assert "scope negative results to tested conditions" in guidance + assert "relax frozen acceptance" in guidance assert packet["autonomous_replan_obligation"]["required"] is True core = packet["autonomous_replan_obligation"]["replan_context"]["core_goal"] assert core["objective"] == objective @@ -391,9 +389,7 @@ def test_later_vision_replan_can_close_covered_frontier_and_settle_once( # retaining the same independently covered frontier and single-debit path. guidance = " ".join(guard["replan_action_packet"]["planning_guidance"]) assert "provisional agent-created candidate rules" in guidance - assert "Do not relax frozen acceptance" in guidance - assert "executed path and current artifact" in guidance - assert "scope negative results to tested conditions" in guidance + assert "relax frozen acceptance" in guidance binding = ("--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", TURN, "--replan-obligation-id", identity["replan_obligation_id"]) vision = tmp_path / "covered-vision.json" diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index cfc41987a1..59421bee60 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -32,8 +32,9 @@ test("open-work review guidance does not require configured checks or outcome de assert.deepEqual(retired, ordinary); assert.deepEqual(current, ordinary, "default review and its authority do not depend on an outcome declaration"); const guidance = (ordinary.planning_guidance as string[]).join(" "); - for (const clause of ["even with open Todos", "Strongest first delivery", "diagnostic validation", - "Flat metrics alone prove neither progress nor exhaustion", "scope negative results"]) assert.ok(guidance.includes(clause)); + for (const clause of ["even with open Todos", "revisable planning choices", "never forces a pivot"]) { + assert.ok(guidance.includes(clause)); + } } } }); @@ -334,18 +335,8 @@ test("planning advice cannot discharge a replan or widen source-specific exits", const source = {triggers: [{kind}]}; const projection = projectReplanSemantics({operation: "requirements", obligation: source}); const guidance = projection.planning_guidance as string[]; - assert.equal(guidance.length, 5); - assert.match(guidance[0], /operating objective and cumulative requirements/); - assert.match(guidance[0], /justified no-change decision/); - assert.match(guidance[0], /scope conflict, missing evidence or stale display text/); - assert.match(guidance[3], /provisional agent-created candidate rules/); - assert.match(guidance[3], /Do not relax frozen acceptance/); - assert.match(guidance[4], /Compare expected goal\/information gain with full marginal cost/); - assert.match(guidance[4], /executed path and current artifact/); - assert.match(guidance[4], /scope negative results to tested conditions/); - assert.match(guidance[4], /Correct within the affected work first/); - assert.match(guidance[4], /A due review can retain the path/); - assert.match(guidance[4], /neither adoption nor improvement/); + assert.ok(guidance.length > 0 && guidance.length <= 3); + assert.ok(guidance.join(" ").length < 3000, "boundaries replace the long method checklist"); const refusal = projectReplanSemantics({operation: "qualify", obligation: source, planning_guidance: projection.planning_guidance}); assert.equal(refusal.accepted, false); @@ -355,7 +346,7 @@ test("planning advice cannot discharge a replan or widen source-specific exits", }); -test("versioned stride advice is opt-in and never a semantic discharge", () => { +test("stride format replaces duplicate strategy advice and never discharges a semantic review", () => { const plain = {triggers: [{kind: "typed_progress_repeat"}]}; const base = projectReplanSemantics({operation: "requirements", obligation: plain}); const off = projectReplanSemantics({operation: "requirements", obligation: { @@ -363,12 +354,11 @@ test("versioned stride advice is opt-in and never a semantic discharge", () => { assert.deepEqual(off, base); const enabled = {...plain, replan_context: {stride_strategy: "evidence_conditioned_v1"}}; const withStrategy = projectReplanSemantics({operation: "requirements", obligation: enabled}); - assert.deepEqual((withStrategy.planning_guidance as string[]).slice(0, 5), base.planning_guidance); - assert.equal((withStrategy.planning_guidance as string[]).length, 6); + assert.deepEqual(withStrategy.planning_guidance, base.planning_guidance); + assert.ok(withStrategy.stride_control); + assert.equal(base.stride_control, undefined); assert.deepEqual(withStrategy.required_any_of, ["fresh_vision_path_outcome", ...(base.required_any_of as string[])]); - assert.match((withStrategy.planning_guidance as string[])[5], /work size, route distance and validation breadth independently/); - assert.match((withStrategy.planning_guidance as string[])[5], /Do not restart unfinished coherent work/); assert.equal(projectReplanSemantics({operation: "qualify", obligation: enabled, planning_guidance: withStrategy.planning_guidance}).accepted, false); for (const version of ["future_v2", null, 1]) { From cda47abc9a5cf12bb1b59f8357ff9d20c04874ae Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:45:09 +0800 Subject: [PATCH 08/10] fix(checkpoint): admit explicit destination model on full continuation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/README.md | 25 ++++ benchmark/edgebench/run.py | 14 ++- benchmark/runtime/sforge_warm_start.py | 26 +++-- .../tests/test_checkpoint_binding_lock.py | 39 +++++-- .../tests/test_checkpoint_model_transition.py | 89 +++++++++++++++ benchmark/tests/test_native_checkpoint.py | 29 +++-- benchmark/tests/test_sforge_warm_start.py | 16 ++- .../turn_driver/codex_agent_session.py | 8 +- .../turn_driver/codex_sessions.py | 37 +++++- loopx/hosts/codex_checkpoint.py | 108 +++++++++++++++--- loopx/hosts/research_checkpoint.py | 20 +++- 11 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 benchmark/tests/test_checkpoint_model_transition.py diff --git a/benchmark/README.md b/benchmark/README.md index 45cdd1dbdb..c76b079dbe 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1038,6 +1038,31 @@ Fresh credentials come from the ordinary runner credential source. The native activation's absolute deadline includes receiver installation and preparation; restarting the entry cannot grant another full budget or submission allowance. +To continue with a different model or reasoning effort, retain the exact sealed +source settings in `--restore-config` and explicitly pass +`--restore-model-config DESTINATION_CONFIG`. Only the top-level `model` and +`model_reasoning_effort` may differ; provider, tools, permissions and other +settings must remain semantically identical. The destination model/effort must +also match `--model`/`--effort`. The native session participant records both +configuration digests, verifies the original session profile and full retained +history, then binds the new profile to a distinct destination session. Retry +uses that same source/destination intent; it cannot switch an already running +receiver. Without the option, exact-configuration admission is unchanged. +The host controller exposes the same explicit transition as +`--destination-model-config`; this remains a CLI/Host journey, with frontend +restore support still open. Changing the model changes the experiment treatment: +do not claim a matched mechanism comparison solely from shared checkpoint data. +It grants no extra native submissions, cooldown relief, permission or time. + +切换模型或思考深度时,`--restore-config` 仍传封存源配置,另显式传入 +`--restore-model-config DESTINATION_CONFIG`。仅顶层 `model` 与 +`model_reasoning_effort` 可变化,其余 provider、工具、权限及配置语义保持一致, +目标选择还须匹配 `--model`/`--effort`。接收端保留源配置摘要和完整历史校验, +在全新会话绑定目标配置;恢复重试不得换意图或改写已运行接收端。省略选项仍要求 +配置完全一致,不增加评测机会、豁免冷却、权限或预算。宿主 CLI 对应选项为 +`--destination-model-config`;frontend restore 旅程仍未交付。模型变化是新的实验因素, +共享 checkpoint 本身不能证明机制收益。 + Warm verification hashes the complete checkpoint with a read window of up to 60 seconds, capped by the original execution deadline. It rejects a read that finishes after that deadline. The launcher reuses this verified metadata before diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index 3d4c3f3e86..ad8933c380 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -159,6 +159,8 @@ def main(argv=None): retention.add_argument("--no-checkpoint-retention", action="store_true", help="Keep every captured point; disable default checkpoint rotation") parser.add_argument("--restore-config", type=Path) + parser.add_argument("--restore-model-config", type=Path, + help="Explicit destination config for changing only model/effort in a new checkpoint receiver; --restore-config retains the sealed source config") parser.add_argument("--restore-goal") parser.add_argument("--restore-agent") parser.add_argument("--restore-source-todo") @@ -228,6 +230,8 @@ def main(argv=None): parser.error("Research component restoration requires an explicit warm checkpoint continuation") if args.restore_fresh_replan and not args.restore_checkpoint: parser.error("Fresh context requires an explicit warm checkpoint continuation") + if args.restore_model_config is not None and not args.restore_checkpoint: + parser.error("Model transition requires an explicit warm checkpoint continuation") if args.restore_checkpoint and (not handoff or args.eval_interval != 0 or args.replan_after_todos is not None): parser.error("Warm continuation requires full native checkpoint capture, no periodic evaluation and effective-Turn cadence") # One directory is one attempt: never reuse native registration or overwrite @@ -307,8 +311,16 @@ def main(argv=None): candidate = WarmStart(args.restore_checkpoint, args.restore_sha256, args.restore_config, args.restore_goal, args.restore_agent, args.restore_source_todo, int(time.time()) + args.timeout, args.replan_after_turns or 6, - tuple(args.restore_research_component), args.restore_fresh_replan) + tuple(args.restore_research_component), args.restore_fresh_replan, + destination_config=args.restore_model_config) metadata = candidate.verify(task.cwd) + if args.restore_model_config is not None: + from loopx.hosts.codex_checkpoint import checkpoint_destination_config + _, transition = checkpoint_destination_config(args.restore_config, + metadata["native_session"]["config_sha256"], args.restore_model_config) + if (transition["destination_model"] != args.model or + transition["destination_effort"] != args.effort): + raise ValueError("Checkpoint destination model must match the explicit runner model/effort") snapshot = metadata["worker_file_snapshot"] capsule = args.restore_checkpoint / "components" / snapshot["native_capsule_component_id"] clock = attempt / "restored-phase-deadline" diff --git a/benchmark/runtime/sforge_warm_start.py b/benchmark/runtime/sforge_warm_start.py index 04bafb9463..ecccaa0f30 100644 --- a/benchmark/runtime/sforge_warm_start.py +++ b/benchmark/runtime/sforge_warm_start.py @@ -45,6 +45,7 @@ class WarmStart: normal_cadence: int = 6 research_component_ids: tuple[str, ...] = () fresh_replan: bool = False + destination_config: Path | None = None def verify(self, workspace: str) -> dict: from loopx.hosts.checkpoint_retention import checkpoint_bank_use @@ -60,8 +61,10 @@ def _verify(self, workspace: str) -> dict: or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", v) for v in (self.goal_id, self.agent_id, self.source_todo_id))): raise ValueError("Warm start needs fresh explicit identities and its original open deadline") - if any(not path.is_absolute() or path.resolve(strict=True) != path - for path in (self.root, self.config)): + paths = (self.root, self.config) + if self.destination_config is not None: + paths += (self.destination_config,) + if any(not path.is_absolute() or path.resolve(strict=True) != path for path in paths): raise ValueError("Warm start requires physical absolute participant paths") # Hashing a complete checkpoint can exceed the ordinary RPC budget. # This read allowance never extends the original execution deadline. @@ -74,9 +77,10 @@ def _verify(self, workspace: str) -> dict: metadata = json.loads((self.root / "checkpoint.json").read_bytes())["point_metadata"] native, source = metadata["native_session"], metadata["loopx_state"] if (native["guest_workspace"] != workspace or native["guest_home"] != _CODEX_HOME - or source["goal_id"] == self.goal_id or source["agent_id"] == self.agent_id - or hashlib.sha256(self.config.read_bytes()).hexdigest() != native["config_sha256"]): + or source["goal_id"] == self.goal_id or source["agent_id"] == self.agent_id): raise ValueError("Warm start paths, configuration or destination lineage differ") + from loopx.hosts.codex_checkpoint import checkpoint_destination_config + checkpoint_destination_config(self.config, native["config_sha256"], self.destination_config) _workspace_component(self.root, metadata, Path(workspace)) _research_components(self.root, metadata, Path(workspace), self.research_component_ids) context = metadata["research_context"] @@ -110,6 +114,12 @@ async def _stage(self, environment, workspace: str) -> list[str]: goal_id=self.goal_id, agent_id=self.agent_id, source_todo_id=self.source_todo_id, deadline_epoch=self.deadline_epoch, normal_cadence=self.normal_cadence) + protected_files = [config, f"{_CONTROL}/warm-start.json"] + if self.destination_config is not None: + target = f"{_CONTROL}/restored-model-config.toml" + await environment.upload_file(self.destination_config, target) + payload["destination_config"] = target + protected_files.append(target) if self.research_component_ids: payload["research_component_ids"] = list(self.research_component_ids) if self.fresh_replan: @@ -118,12 +128,12 @@ async def _stage(self, environment, workspace: str) -> list[str]: path = Path(folder) / "input.json" path.write_text(json.dumps(payload)) await environment.upload_file(path, f"{_CONTROL}/warm-start.json") + protected = shlex.join(protected_files) result = await environment.exec( # Modes are part of the sealed digest. Change transport ownership, # never widen modes (native stores commonly contain 0600 members). f"chown -R -P agent:agent {shlex.quote(unit)} && " - f"chown agent:agent {config} {_CONTROL}/warm-start.json && " - f"chmod 0400 {config} {_CONTROL}/warm-start.json", user="root") + f"chown agent:agent {protected} && chmod 0400 {protected}", user="root") if result.return_code: raise RuntimeError("Could not stage the verified checkpoint participants") # The root-only materializer preserves sealed modes. Its receiver must @@ -290,7 +300,9 @@ def admit_warm_session(env: dict[str, str], declaration: dict) -> dict: timeout = _phase_read_timeout(declaration, 90) result = admit_native_checkpoint_session(env, Path(declaration["root"]), declaration["sha256"], - Path(declaration["config"]), sandbox=env["LOOPX_CODEX_SANDBOX"], timeout=timeout) + Path(declaration["config"]), sandbox=env["LOOPX_CODEX_SANDBOX"], timeout=timeout, + **({"destination_config": Path(declaration["destination_config"])} + if declaration.get("destination_config") is not None else {})) _phase_read_timeout(declaration, 90) return result diff --git a/benchmark/tests/test_checkpoint_binding_lock.py b/benchmark/tests/test_checkpoint_binding_lock.py index 014ccb2042..421f756481 100644 --- a/benchmark/tests/test_checkpoint_binding_lock.py +++ b/benchmark/tests/test_checkpoint_binding_lock.py @@ -9,8 +9,9 @@ @pytest.mark.parametrize("replay", [False, True]) @pytest.mark.parametrize("change_goal", [False, True]) +@pytest.mark.parametrize("model_transition", [False, True]) def test_checkpoint_binding_and_readback_recheck_destination( - tmp_path, monkeypatch, capsys, replay, change_goal, + tmp_path, monkeypatch, capsys, replay, change_goal, model_transition, ): from loopx.cli import main from loopx.control_plane import effect_runtime @@ -25,7 +26,9 @@ def test_checkpoint_binding_and_readback_recheck_destination( for path in (workspace, home, unit): path.mkdir() config = home / "config.toml" - config.write_text("[features]\ngoals=false\n") + config.write_text('model="synthetic"\nmodel_reasoning_effort="high"\n[features]\ngoals=false\n') + source_config = tmp_path / "source.toml" + source_config.write_bytes(config.read_bytes()) prefix = ["--format", "json", "--registry", str(registry), "--runtime-root", str(runtime)] def cli(*args): @@ -53,11 +56,24 @@ def cli(*args): (unit / "checkpoint.json").write_text(json.dumps({"point_metadata": { "session": {"session_profile_digest": prepared.digest}, "native_session": native, "loopx_state": {"goal_id": "source", "agent_id": "source-agent"}}})) + destination = None + if model_transition: + destination = tmp_path / "destination.toml" + destination.write_text(source_config.read_text().replace('"synthetic"', '"synthetic-next"') + .replace('"high"', '"max"')) + config.write_bytes(destination.read_bytes()) + env.update(MODEL_NAME="synthetic-next", REASONING_EFFORT="max") + destination_profile = session_type(env, context="resume", sandbox="read-only", receipt={}).digest + transition = None + if model_transition: + transition = {**codex_checkpoint.checkpoint_destination_config( + source_config, native["config_sha256"], destination)[1], + "source_profile_digest": prepared.digest, "destination_profile_digest": destination_profile} if replay: _store_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent", session_id="destination-session", goal_ref=prepared.goal_ref, - session_profile_digest=prepared.digest, checkpoint_artifact_sha256=digest, - checkpoint_artifact_root=str(unit)) + session_profile_digest=destination_profile, checkpoint_artifact_sha256=digest, + checkpoint_artifact_root=str(unit), checkpoint_model_transition=transition) before = select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") # Native storage/inference is outside this lock regression. Registry, @@ -74,6 +90,7 @@ def verify(name, payload, **kwargs): def fork(*args, **kwargs): forks.append(True) + assert kwargs.get("destination_config") == destination return {"session_id": "destination-session", "model_calls": 0} def construct(*args, **kwargs): @@ -85,18 +102,26 @@ def construct(*args, **kwargs): monkeypatch.setattr(effect_runtime, "effect_runtime_result", verify) monkeypatch.setattr(codex_checkpoint, "fork_native_checkpoint", fork) - monkeypatch.setattr(codex_checkpoint, "native_session_checkpoint", lambda *a, **k: {"metadata": native}) + monkeypatch.setattr(codex_checkpoint, "native_session_checkpoint", lambda *a, **k: { + "metadata": {**native, "config_sha256": hashlib.sha256(config.read_bytes()).hexdigest()}}) monkeypatch.setattr(codex_agent_session, "CodexAgentSession", construct) if change_goal: with pytest.raises(ValueError, match="Goal configuration changed"): codex_checkpoint.admit_native_checkpoint_session( - env, unit, digest, config, sandbox="read-only", timeout=30) + env, unit, digest, source_config, sandbox="read-only", timeout=30, + **({"destination_config": destination} if destination is not None else {})) assert select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") == before else: result = codex_checkpoint.admit_native_checkpoint_session( - env, unit, digest, config, sandbox="read-only", timeout=30) + env, unit, digest, source_config, sandbox="read-only", timeout=30, + **({"destination_config": destination} if destination is not None else {})) assert result["session_binding_admitted"] and result["session_id"] == "destination-session" binding = select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") assert binding["checkpoint_artifact_sha256"] == digest assert binding["checkpoint_artifact_root"] == str(unit) + assert binding["session_profile_digest"] == destination_profile + if replay and model_transition: + assert result["model_transition"]["source_model"] == "synthetic" + assert result["model_transition"]["destination_effort"] == "max" + assert (source_config.read_bytes() != config.read_bytes()) is model_transition assert len(forks) == (0 if replay else 1) diff --git a/benchmark/tests/test_checkpoint_model_transition.py b/benchmark/tests/test_checkpoint_model_transition.py new file mode 100644 index 0000000000..e85ef5585f --- /dev/null +++ b/benchmark/tests/test_checkpoint_model_transition.py @@ -0,0 +1,89 @@ +"""A new receiver can choose a model without rewriting its captured evidence.""" +import hashlib + +import pytest + +from loopx.hosts.codex_checkpoint import checkpoint_destination_config + + +@pytest.fixture +def settings(tmp_path): + source, target = tmp_path / "source.toml", tmp_path / "target.toml" + source.write_text('model="source-model"\nmodel_reasoning_effort="high"\n' + 'sandbox_mode="read-only"\nmodel_provider="openai"\n' + '[features]\ngoals=false\n') + target.write_text(source.read_text().replace('"source-model"', '"next-model"') + .replace('"high"', '"max"')) + return source, target, hashlib.sha256(source.read_bytes()).hexdigest() + + +def test_explicit_model_transition_keeps_source_and_all_other_settings(settings): + source, target, digest = settings + before = source.read_bytes() + data, transition = checkpoint_destination_config(source, digest, target) + assert data == target.read_bytes() and source.read_bytes() == before + assert transition == { + "source_config_sha256": digest, + "destination_config_sha256": hashlib.sha256(data).hexdigest(), + "source_model": "source-model", "source_effort": "high", + "destination_model": "next-model", "destination_effort": "max", + } + assert checkpoint_destination_config(source, digest) == (before, None) + + +@pytest.mark.parametrize("old,new", [ + ('"read-only"', '"danger-full-access"'), ('"openai"', '"other"'), + ('goals=false', 'goals=true'), +]) +def test_model_transition_does_not_change_permissions_provider_or_native_goal(settings, old, new): + source, target, digest = settings + target.write_text(target.read_text().replace(old, new)) + with pytest.raises(ValueError, match="only model and reasoning effort"): + checkpoint_destination_config(source, digest, target) + + +def test_model_transition_preserves_source_digest_and_rejects_redirected_target(settings, tmp_path): + source, target, digest = settings + with pytest.raises(ValueError, match="effective configuration differs"): + checkpoint_destination_config(target, digest, source) + link = tmp_path / "target-link.toml" + link.symlink_to(target) + with pytest.raises(ValueError, match="physical absolute path"): + checkpoint_destination_config(source, digest, link) + + +@pytest.mark.parametrize("replacement", ['model=""', 'model=true', 'model=13']) +def test_model_transition_requires_an_explicit_model(settings, replacement): + source, target, digest = settings + target.write_text(target.read_text().replace('model="next-model"', replacement)) + with pytest.raises(ValueError, match="explicit model and reasoning effort"): + checkpoint_destination_config(source, digest, target) + + +def test_transition_provenance_survives_exact_session_observations_and_qualified_refresh(tmp_path): + from loopx.control_plane.turn_driver.codex_sessions import ( + _replace_checkpoint_codex_session, _store_codex_cli_session, load_codex_cli_session, + ) + facts = {"source_config_sha256": "a" * 64, "destination_config_sha256": "b" * 64, + "source_profile_digest": "c" * 64, "destination_profile_digest": "d" * 64, + "source_model": "original", "source_effort": "high", + "destination_model": "next", "destination_effort": "max"} + runtime, point = tmp_path / "runtime", tmp_path / "point" + point.mkdir() + binding = dict(lineage={"goal_id": "goal", "agent_id": "receiver"}, + session_scope="agent", session_id="fork", session_profile_digest="d" * 64) + _store_codex_cli_session(runtime, **binding, checkpoint_artifact_sha256="e" * 64, + checkpoint_artifact_root=str(point), checkpoint_model_transition=facts) + _store_codex_cli_session(runtime, **binding) + current = load_codex_cli_session(runtime, lineage=binding["lineage"], session_scope="agent") + assert current["checkpoint_model_transition"] == facts + with pytest.raises(ValueError, match="transition cannot change"): + _store_codex_cli_session(runtime, **binding, + checkpoint_model_transition={**facts, "destination_model": "another"}) + _replace_checkpoint_codex_session(runtime, lineage=binding["lineage"], expected=current, + session_id="fresh-context", refresh={"checkpoint_sha256": "e" * 64}) + refreshed = load_codex_cli_session(runtime, lineage=binding["lineage"], session_scope="agent") + assert refreshed["checkpoint_model_transition"] == facts + _store_codex_cli_session(runtime, **{**binding, "session_id": "unrelated"}) + assert "checkpoint_model_transition" not in load_codex_cli_session( + runtime, lineage=binding["lineage"], session_scope="agent") diff --git a/benchmark/tests/test_native_checkpoint.py b/benchmark/tests/test_native_checkpoint.py index c3e85b19e7..46e8f85d4f 100644 --- a/benchmark/tests/test_native_checkpoint.py +++ b/benchmark/tests/test_native_checkpoint.py @@ -291,7 +291,8 @@ def test_real_native_workspace_readback_rejects_foreign_code_basis(tmp_path): @pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="Requires an explicit real native Host binary; only a local static provider is used") @pytest.mark.parametrize("case", ["resume", "scope_change", "profile_change", "joint", "binding_reply_loss", - "fork_reply_loss", "fork_missing", "fork_ambiguous", "warm_preparation"]) + "fork_reply_loss", "fork_missing", "fork_ambiguous", "warm_preparation", + "model_transition"]) def test_real_fork_admission_and_native_resume_use_retained_state(tmp_path, capsys, monkeypatch, case, request): """Real Host/CLI/Goal/session/artifact owners; deterministic synthetic work. @@ -417,7 +418,7 @@ def response(request, index, message): binary = os.environ["LOOPX_NATIVE_CHECKPOINT_BIN"] # The warm receiver uses the benchmark's execution profile inside its # isolated worker. Other cases retain their original sandbox contract. - sandbox = ("danger-full-access" if case == "warm_preparation" else + sandbox = ("danger-full-access" if case in {"warm_preparation", "model_transition"} else "workspace-write" if case == "joint" else "read-only") execution = Execution(context="resume", sandbox=sandbox) with real_native_source(tmp_path / "native", binary, workspace, close_source=True, @@ -516,6 +517,14 @@ def retained_source(): env.update(LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_GOAL_ID=destination_goal, LOOPX_AGENT_ID=destination_agent, MODEL_NAME="fixture", REASONING_EFFORT="high") + admission_options, model_config_args = {}, [] + if case == "model_transition": + target_config = tmp_path / "destination-model.toml" + target_config.write_text(config.read_text().replace('model="fixture"', 'model="fixture-next"') + .replace('model_reasoning_effort="high"', 'model_reasoning_effort="max"')) + env.update(MODEL_NAME="fixture-next", REASONING_EFFORT="max") + admission_options = {"destination_config": target_config} + model_config_args = ["--destination-model-config", str(target_config)] lifecycle_registry = tmp_path / "lifecycle-only" / "registry.json" assert main(["--format", "json", "--registry", str(lifecycle_registry), "--runtime-root", str(tmp_path / "lifecycle-only/runtime"), "project", "register", @@ -619,7 +628,8 @@ def lose_binding_reply(self, *args): "agent_id": destination_agent}, session_scope="agent")["checkpoint_artifact_sha256"] == digest assert len(calls) == 1 admitted_cli = subprocess.run([sys.executable, "-m", "loopx.hosts.codex_checkpoint", - "--root", str(unit), "--expected-sha256", digest, "--config-path", str(config), "--sandbox", execution.sandbox], + "--root", str(unit), "--expected-sha256", digest, "--config-path", str(config), + "--sandbox", execution.sandbox, *model_config_args], env=env, text=True, capture_output=True, timeout=40) assert admitted_cli.returncode == 0, admitted_cli.stderr admitted = json.loads(admitted_cli.stdout) @@ -636,7 +646,8 @@ def lose_binding_reply(self, *args): def forbidden_fork(*args, **kwargs): raise AssertionError("a repeated admission must not create a second native fork") replay.setattr("loopx.hosts.codex_checkpoint.fork_native_checkpoint", forbidden_fork) - recovered = admit_native_checkpoint_session(env, unit, digest, config, sandbox=(execution).sandbox, timeout=30) + recovered = admit_native_checkpoint_session(env, unit, digest, config, + sandbox=(execution).sandbox, timeout=30, **admission_options) assert recovered == admitted assert bound["checkpoint_artifact_sha256"] == digest # Transport reply loss leaves the atomic source-bound binding as the @@ -644,9 +655,9 @@ def forbidden_fork(*args, **kwargs): retry_cli = subprocess.run(admitted_cli.args, env=env, text=True, capture_output=True, timeout=40) assert retry_cli.returncode == 0, retry_cli.stderr assert json.loads(retry_cli.stdout) == admitted - with pytest.raises(ValueError, match="profile changed"): + with pytest.raises(ValueError, match="profile changed|destination model differs"): admit_native_checkpoint_session({**env, "REASONING_EFFORT": "low"}, unit, digest, - config, sandbox=(execution).sandbox, timeout=30) + config, sandbox=(execution).sandbox, timeout=30, **admission_options) assert len(calls) == 1 and load_codex_cli_session(runtime, lineage={ "goal_id": destination_goal, "agent_id": destination_agent}, session_scope="agent") == bound if case == "resume": @@ -788,6 +799,9 @@ def forbidden_fork(*args, **kwargs): (logs / "stdout.jsonl").write_text(result.stdout) wake.observe(logs / "stdout.jsonl") assert wake.session_id == admitted["session_id"] and len(calls) == 3 + if case == "model_transition": + assert requests[1]["model"] == "fixture-next" + assert requests[1]["reasoning"]["effort"] == "max" retained_message = ("previous Host deadline has passed" if case == "warm_preparation" else "Synthetic source result.") assert retained_message in json.dumps(requests[1]) @@ -842,7 +856,8 @@ def forbidden_fork(*args, **kwargs): "agent_id": destination_agent}, session_scope="agent") assert after_work["checkpoint_artifact_sha256"] == digest with pytest.raises(ValueError, match="advanced or differs"): - admit_native_checkpoint_session(env, unit, digest, config, sandbox=(execution).sandbox, timeout=30) + admit_native_checkpoint_session(env, unit, digest, config, + sandbox=(execution).sandbox, timeout=30, **admission_options) assert load_codex_cli_session(runtime, lineage={"goal_id": destination_goal, "agent_id": destination_agent}, session_scope="agent") == after_work if case != "warm_preparation": diff --git a/benchmark/tests/test_sforge_warm_start.py b/benchmark/tests/test_sforge_warm_start.py index 5b621870cb..6300e4cde5 100644 --- a/benchmark/tests/test_sforge_warm_start.py +++ b/benchmark/tests/test_sforge_warm_start.py @@ -74,7 +74,7 @@ def point(tmp_path): (workspace / "candidate").write_text("retained candidate") (workspace / "candidate").chmod(0o600) config = tmp_path / "config.toml" - config.write_text("[features]\ngoals = false\n") + config.write_text('model="original"\nmodel_reasoning_effort="high"\n[features]\ngoals = false\n') context = tmp_path / "context.json" context.write_text(json.dumps({"snapshot": {"todos": [{"todo_id": "source-todo"}]}})) components = [{"id": "workspace", "source_path": str(workspace)}, @@ -174,8 +174,14 @@ def test_restore_replaces_image_files_preserves_modes_and_refuses_second_attempt assert workspace.with_name("workspace.before-checkpoint").is_dir() -def test_staging_preserves_sealed_modes(point, monkeypatch, tmp_path): +@pytest.mark.parametrize("model_transition", [False, True]) +def test_staging_preserves_sealed_modes(point, monkeypatch, tmp_path, model_transition): start, workspace = point + if model_transition: + destination = tmp_path / "destination.toml" + destination.write_text(start.config.read_text().replace('"original"', '"next"') + .replace('"high"', '"max"')) + start = replace(start, destination_config=destination) control = tmp_path / "control" control.mkdir() monkeypatch.setattr(warm, "_CONTROL", str(control)) @@ -194,6 +200,12 @@ async def exec(self, command, **kwargs): "expected_sha256": start.sha256}) declaration = json.loads((control / "warm-start.json").read_bytes()) assert declaration["deadline_epoch"] == start.deadline_epoch + if model_transition: + assert (control / "restored-model-config.toml").read_bytes() == destination.read_bytes() + assert declaration["destination_config"] == str(control / "restored-model-config.toml") + assert (control / "restored-config.toml").read_bytes() == start.config.read_bytes() + else: + assert "destination_config" not in declaration def test_normal_cadence_requires_accepted_current_replan_and_is_applied_once(tmp_path, monkeypatch): diff --git a/loopx/control_plane/turn_driver/codex_agent_session.py b/loopx/control_plane/turn_driver/codex_agent_session.py index 07680bf078..a3bbd6fa87 100644 --- a/loopx/control_plane/turn_driver/codex_agent_session.py +++ b/loopx/control_plane/turn_driver/codex_agent_session.py @@ -178,8 +178,9 @@ def prepare_checkpoint_refresh(self, review_basis: dict | None, attempt: Path, s self.session_id = None self.receipt["session"].update(action="start_new", session_id=None) - def _bind_verified_fork(self, session_id: str, source_profile_digest: str, - checkpoint_artifact_sha256: str, checkpoint_artifact_root: str) -> None: + def _bind_verified_fork(self, session_id: str, verified_profile_digest: str, + checkpoint_artifact_sha256: str, checkpoint_artifact_root: str, + *, model_transition: dict[str, str] | None = None) -> None: """Bind a controller-verified native fork through the existing session owner. This admits the conversation binding only. It does not claim work, @@ -188,7 +189,7 @@ def _bind_verified_fork(self, session_id: str, source_profile_digest: str, """ if self.session_id is not None: raise ValueError("checkpoint session adoption requires an absent destination binding") - require_codex_session_profile({"session_profile_digest": source_profile_digest}, self.digest) + require_codex_session_profile({"session_profile_digest": verified_profile_digest}, self.digest) self.admission.accept_result( lambda: _store_codex_cli_session( self.root, @@ -200,6 +201,7 @@ def _bind_verified_fork(self, session_id: str, source_profile_digest: str, require_absent=True, checkpoint_artifact_sha256=checkpoint_artifact_sha256, checkpoint_artifact_root=checkpoint_artifact_root, + **({"checkpoint_model_transition": model_transition} if model_transition is not None else {}), ) ) self.session_id = session_id diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index e9cbcbc689..725b4d9526 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -198,6 +198,7 @@ def _store_codex_cli_session( require_absent: bool = False, checkpoint_artifact_sha256: str | None = None, checkpoint_artifact_root: str | None = None, + checkpoint_model_transition: Mapping[str, str] | None = None, ) -> None: if checkpoint_artifact_sha256 is not None and not BARE_SHA256_PATTERN.fullmatch(checkpoint_artifact_sha256): raise ValueError("checkpoint session source digest is invalid") @@ -215,12 +216,14 @@ def _store_codex_cli_session( retained_source = None retained_root = None retained_refresh = None + retained_transition = None if prior and (prior.get("session_id"), prior.get("goal_ref"), prior.get("session_profile_digest")) == ( session_id, dict(goal_ref) if goal_ref is not None else None, session_profile_digest ): retained_source = prior.get("checkpoint_artifact_sha256") retained_root = prior.get("checkpoint_artifact_root") retained_refresh = prior.get("checkpoint_context_refresh") + retained_transition = prior.get("checkpoint_model_transition") if retained_source is not None: if not isinstance(retained_source, str) or not BARE_SHA256_PATTERN.fullmatch(retained_source): raise ValueError("stored checkpoint session source digest is invalid") @@ -231,6 +234,9 @@ def _store_codex_cli_session( raise ValueError("stored checkpoint session source root is invalid") if retained_root is not None and checkpoint_artifact_root not in (None, retained_root): raise ValueError("checkpoint session source root cannot change") + if retained_transition is not None and checkpoint_model_transition not in (None, retained_transition): + raise ValueError("checkpoint model transition cannot change") + transition = checkpoint_model_transition if checkpoint_model_transition is not None else retained_transition _write_codex_cli_session( runtime_root, lineage=lineage, @@ -245,6 +251,7 @@ def _store_codex_cli_session( checkpoint_artifact_sha256=checkpoint_artifact_sha256 or retained_source, checkpoint_artifact_root=checkpoint_artifact_root or retained_root, checkpoint_context_refresh=retained_refresh, + checkpoint_model_transition=transition, ) @@ -270,7 +277,8 @@ def _replace_checkpoint_codex_session( session_profile_digest=current["session_profile_digest"], checkpoint_artifact_sha256=current["checkpoint_artifact_sha256"], checkpoint_artifact_root=current["checkpoint_artifact_root"], - checkpoint_context_refresh={**refresh, "session_id": session_id}) + checkpoint_context_refresh={**refresh, "session_id": session_id}, + checkpoint_model_transition=current.get("checkpoint_model_transition")) def _write_codex_cli_session( @@ -288,8 +296,14 @@ def _write_codex_cli_session( checkpoint_artifact_sha256: str | None = None, checkpoint_artifact_root: str | None = None, checkpoint_context_refresh: dict[str, Any] | None = None, + checkpoint_model_transition: Mapping[str, str] | None = None, ) -> None: schema = _host_session_schema(host) + if checkpoint_model_transition is not None: + validate_checkpoint_model_transition(checkpoint_model_transition) + if (not checkpoint_artifact_sha256 or + checkpoint_model_transition["destination_profile_digest"] != session_profile_digest): + raise ValueError("checkpoint model transition requires its source-bound destination profile") normalized_session_id = _valid_session_id(session_id) if not normalized_session_id: raise ValueError("Codex CLI returned an invalid session id") @@ -323,6 +337,8 @@ def _write_codex_cli_session( payload["checkpoint_artifact_root"] = checkpoint_artifact_root if checkpoint_context_refresh is not None: payload["checkpoint_context_refresh"] = checkpoint_context_refresh + if checkpoint_model_transition is not None: + payload["checkpoint_model_transition"] = dict(checkpoint_model_transition) if operation_profile_digest is not None: payload["operation_transport"] = ( "dsh-native-operation-tools-v0" if host == "dsh" @@ -410,6 +426,17 @@ def read() -> dict[str, Any] | None: return dict(value) +def validate_checkpoint_model_transition(value: Mapping[str, str]) -> None: + """Validate the source/destination facts retained by checkpoint admission.""" + digests = ("source_config_sha256", "destination_config_sha256", + "source_profile_digest", "destination_profile_digest") + settings = ("source_model", "source_effort", "destination_model", "destination_effort") + if (not isinstance(value, Mapping) or set(value) != {*digests, *settings} or + any(not isinstance(value[k], str) or not BARE_SHA256_PATTERN.fullmatch(value[k]) for k in digests) or + any(not isinstance(value[k], str) or not value[k].strip() for k in settings)): + raise ValueError("checkpoint model transition facts are invalid") + + def codex_session_profile_digest( *, project: Path, @@ -419,8 +446,13 @@ def codex_session_profile_digest( reasoning_effort: str | None = None, sandbox: str | None = None, mcp_server: Mapping[str, Any] | None = None, + config_bytes: bytes | None = None, ) -> str: - """Bind a shared conversation to its workspace, provider home and settings.""" + """Bind a conversation to its workspace, provider home and settings. + + ``config_bytes`` compares inert captured settings at their qualified guest + paths. It does not install them or admit a changed active-session profile. + """ config = home / "config.toml" profile = { "workspace": str(project.resolve()), @@ -431,6 +463,7 @@ def codex_session_profile_digest( "sandbox": sandbox, "mcp_server": mcp_server, "config_digest": hashlib.sha256( + config_bytes if config_bytes is not None else config.read_bytes() if config.exists() else b"" ).hexdigest(), } diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 76475ea0a2..1ea25ca70b 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -273,8 +273,42 @@ def _native_thread_ids(home: Path) -> set[str]: return set(ids) +def checkpoint_destination_config(config_path: Path, source_sha256: str, + destination_config: Path | None = None) -> tuple[bytes, dict | None]: + """Verify sealed source settings and an explicit model/effort-only change. + + A destination is a new isolated receiver, never permission to change an + active session. Provider, tools, sandbox and all other configuration stay + semantically identical. Source bytes remain part of the sealed evidence. + """ + source = config_path.read_bytes() + if hashlib.sha256(source).hexdigest() != source_sha256: + raise ValueError("native checkpoint effective configuration differs") + if destination_config is None: + return source, None + if (not destination_config.is_absolute() or + destination_config.resolve(strict=True) != destination_config): + raise ValueError("checkpoint model configuration requires a physical absolute path") + target = destination_config.read_bytes() + original, changed = tomllib.loads(source.decode("utf-8")), tomllib.loads(target.decode("utf-8")) + keys = ("model", "model_reasoning_effort") + if any(not isinstance(config.get(key), str) or not config[key].strip() + for config in (original, changed) for key in keys): + raise ValueError("checkpoint model transition requires explicit model and reasoning effort") + if ({k: v for k, v in original.items() if k not in keys} != + {k: v for k, v in changed.items() if k not in keys}): + raise ValueError("checkpoint configuration transition may change only model and reasoning effort") + return target, { + "source_config_sha256": source_sha256, + "destination_config_sha256": hashlib.sha256(target).hexdigest(), + "source_model": original["model"], "source_effort": original["model_reasoning_effort"], + "destination_model": changed["model"], "destination_effort": changed["model_reasoning_effort"], + } + + def fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, timeout: float) -> dict[str, Any]: + config_path: Path, *, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: # The existing process lock serializes reconstruction in this isolated home. # The private attempt below describes one opaque native RPC, not authority # to admit a Goal, a work Turn, or a second fork after an uncertain response. @@ -290,11 +324,13 @@ def fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("native checkpoint reconstruction window closed") - return _fork_native_checkpoint(env, unit, expected_sha256, config_path, timeout=remaining) + return _fork_native_checkpoint(env, unit, expected_sha256, config_path, timeout=remaining, + **({"destination_config": destination_config} if destination_config is not None else {})) def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, timeout: float) -> dict[str, Any]: + config_path: Path, *, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: """Reconstruct one native branch in a new, fixed-path isolated environment. No model starts and no LoopX binding is registered. Existing session/Goal @@ -329,9 +365,8 @@ def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: st recovering = home.exists() if recovering and (not attempt_path.is_file() or attempt_path.resolve() != attempt_path): raise ValueError("native checkpoint destination home must be new or retain its original fork attempt") - config = config_path.read_bytes() - if hashlib.sha256(config).hexdigest() != native["config_sha256"]: - raise ValueError("native checkpoint effective configuration differs") + config, transition = checkpoint_destination_config( + config_path, native["config_sha256"], destination_config) version = subprocess.check_output([env["CODEX_BIN"], "--version"], env=env, text=True, encoding="utf-8", timeout=max(.1, deadline - time.monotonic())).strip() if version != native["host_version"]: @@ -365,6 +400,8 @@ def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: st **cursor_basis, "config_sha256": native["config_sha256"], "destination_goal_id": env.get("LOOPX_GOAL_ID"), "destination_agent_id": env.get("LOOPX_AGENT_ID"), "guest_workspace": env["LOOPX_PROJECT"], "guest_home": str(home)} + if transition is not None: + basis["model_transition"] = transition attempt = None if recovering: attempt = json.loads(attempt_path.read_bytes()) @@ -440,11 +477,13 @@ def request(method, params): raise ValueError("native checkpoint reconstructed history differs") return {"session_id": session, **cursor_basis, "visible_history_sha256": native["visible_history_sha256"], "history_verified": True, + **({"model_transition": transition} if transition is not None else {}), "model_calls": 0, "execution_authority_granted": False, "continuation_qualified": False} def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, sandbox: str, timeout: float) -> dict[str, Any]: + config_path: Path, *, sandbox: str, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: """Explicit controller admission of a verified fork into a destination Goal. Existing native fork, Goal lifetime and session owners retain their rules. @@ -458,7 +497,9 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh from loopx.control_plane.projects.registry_codec import ( project_registry_transaction, ) - from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session + from loopx.control_plane.turn_driver.codex_sessions import ( + codex_session_profile_digest, select_codex_cli_session, + ) from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession if not math.isfinite(timeout) or timeout <= 0: @@ -475,8 +516,27 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh raise ValueError("checkpoint session admission requires its captured source session profile") if tomllib.loads(config_path.read_text(encoding="utf-8")).get("features", {}).get("goals") is not False: raise ValueError("checkpoint session admission requires native automatic Goals explicitly disabled") - if hashlib.sha256(config_path.read_bytes()).hexdigest() != metadata["native_session"]["config_sha256"]: - raise ValueError("native checkpoint effective configuration differs") + config, transition = checkpoint_destination_config( + config_path, metadata["native_session"]["config_sha256"], destination_config) + target_profile = source_profile + if transition is not None: + if (env["MODEL_NAME"] != transition["destination_model"] or + env["REASONING_EFFORT"] != transition["destination_effort"]): + raise ValueError("checkpoint destination model differs from the current launch") + binary = shutil.which(env["CODEX_BIN"]) + if binary is None: + raise ValueError("Codex CLI executable is unavailable") + profile = dict(project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, + home=Path(env["CODEX_HOME"]), sandbox=sandbox) + observed_source = codex_session_profile_digest(**profile, + model=transition["source_model"], reasoning_effort=transition["source_effort"], + config_bytes=config_path.read_bytes()) + if observed_source != source_profile: + raise ValueError("checkpoint source session profile changed") + target_profile = codex_session_profile_digest(**profile, + model=env["MODEL_NAME"], reasoning_effort=env["REASONING_EFFORT"], config_bytes=config) + transition = {**transition, "source_profile_digest": source_profile, + "destination_profile_digest": target_profile} source = metadata.get("loopx_state", {}) lineage = {"goal_id": env["LOOPX_GOAL_ID"], "agent_id": env["LOOPX_AGENT_ID"]} if (not source.get("goal_id") or not source.get("agent_id") or @@ -507,8 +567,10 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh raise ValueError("destination session binding already exists without this checkpoint admission") if binding.get("checkpoint_artifact_root") not in (None, str(unit)): raise ValueError("destination checkpoint source root differs from the original admission") - if wake.digest != source_profile: + if wake.digest != target_profile: raise ValueError("checkpoint source session profile changed") + if transition is not None and binding.get("checkpoint_model_transition") != transition: + raise ValueError("checkpoint model transition differs from its original admission") native = metadata["native_session"] observed = native_session_checkpoint(env, binding["session_id"], timeout=max(.1, deadline - time.monotonic()), @@ -516,15 +578,18 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh cursor_key = "interrupted_turn_id" if native.get("interrupted_guard") is not None else "completed_turn_id" keys = (cursor_key, "turn_ids", "visible_history_sha256", "host_version", "guest_home", "guest_workspace", "config_sha256") - if binding["session_id"] == native["session_id"] or any(observed[key] != native[key] for key in keys): + expected = {**native, "config_sha256": hashlib.sha256(config).hexdigest()} + if binding["session_id"] == native["session_id"] or any(observed[key] != expected[key] for key in keys): raise ValueError("checkpoint admitted native session advanced or differs; use its existing continuation") return {"session_id": binding["session_id"], cursor_key: observed[cursor_key], "visible_history_sha256": observed["visible_history_sha256"], "history_verified": True, "model_calls": 0, "execution_authority_granted": False, "continuation_qualified": False, "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], - "session_binding_admitted": True, "session_profile_digest": wake.digest} + "session_binding_admitted": True, "session_profile_digest": wake.digest, + **({"model_transition": transition} if transition is not None else {})} result = fork_native_checkpoint(env, unit, expected_sha256, config_path, - timeout=max(.1, deadline - time.monotonic())) + timeout=max(.1, deadline - time.monotonic()), + **({"destination_config": destination_config} if destination_config is not None else {})) receipt: dict[str, Any] = {} wake = CodexAgentSession(env, context="resume", sandbox=sandbox, receipt=receipt) # Hold the existing registry owner's fence through the session write. This @@ -534,8 +599,13 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh require_registered_agent_id(registry_path=registry, goal_id=lineage["goal_id"], agent_id=lineage["agent_id"]) if current != goal: raise ValueError("destination Goal configuration changed during native checkpoint reconstruction") - wake._bind_verified_fork(result["session_id"], source_profile, expected_sha256, str(unit)) - return {**result, "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], + # The source profile was independently checked against the sealed + # settings above. Bind the explicitly verified destination profile; + # ordinary session resumes still require an exact profile match. + wake._bind_verified_fork(result["session_id"], target_profile, expected_sha256, str(unit), + **({"model_transition": transition} if transition is not None else {})) + return {**result, **({"model_transition": transition} if transition is not None else {}), + "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], "session_binding_admitted": True, "session_profile_digest": wake.digest} @@ -548,6 +618,8 @@ def main(argv=None) -> None: parser.add_argument("--root", type=Path, required=True) parser.add_argument("--expected-sha256", required=True) parser.add_argument("--config-path", type=Path, required=True) + parser.add_argument("--destination-model-config", type=Path, + help="Explicit new-receiver config changing only model and reasoning effort") parser.add_argument("--sandbox", choices=CODEX_CLI_SANDBOXES, required=True) parser.add_argument("--timeout-seconds", type=float, default=30) args = parser.parse_args(argv) @@ -557,7 +629,9 @@ def main(argv=None) -> None: if missing: parser.error("Explicit destination controller environment is missing: " + ", ".join(missing)) result = admit_native_checkpoint_session(dict(os.environ), args.root, args.expected_sha256, - args.config_path, sandbox=args.sandbox, timeout=args.timeout_seconds) + args.config_path, sandbox=args.sandbox, timeout=args.timeout_seconds, + **({"destination_config": args.destination_model_config} + if args.destination_model_config is not None else {})) print(json.dumps(result)) diff --git a/loopx/hosts/research_checkpoint.py b/loopx/hosts/research_checkpoint.py index d1fb7a7b62..d740d48fe3 100644 --- a/loopx/hosts/research_checkpoint.py +++ b/loopx/hosts/research_checkpoint.py @@ -135,21 +135,33 @@ def _handoff(env, payload): -def _context_session(env, metadata, timeout=30): +def _context_session(env, metadata, timeout=30, *, checkpoint_sha256=None): """Read the admitted native branch before publishing captured Todo context.""" from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession from loopx.hosts.codex_checkpoint import native_session_checkpoint wake = CodexAgentSession(env, context="resume", sandbox=env["LOOPX_CODEX_SANDBOX"], receipt={}) native = metadata["native_session"] + expected_profile = metadata.get("session", {}).get("session_profile_digest") + expected_config = native["config_sha256"] + transition = (wake.binding or {}).get("checkpoint_model_transition") + if transition is not None: + from loopx.control_plane.turn_driver.codex_sessions import validate_checkpoint_model_transition + validate_checkpoint_model_transition(transition) + if (not checkpoint_sha256 or wake.binding.get("checkpoint_artifact_sha256") != checkpoint_sha256 or + transition["source_profile_digest"] != expected_profile or + transition["source_config_sha256"] != expected_config): + raise ValueError("context adoption model transition does not match the captured source") + expected_profile = transition["destination_profile_digest"] + expected_config = transition["destination_config_sha256"] if (not wake.session_id or wake.session_id == native["session_id"] or - wake.digest != metadata.get("session", {}).get("session_profile_digest")): + wake.digest != expected_profile): raise ValueError("context adoption requires the admitted destination session profile") observed = native_session_checkpoint(env, wake.session_id, timeout=timeout, interrupted_guard=native.get("interrupted_guard"))["metadata"] cursor_key = "interrupted_turn_id" if native.get("interrupted_guard") is not None else "completed_turn_id" for key in ("visible_history_sha256", "turn_ids", cursor_key, "host_version", "config_sha256"): - if observed[key] != native[key]: + if observed[key] != (expected_config if key == "config_sha256" else native[key]): raise ValueError("destination native history or configuration moved beyond the captured context") return wake.session_id @@ -189,7 +201,7 @@ def verify_artifact(): read_timeout() # A late successful read cannot authorize the next write. def read_session(): - session = _context_session(env, metadata, read_timeout()) + session = _context_session(env, metadata, read_timeout(), checkpoint_sha256=digest) read_timeout() return session From 71f673b98ea08ee6b9b108cc727682ca67fdfda2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:45:09 +0800 Subject: [PATCH 09/10] fix(checkpoint): admit explicit destination model on full continuation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/README.md | 25 ++++ benchmark/edgebench/run.py | 14 ++- benchmark/runtime/sforge_warm_start.py | 26 +++-- .../tests/test_checkpoint_binding_lock.py | 39 +++++-- .../tests/test_checkpoint_model_transition.py | 89 +++++++++++++++ benchmark/tests/test_native_checkpoint.py | 29 +++-- benchmark/tests/test_sforge_warm_start.py | 16 ++- .../turn_driver/codex_agent_session.py | 8 +- .../turn_driver/codex_sessions.py | 37 +++++- loopx/hosts/codex_checkpoint.py | 108 +++++++++++++++--- loopx/hosts/research_checkpoint.py | 20 +++- 11 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 benchmark/tests/test_checkpoint_model_transition.py diff --git a/benchmark/README.md b/benchmark/README.md index 45cdd1dbdb..c76b079dbe 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1038,6 +1038,31 @@ Fresh credentials come from the ordinary runner credential source. The native activation's absolute deadline includes receiver installation and preparation; restarting the entry cannot grant another full budget or submission allowance. +To continue with a different model or reasoning effort, retain the exact sealed +source settings in `--restore-config` and explicitly pass +`--restore-model-config DESTINATION_CONFIG`. Only the top-level `model` and +`model_reasoning_effort` may differ; provider, tools, permissions and other +settings must remain semantically identical. The destination model/effort must +also match `--model`/`--effort`. The native session participant records both +configuration digests, verifies the original session profile and full retained +history, then binds the new profile to a distinct destination session. Retry +uses that same source/destination intent; it cannot switch an already running +receiver. Without the option, exact-configuration admission is unchanged. +The host controller exposes the same explicit transition as +`--destination-model-config`; this remains a CLI/Host journey, with frontend +restore support still open. Changing the model changes the experiment treatment: +do not claim a matched mechanism comparison solely from shared checkpoint data. +It grants no extra native submissions, cooldown relief, permission or time. + +切换模型或思考深度时,`--restore-config` 仍传封存源配置,另显式传入 +`--restore-model-config DESTINATION_CONFIG`。仅顶层 `model` 与 +`model_reasoning_effort` 可变化,其余 provider、工具、权限及配置语义保持一致, +目标选择还须匹配 `--model`/`--effort`。接收端保留源配置摘要和完整历史校验, +在全新会话绑定目标配置;恢复重试不得换意图或改写已运行接收端。省略选项仍要求 +配置完全一致,不增加评测机会、豁免冷却、权限或预算。宿主 CLI 对应选项为 +`--destination-model-config`;frontend restore 旅程仍未交付。模型变化是新的实验因素, +共享 checkpoint 本身不能证明机制收益。 + Warm verification hashes the complete checkpoint with a read window of up to 60 seconds, capped by the original execution deadline. It rejects a read that finishes after that deadline. The launcher reuses this verified metadata before diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index eb692b9d19..dc319f30e1 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -161,6 +161,8 @@ def main(argv=None): retention.add_argument("--no-checkpoint-retention", action="store_true", help="Keep every captured point; disable default checkpoint rotation") parser.add_argument("--restore-config", type=Path) + parser.add_argument("--restore-model-config", type=Path, + help="Explicit destination config for changing only model/effort in a new checkpoint receiver; --restore-config retains the sealed source config") parser.add_argument("--restore-goal") parser.add_argument("--restore-agent") parser.add_argument("--restore-source-todo") @@ -232,6 +234,8 @@ def main(argv=None): parser.error("Research component restoration requires an explicit warm checkpoint continuation") if args.restore_fresh_replan and not args.restore_checkpoint: parser.error("Fresh context requires an explicit warm checkpoint continuation") + if args.restore_model_config is not None and not args.restore_checkpoint: + parser.error("Model transition requires an explicit warm checkpoint continuation") if args.restore_checkpoint and (not handoff or args.eval_interval != 0 or args.replan_after_todos is not None): parser.error("Warm continuation requires full native checkpoint capture, no periodic evaluation and effective-Turn cadence") # One directory is one attempt: never reuse native registration or overwrite @@ -311,8 +315,16 @@ def main(argv=None): candidate = WarmStart(args.restore_checkpoint, args.restore_sha256, args.restore_config, args.restore_goal, args.restore_agent, args.restore_source_todo, int(time.time()) + args.timeout, args.replan_after_turns or 6, - tuple(args.restore_research_component), args.restore_fresh_replan) + tuple(args.restore_research_component), args.restore_fresh_replan, + destination_config=args.restore_model_config) metadata = candidate.verify(task.cwd) + if args.restore_model_config is not None: + from loopx.hosts.codex_checkpoint import checkpoint_destination_config + _, transition = checkpoint_destination_config(args.restore_config, + metadata["native_session"]["config_sha256"], args.restore_model_config) + if (transition["destination_model"] != args.model or + transition["destination_effort"] != args.effort): + raise ValueError("Checkpoint destination model must match the explicit runner model/effort") snapshot = metadata["worker_file_snapshot"] capsule = args.restore_checkpoint / "components" / snapshot["native_capsule_component_id"] clock = attempt / "restored-phase-deadline" diff --git a/benchmark/runtime/sforge_warm_start.py b/benchmark/runtime/sforge_warm_start.py index 04bafb9463..ecccaa0f30 100644 --- a/benchmark/runtime/sforge_warm_start.py +++ b/benchmark/runtime/sforge_warm_start.py @@ -45,6 +45,7 @@ class WarmStart: normal_cadence: int = 6 research_component_ids: tuple[str, ...] = () fresh_replan: bool = False + destination_config: Path | None = None def verify(self, workspace: str) -> dict: from loopx.hosts.checkpoint_retention import checkpoint_bank_use @@ -60,8 +61,10 @@ def _verify(self, workspace: str) -> dict: or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", v) for v in (self.goal_id, self.agent_id, self.source_todo_id))): raise ValueError("Warm start needs fresh explicit identities and its original open deadline") - if any(not path.is_absolute() or path.resolve(strict=True) != path - for path in (self.root, self.config)): + paths = (self.root, self.config) + if self.destination_config is not None: + paths += (self.destination_config,) + if any(not path.is_absolute() or path.resolve(strict=True) != path for path in paths): raise ValueError("Warm start requires physical absolute participant paths") # Hashing a complete checkpoint can exceed the ordinary RPC budget. # This read allowance never extends the original execution deadline. @@ -74,9 +77,10 @@ def _verify(self, workspace: str) -> dict: metadata = json.loads((self.root / "checkpoint.json").read_bytes())["point_metadata"] native, source = metadata["native_session"], metadata["loopx_state"] if (native["guest_workspace"] != workspace or native["guest_home"] != _CODEX_HOME - or source["goal_id"] == self.goal_id or source["agent_id"] == self.agent_id - or hashlib.sha256(self.config.read_bytes()).hexdigest() != native["config_sha256"]): + or source["goal_id"] == self.goal_id or source["agent_id"] == self.agent_id): raise ValueError("Warm start paths, configuration or destination lineage differ") + from loopx.hosts.codex_checkpoint import checkpoint_destination_config + checkpoint_destination_config(self.config, native["config_sha256"], self.destination_config) _workspace_component(self.root, metadata, Path(workspace)) _research_components(self.root, metadata, Path(workspace), self.research_component_ids) context = metadata["research_context"] @@ -110,6 +114,12 @@ async def _stage(self, environment, workspace: str) -> list[str]: goal_id=self.goal_id, agent_id=self.agent_id, source_todo_id=self.source_todo_id, deadline_epoch=self.deadline_epoch, normal_cadence=self.normal_cadence) + protected_files = [config, f"{_CONTROL}/warm-start.json"] + if self.destination_config is not None: + target = f"{_CONTROL}/restored-model-config.toml" + await environment.upload_file(self.destination_config, target) + payload["destination_config"] = target + protected_files.append(target) if self.research_component_ids: payload["research_component_ids"] = list(self.research_component_ids) if self.fresh_replan: @@ -118,12 +128,12 @@ async def _stage(self, environment, workspace: str) -> list[str]: path = Path(folder) / "input.json" path.write_text(json.dumps(payload)) await environment.upload_file(path, f"{_CONTROL}/warm-start.json") + protected = shlex.join(protected_files) result = await environment.exec( # Modes are part of the sealed digest. Change transport ownership, # never widen modes (native stores commonly contain 0600 members). f"chown -R -P agent:agent {shlex.quote(unit)} && " - f"chown agent:agent {config} {_CONTROL}/warm-start.json && " - f"chmod 0400 {config} {_CONTROL}/warm-start.json", user="root") + f"chown agent:agent {protected} && chmod 0400 {protected}", user="root") if result.return_code: raise RuntimeError("Could not stage the verified checkpoint participants") # The root-only materializer preserves sealed modes. Its receiver must @@ -290,7 +300,9 @@ def admit_warm_session(env: dict[str, str], declaration: dict) -> dict: timeout = _phase_read_timeout(declaration, 90) result = admit_native_checkpoint_session(env, Path(declaration["root"]), declaration["sha256"], - Path(declaration["config"]), sandbox=env["LOOPX_CODEX_SANDBOX"], timeout=timeout) + Path(declaration["config"]), sandbox=env["LOOPX_CODEX_SANDBOX"], timeout=timeout, + **({"destination_config": Path(declaration["destination_config"])} + if declaration.get("destination_config") is not None else {})) _phase_read_timeout(declaration, 90) return result diff --git a/benchmark/tests/test_checkpoint_binding_lock.py b/benchmark/tests/test_checkpoint_binding_lock.py index 014ccb2042..421f756481 100644 --- a/benchmark/tests/test_checkpoint_binding_lock.py +++ b/benchmark/tests/test_checkpoint_binding_lock.py @@ -9,8 +9,9 @@ @pytest.mark.parametrize("replay", [False, True]) @pytest.mark.parametrize("change_goal", [False, True]) +@pytest.mark.parametrize("model_transition", [False, True]) def test_checkpoint_binding_and_readback_recheck_destination( - tmp_path, monkeypatch, capsys, replay, change_goal, + tmp_path, monkeypatch, capsys, replay, change_goal, model_transition, ): from loopx.cli import main from loopx.control_plane import effect_runtime @@ -25,7 +26,9 @@ def test_checkpoint_binding_and_readback_recheck_destination( for path in (workspace, home, unit): path.mkdir() config = home / "config.toml" - config.write_text("[features]\ngoals=false\n") + config.write_text('model="synthetic"\nmodel_reasoning_effort="high"\n[features]\ngoals=false\n') + source_config = tmp_path / "source.toml" + source_config.write_bytes(config.read_bytes()) prefix = ["--format", "json", "--registry", str(registry), "--runtime-root", str(runtime)] def cli(*args): @@ -53,11 +56,24 @@ def cli(*args): (unit / "checkpoint.json").write_text(json.dumps({"point_metadata": { "session": {"session_profile_digest": prepared.digest}, "native_session": native, "loopx_state": {"goal_id": "source", "agent_id": "source-agent"}}})) + destination = None + if model_transition: + destination = tmp_path / "destination.toml" + destination.write_text(source_config.read_text().replace('"synthetic"', '"synthetic-next"') + .replace('"high"', '"max"')) + config.write_bytes(destination.read_bytes()) + env.update(MODEL_NAME="synthetic-next", REASONING_EFFORT="max") + destination_profile = session_type(env, context="resume", sandbox="read-only", receipt={}).digest + transition = None + if model_transition: + transition = {**codex_checkpoint.checkpoint_destination_config( + source_config, native["config_sha256"], destination)[1], + "source_profile_digest": prepared.digest, "destination_profile_digest": destination_profile} if replay: _store_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent", session_id="destination-session", goal_ref=prepared.goal_ref, - session_profile_digest=prepared.digest, checkpoint_artifact_sha256=digest, - checkpoint_artifact_root=str(unit)) + session_profile_digest=destination_profile, checkpoint_artifact_sha256=digest, + checkpoint_artifact_root=str(unit), checkpoint_model_transition=transition) before = select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") # Native storage/inference is outside this lock regression. Registry, @@ -74,6 +90,7 @@ def verify(name, payload, **kwargs): def fork(*args, **kwargs): forks.append(True) + assert kwargs.get("destination_config") == destination return {"session_id": "destination-session", "model_calls": 0} def construct(*args, **kwargs): @@ -85,18 +102,26 @@ def construct(*args, **kwargs): monkeypatch.setattr(effect_runtime, "effect_runtime_result", verify) monkeypatch.setattr(codex_checkpoint, "fork_native_checkpoint", fork) - monkeypatch.setattr(codex_checkpoint, "native_session_checkpoint", lambda *a, **k: {"metadata": native}) + monkeypatch.setattr(codex_checkpoint, "native_session_checkpoint", lambda *a, **k: { + "metadata": {**native, "config_sha256": hashlib.sha256(config.read_bytes()).hexdigest()}}) monkeypatch.setattr(codex_agent_session, "CodexAgentSession", construct) if change_goal: with pytest.raises(ValueError, match="Goal configuration changed"): codex_checkpoint.admit_native_checkpoint_session( - env, unit, digest, config, sandbox="read-only", timeout=30) + env, unit, digest, source_config, sandbox="read-only", timeout=30, + **({"destination_config": destination} if destination is not None else {})) assert select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") == before else: result = codex_checkpoint.admit_native_checkpoint_session( - env, unit, digest, config, sandbox="read-only", timeout=30) + env, unit, digest, source_config, sandbox="read-only", timeout=30, + **({"destination_config": destination} if destination is not None else {})) assert result["session_binding_admitted"] and result["session_id"] == "destination-session" binding = select_codex_cli_session(runtime, lineage=prepared.lineage, session_scope="agent") assert binding["checkpoint_artifact_sha256"] == digest assert binding["checkpoint_artifact_root"] == str(unit) + assert binding["session_profile_digest"] == destination_profile + if replay and model_transition: + assert result["model_transition"]["source_model"] == "synthetic" + assert result["model_transition"]["destination_effort"] == "max" + assert (source_config.read_bytes() != config.read_bytes()) is model_transition assert len(forks) == (0 if replay else 1) diff --git a/benchmark/tests/test_checkpoint_model_transition.py b/benchmark/tests/test_checkpoint_model_transition.py new file mode 100644 index 0000000000..e85ef5585f --- /dev/null +++ b/benchmark/tests/test_checkpoint_model_transition.py @@ -0,0 +1,89 @@ +"""A new receiver can choose a model without rewriting its captured evidence.""" +import hashlib + +import pytest + +from loopx.hosts.codex_checkpoint import checkpoint_destination_config + + +@pytest.fixture +def settings(tmp_path): + source, target = tmp_path / "source.toml", tmp_path / "target.toml" + source.write_text('model="source-model"\nmodel_reasoning_effort="high"\n' + 'sandbox_mode="read-only"\nmodel_provider="openai"\n' + '[features]\ngoals=false\n') + target.write_text(source.read_text().replace('"source-model"', '"next-model"') + .replace('"high"', '"max"')) + return source, target, hashlib.sha256(source.read_bytes()).hexdigest() + + +def test_explicit_model_transition_keeps_source_and_all_other_settings(settings): + source, target, digest = settings + before = source.read_bytes() + data, transition = checkpoint_destination_config(source, digest, target) + assert data == target.read_bytes() and source.read_bytes() == before + assert transition == { + "source_config_sha256": digest, + "destination_config_sha256": hashlib.sha256(data).hexdigest(), + "source_model": "source-model", "source_effort": "high", + "destination_model": "next-model", "destination_effort": "max", + } + assert checkpoint_destination_config(source, digest) == (before, None) + + +@pytest.mark.parametrize("old,new", [ + ('"read-only"', '"danger-full-access"'), ('"openai"', '"other"'), + ('goals=false', 'goals=true'), +]) +def test_model_transition_does_not_change_permissions_provider_or_native_goal(settings, old, new): + source, target, digest = settings + target.write_text(target.read_text().replace(old, new)) + with pytest.raises(ValueError, match="only model and reasoning effort"): + checkpoint_destination_config(source, digest, target) + + +def test_model_transition_preserves_source_digest_and_rejects_redirected_target(settings, tmp_path): + source, target, digest = settings + with pytest.raises(ValueError, match="effective configuration differs"): + checkpoint_destination_config(target, digest, source) + link = tmp_path / "target-link.toml" + link.symlink_to(target) + with pytest.raises(ValueError, match="physical absolute path"): + checkpoint_destination_config(source, digest, link) + + +@pytest.mark.parametrize("replacement", ['model=""', 'model=true', 'model=13']) +def test_model_transition_requires_an_explicit_model(settings, replacement): + source, target, digest = settings + target.write_text(target.read_text().replace('model="next-model"', replacement)) + with pytest.raises(ValueError, match="explicit model and reasoning effort"): + checkpoint_destination_config(source, digest, target) + + +def test_transition_provenance_survives_exact_session_observations_and_qualified_refresh(tmp_path): + from loopx.control_plane.turn_driver.codex_sessions import ( + _replace_checkpoint_codex_session, _store_codex_cli_session, load_codex_cli_session, + ) + facts = {"source_config_sha256": "a" * 64, "destination_config_sha256": "b" * 64, + "source_profile_digest": "c" * 64, "destination_profile_digest": "d" * 64, + "source_model": "original", "source_effort": "high", + "destination_model": "next", "destination_effort": "max"} + runtime, point = tmp_path / "runtime", tmp_path / "point" + point.mkdir() + binding = dict(lineage={"goal_id": "goal", "agent_id": "receiver"}, + session_scope="agent", session_id="fork", session_profile_digest="d" * 64) + _store_codex_cli_session(runtime, **binding, checkpoint_artifact_sha256="e" * 64, + checkpoint_artifact_root=str(point), checkpoint_model_transition=facts) + _store_codex_cli_session(runtime, **binding) + current = load_codex_cli_session(runtime, lineage=binding["lineage"], session_scope="agent") + assert current["checkpoint_model_transition"] == facts + with pytest.raises(ValueError, match="transition cannot change"): + _store_codex_cli_session(runtime, **binding, + checkpoint_model_transition={**facts, "destination_model": "another"}) + _replace_checkpoint_codex_session(runtime, lineage=binding["lineage"], expected=current, + session_id="fresh-context", refresh={"checkpoint_sha256": "e" * 64}) + refreshed = load_codex_cli_session(runtime, lineage=binding["lineage"], session_scope="agent") + assert refreshed["checkpoint_model_transition"] == facts + _store_codex_cli_session(runtime, **{**binding, "session_id": "unrelated"}) + assert "checkpoint_model_transition" not in load_codex_cli_session( + runtime, lineage=binding["lineage"], session_scope="agent") diff --git a/benchmark/tests/test_native_checkpoint.py b/benchmark/tests/test_native_checkpoint.py index cf378f3532..b93d3ca24e 100644 --- a/benchmark/tests/test_native_checkpoint.py +++ b/benchmark/tests/test_native_checkpoint.py @@ -291,7 +291,8 @@ def test_real_native_workspace_readback_rejects_foreign_code_basis(tmp_path): @pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="Requires an explicit real native Host binary; only a local static provider is used") @pytest.mark.parametrize("case", ["resume", "scope_change", "profile_change", "joint", "binding_reply_loss", - "fork_reply_loss", "fork_missing", "fork_ambiguous", "warm_preparation"]) + "fork_reply_loss", "fork_missing", "fork_ambiguous", "warm_preparation", + "model_transition"]) def test_real_fork_admission_and_native_resume_use_retained_state(tmp_path, capsys, monkeypatch, case, request): """Real Host/CLI/Goal/session/artifact owners; deterministic synthetic work. @@ -417,7 +418,7 @@ def response(request, index, message): binary = os.environ["LOOPX_NATIVE_CHECKPOINT_BIN"] # The warm receiver uses the benchmark's execution profile inside its # isolated worker. Other cases retain their original sandbox contract. - sandbox = ("danger-full-access" if case == "warm_preparation" else + sandbox = ("danger-full-access" if case in {"warm_preparation", "model_transition"} else "workspace-write" if case == "joint" else "read-only") execution = Execution(context="resume", sandbox=sandbox) with real_native_source(tmp_path / "native", binary, workspace, close_source=True, @@ -516,6 +517,14 @@ def retained_source(): env.update(LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_GOAL_ID=destination_goal, LOOPX_AGENT_ID=destination_agent, MODEL_NAME="fixture", REASONING_EFFORT="high") + admission_options, model_config_args = {}, [] + if case == "model_transition": + target_config = tmp_path / "destination-model.toml" + target_config.write_text(config.read_text().replace('model="fixture"', 'model="fixture-next"') + .replace('model_reasoning_effort="high"', 'model_reasoning_effort="max"')) + env.update(MODEL_NAME="fixture-next", REASONING_EFFORT="max") + admission_options = {"destination_config": target_config} + model_config_args = ["--destination-model-config", str(target_config)] lifecycle_registry = tmp_path / "lifecycle-only" / "registry.json" assert main(["--format", "json", "--registry", str(lifecycle_registry), "--runtime-root", str(tmp_path / "lifecycle-only/runtime"), "project", "register", @@ -619,7 +628,8 @@ def lose_binding_reply(self, *args): "agent_id": destination_agent}, session_scope="agent")["checkpoint_artifact_sha256"] == digest assert len(calls) == 1 admitted_cli = subprocess.run([sys.executable, "-m", "loopx.hosts.codex_checkpoint", - "--root", str(unit), "--expected-sha256", digest, "--config-path", str(config), "--sandbox", execution.sandbox], + "--root", str(unit), "--expected-sha256", digest, "--config-path", str(config), + "--sandbox", execution.sandbox, *model_config_args], env=env, text=True, capture_output=True, timeout=40) assert admitted_cli.returncode == 0, admitted_cli.stderr admitted = json.loads(admitted_cli.stdout) @@ -636,7 +646,8 @@ def lose_binding_reply(self, *args): def forbidden_fork(*args, **kwargs): raise AssertionError("a repeated admission must not create a second native fork") replay.setattr("loopx.hosts.codex_checkpoint.fork_native_checkpoint", forbidden_fork) - recovered = admit_native_checkpoint_session(env, unit, digest, config, sandbox=(execution).sandbox, timeout=30) + recovered = admit_native_checkpoint_session(env, unit, digest, config, + sandbox=(execution).sandbox, timeout=30, **admission_options) assert recovered == admitted assert bound["checkpoint_artifact_sha256"] == digest # Transport reply loss leaves the atomic source-bound binding as the @@ -644,9 +655,9 @@ def forbidden_fork(*args, **kwargs): retry_cli = subprocess.run(admitted_cli.args, env=env, text=True, capture_output=True, timeout=40) assert retry_cli.returncode == 0, retry_cli.stderr assert json.loads(retry_cli.stdout) == admitted - with pytest.raises(ValueError, match="profile changed"): + with pytest.raises(ValueError, match="profile changed|destination model differs"): admit_native_checkpoint_session({**env, "REASONING_EFFORT": "low"}, unit, digest, - config, sandbox=(execution).sandbox, timeout=30) + config, sandbox=(execution).sandbox, timeout=30, **admission_options) assert len(calls) == 1 and load_codex_cli_session(runtime, lineage={ "goal_id": destination_goal, "agent_id": destination_agent}, session_scope="agent") == bound if case == "resume": @@ -788,6 +799,9 @@ def forbidden_fork(*args, **kwargs): (logs / "stdout.jsonl").write_text(result.stdout) wake.observe(logs / "stdout.jsonl") assert wake.session_id == admitted["session_id"] and len(calls) == 3 + if case == "model_transition": + assert requests[1]["model"] == "fixture-next" + assert requests[1]["reasoning"]["effort"] == "max" retained_message = ("previous Host deadline has passed" if case == "warm_preparation" else "Synthetic source result.") assert retained_message in json.dumps(requests[1]) @@ -842,7 +856,8 @@ def forbidden_fork(*args, **kwargs): "agent_id": destination_agent}, session_scope="agent") assert after_work["checkpoint_artifact_sha256"] == digest with pytest.raises(ValueError, match="advanced or differs"): - admit_native_checkpoint_session(env, unit, digest, config, sandbox=(execution).sandbox, timeout=30) + admit_native_checkpoint_session(env, unit, digest, config, + sandbox=(execution).sandbox, timeout=30, **admission_options) assert load_codex_cli_session(runtime, lineage={"goal_id": destination_goal, "agent_id": destination_agent}, session_scope="agent") == after_work if case != "warm_preparation": diff --git a/benchmark/tests/test_sforge_warm_start.py b/benchmark/tests/test_sforge_warm_start.py index 5b621870cb..6300e4cde5 100644 --- a/benchmark/tests/test_sforge_warm_start.py +++ b/benchmark/tests/test_sforge_warm_start.py @@ -74,7 +74,7 @@ def point(tmp_path): (workspace / "candidate").write_text("retained candidate") (workspace / "candidate").chmod(0o600) config = tmp_path / "config.toml" - config.write_text("[features]\ngoals = false\n") + config.write_text('model="original"\nmodel_reasoning_effort="high"\n[features]\ngoals = false\n') context = tmp_path / "context.json" context.write_text(json.dumps({"snapshot": {"todos": [{"todo_id": "source-todo"}]}})) components = [{"id": "workspace", "source_path": str(workspace)}, @@ -174,8 +174,14 @@ def test_restore_replaces_image_files_preserves_modes_and_refuses_second_attempt assert workspace.with_name("workspace.before-checkpoint").is_dir() -def test_staging_preserves_sealed_modes(point, monkeypatch, tmp_path): +@pytest.mark.parametrize("model_transition", [False, True]) +def test_staging_preserves_sealed_modes(point, monkeypatch, tmp_path, model_transition): start, workspace = point + if model_transition: + destination = tmp_path / "destination.toml" + destination.write_text(start.config.read_text().replace('"original"', '"next"') + .replace('"high"', '"max"')) + start = replace(start, destination_config=destination) control = tmp_path / "control" control.mkdir() monkeypatch.setattr(warm, "_CONTROL", str(control)) @@ -194,6 +200,12 @@ async def exec(self, command, **kwargs): "expected_sha256": start.sha256}) declaration = json.loads((control / "warm-start.json").read_bytes()) assert declaration["deadline_epoch"] == start.deadline_epoch + if model_transition: + assert (control / "restored-model-config.toml").read_bytes() == destination.read_bytes() + assert declaration["destination_config"] == str(control / "restored-model-config.toml") + assert (control / "restored-config.toml").read_bytes() == start.config.read_bytes() + else: + assert "destination_config" not in declaration def test_normal_cadence_requires_accepted_current_replan_and_is_applied_once(tmp_path, monkeypatch): diff --git a/loopx/control_plane/turn_driver/codex_agent_session.py b/loopx/control_plane/turn_driver/codex_agent_session.py index 07680bf078..a3bbd6fa87 100644 --- a/loopx/control_plane/turn_driver/codex_agent_session.py +++ b/loopx/control_plane/turn_driver/codex_agent_session.py @@ -178,8 +178,9 @@ def prepare_checkpoint_refresh(self, review_basis: dict | None, attempt: Path, s self.session_id = None self.receipt["session"].update(action="start_new", session_id=None) - def _bind_verified_fork(self, session_id: str, source_profile_digest: str, - checkpoint_artifact_sha256: str, checkpoint_artifact_root: str) -> None: + def _bind_verified_fork(self, session_id: str, verified_profile_digest: str, + checkpoint_artifact_sha256: str, checkpoint_artifact_root: str, + *, model_transition: dict[str, str] | None = None) -> None: """Bind a controller-verified native fork through the existing session owner. This admits the conversation binding only. It does not claim work, @@ -188,7 +189,7 @@ def _bind_verified_fork(self, session_id: str, source_profile_digest: str, """ if self.session_id is not None: raise ValueError("checkpoint session adoption requires an absent destination binding") - require_codex_session_profile({"session_profile_digest": source_profile_digest}, self.digest) + require_codex_session_profile({"session_profile_digest": verified_profile_digest}, self.digest) self.admission.accept_result( lambda: _store_codex_cli_session( self.root, @@ -200,6 +201,7 @@ def _bind_verified_fork(self, session_id: str, source_profile_digest: str, require_absent=True, checkpoint_artifact_sha256=checkpoint_artifact_sha256, checkpoint_artifact_root=checkpoint_artifact_root, + **({"checkpoint_model_transition": model_transition} if model_transition is not None else {}), ) ) self.session_id = session_id diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index e9cbcbc689..725b4d9526 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -198,6 +198,7 @@ def _store_codex_cli_session( require_absent: bool = False, checkpoint_artifact_sha256: str | None = None, checkpoint_artifact_root: str | None = None, + checkpoint_model_transition: Mapping[str, str] | None = None, ) -> None: if checkpoint_artifact_sha256 is not None and not BARE_SHA256_PATTERN.fullmatch(checkpoint_artifact_sha256): raise ValueError("checkpoint session source digest is invalid") @@ -215,12 +216,14 @@ def _store_codex_cli_session( retained_source = None retained_root = None retained_refresh = None + retained_transition = None if prior and (prior.get("session_id"), prior.get("goal_ref"), prior.get("session_profile_digest")) == ( session_id, dict(goal_ref) if goal_ref is not None else None, session_profile_digest ): retained_source = prior.get("checkpoint_artifact_sha256") retained_root = prior.get("checkpoint_artifact_root") retained_refresh = prior.get("checkpoint_context_refresh") + retained_transition = prior.get("checkpoint_model_transition") if retained_source is not None: if not isinstance(retained_source, str) or not BARE_SHA256_PATTERN.fullmatch(retained_source): raise ValueError("stored checkpoint session source digest is invalid") @@ -231,6 +234,9 @@ def _store_codex_cli_session( raise ValueError("stored checkpoint session source root is invalid") if retained_root is not None and checkpoint_artifact_root not in (None, retained_root): raise ValueError("checkpoint session source root cannot change") + if retained_transition is not None and checkpoint_model_transition not in (None, retained_transition): + raise ValueError("checkpoint model transition cannot change") + transition = checkpoint_model_transition if checkpoint_model_transition is not None else retained_transition _write_codex_cli_session( runtime_root, lineage=lineage, @@ -245,6 +251,7 @@ def _store_codex_cli_session( checkpoint_artifact_sha256=checkpoint_artifact_sha256 or retained_source, checkpoint_artifact_root=checkpoint_artifact_root or retained_root, checkpoint_context_refresh=retained_refresh, + checkpoint_model_transition=transition, ) @@ -270,7 +277,8 @@ def _replace_checkpoint_codex_session( session_profile_digest=current["session_profile_digest"], checkpoint_artifact_sha256=current["checkpoint_artifact_sha256"], checkpoint_artifact_root=current["checkpoint_artifact_root"], - checkpoint_context_refresh={**refresh, "session_id": session_id}) + checkpoint_context_refresh={**refresh, "session_id": session_id}, + checkpoint_model_transition=current.get("checkpoint_model_transition")) def _write_codex_cli_session( @@ -288,8 +296,14 @@ def _write_codex_cli_session( checkpoint_artifact_sha256: str | None = None, checkpoint_artifact_root: str | None = None, checkpoint_context_refresh: dict[str, Any] | None = None, + checkpoint_model_transition: Mapping[str, str] | None = None, ) -> None: schema = _host_session_schema(host) + if checkpoint_model_transition is not None: + validate_checkpoint_model_transition(checkpoint_model_transition) + if (not checkpoint_artifact_sha256 or + checkpoint_model_transition["destination_profile_digest"] != session_profile_digest): + raise ValueError("checkpoint model transition requires its source-bound destination profile") normalized_session_id = _valid_session_id(session_id) if not normalized_session_id: raise ValueError("Codex CLI returned an invalid session id") @@ -323,6 +337,8 @@ def _write_codex_cli_session( payload["checkpoint_artifact_root"] = checkpoint_artifact_root if checkpoint_context_refresh is not None: payload["checkpoint_context_refresh"] = checkpoint_context_refresh + if checkpoint_model_transition is not None: + payload["checkpoint_model_transition"] = dict(checkpoint_model_transition) if operation_profile_digest is not None: payload["operation_transport"] = ( "dsh-native-operation-tools-v0" if host == "dsh" @@ -410,6 +426,17 @@ def read() -> dict[str, Any] | None: return dict(value) +def validate_checkpoint_model_transition(value: Mapping[str, str]) -> None: + """Validate the source/destination facts retained by checkpoint admission.""" + digests = ("source_config_sha256", "destination_config_sha256", + "source_profile_digest", "destination_profile_digest") + settings = ("source_model", "source_effort", "destination_model", "destination_effort") + if (not isinstance(value, Mapping) or set(value) != {*digests, *settings} or + any(not isinstance(value[k], str) or not BARE_SHA256_PATTERN.fullmatch(value[k]) for k in digests) or + any(not isinstance(value[k], str) or not value[k].strip() for k in settings)): + raise ValueError("checkpoint model transition facts are invalid") + + def codex_session_profile_digest( *, project: Path, @@ -419,8 +446,13 @@ def codex_session_profile_digest( reasoning_effort: str | None = None, sandbox: str | None = None, mcp_server: Mapping[str, Any] | None = None, + config_bytes: bytes | None = None, ) -> str: - """Bind a shared conversation to its workspace, provider home and settings.""" + """Bind a conversation to its workspace, provider home and settings. + + ``config_bytes`` compares inert captured settings at their qualified guest + paths. It does not install them or admit a changed active-session profile. + """ config = home / "config.toml" profile = { "workspace": str(project.resolve()), @@ -431,6 +463,7 @@ def codex_session_profile_digest( "sandbox": sandbox, "mcp_server": mcp_server, "config_digest": hashlib.sha256( + config_bytes if config_bytes is not None else config.read_bytes() if config.exists() else b"" ).hexdigest(), } diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 37fba19ddc..40ad7a9131 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -276,8 +276,42 @@ def _native_thread_ids(home: Path) -> set[str]: return set(ids) +def checkpoint_destination_config(config_path: Path, source_sha256: str, + destination_config: Path | None = None) -> tuple[bytes, dict | None]: + """Verify sealed source settings and an explicit model/effort-only change. + + A destination is a new isolated receiver, never permission to change an + active session. Provider, tools, sandbox and all other configuration stay + semantically identical. Source bytes remain part of the sealed evidence. + """ + source = config_path.read_bytes() + if hashlib.sha256(source).hexdigest() != source_sha256: + raise ValueError("native checkpoint effective configuration differs") + if destination_config is None: + return source, None + if (not destination_config.is_absolute() or + destination_config.resolve(strict=True) != destination_config): + raise ValueError("checkpoint model configuration requires a physical absolute path") + target = destination_config.read_bytes() + original, changed = tomllib.loads(source.decode("utf-8")), tomllib.loads(target.decode("utf-8")) + keys = ("model", "model_reasoning_effort") + if any(not isinstance(config.get(key), str) or not config[key].strip() + for config in (original, changed) for key in keys): + raise ValueError("checkpoint model transition requires explicit model and reasoning effort") + if ({k: v for k, v in original.items() if k not in keys} != + {k: v for k, v in changed.items() if k not in keys}): + raise ValueError("checkpoint configuration transition may change only model and reasoning effort") + return target, { + "source_config_sha256": source_sha256, + "destination_config_sha256": hashlib.sha256(target).hexdigest(), + "source_model": original["model"], "source_effort": original["model_reasoning_effort"], + "destination_model": changed["model"], "destination_effort": changed["model_reasoning_effort"], + } + + def fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, timeout: float) -> dict[str, Any]: + config_path: Path, *, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: # The existing process lock serializes reconstruction in this isolated home. # The private attempt below describes one opaque native RPC, not authority # to admit a Goal, a work Turn, or a second fork after an uncertain response. @@ -293,11 +327,13 @@ def fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("native checkpoint reconstruction window closed") - return _fork_native_checkpoint(env, unit, expected_sha256, config_path, timeout=remaining) + return _fork_native_checkpoint(env, unit, expected_sha256, config_path, timeout=remaining, + **({"destination_config": destination_config} if destination_config is not None else {})) def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, timeout: float) -> dict[str, Any]: + config_path: Path, *, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: """Reconstruct one native branch in a new, fixed-path isolated environment. No model starts and no LoopX binding is registered. Existing session/Goal @@ -332,9 +368,8 @@ def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: st recovering = home.exists() if recovering and (not attempt_path.is_file() or attempt_path.resolve() != attempt_path): raise ValueError("native checkpoint destination home must be new or retain its original fork attempt") - config = config_path.read_bytes() - if hashlib.sha256(config).hexdigest() != native["config_sha256"]: - raise ValueError("native checkpoint effective configuration differs") + config, transition = checkpoint_destination_config( + config_path, native["config_sha256"], destination_config) version = subprocess.check_output([env["CODEX_BIN"], "--version"], env=env, text=True, encoding="utf-8", timeout=max(.1, deadline - time.monotonic())).strip() if version != native["host_version"]: @@ -368,6 +403,8 @@ def _fork_native_checkpoint(env: dict[str, str], unit: Path, expected_sha256: st **cursor_basis, "config_sha256": native["config_sha256"], "destination_goal_id": env.get("LOOPX_GOAL_ID"), "destination_agent_id": env.get("LOOPX_AGENT_ID"), "guest_workspace": env["LOOPX_PROJECT"], "guest_home": str(home)} + if transition is not None: + basis["model_transition"] = transition attempt = None if recovering: attempt = json.loads(attempt_path.read_bytes()) @@ -443,11 +480,13 @@ def request(method, params): raise ValueError("native checkpoint reconstructed history differs") return {"session_id": session, **cursor_basis, "visible_history_sha256": native["visible_history_sha256"], "history_verified": True, + **({"model_transition": transition} if transition is not None else {}), "model_calls": 0, "execution_authority_granted": False, "continuation_qualified": False} def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sha256: str, - config_path: Path, *, sandbox: str, timeout: float) -> dict[str, Any]: + config_path: Path, *, sandbox: str, timeout: float, + destination_config: Path | None = None) -> dict[str, Any]: """Explicit controller admission of a verified fork into a destination Goal. Existing native fork, Goal lifetime and session owners retain their rules. @@ -461,7 +500,9 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh from loopx.control_plane.projects.registry_codec import ( project_registry_transaction, ) - from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session + from loopx.control_plane.turn_driver.codex_sessions import ( + codex_session_profile_digest, select_codex_cli_session, + ) from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession if not math.isfinite(timeout) or timeout <= 0: @@ -478,8 +519,27 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh raise ValueError("checkpoint session admission requires its captured source session profile") if tomllib.loads(config_path.read_text(encoding="utf-8")).get("features", {}).get("goals") is not False: raise ValueError("checkpoint session admission requires native automatic Goals explicitly disabled") - if hashlib.sha256(config_path.read_bytes()).hexdigest() != metadata["native_session"]["config_sha256"]: - raise ValueError("native checkpoint effective configuration differs") + config, transition = checkpoint_destination_config( + config_path, metadata["native_session"]["config_sha256"], destination_config) + target_profile = source_profile + if transition is not None: + if (env["MODEL_NAME"] != transition["destination_model"] or + env["REASONING_EFFORT"] != transition["destination_effort"]): + raise ValueError("checkpoint destination model differs from the current launch") + binary = shutil.which(env["CODEX_BIN"]) + if binary is None: + raise ValueError("Codex CLI executable is unavailable") + profile = dict(project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, + home=Path(env["CODEX_HOME"]), sandbox=sandbox) + observed_source = codex_session_profile_digest(**profile, + model=transition["source_model"], reasoning_effort=transition["source_effort"], + config_bytes=config_path.read_bytes()) + if observed_source != source_profile: + raise ValueError("checkpoint source session profile changed") + target_profile = codex_session_profile_digest(**profile, + model=env["MODEL_NAME"], reasoning_effort=env["REASONING_EFFORT"], config_bytes=config) + transition = {**transition, "source_profile_digest": source_profile, + "destination_profile_digest": target_profile} source = metadata.get("loopx_state", {}) lineage = {"goal_id": env["LOOPX_GOAL_ID"], "agent_id": env["LOOPX_AGENT_ID"]} if (not source.get("goal_id") or not source.get("agent_id") or @@ -510,8 +570,10 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh raise ValueError("destination session binding already exists without this checkpoint admission") if binding.get("checkpoint_artifact_root") not in (None, str(unit)): raise ValueError("destination checkpoint source root differs from the original admission") - if wake.digest != source_profile: + if wake.digest != target_profile: raise ValueError("checkpoint source session profile changed") + if transition is not None and binding.get("checkpoint_model_transition") != transition: + raise ValueError("checkpoint model transition differs from its original admission") native = metadata["native_session"] observed = native_session_checkpoint(env, binding["session_id"], timeout=max(.1, deadline - time.monotonic()), @@ -519,15 +581,18 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh cursor_key = "interrupted_turn_id" if native.get("interrupted_guard") is not None else "completed_turn_id" keys = (cursor_key, "turn_ids", "visible_history_sha256", "host_version", "guest_home", "guest_workspace", "config_sha256") - if binding["session_id"] == native["session_id"] or any(observed[key] != native[key] for key in keys): + expected = {**native, "config_sha256": hashlib.sha256(config).hexdigest()} + if binding["session_id"] == native["session_id"] or any(observed[key] != expected[key] for key in keys): raise ValueError("checkpoint admitted native session advanced or differs; use its existing continuation") return {"session_id": binding["session_id"], cursor_key: observed[cursor_key], "visible_history_sha256": observed["visible_history_sha256"], "history_verified": True, "model_calls": 0, "execution_authority_granted": False, "continuation_qualified": False, "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], - "session_binding_admitted": True, "session_profile_digest": wake.digest} + "session_binding_admitted": True, "session_profile_digest": wake.digest, + **({"model_transition": transition} if transition is not None else {})} result = fork_native_checkpoint(env, unit, expected_sha256, config_path, - timeout=max(.1, deadline - time.monotonic())) + timeout=max(.1, deadline - time.monotonic()), + **({"destination_config": destination_config} if destination_config is not None else {})) receipt: dict[str, Any] = {} wake = CodexAgentSession(env, context="resume", sandbox=sandbox, receipt=receipt) # Hold the existing registry owner's fence through the session write. This @@ -537,8 +602,13 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh require_registered_agent_id(registry_path=registry, goal_id=lineage["goal_id"], agent_id=lineage["agent_id"]) if current != goal: raise ValueError("destination Goal configuration changed during native checkpoint reconstruction") - wake._bind_verified_fork(result["session_id"], source_profile, expected_sha256, str(unit)) - return {**result, "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], + # The source profile was independently checked against the sealed + # settings above. Bind the explicitly verified destination profile; + # ordinary session resumes still require an exact profile match. + wake._bind_verified_fork(result["session_id"], target_profile, expected_sha256, str(unit), + **({"model_transition": transition} if transition is not None else {})) + return {**result, **({"model_transition": transition} if transition is not None else {}), + "destination_goal_ref": wake.goal_ref, "destination_agent_id": lineage["agent_id"], "session_binding_admitted": True, "session_profile_digest": wake.digest} @@ -551,6 +621,8 @@ def main(argv=None) -> None: parser.add_argument("--root", type=Path, required=True) parser.add_argument("--expected-sha256", required=True) parser.add_argument("--config-path", type=Path, required=True) + parser.add_argument("--destination-model-config", type=Path, + help="Explicit new-receiver config changing only model and reasoning effort") parser.add_argument("--sandbox", choices=CODEX_CLI_SANDBOXES, required=True) parser.add_argument("--timeout-seconds", type=float, default=30) args = parser.parse_args(argv) @@ -560,7 +632,9 @@ def main(argv=None) -> None: if missing: parser.error("Explicit destination controller environment is missing: " + ", ".join(missing)) result = admit_native_checkpoint_session(dict(os.environ), args.root, args.expected_sha256, - args.config_path, sandbox=args.sandbox, timeout=args.timeout_seconds) + args.config_path, sandbox=args.sandbox, timeout=args.timeout_seconds, + **({"destination_config": args.destination_model_config} + if args.destination_model_config is not None else {})) print(json.dumps(result)) diff --git a/loopx/hosts/research_checkpoint.py b/loopx/hosts/research_checkpoint.py index 62e3332eea..1d84adba1c 100644 --- a/loopx/hosts/research_checkpoint.py +++ b/loopx/hosts/research_checkpoint.py @@ -135,21 +135,33 @@ def _handoff(env, payload): -def _context_session(env, metadata, timeout=30): +def _context_session(env, metadata, timeout=30, *, checkpoint_sha256=None): """Read the admitted native branch before publishing captured Todo context.""" from loopx.control_plane.turn_driver.codex_agent_session import CodexAgentSession from loopx.hosts.codex_checkpoint import native_session_checkpoint wake = CodexAgentSession(env, context="resume", sandbox=env["LOOPX_CODEX_SANDBOX"], receipt={}) native = metadata["native_session"] + expected_profile = metadata.get("session", {}).get("session_profile_digest") + expected_config = native["config_sha256"] + transition = (wake.binding or {}).get("checkpoint_model_transition") + if transition is not None: + from loopx.control_plane.turn_driver.codex_sessions import validate_checkpoint_model_transition + validate_checkpoint_model_transition(transition) + if (not checkpoint_sha256 or wake.binding.get("checkpoint_artifact_sha256") != checkpoint_sha256 or + transition["source_profile_digest"] != expected_profile or + transition["source_config_sha256"] != expected_config): + raise ValueError("context adoption model transition does not match the captured source") + expected_profile = transition["destination_profile_digest"] + expected_config = transition["destination_config_sha256"] if (not wake.session_id or wake.session_id == native["session_id"] or - wake.digest != metadata.get("session", {}).get("session_profile_digest")): + wake.digest != expected_profile): raise ValueError("context adoption requires the admitted destination session profile") observed = native_session_checkpoint(env, wake.session_id, timeout=timeout, interrupted_guard=native.get("interrupted_guard"))["metadata"] cursor_key = "interrupted_turn_id" if native.get("interrupted_guard") is not None else "completed_turn_id" for key in ("visible_history_sha256", "turn_ids", cursor_key, "host_version", "config_sha256"): - if observed[key] != native[key]: + if observed[key] != (expected_config if key == "config_sha256" else native[key]): raise ValueError("destination native history or configuration moved beyond the captured context") return wake.session_id @@ -189,7 +201,7 @@ def verify_artifact(): read_timeout() # A late successful read cannot authorize the next write. def read_session(): - session = _context_session(env, metadata, read_timeout()) + session = _context_session(env, metadata, read_timeout(), checkpoint_sha256=digest) read_timeout() return session From b0935227d3d3e500220f3ce05369054585e2a6d7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:46:52 +0800 Subject: [PATCH 10/10] fix(checkpoint): type explicit model transition at existing boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../control_plane/turn_driver/codex_agent_session.py | 2 +- loopx/hosts/codex_checkpoint.py | 12 +++++++----- loopx/hosts/research_checkpoint.py | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/turn_driver/codex_agent_session.py b/loopx/control_plane/turn_driver/codex_agent_session.py index a3bbd6fa87..a88091595c 100644 --- a/loopx/control_plane/turn_driver/codex_agent_session.py +++ b/loopx/control_plane/turn_driver/codex_agent_session.py @@ -201,7 +201,7 @@ def _bind_verified_fork(self, session_id: str, verified_profile_digest: str, require_absent=True, checkpoint_artifact_sha256=checkpoint_artifact_sha256, checkpoint_artifact_root=checkpoint_artifact_root, - **({"checkpoint_model_transition": model_transition} if model_transition is not None else {}), + checkpoint_model_transition=model_transition, ) ) self.session_id = session_id diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 40ad7a9131..5a6587aa8d 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -277,7 +277,7 @@ def _native_thread_ids(home: Path) -> set[str]: def checkpoint_destination_config(config_path: Path, source_sha256: str, - destination_config: Path | None = None) -> tuple[bytes, dict | None]: + destination_config: Path | None = None) -> tuple[bytes, dict[str, str] | None]: """Verify sealed source settings and an explicit model/effort-only change. A destination is a new isolated receiver, never permission to change an @@ -529,14 +529,16 @@ def admit_native_checkpoint_session(env: dict[str, str], unit: Path, expected_sh binary = shutil.which(env["CODEX_BIN"]) if binary is None: raise ValueError("Codex CLI executable is unavailable") - profile = dict(project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, - home=Path(env["CODEX_HOME"]), sandbox=sandbox) - observed_source = codex_session_profile_digest(**profile, + observed_source = codex_session_profile_digest( + project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, + home=Path(env["CODEX_HOME"]), sandbox=sandbox, model=transition["source_model"], reasoning_effort=transition["source_effort"], config_bytes=config_path.read_bytes()) if observed_source != source_profile: raise ValueError("checkpoint source session profile changed") - target_profile = codex_session_profile_digest(**profile, + target_profile = codex_session_profile_digest( + project=Path(env["LOOPX_PROJECT"]), codex_bin=binary, + home=Path(env["CODEX_HOME"]), sandbox=sandbox, model=env["MODEL_NAME"], reasoning_effort=env["REASONING_EFFORT"], config_bytes=config) transition = {**transition, "source_profile_digest": source_profile, "destination_profile_digest": target_profile} diff --git a/loopx/hosts/research_checkpoint.py b/loopx/hosts/research_checkpoint.py index 1d84adba1c..f45d6f84a5 100644 --- a/loopx/hosts/research_checkpoint.py +++ b/loopx/hosts/research_checkpoint.py @@ -144,11 +144,12 @@ def _context_session(env, metadata, timeout=30, *, checkpoint_sha256=None): native = metadata["native_session"] expected_profile = metadata.get("session", {}).get("session_profile_digest") expected_config = native["config_sha256"] - transition = (wake.binding or {}).get("checkpoint_model_transition") + binding = wake.binding or {} + transition = binding.get("checkpoint_model_transition") if transition is not None: from loopx.control_plane.turn_driver.codex_sessions import validate_checkpoint_model_transition validate_checkpoint_model_transition(transition) - if (not checkpoint_sha256 or wake.binding.get("checkpoint_artifact_sha256") != checkpoint_sha256 or + if (not checkpoint_sha256 or binding.get("checkpoint_artifact_sha256") != checkpoint_sha256 or transition["source_profile_digest"] != expected_profile or transition["source_config_sha256"] != expected_config): raise ValueError("context adoption model transition does not match the captured source")