diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 5759fb9b84..d2791c1347 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -862,6 +862,16 @@ not renumber arguments. Inspect the resolved prefix before that token in the same capture; malformed pointers need syntax repair first. Diagnostics never echo the pointer or task values, and no part of a rejected batch is delivered. +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 +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 +original file/digest checks. Reading again never reruns admission. + The `context_capture_index_v1` view shares the digest-bound pagination contract. An expanded index includes its `list_depth`; `next_command` preserves the depth, selectors and both hashes. Changing depth rejects the prior view's continuation. @@ -905,6 +915,13 @@ pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--ca 路径前缀;语法错误需先修正 pointer。错误信息不回显路径或任务正文,被拒绝的 批次不会返回部分内容。 +读取失败保留 runtime 的 `error_kind` 与 `diagnostic_code`,程序无需解析 `reason`。 +选择错误使用 `request_rejected`,原因码分别为 `context_selection_invalid_pointer`、 +`context_selection_invalid_index` 或 `context_selection_missing_pointer`。 +修正路径语法或在同一 capture 中检查已知父节点,再重试修正后的批次。其他 runtime +故障保留自身分类;启动故障保留原诊断码。这些故障不要求恢复健康的 capture。 +文件缺失或损坏仍由原文件/摘要检查拒绝,重新读取不会重新准入。 + `context_capture_index_v1` 沿用双摘要分页;展开的目录包含 `list_depth`, `next_command` 保留深度、路径和两个摘要。更改深度会拒绝旧视图的续读,目录 cursor 也不能用作正文 cursor。遇到输出截断或缺页,用足够的输出预算恢复同一 capture diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 09261c0f4e..e933c67783 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -197,10 +197,20 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An except (OSError, ValueError, RecursionError, EffectRuntimeRemoteError, EffectRuntimeStartupError) as exc: # Recovery is presentation-only: keep failures separate from quota's # collection error path and retain the existing receipt/capture. - return {"ok": False, "schema_version": "context_capture_page_v1", + result = {"ok": False, "schema_version": "context_capture_page_v1", "reason": str(exc) if isinstance(exc, (ValueError, EffectRuntimeRemoteError, EffectRuntimeStartupError)) else "original capture could not be read", "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} + if isinstance(exc, (EffectRuntimeRemoteError, EffectRuntimeStartupError)): + result["diagnostic_code"] = exc.diagnostic_code + result["recovery"] = ("Resolve the reported runtime failure, then retry reading the same digest-bound capture; " + "no guard, admission or spend was performed.") + if isinstance(exc, EffectRuntimeRemoteError): + result["error_kind"] = exc.error_kind + if exc.error_kind == "request_rejected": + result["recovery"] = ("Correct the rejected read request; for unknown fields use --list on a known parent. " + "Keep the original capture and digest; no guard, admission or spend was performed.") + return result def native_context_capture_view(projected: dict[str, Any], directory: Path, *, diff --git a/loopx/control_plane/quota/context_selection.ts b/loopx/control_plane/quota/context_selection.ts index 379bb01670..69e4d07b46 100644 --- a/loopx/control_plane/quota/context_selection.ts +++ b/loopx/control_plane/quota/context_selection.ts @@ -22,20 +22,23 @@ export function capturedContextSelection(request: JsonObject): JsonObject { const selections = pointers.map(pointer => { const argument = `--select argument ${argumentsByPointer.get(pointer)}`; if (pointer !== "" && !pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) - throw new EffectRuntimeRequestError(`context selection requires RFC 6901 pointers (${argument}; positions are 1-based)`); + throw new EffectRuntimeRequestError(`context selection requires RFC 6901 pointers (${argument}; positions are 1-based)`, + "context_selection_invalid_pointer"); const tokens = pointer === "" ? [] : pointer.slice(1).split("/").map(token => token.replace(/~1/g, "/").replace(/~0/g, "~")); const path: (string | number)[] = []; let value: unknown = context; for (const token of tokens) { 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)`); + throw new EffectRuntimeRequestError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`, + "context_selection_invalid_index"); 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)`); + } else throw new EffectRuntimeRequestError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`, + "context_selection_missing_pointer"); } // 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 1e17306737..3af19b2968 100644 --- a/tests/control_plane/test_context_capture_selection.py +++ b/tests/control_plane/test_context_capture_selection.py @@ -8,6 +8,7 @@ from tests.control_plane.codex_checkpoint_fixture import NativeContextReader, last_context_page from loopx.cli_commands.quota_capture import read_context_capture +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError from loopx.control_plane.turn_driver.decision_capture import capture_page_range from test_quota_settlement_cli import _run_cli @@ -276,3 +277,92 @@ def test_cli_missing_batch_pointer_identifies_original_argument_without_source_l assert f"--select argument 3, token {token}" in result["reason"] assert pointer not in result["reason"] and "stop if unavailable" not in json.dumps(result) assert path.read_bytes() == raw and not registry.exists() and not runtime.exists() + + +def test_cli_repairs_missing_selection_using_typed_error_and_same_capture(tmp_path): + path, raw, original = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + command = ["quota", "context-read", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest()] + code, failure = _run_cli(registry, runtime, *command, + "--select", "/large", "--select", "/rows/0/missing", cwd=tmp_path) + assert code != 0 and failure["ok"] is False and "content" not in failure + assert failure["error_kind"] == "request_rejected" + assert failure["diagnostic_code"] == "context_selection_missing_pointer" + 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) + 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") + code, selected = _run_cli(registry, runtime, *command, "--select", pointer, cwd=tmp_path) + assert code == 0 and selected["ok"] and selected["eof"], selected + assert json.loads(selected["content"])["selections"] == [ + {"pointer": pointer, "value": original["rows"][0]["tail"]}] + assert capture_page_range(selected, raw=raw, digest=selected["sha256"]) is None + assert path.read_bytes() == raw and not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("kind,code", [ + ("request_rejected", "request_too_large"), + ("io_transient", "io_busy"), + ("io_permanent", "io_permission_denied"), + ("internal_failure", "unexpected_handler_error"), +]) +def test_reader_preserves_runtime_diagnostics_without_parsing_reason(tmp_path, monkeypatch, kind, code): + from loopx.control_plane.quota import turn_envelope + + path, raw, _ = fixture_capture(tmp_path) + + def fail(*_args, **_kwargs): + raise EffectRuntimeRemoteError("same opaque reason", error_kind=kind, diagnostic_code=code) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", fail) + result = read_context_capture(args(path, raw)) + assert result["ok"] is False and "content" not in result and "next_command" not in result + assert result["error_kind"] == kind and result["diagnostic_code"] == code + assert result["reason"] == "same opaque reason" + assert "Restore" not in result["recovery"] + assert ("--list" in result["recovery"]) is (kind == "request_rejected") + assert path.read_bytes() == raw + + +def test_runtime_startup_failure_does_not_request_capture_restoration(tmp_path, monkeypatch): + from loopx.control_plane.quota import turn_envelope + + path, raw, _ = fixture_capture(tmp_path) + + def fail(*_args, **_kwargs): + raise EffectRuntimeStartupError("runtime unavailable", diagnostic_code="runtime_startup_timeout") + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", fail) + result = read_context_capture(args(path, raw)) + assert result["ok"] is False and "content" not in result + assert result["diagnostic_code"] == "runtime_startup_timeout" + assert "runtime" in result["recovery"] and "Restore" not in result["recovery"] + assert path.read_bytes() == raw + + +@pytest.mark.parametrize("damage", ["missing", "digest", "json"]) +def test_capture_failure_cannot_be_repaired_as_a_selector_error(tmp_path, monkeypatch, damage): + from loopx.control_plane.quota import turn_envelope + + path, raw, _ = fixture_capture(tmp_path) + request = args(path, raw) + if damage == "missing": + path.unlink() + elif damage == "digest": + path.write_bytes(b"{}") + else: + path.write_bytes(b"{broken") + request.capture_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + + def unexpected(*_args, **_kwargs): + pytest.fail("invalid captures must not reach selection") + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", unexpected) + result = read_context_capture(request) + 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"] diff --git a/tests/control_plane_ts/context_selection.test.ts b/tests/control_plane_ts/context_selection.test.ts index c8a82133f2..d0ad4f6a58 100644 --- a/tests/control_plane_ts/context_selection.test.ts +++ b/tests/control_plane_ts/context_selection.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import {test} from "node:test"; import {evaluateTurnEnvelope} from "../../loopx/control_plane/quota/turn_envelope.ts"; +import {effectRuntimeErrorPayload} from "../../loopx/control_plane/effect_runtime_errors.ts"; const select = (context: object, pointers: unknown) => evaluateTurnEnvelope({operation: "context_select", context, select: pointers}); @@ -70,3 +71,23 @@ test("selection failures identify original argument and token without echoing co } assert.throws(() => select(context, ["/z", "secret-".repeat(1000)]), /--select argument 2/); }); + +test("selection failures retain machine-readable causes at the runtime boundary", () => { + const context = {rows: [{stop: "private-value"}]}; + for (const [pointer, code] of [ + ["rows", "context_selection_invalid_pointer"], + ["/~2", "context_selection_invalid_pointer"], + ["/rows/01", "context_selection_invalid_index"], + ["/rows/9", "context_selection_invalid_index"], + ["/rows/0/missing", "context_selection_missing_pointer"], + ["/toString", "context_selection_missing_pointer"], + ]) { + assert.throws(() => select(context, ["/rows", pointer]), (error: unknown) => { + const failure = effectRuntimeErrorPayload(error); + assert.equal(failure.kind, "request_rejected"); + assert.equal(failure.code, code); + assert.ok(!failure.message.includes(pointer) && !failure.message.includes("private-value")); + return true; + }); + } +});