From c4347e3b2e8c49a460cc95549bdb00e99521633c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:22:37 +0800 Subject: [PATCH] fix(context): return executable recovery for missing selections Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 14 +++++- loopx/cli_commands/quota_capture.py | 17 +++++++- .../control_plane/quota/context_selection.ts | 36 ++++++++++++++-- .../test_context_capture_selection.py | 43 ++++++++++++++++++- .../context_selection.test.ts | 27 ++++++++++++ 5 files changed, 129 insertions(+), 8 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 0b0c79e153..c3f42dae85 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -1035,7 +1035,13 @@ Reader failures preserve the runtime's `error_kind` and `diagnostic_code` so programs need not parse `reason`. Selector rejections use `request_rejected` with `context_selection_invalid_pointer`, `context_selection_invalid_index` or `context_selection_missing_pointer`. Repair the pointer syntax or inspect -the known parent in the same capture, then retry the corrected batch. Other +the known parent in the same capture, then retry the corrected batch. Missing +members and invalid array indexes also return `recovery_command`: it lists the +nearest verified container from the caller's original pointer, using the same +capture digest and page budgets. If traversal reached a scalar, it lists that +scalar's containing object/array instead. This command repeats only the supplied +address prefix, never source values, and starts a new index at offset zero; +it is not continuation of the failed selection or proof of reading its body. Other runtime failures retain their own classification; startup failures retain their diagnostic code. These failures do not require restoring a healthy capture. Missing or damaged capture files still fail closed through the @@ -1091,7 +1097,11 @@ pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--ca 读取失败保留 runtime 的 `error_kind` 与 `diagnostic_code`,程序无需解析 `reason`。 选择错误使用 `request_rejected`,原因码分别为 `context_selection_invalid_pointer`、 `context_selection_invalid_index` 或 `context_selection_missing_pointer`。 -修正路径语法或在同一 capture 中检查已知父节点,再重试修正后的批次。其他 runtime +修正路径语法或在同一 capture 中检查已知父节点,再重试修正后的批次。字段缺失和 +数组索引错误还返回 `recovery_command`:用同一 capture 摘要与分页预算列出原始 +输入路径中最近的已验证容器;若已走到标量,则列出包含它的对象或数组。命令仅重复 +调用方提供的路径前缀,不包含源正文,并从偏移零开始新目录;它不是失败选择的续读, +也不证明读过正文。其他 runtime 故障保留自身分类;启动故障保留原诊断码。这些故障不要求恢复健康的 capture。 文件缺失或损坏仍由原文件/摘要检查拒绝,重新读取不会重新准入。 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 97d09ac7ed..adf08fa6fa 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -231,7 +231,22 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An depth_options = {"list_depth": list_depth} if list_depth is not None else {} selection_options = {"initial_read": True} if initial_read else {"select": selectors} - plan = _turn_envelope_result("context_select", context=payload, list_fields=list_fields, **selection_options, **depth_options) + plan = _turn_envelope_result("context_select", context=payload, list_fields=list_fields, + recover_selection_errors=not initial_read, **selection_options, **depth_options) + if rejection := plan.get("rejected_selection"): + # TS alone resolves the nearest container. Reuse the original + # escaped tokens, without parsing diagnostic prose or traversing + # the payload again in Python. A failed batch delivers no body. + pointer = selectors[rejection["argument_index"] - 1] + parent = "/".join(pointer.split("/")[:rejection["parent_token_count"] + 1]) + command = context_capture_read_command(path, digest, select=[parent], list_fields=True, + page_bytes=next_page_bytes or size, + max_page_output_bytes=next_page_output_bytes or output_limit) + return {"ok": False, "schema_version": "context_capture_page_v1", + "error_kind": "request_rejected", "diagnostic_code": rejection["code"], + "reason": rejection["message"], "recovery_command": command, + "recovery": "Use recovery_command to --list the nearest verified container in the same capture, " + "then retry the corrected batch. No values were delivered and no guard, admission or spend was performed."} views: list[dict[str, Any]] = [] for item in plan["selections"]: selected: Any = payload diff --git a/loopx/control_plane/quota/context_selection.ts b/loopx/control_plane/quota/context_selection.ts index 835a1c1503..cf8243eeea 100644 --- a/loopx/control_plane/quota/context_selection.ts +++ b/loopx/control_plane/quota/context_selection.ts @@ -3,7 +3,33 @@ import {requireJsonObject} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import type {JsonObject} from "../effect_program.ts"; +// Local selection diagnostics: counts only, never source keys or values. +class SelectionLookupError extends EffectRuntimeRequestError { + readonly argumentIndex: number; + readonly parentTokenCount: number; + + constructor(message: string, code: string, argumentIndex: number, parentTokenCount: number) { + super(message, code); + this.argumentIndex = argumentIndex; + this.parentTokenCount = parentTokenCount; + } +} + export function capturedContextSelection(request: JsonObject, initialPointers: string[] = []): JsonObject { + if (request.recover_selection_errors !== undefined && typeof request.recover_selection_errors !== "boolean") + throw new EffectRuntimeRequestError("recover-selection-errors must be boolean"); + try { + return resolveSelection(request, initialPointers); + } catch (error) { + if (!(error instanceof SelectionLookupError) || request.recover_selection_errors !== true) throw error; + // No partial selections survive. The IO adapter can bind a discovery + // command to this verified prefix of the caller's original argument. + return {rejected_selection: {message: error.message, code: error.code, + argument_index: error.argumentIndex, parent_token_count: error.parentTokenCount}}; + } +} + +function resolveSelection(request: JsonObject, initialPointers: string[]): JsonObject { const context = requireJsonObject(request.context, "captured context"); if (request.initial_read !== undefined && typeof request.initial_read !== "boolean") throw new EffectRuntimeRequestError("initial-read must be boolean"); @@ -33,18 +59,20 @@ export function capturedContextSelection(request: JsonObject, initialPointers: s const tokens = pointer === "" ? [] : pointer.slice(1).split("/").map(token => token.replace(/~1/g, "/").replace(/~0/g, "~")); const path: (string | number)[] = []; let value: unknown = context; + let parentTokenCount = 0; for (const token of tokens) { + if (value !== null && typeof value === "object") parentTokenCount = path.length; if (Array.isArray(value)) { if (!/^(0|[1-9][0-9]*)$/.test(token) || !Number.isSafeInteger(Number(token)) || Number(token) >= value.length) - throw new EffectRuntimeRequestError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`, - "context_selection_invalid_index"); + throw new SelectionLookupError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`, + "context_selection_invalid_index", argumentsByPointer.get(pointer)!, parentTokenCount); path.push(Number(token)); value = value[Number(token)]; } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { path.push(token); value = (value as JsonObject)[token]; - } else throw new EffectRuntimeRequestError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`, - "context_selection_missing_pointer"); + } else throw new SelectionLookupError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`, + "context_selection_missing_pointer", argumentsByPointer.get(pointer)!, parentTokenCount); } // Return the verified address, not a JS-number-coerced copy of the value. // The file adapter retains original JSON values and owns byte serialization. diff --git a/tests/control_plane/test_context_capture_selection.py b/tests/control_plane/test_context_capture_selection.py index 0521909e48..220ec7e87d 100644 --- a/tests/control_plane/test_context_capture_selection.py +++ b/tests/control_plane/test_context_capture_selection.py @@ -377,7 +377,7 @@ def test_cli_repairs_missing_selection_using_typed_error_and_same_capture(tmp_pa assert "--list" in failure["recovery"] and "Restore" not in failure["recovery"] # Branch on the typed cause, not wording in reason; discover and retry # against the original capture without another guard or admission. - code, index = _run_cli(registry, runtime, *command, "--select", "/rows/0", "--list", cwd=tmp_path) + code, index = _run_cli(registry, runtime, *shlex.split(failure["recovery_command"])[3:], cwd=tmp_path) assert code == 0 and index["ok"] and index["eof"], index fields = json.loads(index["content"])["selections"][0]["fields"] pointer = next(field["pointer"] for field in fields if field["pointer"] == "/rows/0/tail") @@ -451,3 +451,44 @@ def unexpected(*_args, **_kwargs): assert result["ok"] is False and "content" not in result and "next_command" not in result assert "diagnostic_code" not in result and "error_kind" not in result assert "Restore the original digest-bound capture" in result["recovery"] + + +@pytest.mark.parametrize("reference", [False, True]) +@pytest.mark.parametrize("mode", ["normal", "replan"]) +@pytest.mark.parametrize("pointer,parent", [ + ("/interaction_contract/cli_channel/settlement_plan/absent", "/interaction_contract/cli_channel"), + ("/sources/4/content", "/sources"), + ("/a~1b/~0/child", "/a~1b"), + ("/missing/child", ""), +]) +def test_cli_recovery_command_uses_current_capture_and_nearest_container(tmp_path, mode, pointer, parent, reference): + original = {"interaction_contract": {"cli_channel": {"settlement_plan": None} if mode == "normal" + else {"settlement_read_ref": "opaque-reference"}}, "sources": [], "a/b": {"~": False}, + "unrelated": "private-value" * 1000} + path = tmp_path / "capture with ' quote.json" + raw = json.dumps(original).encode() + path.write_bytes(raw) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + command = ["quota", "context-read", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--select", "/unrelated", "--select", pointer] + if reference: + from loopx.control_plane.turn_driver.decision_capture import write_context_read_reference + reader = write_context_read_reference(path, hashlib.sha256(raw).hexdigest()) + command = ["quota", "context-read", "--reference-file", str(reader), + "--select", "/unrelated", "--select", pointer] + code, failure = _run_cli(registry, runtime, *command, cwd=tmp_path) + assert code != 0 and not failure["ok"] and "content" not in failure + assert "private-value" not in json.dumps(failure) + recovery = shlex.split(failure["recovery_command"]) + assert recovery[recovery.index("--select") + 1] == parent + assert "--list" in recovery and recovery[recovery.index("--offset") + 1] == "0" + code, index = _run_cli(registry, runtime, *recovery[3:], cwd=tmp_path) + assert code == 0 and index["ok"] and index["eof"] + assert json.loads(index["content"])["selections"][0]["pointer"] == parent + assert "private-value" not in index["content"] + assert capture_page_range(index, raw=raw, digest=index["sha256"]) is None + path.write_bytes(raw + b" ") + code, changed = _run_cli(registry, runtime, *recovery[3:], cwd=tmp_path) + assert code != 0 and not changed["ok"] and "content" not in changed + assert "recovery_command" not in changed + assert not registry.exists() and not runtime.exists() diff --git a/tests/control_plane_ts/context_selection.test.ts b/tests/control_plane_ts/context_selection.test.ts index 856e4ba416..14d8b3e370 100644 --- a/tests/control_plane_ts/context_selection.test.ts +++ b/tests/control_plane_ts/context_selection.test.ts @@ -133,3 +133,30 @@ test("unknown decision schemas and ordinary selections keep generic discovery", for (const options of [{select: [""]}, {list_fields: true}, {list_depth: 2}]) assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, initial_read: true, ...options}), /cannot be combined/); }); + + +test("recoverable lookups return only caller positions and nearest container counts, never partial values", () => { + const context = {z: "private-value", "a/b": {"~": [{leaf: null}]}}; + for (const [pointer, parentCount] of [ + ["/a~1b/~0/8", 2], ["/a~1b/~0/01", 2], ["/a~1b/~0/0/absent", 3], + ["/a~1b/~0/0/leaf/deeper", 3], ["/z/deeper", 0], ["/absent/deeper", 0], + ] as const) { + const result = evaluateTurnEnvelope({operation: "context_select", context, + select: ["/z", "/z", pointer], recover_selection_errors: true}); + assert.equal(result.selections, undefined); + assert.deepEqual(Object.keys(result), ["rejected_selection"]); + const failure = result.rejected_selection as Record; + assert.equal(failure.argument_index, 3); + assert.equal(failure.parent_token_count, parentCount); + assert.ok(!JSON.stringify(result).includes("private-value")); + assert.ok(!JSON.stringify(result).includes("a~1b")); + } + for (const options of [{recover_selection_errors: false}, {}]) + assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, select: ["/missing"], ...options})); + assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, + select: ["/~2"], recover_selection_errors: true}), /RFC 6901/); + assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, + select: ["/z"], recover_selection_errors: "true"}), /must be boolean/); + assert.deepEqual(evaluateTurnEnvelope({operation: "context_select", context, + select: ["/z"], recover_selection_errors: true}), select(context, ["/z"])); +});