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 1/4] 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 2/4] 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 3/4] 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 4/4] 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