diff --git a/docs/reference/protocols/quota-replan-planning-selection-v0.md b/docs/reference/protocols/quota-replan-planning-selection-v0.md index 3e68341bd2..0595cce582 100644 --- a/docs/reference/protocols/quota-replan-planning-selection-v0.md +++ b/docs/reference/protocols/quota-replan-planning-selection-v0.md @@ -89,23 +89,37 @@ An enabled stride review can become stale between delivery and writeback. For a context-ID mismatch within the same obligation, the semantic writeback owner keeps `stride_review_basis_mismatch` and returns `host_action=read_current_replan_context` with -`replan_transition.context_recovery`: the received ID, complete current -`replan_context`, current `stride_control`, and ordered recovery guidance. -There is no automatic acceptance or reference replacement. Read the returned -Goal acceptance, evidence and followthrough, perform applicable source reads, -and reassess the decision before retrying the original writeback with its same +`replan_transition.context_recovery`. The JSON CLI saves the exact complete +write-time `replan_context`, `stride_control` and recovery guidance in a private, +immutable file. Its response carries received/current IDs, the file and digest, +and generic index/selection commands. It does not activate context delivery or +advance a native baseline. Native semantic-owner responses and non-POSIX CLI +recovery remain complete inline. +There is no automatic acceptance or reference replacement. Read the applicable +Goal, acceptance, constraints, stride contract and decision-relevant facts; +supporting evidence and history are available on demand. Whole-file traversal +is not a prerequisite. Reassess before retrying the original writeback with its same Goal/Agent/Turn/obligation and settlement identity. The rejection emits no unbound retry command; it commits no writeback or quota spend. Another relevant -change can reject the retry again. Other validation failures retain their +change can reject the retry again. If private publication fails, the compact +response identifies unavailable transport; repair it and retry the unchanged +original writeback for current recovery, preserving any earlier committed +refinement receipt. It neither substitutes an old basis nor repeats admission. +Other validation failures retain their existing repair route, and stride-off behavior is unchanged. 启用 stride 的评审可能在投递与写回之间过期。同一 obligation 内发生 context ID 不匹配时,语义写回 owner 保留 `stride_review_basis_mismatch`,给出 -`host_action=read_current_replan_context`,并返回 `replan_transition.context_recovery`:收到的 ID、完整的当前 `replan_context`、 -当前 `stride_control` 与按序恢复指引。不会自动接受提案或替换引用。消费者先读 -返回的 Goal 验收、证据和后续执行事实,完成适用的来源读取并重新判断,再使用 +`host_action=read_current_replan_context`。JSON CLI 将完整的写入时 `replan_context`、 +`stride_control` 与恢复指引保存在私有不可变文件中;`replan_transition.context_recovery` +返回收到/当前 ID、文件路径与摘要,以及通用索引/选读命令。不会启用差分交付、 +推进原生基线;语义 owner 与非 POSIX CLI 仍返回完整内联响应。不会自动接受提案或替换引用。消费者先读 +适用的 Goal、验收、约束、stride 合同和决策必要事实,按需读取证据与历史,不要求 +遍历整份文件;重新判断后,再使用 原 Goal/Agent/Turn/obligation 和结算身份重试原写回。拒绝响应不再给出丢失身份的 -通用重试命令,不写入进展或扣额;相关事实再次变化仍可再次拒绝。其他校验失败 +通用重试命令,不写入进展或扣额;相关事实再次变化仍可再次拒绝。私有落盘失败时, +紧凑响应说明传输不可用;修复后重试原写回以取得当前恢复文件,保留先前已提交的 +refinement 回执,不替换成旧依据或重复准入。其他校验失败 保留原恢复路径,stride 关闭时的行为不变。 This does not classify another agent's outcome as irrelevant. Shared acceptance @@ -116,6 +130,13 @@ The File/SQLite CLI test covers a source change, readback of the changed facts, reassessment, accepted writeback and an idempotent single spend; it does not qualify installed-host adoption or the complete unified-contract migration. +Autonomous-replan settlement guidance uses `--delivery-boundary semantic_closeout`, +including Todo-bound reviews whose work remains open. Ordinary in-flight work +retains its existing boundary; the projection does not grant completion or spend. + +自主重规划的结算指引使用 `--delivery-boundary semantic_closeout`,包括工作仍未完成的 +Todo 绑定评审。普通未完成工作保留原边界;投影不授予完成或扣额权限。 + 这不把其他 Agent 的 outcome 自动判为无关。共享验收和要求状态的变化仍属于评审 依据;只有明确的依赖范围契约通过验收,才能缩小失效范围。同 Turn 恢复成功不 证明模型 token 减少或效果改善。File/SQLite CLI 测试覆盖来源变化、新事实读回、 diff --git a/loopx/cli_commands/context_machine.py b/loopx/cli_commands/context_machine.py index 897aa34dae..50d9417f81 100644 --- a/loopx/cli_commands/context_machine.py +++ b/loopx/cli_commands/context_machine.py @@ -9,6 +9,8 @@ import os from pathlib import Path import stat +import tempfile +from typing import Any from ..control_plane.turn_driver.codex_context import context_delivery_for_arguments @@ -40,6 +42,31 @@ def _private_transport_root(runtime_root: Path) -> str: return str(root) +def capture_context_recovery(value: dict[str, Any], *, runtime_root: Path) -> dict[str, Any]: + """Publish exact write-time facts through the existing generic file reader. + + This does not present a delta, advance a native baseline or rerun admission. + A failed publication leaves the rejected operation and any earlier effects + unchanged; retry that same operation after repairing its private transport. + """ + from ..control_plane.turn_driver.decision_capture import ( + capture_context_projection, context_capture_read_command, + ) + + try: + directory = Path(tempfile.mkdtemp(prefix="recovery-", dir=_private_transport_root(runtime_root))) + reference = capture_context_projection(value, directory=directory, filename="required-context.json") + path, digest = Path(reference["path"]), reference["sha256"] + return {"path": str(path), "sha256": digest, + "index_command": context_capture_read_command(path, digest, list_fields=True, list_depth=2), + "select_command": context_capture_read_command(path, digest, select=[""])} + except (OSError, ValueError) as error: + return {"available": False, "error_kind": type(error).__name__, + "instruction": "Repair the private context transport, then retry the original unchanged operation " + "to obtain its current recovery reference. Do not rerun admission, substitute an old capture, " + "or repeat an already committed effect."} + + def apply_context_machine_options( args: argparse.Namespace, *, registry_path: Path, runtime_root_arg: str | None, heartbeat: bool = False, diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 8ac1184fa3..5aaf673599 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -11,6 +11,7 @@ import argparse import json +import os from collections.abc import Callable, Mapping, Sequence from pathlib import Path @@ -663,6 +664,17 @@ def handle_refresh_state_command( agent_id=args.agent_id, runtime_root=args.runtime_root, ) + recovery = transition.get("context_recovery") + if fmt == "json" and os.name == "posix" and isinstance(recovery, dict): + from .context_machine import capture_context_recovery + + reference = capture_context_recovery(recovery, + runtime_root=resolve_runtime_root(load_registry(registry_path), args.runtime_root)) + transition["context_recovery"] = { + "received_context_id": recovery["received_context_id"], + "current_context_id": recovery["replan_context"]["context_id"], + "instruction": recovery["instruction"], **reference, + } payload["replan_transition"] = transition if transition["next_cli_actions"]: payload["error"] += " Required transition: " + "; ".join( diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 6d7cbb2b3d..303d406e18 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -659,6 +659,7 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject } const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta); + const visionUsage = Object.values(fieldUsage).reduce((total, used) => total + used, 0); Object.assign(fieldUsage, pathDeltaUsage); const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, boundedPublicText); const allowedStridePaths = request.allow_terminal_stride_path @@ -675,11 +676,12 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject Object.assign(fieldUsage, fallbackUsage); const totalUsage = Object.values(fieldUsage).reduce((total, used) => total + used, 0); if (totalUsage > GOAL_VISION_TOTAL_LIMIT) { - throw visionBudgetError( - "total_agent_vision", - totalUsage, - GOAL_VISION_TOTAL_LIMIT, - ); + const sections = {vision_patch: visionUsage, path_delta: pathDeltaUsage, + stride_decision: strideUsage, fallback_declarations: fallbackUsage}; + const usage = Object.entries(sections).map(([name, fields]) => name + "=" + + (typeof fields === "number" ? fields : Object.values(fields).reduce((total, used) => total + used, 0))).join(", "); + throw new EffectRuntimeRequestError(`${GOAL_VISION_BUDGET_ERROR}: total_agent_vision uses ${totalUsage} chars; ` + + `limit is ${GOAL_VISION_TOTAL_LIMIT}; usage: ${usage}; shorten counted text`, GOAL_VISION_BUDGET_ERROR); } const todoDelta: string[] = []; diff --git a/loopx/control_plane/quota/settlement_plan.ts b/loopx/control_plane/quota/settlement_plan.ts index c66755d40c..4546803367 100644 --- a/loopx/control_plane/quota/settlement_plan.ts +++ b/loopx/control_plane/quota/settlement_plan.ts @@ -52,8 +52,8 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan throw new Error("settlement plan GoalRef is invalid or does not match identity"); } const inFlight = params.delivery_boundary === "in_flight_continuation"; - const autonomousReplan = params.autonomous_replan === undefined ? false - : requireBoolean(params.autonomous_replan, "autonomous_replan"); + const autonomousReplan = (params.autonomous_replan === undefined ? false + : requireBoolean(params.autonomous_replan, "autonomous_replan")) || identity.binding_kind === "autonomous_replan"; const validation: SettlementStep = { kind: "validation", owner: "agent", idempotency_key_ref: "$.identity.effect_id", expected_receipt: "validation_receipt", @@ -80,7 +80,8 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan : "validation succeeded; " + VISION_MATERIAL_CLOSEOUT_HINT + " Pass a new packet with --agent-vision-json.") + " Route elimination needs evidence; failure alone is not progress. " + - "outcome_progress may use in_flight_continuation; outcome_gap requires " + + (autonomousReplan ? "Autonomous replan uses --delivery-boundary semantic_closeout even while its Todo remains open; " + : "outcome_progress may use in_flight_continuation; ") + "outcome_gap requires " + "semantic_closeout plus blocked + blocker/evidence IDs and continuation checks. " + "For an admitted open continuous Monitor whose observation is unavailable, " + "use this original identity for typed blocked outcome_gap; it settles without " + diff --git a/loopx/control_plane/work_items/autonomous_replan_obligation.py b/loopx/control_plane/work_items/autonomous_replan_obligation.py index cd258a1141..a883d18bef 100644 --- a/loopx/control_plane/work_items/autonomous_replan_obligation.py +++ b/loopx/control_plane/work_items/autonomous_replan_obligation.py @@ -177,6 +177,7 @@ def build_autonomous_replan_cli_actions( f"{cli_prefix} --format json refresh-state --goal-id {goal_id} " "--progress-scope agent_lane " "--classification bounded_replan_progress " + "--delivery-boundary semantic_closeout " f"{delivery_args}{semantic_delta_args}" f"{settlement_args}{scoped_cli_args}" ) diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 7446c40266..96aef39ef1 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -542,8 +542,9 @@ export function projectReplanSemantics(value: unknown): JsonObject { received_context_id: strideDecision!.reference_context_id, replan_context: structuredClone(context), stride_control: strideControlContract(context, obligation.obligation_id), - instruction: "Read the complete returned replan_context and stride_control, including Goal acceptance, evidence, followthrough and applicable source reads. " + - "Reassess the decision against those facts before replacing its reference_context_id; copying the new id alone is not a review. " + + instruction: "The exact current review basis is available for progressive reading. Read the applicable Goal, acceptance, constraints, " + + "stride contract and decision-relevant facts; consult evidence and history when needed, without draining the whole file. " + + "Reassess against those facts before replacing reference_context_id; copying the new id alone is not a review. " + "Retry the original writeback with the same Goal, Agent, Turn and replan obligation and its original settlement identity. " + "This response grants no new execution or spend authority and commits no writeback. Do not start another Turn or blindly rerun admission. " + "If the basis changes again, the writer will reject again and return the then-current context.", diff --git a/tests/control_plane/test_context_machine_configuration.py b/tests/control_plane/test_context_machine_configuration.py index 1384f4164e..8b85cf0479 100644 --- a/tests/control_plane/test_context_machine_configuration.py +++ b/tests/control_plane/test_context_machine_configuration.py @@ -20,6 +20,42 @@ ) +@pytest.mark.parametrize("unsafe", [None, "symlink", "public"]) +@pytest.mark.skipif(os.name != "posix", reason="Private POSIX file transport; other CLI hosts retain inline recovery") +def test_rejected_context_snapshot_uses_private_generic_transport_without_native_activation(tmp_path, unsafe): + from loopx.cli_commands.context_machine import capture_context_recovery + from loopx.control_plane.turn_driver.decision_capture import read_context_capture_file + + runtime = tmp_path / "runtime" + runtime.mkdir() + root = runtime / "context-delivery" + if unsafe == "symlink": + target = tmp_path / "other-owner" + target.mkdir() + root.symlink_to(target, target_is_directory=True) + elif unsafe == "public": + root.mkdir(mode=0o755) + value = {"current": {"goal": "Retain scope", "stop": "No new authority"}, + "optional_evidence": "完整材料" * 20000} + result = capture_context_recovery(value, runtime_root=runtime) + if unsafe: + assert result["available"] is False and result["error_kind"] == "ValueError" + assert "path" not in result and "optional_evidence" not in result + assert (root.is_symlink() if unsafe == "symlink" else root.stat().st_mode & 0o077) + assert list((target if unsafe == "symlink" else root).iterdir()) == [] + else: + path, raw, saved = read_context_capture_file(result["path"], result["sha256"], private=True) + assert saved == value and len(raw) > 200000 + assert path.stat().st_mode & 0o077 == 0 + assert path.parent.stat().st_mode & 0o077 == 0 + assert "--list" in result["index_command"] and "--select" in result["select_command"] + # One immutable observation per rejection; publication cannot replace a + # different write-time basis or create/advance a native session baseline. + other = capture_context_recovery({**value, "current": {"goal": "Changed scope"}}, runtime_root=runtime) + assert other["path"] != result["path"] and other["sha256"] != result["sha256"] + assert read_context_capture_file(result["path"], result["sha256"], private=True)[2] == value + assert sorted(p.name for p in root.iterdir()) == sorted([path.parent.name, Path(other["path"]).parent.name]) + def configure(registry, runtime, root, mode): config = root / "context-setting.json" config.write_text(json.dumps({"schema_version": CONTEXT_DELIVERY_MACHINE_SCHEMA, "mode": mode})) diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index 47a322f4f2..4da3b96ae1 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -1,6 +1,7 @@ """Receipt-backed cadence over an open Todo, through the real CLI and runtime.""" import json +import os import shlex import subprocess import sys @@ -469,7 +470,20 @@ def call(*args: str, expected_code: int = 0) -> dict: assert transition["reason_code"] == "stride_review_basis_mismatch" assert transition["host_action"] == "read_current_replan_context" assert transition["next_cli_actions"] == [] # No unbound repeat of the rejected write. - recovery = transition["context_recovery"] + reference = transition["context_recovery"] + # A rejected write must not eagerly import historical acceptance/evidence + # into the model. Its exact write-time basis remains independently readable. + if os.name == "posix": + assert len(json.dumps(foreign, ensure_ascii=False).encode()) < 4096 + from loopx.control_plane.turn_driver.decision_capture import read_context_capture_file + saved, _, recovery = read_context_capture_file(reference["path"], reference["sha256"], private=True) + assert saved.stat().st_mode & 0o077 == 0 + selected = call("quota", "context-read", "--capture-file", reference["path"], + "--capture-sha256", reference["sha256"], "--select", "/replan_context/core_goal/objective") + assert selected["ok"] and selected["eof"] + assert json.loads(selected["content"])["selections"][0]["value"] == recovery["replan_context"]["core_goal"]["objective"] + else: + recovery = reference current = recovery["replan_context"] assert recovery["received_context_id"] == rejected_reference assert current["obligation_id"] == obligation["obligation_id"] diff --git a/tests/control_plane/test_vision_budget_cli.py b/tests/control_plane/test_vision_budget_cli.py index 05d70ff5aa..185af9870b 100644 --- a/tests/control_plane/test_vision_budget_cli.py +++ b/tests/control_plane/test_vision_budget_cli.py @@ -167,6 +167,7 @@ def test_full_budget_roundtrips_without_erasing_replan_or_partial_writes(tmp_pat dry_run=False, autonomous_replan_recorded=False) assert rejected.returncode == 1 assert "total_agent_vision uses 1801 chars; limit is 1800" in fixture.payload(rejected)["error"] + assert "usage: vision_patch=1120, path_delta=681, stride_decision=0, fallback_declarations=0" in fixture.payload(rejected)["error"] assert index.read_bytes() == before_index assert state.read_bytes() == before_state diff --git a/tests/control_plane_ts/quota_settlement_plan.test.ts b/tests/control_plane_ts/quota_settlement_plan.test.ts index 9e5b6358ab..63f5346f6f 100644 --- a/tests/control_plane_ts/quota_settlement_plan.test.ts +++ b/tests/control_plane_ts/quota_settlement_plan.test.ts @@ -92,8 +92,10 @@ test("Todo-bound semantic planning shares its exact autonomous authoring contrac assert.deepEqual(turnScopedCliSettlementPlan({...input, autonomous_replan: false}), ordinary); const replan = turnScopedCliSettlementPlan({...input, autonomous_replan: true}); assert.deepEqual(replan.steps[1].vision_authoring, visionAuthoringContract(true)); - assert.deepEqual({...replan, steps: replan.steps.map((step, i) => i === 1 - ? {...step, vision_authoring: ordinary.steps[1].vision_authoring} : step)}, ordinary); + assert.match(replan.steps[1].precondition, /Autonomous replan uses --delivery-boundary semantic_closeout/); + assert.doesNotMatch(replan.steps[1].precondition, /outcome_progress may use in_flight_continuation/); + assert.deepEqual(replan.identity, ordinary.identity); + for (const i of [0, 2, 3]) assert.deepEqual(replan.steps[i], ordinary.steps[i]); for (const autonomous_replan of [null, "true", 1]) { assert.throws(() => turnScopedCliSettlementPlan({...input, autonomous_replan}), /must be a boolean/); } @@ -107,6 +109,7 @@ test("autonomous replan never manufactures a Todo completion step", () => { assert.deepEqual(plan.steps.map(step => step.kind), ["validation", "durable_writeback", "quota_spend"]); assert.equal(plan.steps[0].command_template, undefined); assert.equal(plan.identity.binding_kind, "autonomous_replan"); + assert.match(plan.steps[1].precondition, /Autonomous replan uses --delivery-boundary semantic_closeout/); assert.ok(plan.steps.every(step => step.vision_authoring === undefined)); assert.match(plan.steps[1].precondition, /^validation succeeded/); assert.match(plan.steps[1].precondition, /Route elimination needs evidence/); diff --git a/tests/control_plane_ts/vision_checkpoint.test.ts b/tests/control_plane_ts/vision_checkpoint.test.ts index fd03e1e99d..a20e969cb5 100644 --- a/tests/control_plane_ts/vision_checkpoint.test.ts +++ b/tests/control_plane_ts/vision_checkpoint.test.ts @@ -193,7 +193,7 @@ test("structured replans have a bounded 1800-character budget including path evi }})), /path_delta.observed_reality uses 321 chars; limit is 320/); assert.throws(() => buildVisionCheckpoint(prepareRequest({agent_vision_packet: { ...packet, path_delta: {...packet.path_delta, changed: [character.repeat(35)]}, - }})), /total_agent_vision uses 1801 chars; limit is 1800/); + }})), /total_agent_vision uses 1801 chars; limit is 1800; usage: vision_patch=1120, path_delta=681, stride_decision=0, fallback_declarations=0/); } });