From 6033baac4ea7506333f7b932128b39827eec3fae Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:01:54 +0800 Subject: [PATCH 01/29] feat(context): share exact subtree delivery and capture recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/explore_turn_context.ts | 15 ++ loopx/control_plane/capability_hooks.ts | 9 + loopx/control_plane/quota/context_delivery.ts | 227 ++++++++++++++++++ loopx/control_plane/quota/turn_envelope.py | 27 +++ loopx/control_plane/quota/turn_envelope.ts | 3 + .../work_items/interaction_contract.ts | 7 +- .../test_context_delivery_cli.py | 79 ++++++ .../control_plane_ts/context_delivery.test.ts | 216 +++++++++++++++++ 8 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 loopx/control_plane/quota/context_delivery.ts create mode 100644 tests/control_plane/test_context_delivery_cli.py create mode 100644 tests/control_plane_ts/context_delivery.test.ts diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 1c50cbf1be..e998b078bb 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -10,6 +10,21 @@ import {EXPLORE_WRITEBACK_SUMMARY_LIMIT, exploreResultWritebackAffordance} from import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "./explore_result_page.ts"; import {projectExploreMeasurementFeedback} from "./explore_measurements.ts"; +/** Reusable evidence and instructions in this owner's read model. Current + * selection, eligibility, source revisions and pagination stay outside this + * split. A changed evidence array is replaced in full, including its status, + * provenance and applicability; no timestamp or revision is normalized away. */ +export function exploreReusableContextPaths(): string[][] { + return [ + ...["recent_nodes", "recent_findings", "writeback_results", "measurement_feedback", + "path_delta_attachment_template", "linked_question_attachment_template", + "optional_question_evolution_example", "result_writeback_guidance", + "result_attachment_template", "optional_measurement_example", "measurement_guidance", + "record_node_template", "record_finding_template", "guidance"].map(key => ["graph", key]), + ["harness", "guidance"], + ]; +} + function rows(value: unknown): JsonObject[] { return Array.isArray(value) ? value.map(item => requireJsonObject(item, "Explore row")) : []; } diff --git a/loopx/control_plane/capability_hooks.ts b/loopx/control_plane/capability_hooks.ts index 14deda4e7a..9fb75e3457 100644 --- a/loopx/control_plane/capability_hooks.ts +++ b/loopx/control_plane/capability_hooks.ts @@ -21,6 +21,15 @@ import { projectRepositoryDeliveryGate } from "./work_items/repository_delivery. import { projectPendingCapabilityIntent } from "./work_items/pending_capability_intent.ts"; import {exploreResultWritebackAffordance} from "./capabilities/explore_result_writeback.ts"; +import {exploreReusableContextPaths} from "./capabilities/explore_turn_context.ts"; + +/** The registered owner selects reusable read-model subtrees. Unknown hooks + * remain complete; payloads cannot nominate their own execution fields. */ +export function reusableTurnContextPaths(source: JsonObject): string[][] { + return source.source === "turn_start_capability_hook" && source.hook_id === "explore.turn_context" + && source.capability_id === "explore" && source.kind === "explore_turn_context" + ? exploreReusableContextPaths() : []; +} export const CAPABILITY_HOOK_REGISTRATION_SCHEMA_VERSION = CAPABILITY_HOOK_REGISTRATION_SCHEMA; diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts new file mode 100644 index 0000000000..f86f6b4bae --- /dev/null +++ b/loopx/control_plane/quota/context_delivery.ts @@ -0,0 +1,227 @@ +/** Presentation of an immutable work-context capture. No admission or state writes. + * A candidate delta is usable only at a host's final model-input assembly boundary; + * a previous CLI output or a successful capture handoff is not retention proof. */ +import type {JsonObject} from "../effect_program.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {canonicalAuthorityJson, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {reusableTurnContextPaths} from "../capability_hooks.ts"; + +const VERSION = "work_context_delivery_v1"; +const MAX_ANCESTRY = 32; +type Path = (string | number)[]; +type Part = {path: Path; value: unknown}; +type Split = {fresh: JsonObject; parts: Map; valid: boolean}; +const clone = (value: T): T => structuredClone(value); +const equal = (a: unknown, b: unknown): boolean => a === undefined || b === undefined ? a === b + : JSON.stringify(canonicalAuthorityJson(a)) === JSON.stringify(canonicalAuthorityJson(b)); +const digest = (value: unknown): string => `sha256:${canonicalAuthoritySha256(value)}`; +const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8"); + +/** A title equal to the complete body carries no second instruction. Never use + * the bounded hot title/text as the source, and retain a genuinely distinct title. */ +export function compactSelectedTodoSource(content: JsonObject): JsonObject { + const todo = jsonObject(content.todo); + if (!todo || typeof todo.text !== "string" || todo.title !== todo.text) return content; + const copy = {...todo}; + delete copy.title; + return {...content, todo: copy}; +} + +function parentAt(root: JsonObject, path: Path): Record { + let value: unknown = root; + for (const key of path.slice(0, -1)) value = (value as Record)[key]; + return value as Record; +} + +/** Registered requirement fields and capability evidence subtrees are reusable. Revisions, source + * failures, claims, reviews, grants, current gates and unknown fields stay fresh. + * Source-array order is transport layout, never the identity of a requirement. */ +function splitContext(context: JsonObject): Split { + const fresh = clone(context), parts = new Map(), identities = new Set(); + let valid = context.complete === true; + const add = (identity: string, path: Path, structured = false) => { + if (identities.has(identity)) { valid = false; return; } + identities.add(identity); + const parent = parentAt(fresh, path), key = path[path.length - 1]; + if (!Object.hasOwn(parent, key)) return; + // These adapters own literal text, not arbitrary nested control records. + if (!structured && parent[key] !== null && typeof parent[key] !== "string") { valid = false; return; } + parts.set(identity, {path, value: parent[key]}); + delete parent[key]; + }; + const sources = Array.isArray(fresh.sources) ? fresh.sources : []; + const sourceIds = new Set(); + for (let i = 0; i < sources.length; i++) { + const source = jsonObject(sources[i]), content = jsonObject(source?.content); + if (!source || !content || content.ok === false || content.read_complete === false) continue; + const prefix: Path = ["sources", i, "content"]; + const kind = source.source; + if (kind === "goal_state" && typeof content.state_file === "string" + && typeof content.text === "string" && Array.isArray(content.excluded_todo_regions)) { + const id = JSON.stringify([kind, content.state_file]); + if (sourceIds.has(id)) { valid = false; continue; } + sourceIds.add(id); + add(id, [...prefix, "text"]); + } else if (kind === "selected_todo") { + const todo = jsonObject(content.todo); + // An already referenced selected body lacks an independent source id. + // Keep such residual metadata full rather than guessing its identity. + if (!todo || typeof todo.todo_id !== "string" || !todo.todo_id) continue; + const id = JSON.stringify([kind, todo.todo_id]); + if (sourceIds.has(id)) { valid = false; continue; } + sourceIds.add(id); + for (const key of ["text", "note", "title"]) add(JSON.stringify([kind, todo.todo_id, key]), [...prefix, "todo", key]); + } else if (kind === "goal_acceptance") { + if (sourceIds.has(kind)) { valid = false; continue; } + sourceIds.add(kind); + const contract = jsonObject(content.goal_acceptance_contract); + const outcomes = jsonObject(contract?.outcome_requirements)?.outcomes; + if (!Array.isArray(outcomes)) continue; + const seen = new Set(); + for (let j = 0; j < outcomes.length; j++) { + const outcome = jsonObject(outcomes[j]); + if (!outcome || typeof outcome.id !== "string" || !outcome.id || seen.has(outcome.id)) { + valid = false; continue; + } + seen.add(outcome.id); + const path = [...prefix, "goal_acceptance_contract", "outcome_requirements", "outcomes", j]; + add(JSON.stringify([kind, outcome.id, "objective"]), [...path, "objective"]); + const requirements = outcome.requirements; + if (!Array.isArray(requirements)) continue; + const requirementIds = new Set(); + for (let k = 0; k < requirements.length; k++) { + const requirement = jsonObject(requirements[k]); + if (!requirement || typeof requirement.id !== "string" || !requirement.id || requirementIds.has(requirement.id)) { + valid = false; continue; + } + requirementIds.add(requirement.id); + add(JSON.stringify([kind, outcome.id, requirement.id, "description"]), [...path, "requirements", k, "description"]); + } + } + } else { + const paths = reusableTurnContextPaths(source); + if (!paths.length) continue; + if (content.ok !== true || typeof content.goal_id !== "string" || typeof content.agent_id !== "string") continue; + const identity = [kind, source.hook_id, source.capability_id, content.goal_id, content.agent_id, content.todo_id ?? null]; + const id = JSON.stringify(identity); + if (sourceIds.has(id)) { valid = false; continue; } + sourceIds.add(id); + for (const path of paths) { + let value: unknown = content; + for (const key of path.slice(0, -1)) value = jsonObject(value)?.[key]; + if (jsonObject(value)) add(JSON.stringify([...identity, ...path]), [...prefix, ...path], true); + } + } + } + return {fresh, parts, valid}; +} + +function targetBinding(request: JsonObject, context: JsonObject): JsonObject { + const scope = requireJsonObject(request.scope, "context delivery scope"); + const binding: JsonObject = {projection_version: VERSION}; + for (const key of ["goal_id", "agent_id", "session_id", "context_epoch"]) { + binding[key] = requireNonEmptyString(scope[key], `scope.${key}`); + } + binding.capture_id = requireNonEmptyString(request.capture_id, "capture_id"); + binding.context_digest = digest(context); + return binding; +} + +function compatible(base: JsonObject, target: JsonObject): boolean { + return ["goal_id", "agent_id", "session_id", "context_epoch", "projection_version"] + .every(key => base[key] === target[key]); +} + +/** Host-facing preparation, also used through the existing Turn projection RPC. + * `retained` is an exact host observation, not an Agent-supplied CLI option. + * The host MUST re-evaluate at final input assembly, then ACK only intact input. + * Unsupported native hosts keep their existing complete output unchanged. */ +export function prepareContextDelivery(request: JsonObject): JsonObject { + const context = requireJsonObject(request.context, "current work context"); + const target = targetBinding(request, context); + const full = (reason: string): JsonObject => ({schema_version: VERSION, mode: "full", target, + ancestry: 0, reason, context: clone(context)}); + const baseline = jsonObject(request.baseline), retained = jsonObject(request.retained); + const binding = jsonObject(baseline?.target), prior = jsonObject(baseline?.context); + if (!binding || !prior || !retained || retained.complete !== true + || !equal(retained.target, binding) || !compatible(binding, target) + || binding.context_digest !== digest(prior)) return full("baseline_unavailable"); + const depth = baseline?.ancestry; + if (!Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) >= MAX_ANCESTRY) return full("full_refresh_due"); + const before = splitContext(prior), after = splitContext(context); + if (!before.valid || !after.valid) return full("source_not_reusable"); + const replacements: JsonObject[] = [], removals: Path[] = [], reused: JsonObject[] = []; + for (const [id, part] of after.parts) { + const previous = before.parts.get(id); + if (!previous || !equal(previous.value, part.value)) replacements.push({path: part.path, value: clone(part.value)}); + else reused.push({path: part.path, ...(!equal(previous.path, part.path) ? {from: previous.path} : {})}); + } + for (const [id, part] of before.parts) if (!after.parts.has(id)) removals.push(part.path); + const delta: JsonObject = {schema_version: VERSION, mode: "delta", target, base: binding, + ancestry: Number(depth) + 1, context: after.fresh, reused, replacements, removals, + instruction: "Fresh control and source metadata are complete. Reuse unchanged registered subtrees from the confirmed baseline; their requirements remain binding. Apply exact replacements/removals. Lost context requires automatic same-capture full delivery, not an owner question."}; + if (bytes(delta) >= bytes(full("delta_not_smaller"))) return full("delta_not_smaller"); + // An independent reconstruction check guards the released candidate. + const restored = restoreContextDelivery({delivery: delta, baseline, current: full("recovery")}); + if (restored.mode !== "delta" || !equal(restored.context, context)) return full("reconstruction_failed"); + return delta; +} + +/** Recover against the immutable CURRENT capture, never by rerunning admission. + * A changed epoch, truncated/lost/reordered delivery or failed reconstruction + * returns that full capture. Fresh authority/source checks remain caller-owned. */ +export function restoreContextDelivery(request: JsonObject): JsonObject { + const current = requireJsonObject(request.current, "current full capture"); + const currentContext = requireJsonObject(current.context, "current full context"); + const target = requireJsonObject(current.target, "current capture binding"); + if (current.mode !== "full" || current.schema_version !== VERSION || target.context_digest !== digest(currentContext)) { + throw new Error("current full capture is invalid"); + } + const fallback = (): JsonObject => ({...clone(current), reason: "automatic_full_recovery", ancestry: 0}); + try { + const delivery = requireJsonObject(request.delivery, "delivery"), baseline = requireJsonObject(request.baseline, "baseline"); + const base = requireJsonObject(baseline.target, "baseline target"), context = requireJsonObject(baseline.context, "baseline context"); + if (delivery.schema_version !== VERSION || delivery.mode !== "delta" || !equal(delivery.target, target) + || !equal(delivery.base, base) || !compatible(base, target) || base.context_digest !== digest(context) + || !Number.isSafeInteger(baseline.ancestry) || Number(baseline.ancestry) < 0 + || delivery.ancestry !== Number(baseline.ancestry) + 1 || Number(delivery.ancestry) > MAX_ANCESTRY) return fallback(); + const split = splitContext(context), expected = splitContext(currentContext); + if (!split.valid || !expected.valid || !Array.isArray(delivery.removals) + || !Array.isArray(delivery.replacements) || !Array.isArray(delivery.reused)) return fallback(); + const removed = [...split.parts].filter(([id]) => !expected.parts.has(id)).map(([, part]) => part.path); + if (!equal(delivery.removals, removed)) return fallback(); + // Stable source identities stay internal. Wire paths are readable and occur + // once; a moved source adds its prior path rather than repeating long ids. + const rows = (items: unknown[]): Map => { + const result = new Map(); + for (const raw of items) { + const item = requireJsonObject(raw, "subtree"); + if (!Array.isArray(item.path)) throw new Error("subtree path missing"); + const key = JSON.stringify(item.path); + if (result.has(key)) throw new Error("duplicate subtree path"); + result.set(key, item); + } + return result; + }; + const replacements = rows(delivery.replacements), reused = rows(delivery.reused); + if (replacements.size + reused.size !== expected.parts.size) return fallback(); + const result = clone(requireJsonObject(delivery.context, "fresh context")); + for (const [id, part] of expected.parts) { + const key = JSON.stringify(part.path), replacement = replacements.get(key), reuse = reused.get(key); + if (Boolean(replacement) === Boolean(reuse)) return fallback(); + let value: unknown; + if (replacement) { + if (!Object.hasOwn(replacement, "value")) return fallback(); + value = replacement.value; + } else { + const previous = split.parts.get(id); + if (!previous || !equal(reuse!.from ?? reuse!.path, previous.path)) return fallback(); + value = previous.value; + } + // Write only independently derived current paths, never wire paths. + parentAt(result, part.path)[part.path[part.path.length - 1]] = clone(value); + } + if (!equal(result, currentContext)) return fallback(); + return {...clone(delivery), context: result}; + } catch { return fallback(); } +} diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 02aa223b5a..9f8c78b75c 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -105,3 +105,30 @@ def bind_turn_decision_capture( captured_decision_path=captured_decision_path, turn_instance_id=turn_instance_id, turn_key=turn_key, ) + + +def prepare_work_context_delivery( + context: Mapping[str, Any], *, scope: Mapping[str, str], capture_id: str, + baseline: Mapping[str, Any] | None = None, + retained: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Prepare at a Host's final input boundary, never from CLI self-attestation. + + Quota and Turn hosts pass the same work_context and immutable capture id. + Unsupported hosts omit retention and keep complete delivery. A candidate is + not an ACK: only intact final model-input assembly may confirm its target. + """ + return _turn_envelope_result("context_delivery", context=dict(context), + scope=dict(scope), capture_id=capture_id, + baseline=dict(baseline) if baseline is not None else None, + retained=dict(retained) if retained is not None else None) + + +def restore_work_context_delivery( + delivery: Mapping[str, Any], *, baseline: Mapping[str, Any] | None, + current: Mapping[str, Any], +) -> dict[str, Any]: + """Validate/reconstruct or return the same-capture full body without effects.""" + return _turn_envelope_result("context_recovery", delivery=dict(delivery), + baseline=dict(baseline) if baseline is not None else None, + current=dict(current)) diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 4978401a9b..06f8de318b 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,6 +1,7 @@ import { EffectiveAction } from "./effective_action.generated.ts"; import { createHash } from "node:crypto"; import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; +import {prepareContextDelivery, restoreContextDelivery} from "./context_delivery.ts"; import { interpretQuotaShouldRunPacket, @@ -998,6 +999,8 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; if (operation === "build") return buildTurnEnvelope(request); + if (operation === "context_delivery") return prepareContextDelivery(request); + if (operation === "context_recovery") return restoreContextDelivery(request); if (operation === "bind_capture") return bindTurnDecisionCapture(request); if (operation === "replan_settlement_identity") return {identity: turnEnvelopeReplanSettlementIdentity( requireJsonObject(request.envelope, "turn_envelope"), request.turn_instance_id, diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index b4905fd499..487a218846 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -19,6 +19,7 @@ import { AgentScopeFrontierAction } from "../agents/agent_scope_frontier.generat import { resumeConditionHasKnownPendingTarget } from "../todos/resume_condition.ts"; import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; import { monitorIsActive } from "../todos/summary_lanes.ts"; +import {compactSelectedTodoSource} from "../quota/context_delivery.ts"; function exactBlockedSuccessorWait(request: JsonObject, monitorWait = false): JsonObject | null { const wait = jsonObject(request.vision_wait_state); @@ -763,7 +764,7 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { const matches = results.filter(result => result.command === read.command); if (matches.length === 0) { pending.push(read); continue; } const result = matches[0]; - const content = jsonObject(result.content); + let content = jsonObject(result.content); let valid = matches.length === 1 && content !== null && content.ok !== false && !result.error_code; let selectedTodoRecord: JsonObject | null = null; if (valid && read.source === "selected_todo") { @@ -789,6 +790,10 @@ export function projectInteractionWorkContext(request: JsonObject): JsonObject { if (valid && read.source === "goal_acceptance") { valid = (!!configuredGoalChecks(jsonObject(content!.goal_acceptance_contract)) || !!jsonObject(content!.goal_acceptance_contract)?.outcome_requirements || !!jsonObject(content!.goal_acceptance_contract)?.goal_objective); } + if (valid && read.source === "selected_todo") { + content = compactSelectedTodoSource(content!); + selectedTodoRecord = jsonObject(content.todo); + } if (!valid) { pending.push(read); failures.push({source: read.source ?? read.kind, command: read.command, diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py new file mode 100644 index 0000000000..a54a2c0dc4 --- /dev/null +++ b/tests/control_plane/test_context_delivery_cli.py @@ -0,0 +1,79 @@ +"""Real quota sources share an exact, effect-free full/delta presentation owner. + +These tests qualify the CLI and RPC, not native Host retention observation. +""" +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, prepare_work_context_delivery, restore_work_context_delivery, +) +from test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, + _run_cli, _spend_run_count, _write_fixture, +) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("profile", ["codex_app", "generic_cli"]) +def test_cli_capture_exact_delta_and_same_capture_recovery(tmp_path: Path, monkeypatch, provider, profile): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + body = "Preserve every source row and cancellation condition. " * 500 + body += "FINAL CONDITION: stop before publication." + todos = read_fixture_todos(state) + todos[0].update(text=body, title=body) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider=provider) + before_source = state.read_bytes() + host = ["--codex-app"] if profile == "codex_app" else ["--runtime-profile", profile] + rc, guard = _run_cli(registry, runtime, "quota", "should-run", *host, + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "context-presentation", cwd=project) + assert rc == 0 and guard["should_run"] is True, guard + context = guard["interaction_contract"]["agent_channel"]["work_context"] + selected = next(s["content"]["todo"] for s in context["sources"] if s.get("source") == "selected_todo") + assert selected["text"] == body + assert selected.get("title") != body # only the exact duplicate is elided + turn_context = build_turn_envelope(guard)["work_context"] + assert turn_context["sources"] == context["sources"] + assert turn_context["instruction"] == context["instruction"] + original = deepcopy(guard) + scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id=profile, context_epoch="epoch-a") + full = prepare_work_context_delivery(context, scope=scope, capture_id="capture-a") + assert full["mode"] == "full" and full["context"] == context + warm = prepare_work_context_delivery(context, scope=scope, capture_id="capture-b", + baseline=full, retained={"complete": True, "target": full["target"]}) + assert warm["mode"] == "delta" + assert len(json.dumps(warm).encode()) < len(json.dumps(full).encode()) / 2 + current = prepare_work_context_delivery(context, scope=scope, capture_id="capture-b") + assert restore_work_context_delivery(warm, baseline=full, current=current)["context"] == context + turn_full = prepare_work_context_delivery(turn_context, scope=scope, capture_id="turn-capture") + turn_delta = prepare_work_context_delivery(turn_context, scope=scope, capture_id="turn-capture", + baseline=full, retained={"complete": True, "target": full["target"]}) + assert turn_delta["mode"] == "delta" + assert restore_work_context_delivery(turn_delta, baseline=full, current=turn_full)["context"] == turn_context + for baseline, delivery in [(None, warm), (full, {**warm, "reused": []})]: + recovered = restore_work_context_delivery(delivery, baseline=baseline, current=current) + assert recovered["mode"] == "full" and recovered["context"] == context + assert recovered["target"] == current["target"] + reset = prepare_work_context_delivery(context, scope={**scope, "context_epoch": "epoch-b"}, + capture_id="capture-b", baseline=full, retained={"complete": True, "target": full["target"]}) + assert reset["mode"] == "full" and reset["context"] == context + # Neither native source state, the signed decision nor settlement is changed + # by presentation, warm reuse or recovery. A new capture is not an admission. + assert state.read_bytes() == before_source + assert guard == original + assert _spend_run_count(runtime) == 0 + rc, readback = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_ID) + assert rc == 0 and readback["todo"]["text"] == body, readback + assert readback["todo"]["status"] == "open" diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts new file mode 100644 index 0000000000..9ee1968ede --- /dev/null +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {compactSelectedTodoSource, prepareContextDelivery, restoreContextDelivery} from "../../loopx/control_plane/quota/context_delivery.ts"; + +const scope = {goal_id: "goal", agent_id: "agent", session_id: "session", context_epoch: "epoch-1"}; +const long = "Keep the original acceptance and stop before publication. ".repeat(400); +function context(): JsonObject { + return {complete: true, instruction: "Read requirements before work.", sources: [ + {source: "goal_state", command: "read-goal", content: {state_file: "goal.md", revision: "revision-a", + excluded_todo_regions: [{role: "agent", start_line: 4, end_line: 6, revision: "tasks-a"}], text: long}}, + {source: "selected_todo", command: "read-task", content: {ok: true, matched: true, + authority_read: {provider_revision: "a"}, todo: {todo_id: "todo_work", text: long, + note: "Keep the validation evidence.", status: "open", claimed_by: "agent"}}}, + {source: "goal_acceptance", content: {provider_revision: "a", goal_acceptance_contract: { + outcome_requirements: {revision: 1, outcomes: [{id: "outcome-a", objective: long, + requirements: [{id: "r1", description: long, status: "unproven", agent_review: {note: "No evidence yet."}}]}]}}}}, + {kind: "unknown_source", content: {large_mixed_control: long}}, + ]}; +} +const source = (value: JsonObject, index: number): JsonObject => (value.sources as JsonObject[])[index].content as JsonObject; +const full = (value: JsonObject, capture_id = "capture-1", extraScope = scope): JsonObject => + prepareContextDelivery({context: value, scope: extraScope, capture_id}); +function warm(value: JsonObject, baseline: JsonObject): JsonObject { + return prepareContextDelivery({context: value, scope, capture_id: "capture-2", baseline, + retained: {complete: true, target: baseline.target}}); +} +const recover = (delivery: JsonObject, baseline: JsonObject, value: JsonObject): JsonObject => + restoreContextDelivery({delivery, baseline, current: full(value, "capture-2")}); + +test("full first; unsupported or uncertain retention never becomes an Agent gate", () => { + const original = context(), saved = structuredClone(original), baseline = full(original); + assert.equal(baseline.mode, "full"); + assert.deepEqual(baseline.context, original); + for (const retained of [undefined, {complete: false, target: baseline.target}, {complete: true, target: {}}]) { + const result = prepareContextDelivery({context: original, scope, capture_id: "capture-2", baseline, retained}); + assert.equal(result.mode, "full"); + assert.deepEqual(result.context, original); + } + assert.deepEqual(original, saved); +}); + +test("fresh control with exact warm bodies; changed source revisions do not resend requirements", () => { + const original = context(), baseline = full(original), current = structuredClone(original); + source(current, 0).revision = "revision-b"; + source(current, 1).authority_read = {provider_revision: "b"}; + (source(current, 1).todo as JsonObject).claimed_by = "different-agent"; + const requirements = (((source(current, 2).goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject).outcomes as JsonObject[])[0].requirements as JsonObject[]; + requirements[0].status = "failed"; + requirements[0].agent_review = {note: "New failure invalidates the earlier plan."}; + const result = warm(current, baseline), fresh = result.context as JsonObject; + assert.equal(result.mode, "delta"); + assert.deepEqual(result.replacements, []); + assert.equal(source(fresh, 0).revision, "revision-b"); + assert.equal((source(fresh, 1).todo as JsonObject).claimed_by, "different-agent"); + assert.deepEqual(source(fresh, 3), source(current, 3)); // Mixed/unknown sources stay full. + assert.deepEqual(recover(result, baseline, current).context, current); + assert.ok(JSON.stringify(result).length < JSON.stringify(full(current)).length / 2); + assert.deepEqual(baseline.context, original); +}); + +test("source and requirement reordering use identities, with current positions preserved", () => { + const initial = context(); + const outcomes = ((source(initial, 2).goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject).outcomes as JsonObject[]; + (outcomes[0].requirements as JsonObject[]).push({id: "r2", description: "Keep a distinct requirement", status: "unproven"}); + const baseline = full(initial), current = structuredClone(initial); + const currentOutcomes = ((source(current, 2).goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject).outcomes as JsonObject[]; + (currentOutcomes[0].requirements as JsonObject[]).reverse(); + (current.sources as JsonObject[]).reverse(); + const delta = warm(current, baseline); + assert.equal(delta.mode, "delta"); + assert.deepEqual(delta.replacements, []); + assert.deepEqual(recover(delta, baseline, current).context, current); +}); + +test("late constraints, null, empty and removed fields remain distinct", () => { + for (const value of ["Late constraint: never delete the original source.", "", null, undefined]) { + const initial = context(), baseline = full(initial), current = structuredClone(initial); + const task = source(current, 1).todo as JsonObject; + if (value === undefined) delete task.note; + else task.note = value; + const delta = warm(current, baseline); + assert.equal(delta.mode, "delta"); + if (value === undefined) assert.equal((delta.removals as string[]).length, 1); + else assert.equal((delta.replacements as JsonObject[]).length, 1); + const restored = recover(delta, baseline, current); + assert.equal(restored.mode, "delta"); + assert.deepEqual(restored.context, current); + } +}); + +test("unexpected structured values in text-only adapters stay full", () => { + const initial = context(), baseline = full(initial); + for (const note of [{stop: true}, ["A", "B"], 42]) { + const current = structuredClone(initial); + (source(current, 1).todo as JsonObject).note = note; + const result = warm(current, baseline); + assert.equal(result.mode, "full"); + assert.deepEqual(result.context, current); + } +}); + +test("scope/epoch/version, corruption, loss and ancestry automatically select full", () => { + const initial = context(), baseline = full(initial); + for (const key of ["goal_id", "agent_id", "session_id", "context_epoch", "projection_version"]) { + const wrong = structuredClone(baseline); (wrong.target as JsonObject)[key] = "other"; + assert.equal(warm(initial, wrong).mode, "full", key); + } + const corrupt = structuredClone(baseline); (corrupt.context as JsonObject).instruction = "Changed after ACK"; + assert.equal(warm(initial, corrupt).mode, "full"); + assert.equal(warm(initial, {...baseline, ancestry: 32}).mode, "full"); + const tiny = {complete: true, sources: []}; + assert.equal(warm(tiny, full(tiny)).mode, "full"); + const failed = {...initial, complete: false}; + assert.equal(warm(failed, baseline).mode, "full"); + const duplicate = structuredClone(initial); + (duplicate.sources as JsonObject[]).push(structuredClone((initial.sources as JsonObject[])[0])); + assert.equal(warm(duplicate, baseline).mode, "full"); +}); + +test("final assembly race, truncated or out-of-order delta uses same-capture full", () => { + const initial = context(), baseline = full(initial), current = structuredClone(initial); + source(current, 0).text = long + "NEW STOP CONDITION"; + const delta = warm(current, baseline), capture = full(current, "capture-2"); + const corruptions = [ + {...delta, replacements: []}, {...delta, removals: ["unknown"]}, {...delta, ancestry: 20}, + {...delta, reused: []}, {...delta, base: {...baseline.target as JsonObject, capture_id: "lost"}}, + {...delta, context: {complete: true}}, + ]; + for (const delivery of corruptions) { + const restored = restoreContextDelivery({delivery, baseline, current: capture}); + assert.equal(restored.mode, "full"); + assert.deepEqual(restored.context, current); + } + // Epoch moves after preparation but before the final model request. + const resetCapture = full(current, "capture-2", {...scope, context_epoch: "epoch-2"}); + const reset = restoreContextDelivery({delivery: delta, baseline, current: resetCapture}); + assert.equal(reset.mode, "full"); + assert.deepEqual(reset.context, current); + // Missing acknowledgement leaves the previous baseline in place: a later + // delta referring to another capture cannot advance it retroactively. + const retained = {...delta, context: current}; + const next = warm(current, retained); + assert.equal(recover(next, baseline, current).mode, "full"); +}); + +test("cold-source title deduplication retains full body, distinct title and original capture", () => { + const original = {todo: {text: long, title: long, note: "Keep this note", status: "open"}}; + const result = compactSelectedTodoSource(original); + assert.deepEqual(result, {todo: {text: long, note: "Keep this note", status: "open"}}); + assert.equal(original.todo.title, long); + const distinct = {todo: {...original.todo, title: "A meaningful short title"}}; + assert.strictEqual(compactSelectedTodoSource(distinct), distinct); +}); + +function evidenceContext(): JsonObject { + return {complete: true, sources: [{source: "turn_start_capability_hook", kind: "explore_turn_context", + hook_id: "explore.turn_context", capability_id: "explore", content: { + ok: true, goal_id: "goal", agent_id: "agent", graph_enabled: true, harness_enabled: true, + graph: {writeback_results: [{finding_id: "f1", status: "confirmed", summary: long, + evidence_refs: ["artifact:source"], execution_observation: {scope: "candidate-a"}}], + measurement_feedback: [{metric: "cost", value: 3, unit: "tokens", input_revision: "input-a"}], + result_page: {revision: "r1", remaining: 0}, guidance: long, + unknown_future_gate: {allowed: true}}, + harness: {orchestration_gate: {allowed: true}, selected_branches: [{todo_id: "task-a", status: "open"}], + guidance: long}, boundary: {starts_agents: false}}}]}; +} + +test("registered structured evidence is reusable; live gates and unknown fields stay fresh", () => { + const initial = evidenceContext(), baseline = full(initial), current = structuredClone(initial); + const graph = source(current, 0).graph as JsonObject, harness = source(current, 0).harness as JsonObject; + graph.result_page = {revision: "r2", remaining: 1, next_command: "read-next-page"}; + graph.unknown_future_gate = {allowed: false}; + harness.orchestration_gate = {allowed: false, reason: "stop"}; + harness.selected_branches = []; + const delta = warm(current, baseline), fresh = source(delta.context as JsonObject, 0); + assert.equal(delta.mode, "delta"); + assert.deepEqual(delta.replacements, []); + assert.equal((fresh.graph as JsonObject).writeback_results, undefined); + assert.deepEqual((fresh.graph as JsonObject).result_page, graph.result_page); + assert.deepEqual((fresh.graph as JsonObject).unknown_future_gate, {allowed: false}); + assert.deepEqual((fresh.harness as JsonObject).orchestration_gate, harness.orchestration_gate); + assert.deepEqual((fresh.harness as JsonObject).selected_branches, []); + assert.deepEqual(recover(delta, baseline, current).context, current); +}); + +test("changed evidence replaces whole arrays including provenance, null and late refutation", () => { + const initial = evidenceContext(), baseline = full(initial); + for (const evidence of [[], null, [{finding_id: "f1", status: "refuted", summary: "A new counterexample.", + evidence_refs: ["artifact:new-source"], execution_observation: {scope: "candidate-b"}}], undefined]) { + const current = structuredClone(initial), graph = source(current, 0).graph as JsonObject; + if (evidence === undefined) delete graph.writeback_results; + else graph.writeback_results = evidence; + const delta = warm(current, baseline); + assert.equal(delta.mode, "delta"); + if (evidence === undefined) assert.equal((delta.removals as string[]).length, 1); + else assert.deepEqual((delta.replacements as JsonObject[]).map(row => row.value), [evidence]); + assert.deepEqual(recover(delta, baseline, current).context, current); + } +}); + +test("unknown/failed hook and ambiguous scope never borrow a registered hook's evidence", () => { + const initial = evidenceContext(), baseline = full(initial); + for (const field of ["hook_id", "capability_id", "kind"]) { + const current = structuredClone(initial); + (current.sources as JsonObject[])[0][field] = "unknown"; + assert.equal(warm(current, baseline).mode, "full"); + } + const failed = structuredClone(initial); source(failed, 0).ok = false; + assert.equal(warm(failed, baseline).mode, "full"); + const duplicate = structuredClone(initial); + (duplicate.sources as JsonObject[]).push(structuredClone((initial.sources as JsonObject[])[0])); + assert.equal(warm(duplicate, baseline).mode, "full"); + const different = structuredClone(initial); source(different, 0).todo_id = "different-task"; + assert.equal(warm(different, baseline).mode, "full"); +}); From 553704f77e7bbb11209ab8e76eaa0c0202777202 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:02:00 +0800 Subject: [PATCH 02/29] test(context): qualify small native hooks and paged capture recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_codex_context_boundary.py | 134 +++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index 496416e365..268fbdec04 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -66,8 +66,15 @@ def _usage(request, number): def _input_text(request): """Read actual model-input text; tools/instructions are not source delivery.""" - return "\n".join(part.get("text", "") for item in request["input"] - for part in item.get("content", []) if isinstance(part, dict)) + text = [] + for item in request["input"]: + output = item.get("output") if item.get("type") == "function_call_output" else None + if isinstance(output, str): + text.append(output) + for part in output if isinstance(output, list) else item.get("content", []): + if isinstance(part, dict): + text.append(part.get("text", "")) + return "\n".join(text) def _app_server(binary, env, workspace, home, trusted, additional_context=None): @@ -126,6 +133,129 @@ def _configure_provider(home, url): 'wire_api="responses"\nrequires_openai_auth=false\n') +@pytest.mark.parametrize("host", ["exec", "app_server"]) +@pytest.mark.parametrize("failed_primary", [False, True]) +def test_native_shared_delta_with_small_compact_recovery_reference(tmp_path, host, failed_primary): + """Real native input: shared delta, small event hook, same-capture file read. + + The deterministic provider requests the necessary reads. This establishes + transport ordering/recovery, not autonomous solver adoption or token savings. + """ + from loopx.control_plane.quota.turn_envelope import ( + prepare_work_context_delivery, restore_work_context_delivery, + ) + binary, env, workspace, home = _native_fixture(tmp_path) + scope = dict(goal_id="fixture", agent_id="agent", session_id="synthetic-session", context_epoch="before-compact") + source = {"complete": True, "sources": [{"source": "selected_todo", "content": { + "todo": {"todo_id": "todo_fixture", "text": SOURCE, "note": "Stop before publishing.", "status": "open"}}}]} + base = prepare_work_context_delivery(source, scope=scope, capture_id="capture-a") + target_source = json.loads(json.dumps(source)) + target_source["sources"][0]["content"]["todo"]["note"] = "Stop before publishing or deploying." + target = prepare_work_context_delivery(target_source, scope=scope, capture_id="capture-b") + delta = prepare_work_context_delivery(target_source, scope=scope, capture_id="capture-b", + baseline=base, retained={"complete": True, "target": base["target"]}) + assert delta["mode"] == "delta" + assert restore_work_context_delivery(delta, baseline=base, current=target)["context"] == target_source + files = {} + for name, value in (("base", base), ("target", target), ("delta", delta)): + files[name] = workspace / f"{name}.json" + files[name].write_text(json.dumps(value, separators=(",", ":"))) + saved = {name: path.read_bytes() for name, path in files.items()} + # The hook transports only a bounded invalidation/reference. A failed + # primary handler is caught by the small recovery wrapper; no big payload + # needs to survive the hook's default additionalContext limit. + hook = home / "hook.py" + hook.write_text( + "import json, pathlib, sys\n" + "event=json.load(sys.stdin)\n" + "if event.get('source') == 'compact':\n" + f" pathlib.Path({str(home / 'epoch-invalidated')!r}).write_text('compact')\n" + " try:\n" + + (" raise RuntimeError('injected primary handler failure')\n" if failed_primary else " pass\n") + + " except RuntimeError:\n pass\n" + f" reference={{'mode':'full_recovery','capture_id':'capture-b','path':{str(files['target'])!r}," + f"'context_digest':{target['target']['context_digest']!r}}}\n" + " print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart'," + "'additionalContext':'Baseline invalidated. Read this same-capture full source before dependent work; '" + "'do not rerun admission or ask the owner. '+json.dumps(reference)}}))\n" + ) + handler = {"type": "command", "command": shlex.join([sys.executable, str(hook)]), "timeout": 10} + (home / "hooks.json").write_text(json.dumps({"hooks": { + name: [{"hooks": [handler]}] for name in ("SessionStart", "PostToolUse")}})) + + # Native tool feedback can impose its own cap even when max_output_tokens + # is larger. Reconstruct from bounded pages, never a head/tail preview. + page_size = 6000 + page_count = lambda name: (len(saved[name].decode()) + page_size - 1) // page_size + base_pages, target_pages = page_count("base"), page_count("target") + compact_call = base_pages + 3 + final_call = compact_call + target_pages + 1 + + def page_command(name, index): + script = ("from pathlib import Path; import sys; " + f"sys.stdout.write('SOURCE_PAGE {name} {index}\\n' + " + f"Path({str(files[name])!r}).read_text()[{index * page_size}:{(index + 1) * page_size}])") + return shlex.join([sys.executable, "-c", script]) + + def response(request, number, message): + if number <= base_pages: + command = page_command("base", number - 1) + elif number == base_pages + 1: + command = page_command("delta", 0) + elif number == base_pages + 2: + command = "printf synthetic-work" + elif compact_call < number < final_call: + command = page_command("target", number - compact_call - 1) + else: + if number == compact_call: + message["content"][0]["text"] = "Synthetic summary with the original source deliberately removed." + return message + return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", + "name": "exec_command", "arguments": json.dumps({"cmd": command, "max_output_tokens": 2000})} + + def usage(request, number): + count = 30000 if number == base_pages + 2 else 1 + return {"input_tokens": count, "output_tokens": 1, "total_tokens": count + 1} + + with local_inference_provider(response, usage) as (url, calls, requests): + _configure_provider(home, url) + if host == "exec": + result = subprocess.run([binary, "exec", "--dangerously-bypass-hook-trust", "--skip-git-repo-check", "--json", + "-C", str(workspace), "Synthetic context-delivery fixture."], env=env, + capture_output=True, text=True, timeout=40) + assert result.returncode == 0, result.stderr + else: + _app_server(binary, env, workspace, home, True) + assert len(calls) == final_call + + def delivered_pages(request, name): + pages = {} + for item in request["input"]: + output = item.get("output") + if item.get("type") != "function_call_output" or not isinstance(output, str): + continue + marker = f"SOURCE_PAGE {name} " + if marker in output: + index, body = output.split(marker, 1)[1].split("\n", 1) + assert "truncated output" not in output + pages[int(index)] = body.rstrip("\n") + return "".join(pages[i] for i in range(len(pages))) + + assert delivered_pages(requests[base_pages], "base") == saved["base"].decode() + warm = requests[base_pages + 1] + assert delivered_pages(warm, "base") == saved["base"].decode() + assert delivered_pages(warm, "delta") == saved["delta"].decode() + assert delivered_pages(warm, "target") == "" + after_compact = requests[compact_call] + assert (home / "epoch-invalidated").read_text() == "compact" + assert 'Baseline invalidated.' in _input_text(after_compact) + assert str(files['target']) in _input_text(after_compact) + assert delivered_pages(after_compact, "base") == "" + assert delivered_pages(requests[-1], "target") == saved["target"].decode() + assert delivered_pages(requests[-1], "base") == "" + assert all(path.read_bytes() == saved[name] for name, path in files.items()) + + @pytest.mark.parametrize("host", ["exec", "app_server"]) @pytest.mark.parametrize("case", ["intact", "spill", "untrusted", "failed_recovery"]) def test_native_hook_context_boundary(tmp_path, host, case): From 1be08eefad1dd39a2048c62dbacbdfc53c4c2f71 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:02:00 +0800 Subject: [PATCH 03/29] docs(context): clarify subtree scope and automatic recovery boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 74 ++++++++++++++++++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 52 ++++++++++++- .../references/repair-patterns.md | 2 +- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index cb8c59ebbd..3cee6f93c8 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -763,9 +763,16 @@ App consumers, and their full fallback. A standalone diff helper is insufficient | Turn adoption | Consume the same full/delta context owner while retaining Turn signatures and outer-executor effects. Host-only settlement execution remains distinct from a manual CLI agent's executable settlement obligations. | | Choice of representation | Emit a delta only when it is smaller after binding/removal metadata. Otherwise emit full context. Do not truncate values or change source selection to meet a byte target. | -**Source and change boundaries.** Start with registered complete Goal narrative, +**Source and change boundaries.** Include registered complete Goal narrative, canonical acceptance requirement bodies and selected Todo text/notes, retaining -their exact strings and source identity. Eligibility comes from the existing +their exact strings and source identity. The Explore typed owner also supplies +its reusable evidence and instruction subtrees: recorded findings, measurement +feedback and their provenance/applicability, plus guidance and writeback examples. +Keep orchestration gates, selected/rejected branches, source health, pagination +revisions and unknown fields fresh. Evidence status inside a finding is part of +that exact evidence record, not live execution admission; any changed array is +replaced completely. Do not ignore timestamps or revisions inside reusable data. +Eligibility comes from the existing source adapter and typed field contract, never a classifier of sentence meaning. Keep current source revision, completeness, failures, status/claim, user gates, adoption and other execution-control fields in the fresh portion. An unknown or @@ -778,6 +785,16 @@ the first version. A changed field is replaced completely, including any late constraint; deleted fields have explicit removal paths. The owner reconstructs and compares against the complete target before releasing a delta. +Stable source identities are internal matching keys. On the wire, unchanged +subtrees carry readable current paths and a prior path only when their position +moved; replacements carry a path and complete value, and removals a prior path. +Do not repeat a long entity identity plus a location map for every subtree. +Validate these paths against the current typed split before reconstructing; +wire paths cannot nominate additional reusable fields. Include all binding, +path, replacement and removal overhead in the full-versus-delta comparison. +A narrow body-only saving is not an estimate of the packet's total reusable +content; report eligible bytes and metadata separately from total bytes. + **First real consumer and the compaction race.** The current native Codex CLI session adapter observes session identity; its capture/handoff release occurs before stdout delivery. Neither supplies the required model-input continuity @@ -885,6 +902,59 @@ host's final input assembly boundary for each consumer, with the shared typed projection following that qualified seam. This checkpoint keeps phase one open and does not reinterpret synthetic usage counters as token or outcome savings. +**Small lifecycle hooks and capture-file recovery (architecture refinement for review).** +Keep the negative native-hook results above as evidence about bulk hook injection, +not a dependency that blocks the shared implementation. A lifecycle event can +invalidate a baseline without transporting a large requirement document. The +proposed recovery route is an ordinary complete read of the immutable capture; +a recovery control message may therefore precede the complete source read. +Dependent business work still requires the complete current source. This refines +the earlier requirement to place all recovered text in the first post-compaction +model request; it adds no owner question, admission or debit. + +| Owner | Responsibility and boundary | +|---|---| +| Existing quota/Turn producer | Compute the full authoritative result once and persist the immutable capture before publishing a presentation. Preserve the existing required-source, signature, selection and settlement owners. | +| Shared TypeScript context projector | Bind Goal/Agent/session/epoch, capture and digest; compare registered source bodies; keep current control/source facts fresh; choose smaller exact delta or full. Reconstruct against the complete target before release. | +| Codex lifecycle adapter | Observe startup/resume/fork/compaction and invalidate session continuity. Carry a small event and capture recovery reference, never the complete packet. Hook configuration/trust and event coverage must be qualified on each actual host. | +| Existing local capture/detail IO | Read the original capture in bounded pages, preserving complete text and validating target identity/digest. A preview or successful file write does not establish complete consumption. No new guard or quota operation is needed to recover presentation. | + +**Sequence and failure handling.** First use or unknown continuity selects full. +Only an intact, observed delivery in the same session/epoch may become the warm +baseline; a stored file alone cannot. Generate the next presentation against that +baseline while retaining the current full capture. If the epoch moves during +preparation, discard the pending delta and use full recovery. If the event arrives +after publication, its small recovery reference points to that same current +capture. A lost/truncated delta does not advance the confirmed baseline. Bound +ancestry and force periodic full refresh; do not accumulate unbounded patch chains. + +The recovery wrapper catches primary-handler errors and emits a bounded full-read +reference from the saved capture. Missing event observations or unknown wrapper +health invalidate warm eligibility at the next ordinary entrypoint. Retry only +presentation IO; never repeat admission or business effects. A missing/corrupt +capture retains the existing source-recovery failure rather than fabricating a +baseline. Treat event detection, intact delivery and complete file consumption as +separate evidence. Recovery guidance does not become a new enforced user gate. + +**Implementation and qualification checkpoint.** The shared projector now has +full/delta preparation and same-capture reconstruction through the existing Turn +RPC, reused for ordinary quota context. Exact duplicate selected-Todo titles are +elided only when the complete body is identical; distinct titles and source state +remain intact. Real File/SQLite quota tests cover ordinary CLI and Codex App +profiles, shared Turn source content, epoch loss, corrupt delta, unchanged source +state and zero extra spend. This is a shipped-code proposal, not an enabled native +warm adapter or installed App delivery. + +The native transport probe uses the actual shared projector, bounded tool reads +and a small `SessionStart(compact)` reference. Its deterministic local provider +requests the reads; it does not prove an autonomous solver follows the recovery +route. Preserve the earlier bulk-hook and fail-open negatives. Qualify complete +first read, warm delta, compaction, primary-handler failure and full target read +from outgoing model inputs. Include every recovery page in cost and call counts; +a smaller hook or packet alone is not a saving. The next integration is the +existing ordinary quota capture/detail path plus the Codex lifecycle adapter for +both phase-one consumers, without a second runner or a new approval flow. + Qualification compares matched normal, replan and wait/recovery workloads with optional capability modes unchanged. Include first delivery, warm reuse, reset, deletion, array reordering, lost/duplicated/out-of-order deltas, partial output, diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index bdf46f4136..67f2e5331b 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -519,8 +519,12 @@ live 模型结果、App 采用或以下三项收敛验收完成。 | Turn 采用 | 消费同一完整/增量上下文 owner,同时保留 Turn 签名和外层 executor 效果归属。host 独占的结算执行与手动 CLI agent 的可执行结算义务仍有区别。 | | 表示选择 | 计入绑定和删除元数据后,只有更小才发送 delta,否则发送全量。不为字节目标截断值或改变来源选择。 | -**来源与变化边界。** 首批面向注册的完整 Goal 叙述、canonical 验收要求正文及选中 Todo 的 -正文/备注,保留精确字符串与来源身份。可复用范围由现有 source adapter 和 typed 字段契约 +**来源与变化边界。** 包含注册的完整 Goal 叙述、canonical 验收要求正文及选中 Todo 的 +正文/备注,保留精确字符串与来源身份。Explore typed owner 另提供可复用的证据与指令 +子树:已记录 findings、measurement feedback 及其来源和适用条件,以及引导与回写示例。 +编排 gate、选中/拒绝分支、来源健康、分页版本和未知字段保持每次完整给出。finding 中的 +证据状态属于精确证据记录,不是当前执行准入;数组变化时整体替换,不忽略可复用数据里的 +时间戳或 revision。可复用范围由现有 source adapter 和 typed 字段契约 确定,不分类句子含义。当前来源版本、完整性、失败、status/claim、User gate、目标采用及 其他执行控制字段仍每次完整给出。不能可靠拆分的未知或混合来源保持全量。每个已知来源槽 按既有来源种类和 canonical 实体身份生成稳定 key,身份重复或不明确时回退全量。不能将整个 @@ -528,6 +532,12 @@ live 模型结果、App 采用或以下三项收敛验收完成。 内部的数组在首版仍整体替换;字段改变时完整替换,包括迟到的约束,删除使用显式路径。 owner 在释放 delta 前重建并与完整目标逐项比较。 +稳定来源身份只作为内部匹配 key。wire 中未变子树带可读的当前路径,只有位置移动时 +才另带原路径;替换携带路径与完整值,删除携带原路径。不要给每个子树重复长实体身份 +及位置映射。重建前用当前 typed split 核对路径,wire 路径不能自行扩大复用范围。 +完整/增量比较计入全部绑定、路径、替换与删除开销。仅正文白名单的节省率不能代表 +整包的可复用量;应将可复用字节和元数据开销与总字节分开报告。 + **首个真实调用方与压缩竞态。** 当前原生 Codex CLI session adapter 只观察会话身份, capture/handoff 的 release 发生在 stdout 交付之前,两者都不提供所需的模型输入连续性 证明。读取保存的原生历史,或在模型请求发出后才观察到 compaction,也已经太迟。因此首个 @@ -606,6 +616,44 @@ oracle 检查实际发出的模型输入,而非 hook 日志或 stdout。这只 最终输入组装边界证明这一集成,再让共享 typed 投影采用已验证的接缝。本检查点保持 一期未完成,不将合成 usage 计数解释为 token 或成果收益。 +**轻量生命周期 hook 与捕获文件恢复(架构修订,待 review)。** 上述原生 hook +负例继续作为整包 hook 注入的证据,不应成为共享实现的前置阻塞。生命周期事件可以 +使基线失效,而不传输大型要求正文。恢复通过普通入口完整读取不可变捕获;因此, +恢复控制消息可以先于完整来源读取,依赖该来源的业务工作仍须等完整来源读取后执行。 +这修订了此前“压缩后第一条模型请求必须装入全部恢复正文”的要求,不增加 owner +问询、准入或扣账。 + +| Owner | 职责与边界 | +|---|---| +| 既有 quota/Turn producer | 只计算一次完整权威结果,先持久化不可变捕获,再发布展示形式。保留既有必读来源、签名、选择和结算 owner。 | +| 共享 TypeScript 上下文投影 | 绑定 Goal/Agent/session/epoch、capture 和摘要;比较已注册来源正文;每次保留新鲜控制与来源事实;选择较小的精确差分或全量,发布前按完整目标重建核对。 | +| Codex 生命周期 adapter | 观察 startup/resume/fork/compaction,使会话连续性失效;只携带小事件和捕获恢复引用,不传整个 packet。每种真实宿主分别验证 hook 配置、信任与事件覆盖。 | +| 既有本地 capture/detail IO | 有边界地分段读取原捕获,保留完整正文,核对目标身份和摘要。预览或写文件成功不证明完整消费。恢复展示不需要新 guard 或 quota 操作。 | + +**时序与失败处理。** 首次使用或连续性未知时走全量;只有同会话、同 epoch 内 +完整且可观察的交付才能成为温态基线,文件存在本身不够。依据该基线生成下一展示, +同时留存当前完整捕获。生成期间 epoch 改变则丢弃待发布差分,走全量恢复;事件在 +发布后到达时,小型恢复引用指向同一份当前捕获。丢失或截断的 delta 不推进确认基线。 +限制差分继承深度并定期全量刷新,不积累无限 patch 链。 + +恢复 wrapper 捕获主 handler 错误,从保存的捕获生成有界的全文读取引用。事件观察 +缺失或 wrapper 健康未知时,在下一次普通入口取消温态资格。只重试展示 IO,不重放 +准入或业务效果。捕获缺失/损坏继续走既有来源恢复失败,不能伪造基线。事件检测、 +完整交付与完整文件消费分别保留证据;恢复提示不能变成新增的强制用户问询门槛。 + +**实现与验证检查点。** 共享投影已通过既有 Turn RPC 提供 full/delta 准备和同捕获 +重建,也复用于普通 quota 上下文。仅当完整正文逐字相同时省略重复的 selected-Todo +标题,保留不同标题及来源状态。真实 File/SQLite quota 测试覆盖普通 CLI、Codex App +模式、共享 Turn 来源内容、epoch 丢失、差分损坏、来源状态不变和零额外扣账。这是 +产品代码提案,还不是已启用的原生温态 adapter 或安装态 App 交付。 + +原生传输 probe 使用实际共享投影、有界工具读取和很小的 `SessionStart(compact)` +引用。确定性的本机 provider 请求相应读取,不证明自主 solver 会遵循恢复路线。 +保留之前整包 hook 与 fail-open 负例;从实际发出的模型输入验证首次完整读取、温态 +差分、压缩、主 handler 失败和完整目标读回。成本与调用次数须包含每个恢复分段, +hook 或 packet 变小本身不等于节省。下一接入是既有普通 quota capture/detail 路径与 +Codex 生命周期 adapter,覆盖一期两个调用方,不增加第二套 runner 或审批流程。 + 在可选能力模式不变的条件下,比较匹配的普通执行、重规划和等待/恢复负载,覆盖首次交付、 温态复用、reset、删除、数组重排、丢失/重复/乱序 delta、部分输出、来源/身份不匹配及迟到 要求变化。重建结果须等于完整当前投影,fresh 控制事实始终不得省略。统计首次和补读的模型输入、上下文占用、 diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 186ed398f4..4e0fe1a4ad 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -20,7 +20,7 @@ teaches a reusable control-plane lesson. | `cli_json_file_argument_contract_gap` | A `--*-json` option repeatedly rejects inline JSON as an unreadable object; reshaping it does not help, or Markdown shows only an invalid-request status. | The command's current help, file-input loader, structured error, Markdown renderer, and a synthetic valid-file recovery. | The option accepts a file path but help does not say so; file-read, JSON-decode and root-shape failures are conflated, or the renderer omits the error reason. | Clarify the existing file contract without silently adding inline input. Separate read, parse and shape diagnostics without echoing supplied contents or paths; preserve the reason in each output format. Validate missing/inline input, malformed/non-object files, same-path recovery and unchanged typed admission. Repair the owning CLI adapter, not provider readiness or Goal retry authority. | | `public_source_receipt_clock_precedes_reads` | Multiple sources share a batch-start observation time, or a receipt reports completion before its final source read returns. | Installed provider source pin, actual execution process bounds, source/receipt timestamps, a controlled advancing-clock case and canonical readback. | The provider captures one UTC timestamp before its HTTP loop and reuses it for source observations and completion. | Record each successful source observation after its bounded read and record completion after all attempts, including partial, empty and failed results. Keep publication/first-availability unknown, retain original receipts and reuse the existing typed identity/admission/projection owner. Verify late failures cannot move the completion boundary before an earlier successful observation; do not claim authenticated execution or downstream coverage from timestamp correctness. | | `public_source_failure_step_erasure` | A public-source plan is ready, but execution reports only a generic unavailable read, hiding whether visibility, download or text validation failed. | Original exact-plan receipt, source index, read-only process bounds, provider source pin and shared readback. | One catch path erased the reached provider step; readiness was mistaken for successful retrieval or the generic error was guessed to mean lost permission. | Preserve the actual reached step in existing redacted limitations, without exposing exception reasons or changing admission, retry or source status. Cover visibility, download, malformed text and partial HTTP failure through the canonical CLI and shared sinks. Keep original failed receipts; diagnose a changed producer under a bounded trial rather than repeatedly fetching the same object or freezing independent work. | -| `candidate_outcome_as_execution_blocker` | An evaluation repeatedly enters a bounded retry after rejecting individual candidates, although independent authorized evaluation remains executable. | Exact selected Todo, typed delivery outcome and result class, committed retry origin/due time, validated evidence delta, current vision/frontier and installed source pin. | The caller classified candidate uncertainty or rejection as blocked execution for the whole work item; a stale runtime may also explain the retry as an unrelated user gate. | Correct the existing Todo/vision and classify new validated evaluation evidence as progress only when it actually advances acceptance. Stop answered finite questions; keep pure reuse non-progress. Preserve genuine blocked retries and independent clocks. Reproduce retry versus ordinary notice through the existing quota/interaction/scheduler path, verify the installed revision contains the owner fix, and retain old receipts. Never remove cooldowns globally or manufacture progress. | +| `candidate_outcome_as_execution_blocker` | An evaluation repeatedly enters a bounded retry after rejecting individual candidates, although independent authorized evaluation remains executable. | Exact selected Todo, typed delivery outcome and result class, committed retry origin/due time, validated evidence delta, current vision/frontier and installed source pin. | The caller classified candidate uncertainty or rejection as blocked execution for the whole work item; a stale runtime may also explain the retry as an unrelated user gate. | Correct the existing Todo/vision and classify new validated evaluation evidence as progress only when it actually advances acceptance. Stop answered finite questions; keep pure reuse non-progress. Scope a missing dependency to the acceptance it actually prevents: missing experiment cost data or one Host integration must not block independent shared implementation, local validation or another authorized consumer. A fault-injection failure calls for a bounded recovery implementation and retest, not an assumed requirement to wait for a new Host interface. Preserve genuine blocked retries and independent clocks. Reproduce retry versus ordinary notice through the existing quota/interaction/scheduler path, verify the installed revision contains the owner fix, and retain old receipts. Never remove cooldowns globally or manufacture progress. | | `due_monitor_display_order_starvation` | A recurring Monitor is selected on every wake while equally ranked older due Monitors never run. | Complete eligible due set, claim/profile/priority ranks, due instants, display caps, selected Todo and exact poll/Turn receipts. | Display index determines execution order before a one-row cap; the first Monitor becomes due again before the next admitted Turn. | In the existing typed planning owner, retain scope and eligibility fences, then rank equally eligible work by due instant before display index and truncation. Cover recurring reschedules, stable ties, capability/claim negatives and real provider replay. Preserve exact per-Turn settlement; fairness does not authorize same-wake loops, extra quota spend or a shorter owner cadence. | | `receipt_freshness_commit_gap` | A receipt-bound follow-up passes its first freshness check, then commits an older Turn after a newer receipt arrives during lock contention or final persistence. An immediate retry correctly reports the old Turn as stale. | Ordered receipt events, the exact Turn ID, state-lock timing, commit result, and persisted state identity. | Receipt freshness and scheduler persistence did not hold the same cross-runtime append fence, so a newer receipt could arrive after the final check. | Keep the early check for fast rejection. Acquire the scheduler-state lock before the receipt-log mutation lock, then retain both locks from the final receipt read through legacy migration and the final state rename. Make every receipt writer use the same mutation lock. Return the existing typed missing or stale failure without mutation. Prove both lock-wait and read-to-rename interleavings with real writers, then scan for reverse lock order. | | `notification_receipt_scope_and_frontier_gap` | A recovered blocker never notifies again, later notices starve, or a new channel claims delivery from an old receipt. | Canonical blocked/open/blocked transitions, full candidate frontier, destination-bound private receipts, and public readback. | Receipt lookup omitted destination/generation, recovery used an incomplete status list, or the batch limit was applied before delivered effects were removed. | Reuse canonical status and notice builders; retire only explicit recovery or supersession facts, preserve missing-row uncertainty, bind receipts to destination and durable generation, and budget pending effects with fair retries. Validate repeated same-clock recovery, more than one batch, persistent failures, target switches and default-off parity; keep historical receipts private. | From fb2b6dca9e67e14482bc59f58f41bbd1ab1a0e16 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:30:58 +0800 Subject: [PATCH 04/29] feat(context): diff complete packets with exact array edits Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/explore_turn_context.ts | 6 +- loopx/control_plane/quota/context_delivery.ts | 273 +++++++++++++----- loopx/control_plane/quota/turn_envelope.py | 10 +- .../test_codex_context_boundary.py | 19 +- .../test_context_delivery_cli.py | 34 ++- .../control_plane_ts/context_delivery.test.ts | 151 ++++++++-- 6 files changed, 376 insertions(+), 117 deletions(-) diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index e998b078bb..4d864d2ebf 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -12,7 +12,7 @@ import {projectExploreMeasurementFeedback} from "./explore_measurements.ts"; /** Reusable evidence and instructions in this owner's read model. Current * selection, eligibility, source revisions and pagination stay outside this - * split. A changed evidence array is replaced in full, including its status, + * split. Exact array edits retain every change, including its status, * provenance and applicability; no timestamp or revision is normalized away. */ export function exploreReusableContextPaths(): string[][] { return [ @@ -20,7 +20,9 @@ export function exploreReusableContextPaths(): string[][] { "path_delta_attachment_template", "linked_question_attachment_template", "optional_question_evolution_example", "result_writeback_guidance", "result_attachment_template", "optional_measurement_example", "measurement_guidance", - "record_node_template", "record_finding_template", "guidance"].map(key => ["graph", key]), + "record_node_template", "record_finding_template", "guidance", + "result_writeback_option", "result_writeback_inline_option", "result_writeback_inline_field", + "result_attachment_schema", "path_delta_attachment_schema"].map(key => ["graph", key]), ["harness", "guidance"], ]; } diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index f86f6b4bae..e3d7161061 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -1,4 +1,4 @@ -/** Presentation of an immutable work-context capture. No admission or state writes. +/** Presentation of immutable quota, Turn and work-context captures. No admission or state writes. * A candidate delta is usable only at a host's final model-input assembly boundary; * a previous CLI output or a successful capture handoff is not retention proof. */ import type {JsonObject} from "../effect_program.ts"; @@ -6,11 +6,10 @@ import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_d import {canonicalAuthorityJson, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {reusableTurnContextPaths} from "../capability_hooks.ts"; -const VERSION = "work_context_delivery_v1"; +const VERSION = "context_delivery_v1"; const MAX_ANCESTRY = 32; type Path = (string | number)[]; -type Part = {path: Path; value: unknown}; -type Split = {fresh: JsonObject; parts: Map; valid: boolean}; +type Split = {fresh: JsonObject; valid: boolean}; const clone = (value: T): T => structuredClone(value); const equal = (a: unknown, b: unknown): boolean => a === undefined || b === undefined ? a === b : JSON.stringify(canonicalAuthorityJson(a)) === JSON.stringify(canonicalAuthorityJson(b)); @@ -37,7 +36,7 @@ function parentAt(root: JsonObject, path: Path): Record(), identities = new Set(); + const fresh = clone(context), identities = new Set(); let valid = context.complete === true; const add = (identity: string, path: Path, structured = false) => { if (identities.has(identity)) { valid = false; return; } @@ -46,9 +45,12 @@ function splitContext(context: JsonObject): Split { if (!Object.hasOwn(parent, key)) return; // These adapters own literal text, not arbitrary nested control records. if (!structured && parent[key] !== null && typeof parent[key] !== "string") { valid = false; return; } - parts.set(identity, {path, value: parent[key]}); delete parent[key]; }; + add("work_context_instruction", ["instruction"]); + const userRead = jsonObject(jsonObject(jsonObject(fresh.user_todos)?.authority_read)?.todo_read_model); + if (Array.isArray(userRead?.contract_fields) && userRead.contract_fields.every(field => typeof field === "string")) + add("user_todo_contract_fields", ["user_todos", "authority_read", "todo_read_model", "contract_fields"], true); const sources = Array.isArray(fresh.sources) ? fresh.sources : []; const sourceIds = new Set(); for (let i = 0; i < sources.length; i++) { @@ -56,6 +58,12 @@ function splitContext(context: JsonObject): Split { if (!source || !content || content.ok === false || content.read_complete === false) continue; const prefix: Path = ["sources", i, "content"]; const kind = source.source; + const known = ["goal_state", "selected_todo", "goal_acceptance"].includes(String(kind)) || reusableTurnContextPaths(source).length > 0; + if (known && typeof source.reason === "string") add(`source_reason:${i}`, ["sources", i, "reason"]); + const readModel = jsonObject(jsonObject(content.authority_read)?.todo_read_model); + if (kind === "selected_todo" && Array.isArray(readModel?.contract_fields) + && readModel.contract_fields.every(field => typeof field === "string")) + add(`todo_contract_fields:${i}`, [...prefix, "authority_read", "todo_read_model", "contract_fields"], true); if (kind === "goal_state" && typeof content.state_file === "string" && typeof content.text === "string" && Array.isArray(content.excluded_todo_regions)) { const id = JSON.stringify([kind, content.state_file]); @@ -113,12 +121,173 @@ function splitContext(context: JsonObject): Split { } } } - return {fresh, parts, valid}; + return {fresh, valid}; } -function targetBinding(request: JsonObject, context: JsonObject): JsonObject { +/** The projection surface is a transport binding, never a new admission mode. + * Keep one exact codec for quota and Turn; source-only callers use the same codec. */ +const SURFACES = ["work_context", "quota_packet", "turn_envelope"] as const; +type Surface = typeof SURFACES[number]; +type Delta = {value: unknown} | {changed: Record; removed?: string[]} + | {items: Record; length?: number}; +const pathKey = (path: Path): string => JSON.stringify(path); + +// These are existing quota projections. Unknown root fields stay fresh. Within +// these complete, capture-bound projections, exact reuse does not change any +// obligation or make a historical source authoritative. +const REUSABLE_PACKET_FIELDS = new Set([ + "registry", "runtime_root", "source", "project_asset_source", "long_task_cadence_hint", + "plan_summary", "reason", "recommended_action", "heartbeat_recommendation", + "goal_frontier_projection", "todo_write_hint", "vision_continuation_audit", "agent_lane_next_action", + "user_todo_summary", "agent_todo_summary", "blocked_priority_fallback", + "autonomous_backlog_candidates", "autonomous_monitor_candidates", "next_action_projection_warning", + "completed_todo_archive_warning", "promotion_readiness_warning", "next_handoff_condition", "agent_command", + "automation_cadence", "automation_cadence_readback", "open_count", + "turn_start_capability_hook_dispatch", "rollout_event", "reward_memory_recall", "todo_summary_projection", + "planning_horizon", "autonomous_replan_obligation", "replan_action_packet", "bounded_research_frontier", + "action_portfolio", "autonomous_replan_decision", "active_state_next_action", +]); + +/** Current controls are forced into the change tree even when equal. This avoids + * duplicating them in both a fresh frame and a patch. Static authoring guidance + * remains binding via exact reuse; commands, conditions and identity stay fresh. */ +function freshPaths(context: JsonObject, surface: Surface): {paths: Path[]; valid: boolean} { + const paths: Path[] = []; + let valid = true; + const leaves = (value: unknown, path: Path) => { + if (Array.isArray(value) && value.length) value.forEach((v, i) => leaves(v, [...path, i])); + else if (jsonObject(value) && Object.keys(value as JsonObject).length) + Object.entries(value as JsonObject).forEach(([k, v]) => leaves(v, [...path, k])); + else paths.push(path); + }; + const work = (value: unknown, path: Path) => { + const source = jsonObject(value); + if (!source) { paths.push(path); return; } + const split = splitContext(source); valid &&= split.valid; + leaves(split.fresh, path); + }; + const vision = (value: unknown, path: Path) => { + const audit = jsonObject(value); + if (!audit) { paths.push(path); return; } + for (const [key, item] of Object.entries(audit)) { + if (key === "required_before_closeout") continue; // complete binding instructions in the exact patch + if (key !== "vision_gap_judge" || !jsonObject(item)) { paths.push([...path, key]); continue; } + for (const field of Object.keys(item as JsonObject)) + if (!["agent_judge_instruction", "evidence_read_instruction", "registry_read_instruction", "done_only_when", "continue_when"].includes(field)) + paths.push([...path, key, field]); + } + }; + const settlement = (value: unknown, path: Path) => { + const plan = jsonObject(value); + if (!plan) { paths.push(path); return; } + for (const [key, item] of Object.entries(plan)) { + if (key !== "ordered_steps" || !Array.isArray(item)) { paths.push([...path, key]); continue; } + item.forEach((step, i) => { + if (!jsonObject(step)) { paths.push([...path, key, i]); return; } + for (const [field, detail] of Object.entries(step as JsonObject)) { + if (["precondition", "vision_authoring", "guidance"].includes(field)) continue; + if (field === "optional_attachments" && Array.isArray(detail)) { + detail.forEach((attachment, j) => { + const item = jsonObject(attachment), prefix = [...path, key, i, field, j]; + if (!item) { paths.push(prefix); return; } + for (const property of Object.keys(item)) if (property !== "guidance") paths.push([...prefix, property]); + }); + } else paths.push([...path, key, i, field]); + } + }); + } + }; + if (surface === "work_context") work(context, []); + else if (surface === "turn_envelope") { + for (const [key, value] of Object.entries(context)) { + if (key === "work_context") work(value, [key]); + else if (key === "writeback") { + const writeback = jsonObject(value); + if (!writeback) { paths.push([key]); continue; } + for (const [field, item] of Object.entries(writeback)) { + if (field === "settlement_plan") settlement(item, [key, field]); + else paths.push([key, field]); + } + } else paths.push([key]); + } + } else { + for (const [key, value] of Object.entries(context)) { + if (key !== "interaction_contract") { if (!REUSABLE_PACKET_FIELDS.has(key)) paths.push([key]); continue; } + const contract = jsonObject(value); + if (!contract) { paths.push([key]); continue; } + for (const [channel, fields] of Object.entries(contract)) { + if (!["agent_channel", "cli_channel"].includes(channel) || !jsonObject(fields)) { + paths.push([key, channel]); continue; + } + for (const [field, item] of Object.entries(fields as JsonObject)) { + const path = [key, channel, field]; + if (channel === "agent_channel" && field === "work_context") work(item, path); + else if (channel === "cli_channel" && field === "settlement_plan") settlement(item, path); + else if (field === "vision_continuation_audit") vision(item, path); + else paths.push(path); + } + } + } + } + return {paths, valid}; +} + +function changeTree(before: unknown, after: unknown, forced: Path[]): Delta | null { + const exact = new Set(forced.map(pathKey)), ancestors = new Set(); + for (const path of forced) for (let i = 0; i < path.length; i++) ancestors.add(pathKey(path.slice(0, i))); + const visit = (a: unknown, b: unknown, path: Path): Delta | null => { + const key = pathKey(path), replacement: Delta = {value: clone(b)}; + if (exact.has(key)) return replacement; + if (!ancestors.has(key) && equal(a, b)) return null; + let delta: Delta; + if (jsonObject(a) && jsonObject(b)) { + const old = a as JsonObject, next = b as JsonObject; + const changed: [string, Delta][] = []; + for (const [field, value] of Object.entries(next)) { + const child = Object.hasOwn(old, field) ? visit(old[field], value, [...path, field]) : {value: clone(value)}; + if (child) changed.push([field, child]); + } + const removed = Object.keys(old).filter(field => !Object.hasOwn(next, field)); + delta = {changed: Object.fromEntries(changed), ...(removed.length ? {removed} : {})}; + } else if (Array.isArray(a) && Array.isArray(b)) { + const items: [string, Delta][] = []; + b.forEach((value, i) => { + const child = i < a.length ? visit(a[i], value, [...path, i]) : {value: clone(value)}; + if (child) items.push([String(i), child]); + }); + delta = {items: Object.fromEntries(items), ...(a.length !== b.length ? {length: b.length} : {})}; + } else return replacement; + return bytes(delta) < bytes(replacement) ? delta : replacement; + }; + return visit(before, after, []); +} + +/** Array indices address the exact hashed baseline, not inferred entity ids. + * Reorders, duplicate values and length changes are lossless structural edits. + * Full strings are replaced; no timestamp/revision normalization or prose diff. */ +function applyTree(before: unknown, delta: Delta | null): unknown { + if (delta === null) return clone(before); + if ("value" in delta) return clone(delta.value); + if ("items" in delta) { + if (!Array.isArray(before)) throw new Error("array baseline missing"); + const result = clone(before); + if (delta.length !== undefined) result.length = delta.length; + for (const [key, item] of Object.entries(delta.items)) result[Number(key)] = applyTree(before[Number(key)], item); + return result; + } + const source = requireJsonObject(before, "object baseline"), result = clone(source); + for (const key of delta.removed ?? []) delete result[key]; + for (const [key, item] of Object.entries(delta.changed)) { + // Avoid prototype setters for JSON property names such as __proto__. + Object.defineProperty(result, key, {value: applyTree(Object.hasOwn(source, key) ? source[key] : undefined, item), + enumerable: true, writable: true, configurable: true}); + } + return result; +} + +function targetBinding(request: JsonObject, context: JsonObject, surface: Surface): JsonObject { const scope = requireJsonObject(request.scope, "context delivery scope"); - const binding: JsonObject = {projection_version: VERSION}; + const binding: JsonObject = {projection_version: VERSION, surface}; for (const key of ["goal_id", "agent_id", "session_id", "context_epoch"]) { binding[key] = requireNonEmptyString(scope[key], `scope.${key}`); } @@ -128,17 +297,19 @@ function targetBinding(request: JsonObject, context: JsonObject): JsonObject { } function compatible(base: JsonObject, target: JsonObject): boolean { - return ["goal_id", "agent_id", "session_id", "context_epoch", "projection_version"] + return ["goal_id", "agent_id", "session_id", "context_epoch", "projection_version", "surface"] .every(key => base[key] === target[key]); } +function surfaceOf(value: unknown): Surface { + if (!SURFACES.includes(value as Surface)) throw new Error("unsupported context delivery surface"); + return value as Surface; +} -/** Host-facing preparation, also used through the existing Turn projection RPC. - * `retained` is an exact host observation, not an Agent-supplied CLI option. - * The host MUST re-evaluate at final input assembly, then ACK only intact input. - * Unsupported native hosts keep their existing complete output unchanged. */ +/** Final-input host boundary only. Unsupported/uncertain retention returns full; + * the host ACKs intact delivery, never file creation or an Agent assertion. */ export function prepareContextDelivery(request: JsonObject): JsonObject { - const context = requireJsonObject(request.context, "current work context"); - const target = targetBinding(request, context); + const context = requireJsonObject(request.context, "current projection"); + const surface = surfaceOf(request.surface ?? "work_context"), target = targetBinding(request, context, surface); const full = (reason: string): JsonObject => ({schema_version: VERSION, mode: "full", target, ancestry: 0, reason, context: clone(context)}); const baseline = jsonObject(request.baseline), retained = jsonObject(request.retained); @@ -148,79 +319,43 @@ export function prepareContextDelivery(request: JsonObject): JsonObject { || binding.context_digest !== digest(prior)) return full("baseline_unavailable"); const depth = baseline?.ancestry; if (!Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) >= MAX_ANCESTRY) return full("full_refresh_due"); - const before = splitContext(prior), after = splitContext(context); + const before = freshPaths(prior, surface), after = freshPaths(context, surface); if (!before.valid || !after.valid) return full("source_not_reusable"); - const replacements: JsonObject[] = [], removals: Path[] = [], reused: JsonObject[] = []; - for (const [id, part] of after.parts) { - const previous = before.parts.get(id); - if (!previous || !equal(previous.value, part.value)) replacements.push({path: part.path, value: clone(part.value)}); - else reused.push({path: part.path, ...(!equal(previous.path, part.path) ? {from: previous.path} : {})}); - } - for (const [id, part] of before.parts) if (!after.parts.has(id)) removals.push(part.path); + const changes = changeTree(prior, context, after.paths); const delta: JsonObject = {schema_version: VERSION, mode: "delta", target, base: binding, - ancestry: Number(depth) + 1, context: after.fresh, reused, replacements, removals, - instruction: "Fresh control and source metadata are complete. Reuse unchanged registered subtrees from the confirmed baseline; their requirements remain binding. Apply exact replacements/removals. Lost context requires automatic same-capture full delivery, not an owner question."}; + ancestry: Number(depth) + 1, changes, + instruction: "Current controls are inline even when unchanged. Other fields inherit the confirmed baseline; all requirements remain binding. Changes contain complete values, object changes/removals or exact array-index edits. Lost context triggers automatic full recovery from this capture, never an owner question."}; if (bytes(delta) >= bytes(full("delta_not_smaller"))) return full("delta_not_smaller"); - // An independent reconstruction check guards the released candidate. - const restored = restoreContextDelivery({delivery: delta, baseline, current: full("recovery")}); - if (restored.mode !== "delta" || !equal(restored.context, context)) return full("reconstruction_failed"); + if (!equal(applyTree(prior, changes), context)) return full("reconstruction_failed"); return delta; } -/** Recover against the immutable CURRENT capture, never by rerunning admission. - * A changed epoch, truncated/lost/reordered delivery or failed reconstruction - * returns that full capture. Fresh authority/source checks remain caller-owned. */ +/** Validate the exact change tree against the immutable CURRENT capture before + * applying it. Wire keys never choose freshness, scope or arbitrary write paths. + * Recovery neither reruns admission nor mutates Goal/quota state. */ export function restoreContextDelivery(request: JsonObject): JsonObject { const current = requireJsonObject(request.current, "current full capture"); const currentContext = requireJsonObject(current.context, "current full context"); const target = requireJsonObject(current.target, "current capture binding"); - if (current.mode !== "full" || current.schema_version !== VERSION || target.context_digest !== digest(currentContext)) { + if (current.mode !== "full" || current.schema_version !== VERSION || target.projection_version !== VERSION || target.context_digest !== digest(currentContext)) { throw new Error("current full capture is invalid"); } const fallback = (): JsonObject => ({...clone(current), reason: "automatic_full_recovery", ancestry: 0}); try { + const surface = surfaceOf(target.surface); const delivery = requireJsonObject(request.delivery, "delivery"), baseline = requireJsonObject(request.baseline, "baseline"); const base = requireJsonObject(baseline.target, "baseline target"), context = requireJsonObject(baseline.context, "baseline context"); if (delivery.schema_version !== VERSION || delivery.mode !== "delta" || !equal(delivery.target, target) || !equal(delivery.base, base) || !compatible(base, target) || base.context_digest !== digest(context) || !Number.isSafeInteger(baseline.ancestry) || Number(baseline.ancestry) < 0 || delivery.ancestry !== Number(baseline.ancestry) + 1 || Number(delivery.ancestry) > MAX_ANCESTRY) return fallback(); - const split = splitContext(context), expected = splitContext(currentContext); - if (!split.valid || !expected.valid || !Array.isArray(delivery.removals) - || !Array.isArray(delivery.replacements) || !Array.isArray(delivery.reused)) return fallback(); - const removed = [...split.parts].filter(([id]) => !expected.parts.has(id)).map(([, part]) => part.path); - if (!equal(delivery.removals, removed)) return fallback(); - // Stable source identities stay internal. Wire paths are readable and occur - // once; a moved source adds its prior path rather than repeating long ids. - const rows = (items: unknown[]): Map => { - const result = new Map(); - for (const raw of items) { - const item = requireJsonObject(raw, "subtree"); - if (!Array.isArray(item.path)) throw new Error("subtree path missing"); - const key = JSON.stringify(item.path); - if (result.has(key)) throw new Error("duplicate subtree path"); - result.set(key, item); - } - return result; - }; - const replacements = rows(delivery.replacements), reused = rows(delivery.reused); - if (replacements.size + reused.size !== expected.parts.size) return fallback(); - const result = clone(requireJsonObject(delivery.context, "fresh context")); - for (const [id, part] of expected.parts) { - const key = JSON.stringify(part.path), replacement = replacements.get(key), reuse = reused.get(key); - if (Boolean(replacement) === Boolean(reuse)) return fallback(); - let value: unknown; - if (replacement) { - if (!Object.hasOwn(replacement, "value")) return fallback(); - value = replacement.value; - } else { - const previous = split.parts.get(id); - if (!previous || !equal(reuse!.from ?? reuse!.path, previous.path)) return fallback(); - value = previous.value; - } - // Write only independently derived current paths, never wire paths. - parentAt(result, part.path)[part.path[part.path.length - 1]] = clone(value); - } + const before = freshPaths(context, surface), after = freshPaths(currentContext, surface); + if (!before.valid || !after.valid) return fallback(); + const expected = changeTree(context, currentContext, after.paths); + // Also rejects omitted unchanged controls, redundant/conflicting edits, + // malformed array indices/lengths and unsupported operations before applying. + if (!equal(delivery.changes, expected)) return fallback(); + const result = applyTree(context, expected); if (!equal(result, currentContext)) return fallback(); return {...clone(delivery), context: result}; } catch { return fallback(); } diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 9f8c78b75c..fa53d3ce98 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -107,24 +107,26 @@ def bind_turn_decision_capture( ) -def prepare_work_context_delivery( +def prepare_context_delivery( context: Mapping[str, Any], *, scope: Mapping[str, str], capture_id: str, baseline: Mapping[str, Any] | None = None, retained: Mapping[str, Any] | None = None, + surface: str = "work_context", ) -> dict[str, Any]: """Prepare at a Host's final input boundary, never from CLI self-attestation. - Quota and Turn hosts pass the same work_context and immutable capture id. + Quota and Turn hosts pass their complete immutable projection (or the shared + work_context) and its capture id. Surface binding prevents cross-projection reuse. Unsupported hosts omit retention and keep complete delivery. A candidate is not an ACK: only intact final model-input assembly may confirm its target. """ return _turn_envelope_result("context_delivery", context=dict(context), - scope=dict(scope), capture_id=capture_id, + scope=dict(scope), capture_id=capture_id, surface=surface, baseline=dict(baseline) if baseline is not None else None, retained=dict(retained) if retained is not None else None) -def restore_work_context_delivery( +def restore_context_delivery( delivery: Mapping[str, Any], *, baseline: Mapping[str, Any] | None, current: Mapping[str, Any], ) -> dict[str, Any]: diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index 268fbdec04..35c87bccf6 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -135,27 +135,34 @@ def _configure_provider(home, url): @pytest.mark.parametrize("host", ["exec", "app_server"]) @pytest.mark.parametrize("failed_primary", [False, True]) -def test_native_shared_delta_with_small_compact_recovery_reference(tmp_path, host, failed_primary): +@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"]) +def test_native_shared_delta_with_small_compact_recovery_reference(tmp_path, host, failed_primary, surface): """Real native input: shared delta, small event hook, same-capture file read. The deterministic provider requests the necessary reads. This establishes transport ordering/recovery, not autonomous solver adoption or token savings. """ from loopx.control_plane.quota.turn_envelope import ( - prepare_work_context_delivery, restore_work_context_delivery, + prepare_context_delivery, restore_context_delivery, ) binary, env, workspace, home = _native_fixture(tmp_path) scope = dict(goal_id="fixture", agent_id="agent", session_id="synthetic-session", context_epoch="before-compact") source = {"complete": True, "sources": [{"source": "selected_todo", "content": { "todo": {"todo_id": "todo_fixture", "text": SOURCE, "note": "Stop before publishing.", "status": "open"}}}]} - base = prepare_work_context_delivery(source, scope=scope, capture_id="capture-a") target_source = json.loads(json.dumps(source)) target_source["sources"][0]["content"]["todo"]["note"] = "Stop before publishing or deploying." - target = prepare_work_context_delivery(target_source, scope=scope, capture_id="capture-b") - delta = prepare_work_context_delivery(target_source, scope=scope, capture_id="capture-b", + def projection(context): + if surface == "quota_packet": + return {"goal_id": "fixture", "agent_id": "agent", "should_run": True, + "interaction_contract": {"agent_channel": {"delivery_allowed": True, "work_context": context}}} + return {"schema_version": "loopx_turn_envelope_v0", "action": {"delivery_allowed": True}, "work_context": context} + base_source, target_source = projection(source), projection(target_source) + base = prepare_context_delivery(base_source, scope=scope, capture_id="capture-a", surface=surface) + target = prepare_context_delivery(target_source, scope=scope, capture_id="capture-b", surface=surface) + delta = prepare_context_delivery(target_source, scope=scope, capture_id="capture-b", surface=surface, baseline=base, retained={"complete": True, "target": base["target"]}) assert delta["mode"] == "delta" - assert restore_work_context_delivery(delta, baseline=base, current=target)["context"] == target_source + assert restore_context_delivery(delta, baseline=base, current=target)["context"] == target_source files = {} for name, value in (("base", base), ("target", target), ("delta", delta)): files[name] = workspace / f"{name}.json" diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index a54a2c0dc4..ca952cb45a 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -13,7 +13,7 @@ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.quota.turn_envelope import ( - build_turn_envelope, prepare_work_context_delivery, restore_work_context_delivery, + build_turn_envelope, prepare_context_delivery, restore_context_delivery, ) from test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, @@ -49,26 +49,38 @@ def test_cli_capture_exact_delta_and_same_capture_recovery(tmp_path: Path, monke assert turn_context["instruction"] == context["instruction"] original = deepcopy(guard) scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id=profile, context_epoch="epoch-a") - full = prepare_work_context_delivery(context, scope=scope, capture_id="capture-a") + full = prepare_context_delivery(context, scope=scope, capture_id="capture-a") assert full["mode"] == "full" and full["context"] == context - warm = prepare_work_context_delivery(context, scope=scope, capture_id="capture-b", + warm = prepare_context_delivery(context, scope=scope, capture_id="capture-b", baseline=full, retained={"complete": True, "target": full["target"]}) assert warm["mode"] == "delta" assert len(json.dumps(warm).encode()) < len(json.dumps(full).encode()) / 2 - current = prepare_work_context_delivery(context, scope=scope, capture_id="capture-b") - assert restore_work_context_delivery(warm, baseline=full, current=current)["context"] == context - turn_full = prepare_work_context_delivery(turn_context, scope=scope, capture_id="turn-capture") - turn_delta = prepare_work_context_delivery(turn_context, scope=scope, capture_id="turn-capture", + current = prepare_context_delivery(context, scope=scope, capture_id="capture-b") + assert restore_context_delivery(warm, baseline=full, current=current)["context"] == context + turn_full = prepare_context_delivery(turn_context, scope=scope, capture_id="turn-capture") + turn_delta = prepare_context_delivery(turn_context, scope=scope, capture_id="turn-capture", baseline=full, retained={"complete": True, "target": full["target"]}) assert turn_delta["mode"] == "delta" - assert restore_work_context_delivery(turn_delta, baseline=full, current=turn_full)["context"] == turn_context - for baseline, delivery in [(None, warm), (full, {**warm, "reused": []})]: - recovered = restore_work_context_delivery(delivery, baseline=baseline, current=current) + assert restore_context_delivery(turn_delta, baseline=full, current=turn_full)["context"] == turn_context + for baseline, delivery in [(None, warm), (full, {**warm, "changes": None})]: + recovered = restore_context_delivery(delivery, baseline=baseline, current=current) assert recovered["mode"] == "full" and recovered["context"] == context assert recovered["target"] == current["target"] - reset = prepare_work_context_delivery(context, scope={**scope, "context_epoch": "epoch-b"}, + reset = prepare_context_delivery(context, scope={**scope, "context_epoch": "epoch-b"}, capture_id="capture-b", baseline=full, retained={"complete": True, "target": full["target"]}) assert reset["mode"] == "full" and reset["context"] == context + # The same RPC now transports the whole ordinary quota packet and whole + # Turn envelope, rather than assuming the common work_context is the packet. + for surface, projection in [("quota_packet", guard), ("turn_envelope", build_turn_envelope(guard))]: + first = prepare_context_delivery(projection, scope=scope, capture_id="whole-a", surface=surface) + next_capture = prepare_context_delivery(projection, scope=scope, capture_id="whole-b", surface=surface) + delta = prepare_context_delivery(projection, scope=scope, capture_id="whole-b", surface=surface, + baseline=first, retained={"complete": True, "target": first["target"]}) + assert delta["mode"] == "delta" + restored = restore_context_delivery(delta, baseline=first, current=next_capture) + assert restored["mode"] == "delta" and restored["context"] == projection + missing = restore_context_delivery(delta, baseline=None, current=next_capture) + assert missing["mode"] == "full" and missing["context"] == projection # Neither native source state, the signed decision nor settlement is changed # by presentation, warm reuse or recovery. A new capture is not an admission. assert state.read_bytes() == before_source diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index 9ee1968ede..7ff92ce858 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -28,6 +28,22 @@ function warm(value: JsonObject, baseline: JsonObject): JsonObject { const recover = (delivery: JsonObject, baseline: JsonObject, value: JsonObject): JsonObject => restoreContextDelivery({delivery, baseline, current: full(value, "capture-2")}); +// Read an inline value from the wire, without borrowing any baseline content. +// Tests use this independent walker to verify controls really reach each input. +function inline(delivery: JsonObject, path: (string | number)[]): unknown { + let node = delivery.changes as JsonObject; + for (let i = 0; ; i++) { + if (!node) return undefined; + if (Object.hasOwn(node, "value")) { + let value = node.value; + for (const key of path.slice(i)) value = (value as JsonObject)?.[key]; + return value; + } + if (i === path.length) return undefined; + node = ((node.changed ?? node.items) as Record)?.[path[i]]; + } +} + test("full first; unsupported or uncertain retention never becomes an Agent gate", () => { const original = context(), saved = structuredClone(original), baseline = full(original); assert.equal(baseline.mode, "full"); @@ -48,18 +64,17 @@ test("fresh control with exact warm bodies; changed source revisions do not rese const requirements = (((source(current, 2).goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject).outcomes as JsonObject[])[0].requirements as JsonObject[]; requirements[0].status = "failed"; requirements[0].agent_review = {note: "New failure invalidates the earlier plan."}; - const result = warm(current, baseline), fresh = result.context as JsonObject; + const result = warm(current, baseline); assert.equal(result.mode, "delta"); - assert.deepEqual(result.replacements, []); - assert.equal(source(fresh, 0).revision, "revision-b"); - assert.equal((source(fresh, 1).todo as JsonObject).claimed_by, "different-agent"); - assert.deepEqual(source(fresh, 3), source(current, 3)); // Mixed/unknown sources stay full. + assert.equal(inline(result, ["sources", 0, "content", "revision"]), "revision-b"); + assert.equal(inline(result, ["sources", 1, "content", "todo", "claimed_by"]), "different-agent"); + assert.equal(inline(result, ["sources", 3, "content", "large_mixed_control"]), long); assert.deepEqual(recover(result, baseline, current).context, current); assert.ok(JSON.stringify(result).length < JSON.stringify(full(current)).length / 2); assert.deepEqual(baseline.context, original); }); -test("source and requirement reordering use identities, with current positions preserved", () => { +test("exact-base source and requirement reorders preserve current positions", () => { const initial = context(); const outcomes = ((source(initial, 2).goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject).outcomes as JsonObject[]; (outcomes[0].requirements as JsonObject[]).push({id: "r2", description: "Keep a distinct requirement", status: "unproven"}); @@ -68,9 +83,10 @@ test("source and requirement reordering use identities, with current positions p (currentOutcomes[0].requirements as JsonObject[]).reverse(); (current.sources as JsonObject[]).reverse(); const delta = warm(current, baseline); - assert.equal(delta.mode, "delta"); - assert.deepEqual(delta.replacements, []); - assert.deepEqual(recover(delta, baseline, current).context, current); + if (delta.mode === "full") { + assert.equal(delta.reason, "delta_not_smaller"); + assert.deepEqual(delta.context, current); + } else assert.deepEqual(recover(delta, baseline, current).context, current); }); test("late constraints, null, empty and removed fields remain distinct", () => { @@ -81,8 +97,7 @@ test("late constraints, null, empty and removed fields remain distinct", () => { else task.note = value; const delta = warm(current, baseline); assert.equal(delta.mode, "delta"); - if (value === undefined) assert.equal((delta.removals as string[]).length, 1); - else assert.equal((delta.replacements as JsonObject[]).length, 1); + const restored = recover(delta, baseline, current); assert.equal(restored.mode, "delta"); assert.deepEqual(restored.context, current); @@ -123,9 +138,10 @@ test("final assembly race, truncated or out-of-order delta uses same-capture ful source(current, 0).text = long + "NEW STOP CONDITION"; const delta = warm(current, baseline), capture = full(current, "capture-2"); const corruptions = [ - {...delta, replacements: []}, {...delta, removals: ["unknown"]}, {...delta, ancestry: 20}, - {...delta, reused: []}, {...delta, base: {...baseline.target as JsonObject, capture_id: "lost"}}, - {...delta, context: {complete: true}}, + {...delta, changes: null}, {...delta, changes: {removed: ["sources"]}}, {...delta, ancestry: 20}, + {...delta, changes: {items: {"-1": {value: "malformed"}}, length: -1}}, + {...delta, base: {...baseline.target as JsonObject, capture_id: "lost"}}, + {...delta, changes: {value: {complete: true}}}, ]; for (const delivery of corruptions) { const restored = restoreContextDelivery({delivery, baseline, current: capture}); @@ -173,18 +189,17 @@ test("registered structured evidence is reusable; live gates and unknown fields graph.unknown_future_gate = {allowed: false}; harness.orchestration_gate = {allowed: false, reason: "stop"}; harness.selected_branches = []; - const delta = warm(current, baseline), fresh = source(delta.context as JsonObject, 0); + const delta = warm(current, baseline); assert.equal(delta.mode, "delta"); - assert.deepEqual(delta.replacements, []); - assert.equal((fresh.graph as JsonObject).writeback_results, undefined); - assert.deepEqual((fresh.graph as JsonObject).result_page, graph.result_page); - assert.deepEqual((fresh.graph as JsonObject).unknown_future_gate, {allowed: false}); - assert.deepEqual((fresh.harness as JsonObject).orchestration_gate, harness.orchestration_gate); - assert.deepEqual((fresh.harness as JsonObject).selected_branches, []); + assert.equal(inline(delta, ["sources", 0, "content", "graph", "writeback_results"]), undefined); + assert.equal(inline(delta, ["sources", 0, "content", "graph", "result_page", "revision"]), "r2"); + assert.equal(inline(delta, ["sources", 0, "content", "graph", "unknown_future_gate", "allowed"]), false); + assert.equal(inline(delta, ["sources", 0, "content", "harness", "orchestration_gate", "allowed"]), false); + assert.deepEqual(inline(delta, ["sources", 0, "content", "harness", "selected_branches"]), []); assert.deepEqual(recover(delta, baseline, current).context, current); }); -test("changed evidence replaces whole arrays including provenance, null and late refutation", () => { +test("changed evidence retains exact provenance, null and late refutation", () => { const initial = evidenceContext(), baseline = full(initial); for (const evidence of [[], null, [{finding_id: "f1", status: "refuted", summary: "A new counterexample.", evidence_refs: ["artifact:new-source"], execution_observation: {scope: "candidate-b"}}], undefined]) { @@ -193,8 +208,7 @@ test("changed evidence replaces whole arrays including provenance, null and late else graph.writeback_results = evidence; const delta = warm(current, baseline); assert.equal(delta.mode, "delta"); - if (evidence === undefined) assert.equal((delta.removals as string[]).length, 1); - else assert.deepEqual((delta.replacements as JsonObject[]).map(row => row.value), [evidence]); + assert.deepEqual(recover(delta, baseline, current).context, current); } }); @@ -212,5 +226,92 @@ test("unknown/failed hook and ambiguous scope never borrow a registered hook's e (duplicate.sources as JsonObject[]).push(structuredClone((initial.sources as JsonObject[])[0])); assert.equal(warm(duplicate, baseline).mode, "full"); const different = structuredClone(initial); source(different, 0).todo_id = "different-task"; - assert.equal(warm(different, baseline).mode, "full"); + const delta = warm(different, baseline); + assert.equal(inline(delta, ["sources", 0, "content", "todo_id"]), "different-task"); + assert.deepEqual(recover(delta, baseline, different).context, different); +}); + +function packet(): JsonObject { + return {goal_id: "goal", agent_id: "agent", should_run: true, selected_todo: {todo_id: "todo_work", claimed_by: "agent"}, + goal_boundary: {write_scope: ["src/**"], approval: "pending", stop: "publication"}, + unknown_future_control: {allowed: false}, handoff_readiness: {ready: false}, + automation_liveness: {pause_allowed: false}, autonomous_replan_scope: {owner_agent_ids: ["agent"]}, planning_horizon: {items: [{id: "one", body: long}, {id: "two", body: long}]}, + interaction_contract: {mode: "bounded_delivery", user_channel: {notify: "quiet"}, + agent_channel: {delivery_allowed: true, must_attempt: true, work_context: context()}, + cli_channel: {spend_policy: "after_validation", settlement_plan: {identity: {turn: "turn-a"}, ordered_steps: [ + {kind: "validation", owner: "agent", precondition: long, command_template: "validate-a", command_condition: "todo_complete"}, + {kind: "writeback", owner: "agent", precondition: long, command_template: "writeback-a", optional_attachments: [{capability_id: "explore", guidance: long, required: false}]}, + ]}}}}; +} +const packetFull = (value: JsonObject, capture_id = "capture-a", surface = "quota_packet") => + prepareContextDelivery({context: value, scope, capture_id, surface}); +function packetWarm(value: JsonObject, baseline: JsonObject, surface = "quota_packet") { + return prepareContextDelivery({context: value, scope, capture_id: "capture-b", surface, baseline, + retained: {complete: true, target: baseline.target}}); +} + +test("whole packet reuses complete projections; live controls and unknown fields remain inline", () => { + const before = packet(), baseline = packetFull(before), current = structuredClone(before); + const cli = ((current.interaction_contract as JsonObject).cli_channel as JsonObject); + const plan = cli.settlement_plan as JsonObject; + plan.identity = {turn: "turn-b"}; + (plan.ordered_steps as JsonObject[])[1].command_template = "writeback-b"; + const delta = packetWarm(current, baseline); + assert.equal(delta.mode, "delta"); + for (const [path, value] of [ + [["interaction_contract", "cli_channel", "settlement_plan", "ordered_steps", 1, "optional_attachments", 0, "required"], false], + [["handoff_readiness", "ready"], false], [["automation_liveness", "pause_allowed"], false], + [["autonomous_replan_scope", "owner_agent_ids"], ["agent"]], [["should_run"], true], [["unknown_future_control", "allowed"], false], + [["selected_todo", "claimed_by"], "agent"], [["goal_boundary", "approval"], "pending"], + [["interaction_contract", "agent_channel", "delivery_allowed"], true], + [["interaction_contract", "cli_channel", "settlement_plan", "identity", "turn"], "turn-b"], + [["interaction_contract", "cli_channel", "settlement_plan", "ordered_steps", 0, "command_condition"], "todo_complete"], + [["interaction_contract", "cli_channel", "settlement_plan", "ordered_steps", 1, "command_template"], "writeback-b"], + ] as [(string | number)[], unknown][]) assert.deepEqual(inline(delta, path), value, JSON.stringify(path)); + const recovered = restoreContextDelivery({delivery: delta, baseline, current: packetFull(current, "capture-b")}); + assert.equal(recovered.mode, "delta"); assert.deepEqual(recovered.context, current); + assert.ok(JSON.stringify(delta).length < JSON.stringify(current).length / 3); + assert.equal(inline(delta, ["planning_horizon"]), undefined); + // A forged wire can reconstruct correctly yet omit an unchanged live gate. + const broken = structuredClone(delta); + delete ((broken.changes as JsonObject).changed as JsonObject).should_run; + assert.equal(restoreContextDelivery({delivery: broken, baseline, current: packetFull(current, "capture-b")}).mode, "full"); +}); + +test("array edits preserve append, removal, reorder, duplicate entries and exact scalar types", () => { + const before = packet(), baseline = packetFull(before); + const old = (before.planning_horizon as JsonObject).items as JsonObject[]; + const cases: unknown[] = [[], [old[1]], [...old, {id: "three", body: long}], [old[1], old[0]], + [old[0], old[0]], [null, "", false, 0], null, "", {items: old}, + [{...old[0], late_constraint: "STOP now", revision: "r2"}, old[1]]]; + for (const items of cases) { + const current = {...before, planning_horizon: {items}}; + const delta = packetWarm(current, baseline); + const capture = packetFull(current, "capture-b"); + const restored = delta.mode === "full" ? delta : restoreContextDelivery({delivery: delta, baseline, current: capture}); + assert.deepEqual(restored.context, current); + } + assert.deepEqual(before, packet()); +}); + +test("JSON property names are data; prototype keys never become assignment paths", () => { + const before = packet(), baseline = packetFull(before), current = structuredClone(before); + current.planning_horizon = JSON.parse('{"__proto__":{"unsafe":true},"constructor":null,"removed":false}'); + const delta = packetWarm(current, baseline); + const restored = restoreContextDelivery({delivery: delta, baseline, current: packetFull(current, "capture-b")}); + assert.deepEqual(restored.context, current); + assert.equal(({} as JsonObject).unsafe, undefined); +}); + +test("quota and Turn use one codec but cannot borrow another surface's baseline", () => { + const value = packet(), quota = packetFull(value), turn = {schema_version: "loopx_turn_envelope_v0", + action: {kind: "run", allowed: true}, work_context: context(), writeback: {settlement_plan: + ((value.interaction_contract as JsonObject).cli_channel as JsonObject).settlement_plan}}; + const baseline = packetFull(turn, "capture-a", "turn_envelope"); + assert.equal(packetWarm(turn, quota, "turn_envelope").mode, "full"); + const delta = packetWarm(turn, baseline, "turn_envelope"); + assert.equal(delta.mode, "delta"); + assert.equal(inline(delta, ["action", "allowed"]), true); + assert.deepEqual(restoreContextDelivery({delivery: delta, baseline, + current: packetFull(turn, "capture-b", "turn_envelope")}).context, turn); }); From 456d19824e68430abd711d7a11ac3b3c1451a8b9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:31:10 +0800 Subject: [PATCH 05/29] docs(context): specify whole-packet reuse and fresh controls Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 79 +++++++++++-------- .../agent-loop-effect-interpreter-v0.zh-CN.md | 52 +++++++----- .../references/repair-patterns.md | 1 + 3 files changed, 77 insertions(+), 55 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 3cee6f93c8..f15781c186 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -729,8 +729,8 @@ The next slice stays within the existing three convergence acceptances above: 3. **Retain full current authority and cold-start requirements.** Every response supplies fresh identity, action, admission, scope/approval/stop conditions, cancellation, selection, claim/lease, unresolved reads and settlement/scheduler - obligations. Only allowlisted source payloads are eligible for reuse; - current control results remain full even when their bytes are unchanged. + obligations. Registered complete source and packet projections are eligible for exact reuse; + current control results remain inline even when their bytes are unchanged. Reused requirement text remains binding, including its textual stop clauses; it is neither a summary nor optional background. Preserve complete original requirements in the reconstructed context. Treat @@ -757,43 +757,54 @@ App consumers, and their full fallback. A standalone diff helper is insufficient |---|---| | Baseline owner | The host tracks complete model-input delivery and context continuity. The typed projection validates the supplied binding and computes the delta; it cannot infer retention from storage. Unsupported hosts continue full delivery. No model self-attestation is required. | | Full capture | Retain the immutable complete current decision for audit, signatures and same-capture recovery. A delta is a presentation, never a new authority store or mutation patch against Goal state. | -| Delta unit | Initially compare JSON object subtrees exactly, preserving scalar types, missing versus null, empty values and full strings. Replace changed arrays atomically; do not infer element identity or equivalence from prose. State removals explicitly. Give base/target identities and digests once, with readable paths and changed values; avoid nested reference chains and instructions that require the model to reconstruct the entire JSON mechanically. | +| Delta unit | Compare complete registered JSON subtrees exactly, preserving scalar types, missing versus null, empty values and full strings. Arrays use edits at indices in the exact hashed baseline, including length changes; indices are transport coordinates, never inferred entity identities. Reorders remain exact or select full when smaller. Do not normalize timestamps/revisions or infer equivalence from prose. Use a hierarchical change tree with complete values, object changes/removals and array items/length, avoiding repeated long path prefixes. | | Consumer recovery | Missing or mismatched baseline, skipped/out-of-order delta or truncated delivery returns the full current projection from the original capture, without rerunning admission. A lost delta is never acknowledged as the next baseline. Reset the baseline after full delivery; bound incremental ancestry with a full refresh rather than an ever-growing patch chain. Source freshness and authority are still checked before execution; presentation recovery cannot revive a stale or cancelled decision. | | CLI adoption | Keep the ordinary quota entrypoint and existing capture/detail route. A host that can observe the active model context passes the optional baseline binding at that transport boundary. A bare CLI with no continuity observer returns full context; it must not silently trust a previous output file. Exact argument/wire shape follows caller characterization, not a new command family. | | Turn adoption | Consume the same full/delta context owner while retaining Turn signatures and outer-executor effects. Host-only settlement execution remains distinct from a manual CLI agent's executable settlement obligations. | | Choice of representation | Emit a delta only when it is smaller after binding/removal metadata. Otherwise emit full context. Do not truncate values or change source selection to meet a byte target. | -**Source and change boundaries.** Include registered complete Goal narrative, -canonical acceptance requirement bodies and selected Todo text/notes, retaining -their exact strings and source identity. The Explore typed owner also supplies -its reusable evidence and instruction subtrees: recorded findings, measurement -feedback and their provenance/applicability, plus guidance and writeback examples. -Keep orchestration gates, selected/rejected branches, source health, pagination -revisions and unknown fields fresh. Evidence status inside a finding is part of -that exact evidence record, not live execution admission; any changed array is -replaced completely. Do not ignore timestamps or revisions inside reusable data. -Eligibility comes from the existing -source adapter and typed field contract, never a classifier of sentence meaning. -Keep current source revision, completeness, failures, status/claim, user gates, -adoption and other execution-control fields in the fresh portion. An unknown or -mixed source without a qualified split stays full. Give each known source slot -a stable key derived from its existing source kind and canonical entity identity; -ambiguous or duplicate identities fall back to full. Do not compare the whole -`sources` array as one unit: a changing authority revision must not resend an -unchanged requirement body. Inside a source payload, arrays remain atomic in -the first version. A changed field is replaced completely, including any late -constraint; deleted fields have explicit removal paths. The owner reconstructs -and compares against the complete target before releasing a delta. - -Stable source identities are internal matching keys. On the wire, unchanged -subtrees carry readable current paths and a prior path only when their position -moved; replacements carry a path and complete value, and removals a prior path. -Do not repeat a long entity identity plus a location map for every subtree. -Validate these paths against the current typed split before reconstructing; -wire paths cannot nominate additional reusable fields. Include all binding, -path, replacement and removal overhead in the full-versus-delta comparison. -A narrow body-only saving is not an estimate of the packet's total reusable -content; report eligible bytes and metadata separately from total bytes. +**Source and change boundaries.** The shared `context_delivery_v1` presentation +covers whole quota packets, whole Turn envelopes and their common work context. +The surface is part of the existing Goal/Agent/session/epoch/capture binding, so +one surface cannot borrow another surface's baseline. This extends presentation, +not admission, persistence or settlement authority. Registered complete packet +projections can reuse unchanged subtrees. Unknown root fields stay fresh. + +Current identity, action, admission, scope/approval/stops, cancellation, claims, +source health, pending reads, quota and scheduler facts stay inline. Settlement +identity, commands, step order, conditions and attachment requirements also stay +inline. Static preconditions, authoring schemas and optional attachment guidance +can be reused exactly; they remain binding, not optional because omitted from a +warm wire. The same rule covers existing vision-audit instructions while its +current judgment stays inline. Unknown fields within these control owners stay +fresh. There is no second copy of the fresh controls in the delta. + +Work context retains registered complete Goal narrative, canonical acceptance +bodies and selected Todo text/notes. The Explore owner registers exact evidence, +guidance and authoring metadata; orchestration gates, branch selection, pagination, +current source revisions and unknown fields remain fresh. Source explanations, +context-read instructions and the Todo read-model's field-name descriptor may be +reused without dropping the actual Todo fields, current revision or provenance. +An unknown or mixed source without a qualified split stays fresh; ambiguous source +identities and incomplete sources select full. Changed strings remain complete, +including late constraints. Evidence statuses and timestamps are never ignored. + +The previous body-only split, atomic changed arrays and per-leaf path lists were +an initial implementation, not a limit on the accepted efficiency goal. The +current codec forces fresh fields into one hierarchical change tree and computes +exact edits for the rest. Array growth, shrinkage, reorder, duplicates and null +are structural changes against that specific baseline, with no fuzzy matching. +The owner validates the expected tree and reconstructs the exact immutable target +before delivery. Omitting an unchanged live control is rejected even if the +remaining patch could otherwise reconstruct correctly. Corrupt, lost, mismatched +or more expensive deltas use the same-capture full representation automatically. + +Count cold full envelopes, bindings, repeated controls, change-tree metadata, +changed values and recovery reads in the comparison. A narrow-body percentage +cannot estimate whole-packet reuse; offline retained-baseline bytes still do not +establish actual solver input savings or model outcomes. The existing projection +RPC is the only preparation/recovery interface; both transports use the same TS +codec, and Python remains a thin adapter. Production host adoption stays open. **First real consumer and the compaction race.** The current native Codex CLI session adapter observes session identity; its capture/handoff release occurs diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 67f2e5331b..e2c2bf49a3 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -494,8 +494,8 @@ live 模型结果、App 采用或以下三项收敛验收完成。 删除被替代投影。输出释放前的 host 传输 ACK 既不证明模型完整收到了内容,也不证明其仍被 保留。现有 capture/handoff 是接入位置,并非已经实现的连续性契约。 3. **保留完整当前权限事实与冷启动要求。** 每次响应重新给出身份、动作、准入、范围/审批/停止 - 条件、取消、选择、claim/lease、未完成必读及结算/scheduler 义务。只有明确允许的来源正文 - 可以复用,当前控制结果即使字节未变也完整交付。复用的要求正文仍具有完整约束力,包括其中 + 条件、取消、选择、claim/lease、未完成必读及结算/scheduler 义务。注册的完整来源及 packet + 投影可以精确复用,当前控制结果即使字节未变也完整交付。复用的要求正文仍具有完整约束力,包括其中 的文字停止条件;它不是摘要或可选背景。重建后的上下文保留全部原始 要求。先在结构化 owner 理清混合 Todo 历史和验收解释,再改变必读范围;时间、owner、状态、 标题、长度均不能证明过时。把必读正文放到引用后再全读回来不是节省,替换累计备注是状态 @@ -513,30 +513,40 @@ live 模型结果、App 采用或以下三项收敛验收完成。 |---|---| | 基线归属 | host 跟踪模型输入的完整交付和上下文连续性;typed 投影核验所提供的绑定并计算 delta,不能从磁盘推断保留状态。不支持的 host 继续全量交付,不要求模型自行声明“仍然记得”。 | | 完整捕获 | 保留不可变的当前完整决策,供审计、签名与同次捕获恢复。delta 只是展示,不成为新的权威存储,也不用于修改 Goal 状态。 | -| 增量单位 | 首版精确比较 JSON 对象子树,保留标量类型、缺失与 null、空值及完整字符串;数组改变时整段替换,不从正文猜测元素身份或等价性。删除须显式表达。基线/目标身份及 digest 只给一次,并附可读路径和变化值;避免嵌套引用链,也不要求模型机械重建整份 JSON。 | +| 增量单位 | 精确比较注册的完整 JSON 子树,保留标量类型、缺失与 null、空值及完整字符串。数组按精确摘要所绑定的基线索引编辑,并表达长度变化;索引只是传输坐标,不是推测的实体身份。重排须精确恢复,若全量更小则选全量。不忽略时间戳/revision,也不从正文猜等价性。使用分层变化树表达完整值、对象变化/删除及数组项目/长度,避免重复长路径前缀。 | | 消费恢复 | 基线缺失/不匹配、跳过/乱序 delta 或输出截断,从原始 capture 返回完整当前投影,不重新准入。丢失的 delta 不能确认为下一基线;完整交付后重设基线,以全量刷新限制增量祖先链,避免无限累积 patch。执行前仍核验来源新鲜度与权限;展示恢复不能重新授权过时或已取消的决策。 | | CLI 采用 | 保持普通 quota 入口和现有 capture/detail 路径;能观察活跃模型上下文的 host 在传输边界提供可选基线绑定。没有连续性观察者的裸 CLI 返回全量,不能静默信任上次输出文件。具体参数/wire 形式在调用方刻画后确定,不新增命令族。 | | Turn 采用 | 消费同一完整/增量上下文 owner,同时保留 Turn 签名和外层 executor 效果归属。host 独占的结算执行与手动 CLI agent 的可执行结算义务仍有区别。 | | 表示选择 | 计入绑定和删除元数据后,只有更小才发送 delta,否则发送全量。不为字节目标截断值或改变来源选择。 | -**来源与变化边界。** 包含注册的完整 Goal 叙述、canonical 验收要求正文及选中 Todo 的 -正文/备注,保留精确字符串与来源身份。Explore typed owner 另提供可复用的证据与指令 -子树:已记录 findings、measurement feedback 及其来源和适用条件,以及引导与回写示例。 -编排 gate、选中/拒绝分支、来源健康、分页版本和未知字段保持每次完整给出。finding 中的 -证据状态属于精确证据记录,不是当前执行准入;数组变化时整体替换,不忽略可复用数据里的 -时间戳或 revision。可复用范围由现有 source adapter 和 typed 字段契约 -确定,不分类句子含义。当前来源版本、完整性、失败、status/claim、User gate、目标采用及 -其他执行控制字段仍每次完整给出。不能可靠拆分的未知或混合来源保持全量。每个已知来源槽 -按既有来源种类和 canonical 实体身份生成稳定 key,身份重复或不明确时回退全量。不能将整个 -`sources` 数组作为比较单位,否则 authority revision 变化会迫使未变要求全文重发。来源正文 -内部的数组在首版仍整体替换;字段改变时完整替换,包括迟到的约束,删除使用显式路径。 -owner 在释放 delta 前重建并与完整目标逐项比较。 - -稳定来源身份只作为内部匹配 key。wire 中未变子树带可读的当前路径,只有位置移动时 -才另带原路径;替换携带路径与完整值,删除携带原路径。不要给每个子树重复长实体身份 -及位置映射。重建前用当前 typed split 核对路径,wire 路径不能自行扩大复用范围。 -完整/增量比较计入全部绑定、路径、替换与删除开销。仅正文白名单的节省率不能代表 -整包的可复用量;应将可复用字节和元数据开销与总字节分开报告。 +**来源与变化边界。** 共享的 `context_delivery_v1` 展示覆盖完整 quota packet、 +完整 Turn envelope 及其公共 work context。surface 纳入既有 Goal/Agent/session/epoch/ +capture 绑定,不同展示不能借用彼此基线。扩展的是展示,不改变准入、持久化或结算 authority。 +注册的完整 packet 投影可精确复用未变子树,未知根字段仍每轮完整交付。 + +当前身份、动作、准入、范围/批准/停止条件、取消、claim、来源健康、待完成读取、quota +及 scheduler 事实仍每轮给出。结算身份、命令、步骤顺序、条件和附件要求也保持当前完整。 +静态前置说明、编写 schema 和可选附件 guidance 可精确复用;温态 wire 未重发,不减弱 +它们的约束力。既有 vision audit 的说明适用相同规则,当前判断仍每轮给出。这些控制 owner +内的未知字段保留每轮交付。delta 不再重复携带另一份相同的当前控制字段。 + +work context 保留注册的完整 Goal 叙述、canonical 验收正文和选定 Todo 文字/备注。 +Explore owner 注册精确证据、guidance 和编写元数据;编排 gate、分支选择、分页、当前 +来源 revision 和未知字段继续每轮交付。来源解释、上下文读取指令以及 Todo read-model +字段名描述可复用,不删除实际 Todo 字段、当前 revision 或来源依据。未知或混合来源若无 +合格 split 则完整交付;来源身份不明确或读取不完整时选全量。变更字符串仍完整替换,包含 +迟到约束;不忽略证据状态或时间戳。 + +此前仅正文 split、变化数组整段替换及逐叶重复路径是初步实现,不是效率目标的上限。 +当前 codec 将必发字段直接放进同一分层变化树,其余部分计算精确变化。数组增删、重排、 +重复值和 null 都针对该精确基线做结构转换,不模糊匹配。owner 校验预期变化树并重建 +不可变完整目标后才交付;即使其余 patch 能重建正确,漏掉未变的当前控制字段仍会被拒绝。 +损坏、丢失、基线不匹配或更贵的 delta 自动采用同 capture 全量表示。 + +比较计入冷启动全量 envelope、绑定、必发控制、变化树元数据、变化值及恢复读取。 +狭窄正文比例不能代表整包复用;假定基线保留的离线字节结果仍不证明真实 solver 输入节省 +或模型成果。准备/恢复继续使用既有 projection RPC,两种传输共用同一个 TS codec, +Python 仅作薄 adapter;生产 host 采用验收继续保留。 **首个真实调用方与压缩竞态。** 当前原生 Codex CLI session adapter 只观察会话身份, capture/handoff 的 release 发生在 stdout 交付之前,两者都不提供所需的模型输入连续性 diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 4e0fe1a4ad..5f0f4132d7 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -20,6 +20,7 @@ teaches a reusable control-plane lesson. | `cli_json_file_argument_contract_gap` | A `--*-json` option repeatedly rejects inline JSON as an unreadable object; reshaping it does not help, or Markdown shows only an invalid-request status. | The command's current help, file-input loader, structured error, Markdown renderer, and a synthetic valid-file recovery. | The option accepts a file path but help does not say so; file-read, JSON-decode and root-shape failures are conflated, or the renderer omits the error reason. | Clarify the existing file contract without silently adding inline input. Separate read, parse and shape diagnostics without echoing supplied contents or paths; preserve the reason in each output format. Validate missing/inline input, malformed/non-object files, same-path recovery and unchanged typed admission. Repair the owning CLI adapter, not provider readiness or Goal retry authority. | | `public_source_receipt_clock_precedes_reads` | Multiple sources share a batch-start observation time, or a receipt reports completion before its final source read returns. | Installed provider source pin, actual execution process bounds, source/receipt timestamps, a controlled advancing-clock case and canonical readback. | The provider captures one UTC timestamp before its HTTP loop and reuses it for source observations and completion. | Record each successful source observation after its bounded read and record completion after all attempts, including partial, empty and failed results. Keep publication/first-availability unknown, retain original receipts and reuse the existing typed identity/admission/projection owner. Verify late failures cannot move the completion boundary before an earlier successful observation; do not claim authenticated execution or downstream coverage from timestamp correctness. | | `public_source_failure_step_erasure` | A public-source plan is ready, but execution reports only a generic unavailable read, hiding whether visibility, download or text validation failed. | Original exact-plan receipt, source index, read-only process bounds, provider source pin and shared readback. | One catch path erased the reached provider step; readiness was mistaken for successful retrieval or the generic error was guessed to mean lost permission. | Preserve the actual reached step in existing redacted limitations, without exposing exception reasons or changing admission, retry or source status. Cover visibility, download, malformed text and partial HTTP failure through the canonical CLI and shared sinks. Keep original failed receipts; diagnose a changed producer under a bounded trial rather than repeatedly fetching the same object or freezing independent work. | +| `context_delta_narrow_eligibility` | Warm context reuse saves little despite large unchanged adjacent packets. | Same captured workload, complete wire bytes, exact subtree changes, fresh controls, cold starts, metadata and full recovery costs. | A body-only split excludes most repeated projections, atomic arrays resend unchanged siblings, and a separate fresh frame duplicates fields already present in patches. | Measure eligibility and overhead separately before changing source semantics. Reuse the existing typed full/delta owner across complete quota and Turn projections; compare arrays against the exact hashed baseline without fuzzy identity or timestamp normalization. Keep current authority and unknown controls inline, validate exact reconstruction, and automatically use the same capture on loss or mismatch. Include cold full inputs and recovery reads; offline byte potential is not installed solver input or outcome evidence. | | `candidate_outcome_as_execution_blocker` | An evaluation repeatedly enters a bounded retry after rejecting individual candidates, although independent authorized evaluation remains executable. | Exact selected Todo, typed delivery outcome and result class, committed retry origin/due time, validated evidence delta, current vision/frontier and installed source pin. | The caller classified candidate uncertainty or rejection as blocked execution for the whole work item; a stale runtime may also explain the retry as an unrelated user gate. | Correct the existing Todo/vision and classify new validated evaluation evidence as progress only when it actually advances acceptance. Stop answered finite questions; keep pure reuse non-progress. Scope a missing dependency to the acceptance it actually prevents: missing experiment cost data or one Host integration must not block independent shared implementation, local validation or another authorized consumer. A fault-injection failure calls for a bounded recovery implementation and retest, not an assumed requirement to wait for a new Host interface. Preserve genuine blocked retries and independent clocks. Reproduce retry versus ordinary notice through the existing quota/interaction/scheduler path, verify the installed revision contains the owner fix, and retain old receipts. Never remove cooldowns globally or manufacture progress. | | `due_monitor_display_order_starvation` | A recurring Monitor is selected on every wake while equally ranked older due Monitors never run. | Complete eligible due set, claim/profile/priority ranks, due instants, display caps, selected Todo and exact poll/Turn receipts. | Display index determines execution order before a one-row cap; the first Monitor becomes due again before the next admitted Turn. | In the existing typed planning owner, retain scope and eligibility fences, then rank equally eligible work by due instant before display index and truncation. Cover recurring reschedules, stable ties, capability/claim negatives and real provider replay. Preserve exact per-Turn settlement; fairness does not authorize same-wake loops, extra quota spend or a shorter owner cadence. | | `receipt_freshness_commit_gap` | A receipt-bound follow-up passes its first freshness check, then commits an older Turn after a newer receipt arrives during lock contention or final persistence. An immediate retry correctly reports the old Turn as stale. | Ordered receipt events, the exact Turn ID, state-lock timing, commit result, and persisted state identity. | Receipt freshness and scheduler persistence did not hold the same cross-runtime append fence, so a newer receipt could arrive after the final check. | Keep the early check for fast rejection. Acquire the scheduler-state lock before the receipt-log mutation lock, then retain both locks from the final receipt read through legacy migration and the final state rename. Make every receipt writer use the same mutation lock. Return the existing typed missing or stale failure without mutation. Prove both lock-wait and read-to-rename interleavings with real writers, then scan for reverse lock order. | From 1b1893f34dedb54ce47e8d837857027c3d11558b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:56:13 +0800 Subject: [PATCH 06/29] test(quota): initialize capture fixtures through canonical authority Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_quota_decision_capture.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/control_plane/test_quota_decision_capture.py b/tests/control_plane/test_quota_decision_capture.py index 085a27a39e..a3affffaff 100644 --- a/tests/control_plane/test_quota_decision_capture.py +++ b/tests/control_plane/test_quota_decision_capture.py @@ -10,6 +10,9 @@ from loopx.cli_commands.quota_capture import capture_decision, prepare_decision_capture from loopx.control_plane.quota.error_codes import QuotaCommandValidationError +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_source_fixture, read_fixture_todos, +) from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _write_fixture, _configure_read_only_todo, _run_cli, _heartbeat_receipt_count, @@ -84,10 +87,8 @@ def test_real_cli_fallback_reference_keeps_capture_and_turn_effects(tmp_path, mo f' \n') - code, listed = _run_cli(registry, runtime, 'todo', 'list', '--goal-id', GOAL_ID) - assert code == 0, listed projection = build_todo_runtime_shadow_projection( - goal_id=GOAL_ID, handoff_mode='soft_claim', todos=listed['todos'], + goal_id=GOAL_ID, handoff_mode='soft_claim', todos=read_fixture_todos(state), ) initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) args = ['quota', 'should-run', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, @@ -140,10 +141,8 @@ def test_real_cli_boundary_clauses_reach_shared_host_without_loss(tmp_path, monk goal['coordination'].update(write_scope=scopes, requires_parent_approval=['publish', 'production-action']) goal['guards'] = guards registry.write_text(json.dumps(configured)) - code, listed = _run_cli(registry, runtime, 'todo', 'list', '--goal-id', GOAL_ID) - assert code == 0, listed projection = build_todo_runtime_shadow_projection( - goal_id=GOAL_ID, handoff_mode='soft_claim', todos=listed['todos'], + goal_id=GOAL_ID, handoff_mode='soft_claim', todos=read_fixture_todos(state), ) initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) args = ['quota', 'should-run', '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, @@ -199,10 +198,8 @@ def test_real_guard_saved_before_envelope_and_read_without_reexecution(tmp_path, registry.write_text(json.dumps(configured)) _configure_read_only_todo(project) state = project / f'.codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md' - code, listed = _run_cli(registry, runtime, 'todo', 'list', '--goal-id', GOAL_ID) - assert code == 0, listed projection = build_todo_runtime_shadow_projection( - goal_id=GOAL_ID, handoff_mode='soft_claim', todos=listed['todos'], + goal_id=GOAL_ID, handoff_mode='soft_claim', todos=read_fixture_todos(state), ) initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) capture = tmp_path / "guard ' $(touch INJECTED) `touch INJECTED`" @@ -322,7 +319,8 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): from pathlib import Path project, runtime, registry = _write_fixture(tmp_path) - _configure_read_only_todo(project) + state = _configure_read_only_todo(project) + initialize_canonical_source_fixture(runtime, GOAL_ID, state_path=state) capture = tmp_path / 'handoff-capture' with tempfile.TemporaryDirectory(prefix='quota-host-', dir='/tmp') as short: endpoint = Path(short).resolve() / 'host.sock' @@ -428,6 +426,8 @@ def test_capture_root_accepts_guided_turn_before_identity_is_minted(tmp_path): def test_guided_turn_captures_the_minted_identity(tmp_path): project, runtime, registry = _write_fixture(tmp_path) + initialize_canonical_source_fixture(runtime, GOAL_ID, + state_path=project / f'.codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md') capture_root = tmp_path / "captures" capture_root.mkdir() @@ -473,6 +473,8 @@ def _replan_selection_hop(tmp_path, host): project, runtime, registry = _write_fixture(tmp_path) _configure_selected_todo_replan_fixture(project, registry) + initialize_canonical_source_fixture(runtime, GOAL_ID, + state_path=project / f'.codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md') common = ('--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--runtime-profile', 'generic_cli', '--turn-instance-id', TURN_ID, '--available-capability', 'shell', '--available-capability', 'filesystem_write') From e7365d04dcad508b4d3b3b3fe78cbacf245cfdc6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:56:22 +0800 Subject: [PATCH 07/29] feat(quota): connect captured CLI output to shared context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota.py | 21 +++- loopx/cli_commands/quota_capture.py | 67 +++++++++- .../test_context_delivery_cli.py | 115 ++++++++++++++++++ 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index d84984529f..3de6f7f8c4 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -5,7 +5,10 @@ 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, + prepare_decision_capture, project_captured_context, +) from ..usage_goal import observe_quota_result from ..capabilities.explore.composition_frontier import ( @@ -464,10 +467,12 @@ def _emit_quota_result( started_at=usage_quota_started, ) bind_capture_selection_transport(payload, args) + delivery_request = None + decision_identity = ("", "") try: capture_decision(capture_directory, payload) if payload.get("ok") is True: - handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None)) + delivery_request = handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None)) except QuotaDecisionTransportError as exc: # The guard already ran. Report that typed fact with the durable receipt # and saved decision, never a new collection failure that could replace @@ -477,6 +482,10 @@ def _emit_quota_result( ) print_payload(failure, args.format, _quota_renderer(args)) return 1 + if delivery_request is not None: + agent = payload.get("agent_identity") + decision_identity = (str(payload.get("goal_id") or ""), + str(agent.get("agent_id") or "") if isinstance(agent, Mapping) else str(args.agent_id or "")) payload = _project_quota_cli_payload( payload, args, detail_sections, context.scheduler_context if context is not None else None, @@ -491,8 +500,14 @@ def _emit_quota_result( payload, registry_path=registry_path, ) + succeeded = bool(payload.get("ok")) + if args.format == "json" and succeeded and delivery_request is not None: + payload = project_captured_context(payload, directory=capture_directory, + request=delivery_request[0], capture_id=delivery_request[1], + goal_id=decision_identity[0], agent_id=decision_identity[1], + surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet") print_payload(payload, args.format, _quota_renderer(args)) - return 0 if payload.get("ok") else 1 + return 0 if succeeded else 1 def handle_quota_command( diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index f19c3f03a4..d872e2f846 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -7,6 +7,7 @@ import os import shlex import socket +import stat import tempfile from pathlib import Path @@ -92,7 +93,7 @@ def bind_capture_selection_transport(payload: dict, args: argparse.Namespace) -> selection["command_args_template"] = template + suffix -def handoff_decision(directory: Path | None, socket_path: str | None) -> None: +def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[dict, str] | None: """Withhold the original output until the Host releases these exact bytes. No second guard, admission, settlement or retry is performed here. A lost @@ -114,7 +115,8 @@ def handoff_decision(directory: Path | None, socket_path: str | None) -> None: with channel.makefile("rb") as stream: line = stream.readline(1025) response = json.loads(line) - if response != {"released": True, "sha256": digest}: + ordinary_release = {"released": True, "sha256": digest} + if response not in (ordinary_release, {**ordinary_release, "context_delivery": True}): raise ValueError("Host did not release the original decision digest") if path.read_bytes() != raw: raise ValueError("captured decision changed during handoff") @@ -123,3 +125,64 @@ 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 + if response.get("context_delivery") is not True: + return None + # Only an explicit Host request opts in. The release itself does not prove + # delivery/retention. Read one private snapshot, never an arbitrary pathname + # supplied by a response or a model. Invalid optimization input selects full. + request_path = directory / "delivery-request.json" + try: + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + descriptor = os.open(request_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as source: + metadata = os.fstat(source.fileno()) + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077 or + metadata.st_size > MAX_REQUEST_BYTES or metadata.st_uid != os.getuid()): + return {}, digest + raw = source.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES: + return {}, digest + request = json.loads(raw) + return (request if isinstance(request, dict) else {}), digest + except (OSError, ValueError, RecursionError): + return {}, digest + + +def project_captured_context( + payload: dict, *, directory: Path | None, request: dict | None, + goal_id: str, agent_id: str, surface: str, capture_id: str, +) -> dict: + """Adapt an admitted capture to the shared TS presentation owner. + + The caller-owned rendezvous may supply a continuity observation. It must + still recheck the candidate at final model-input assembly and ACK only intact + delivery. Unsupported ordinary CLI clients never supply this request. Every + opt-in attempt saves the complete projected JSON for same-capture recovery; + neither malformed requests nor optimization failures rerun the guard. + """ + if request is None or directory is None: + return payload + from ..control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError + from ..control_plane.quota.turn_envelope import prepare_context_delivery + from ..control_plane.turn_driver.decision_capture import write_decision_capture + + try: + projection = directory / "projection.json" + write_decision_capture(projection, payload) + scope = request.get("scope") + if not isinstance(scope, dict) or scope.get("goal_id") != goal_id or scope.get("agent_id") != agent_id: + return payload + delivery = prepare_context_delivery(payload, scope=scope, + capture_id=capture_id, surface=surface, + baseline=request.get("baseline"), retained=request.get("retained")) + delivery["recovery"] = { + "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), + "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " + "truncated or uncertain, use this full projection before dependent work. Read bounded pages " + "if necessary; do not rerun admission or ask the owner. This file is not retention proof.", + } + return delivery + except (OSError, ValueError, TypeError, EffectRuntimeRemoteError, EffectRuntimeStartupError): + # The original full payload is already available. An unavailable optional + # codec/capture must not convert safe full delivery into an execution gate. + return payload diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index ca952cb45a..e4db501085 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -6,6 +6,7 @@ from copy import deepcopy import json +import os from pathlib import Path import pytest @@ -89,3 +90,117 @@ def test_cli_capture_exact_delta_and_same_capture_recovery(tmp_path: Path, monke rc, readback = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID, "--todo-id", TODO_ID) assert rc == 0 and readback["todo"]["text"] == body, readback assert readback["todo"]["status"] == "open" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"]) +@pytest.mark.skipif(os.name != "posix", reason="Unix local Host transport") +def test_real_cli_host_rendezvous_sends_delta_and_keeps_same_capture_full(tmp_path, monkeypatch, provider, surface): + """The ordinary CLI really uses the codec; the listener models a Host, + not a claim that a particular installed Codex has observed retention.""" + import hashlib + import socket + import tempfile + from concurrent.futures import ThreadPoolExecutor + from test_quota_settlement_cli import _heartbeat_receipt_count + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + body = "Retain full task evidence and permission boundaries. " * 800 + "Stop before publication." + todos = read_fixture_todos(state) + todos[0].update(text=body, title=body) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider=provider) + before_source = state.read_bytes() + root = tmp_path / "captures" + root.mkdir() + scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id="host-session", context_epoch="epoch-1") + saved = [] + + def invoke(host_request, *, opt_in=True): + with tempfile.TemporaryDirectory(prefix="context-host-", dir="/tmp") as short: + endpoint = Path(short).resolve() / "host.sock" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(endpoint)) + server.listen(1) + server.settimeout(30) + def release(): + connection, _ = server.accept() + with connection: + connection.settimeout(30) + with connection.makefile("rb") as stream: + observed = json.loads(stream.readline(4096)) + path = Path(observed["path"]) + raw = path.read_bytes() + assert hashlib.sha256(raw).hexdigest() == observed["sha256"] + if opt_in: + request = path.parent / "delivery-request.json" + request.write_text(host_request if isinstance(host_request, str) else json.dumps(host_request)) + request.chmod(0o600) + response = {"released": True, "sha256": observed["sha256"]} + if opt_in: + response["context_delivery"] = True + connection.sendall(json.dumps(response).encode() + b"\n") + return path, raw + with ThreadPoolExecutor(max_workers=1) as workers: + future = workers.submit(release) + code, wire = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "host-context-delivery", + "--decision-output-root", str(root), "--decision-handoff-socket", str(endpoint), + *(["--turn-envelope"] if surface == "turn_envelope" else []), cwd=project) + path, raw = future.result(timeout=30) + assert code == 0, wire + assert path.read_bytes() == raw + saved.append((path, raw)) + return wire, path.parent + + cold, first = invoke({"scope": scope}) + assert cold["mode"] == "full" + assert json.loads((first / "projection.json").read_bytes()) == cold["context"] + warm, second = invoke({"scope": scope, "baseline": cold, + "retained": {"complete": True, "target": cold["target"]}}) + assert warm["mode"] == "delta" + projection = json.loads((second / "projection.json").read_bytes()) + current = prepare_context_delivery(projection, scope=scope, + capture_id=warm["target"]["capture_id"], surface=surface) + assert restore_context_delivery(warm, baseline=cold, current=current)["context"] == projection + assert len(json.dumps(warm)) < len(json.dumps(current)) + assert warm["recovery"]["path"] == str(second / "projection.json") + assert warm["target"]["capture_id"] == hashlib.sha256((second / "decision.json").read_bytes()).hexdigest() + assert warm["recovery"]["sha256"] == hashlib.sha256((second / "projection.json").read_bytes()).hexdigest() + before_receipts = _heartbeat_receipt_count(runtime, "host-context-delivery") + # Compaction between preparation and dispatch: the Host can select the exact + # full projection without a second CLI call, admission or state mutation. + recovered = restore_context_delivery(warm, baseline=None, current=current) + assert recovered["mode"] == "full" and recovered["context"] == projection + assert _heartbeat_receipt_count(runtime, "host-context-delivery") == before_receipts + reset, _ = invoke({"scope": {**scope, "context_epoch": "compacted"}, "baseline": cold, + "retained": {"complete": True, "target": cold["target"]}}) + assert reset["mode"] == "full" + for malformed in ["{truncated", "[" * 2000 + "]" * 2000, + {"scope": {**scope, "agent_id": "another-agent"}}]: + full, directory = invoke(malformed) + assert "target" not in full # Automatic ordinary full output, not a gate. + assert full == json.loads((directory / "projection.json").read_bytes()) + ordinary, directory = invoke(None, opt_in=False) + assert "target" not in ordinary and not (directory / "projection.json").exists() + assert state.read_bytes() == before_source and _spend_run_count(runtime) == 0 + assert all(path.read_bytes() == raw for path, raw in saved) + + +def test_optional_capture_projection_failure_falls_back_without_hiding_source(tmp_path, monkeypatch): + from loopx.cli_commands.quota_capture import project_captured_context + from loopx.control_plane.quota import turn_envelope + from loopx.control_plane.effect_runtime import EffectRuntimeStartupError + payload = {"ok": True, "goal_id": "fixture", "requirements": "Keep late stop conditions."} + (tmp_path / "decision.json").write_text(json.dumps(payload)) + def unavailable(*args, **kwargs): + raise EffectRuntimeStartupError("synthetic unavailable codec") + monkeypatch.setattr(turn_envelope, "prepare_context_delivery", unavailable) + assert project_captured_context(payload, directory=tmp_path, request={"scope": { + "goal_id": "fixture", "agent_id": "agent", "session_id": "session", "context_epoch": "epoch"}}, + goal_id="fixture", agent_id="agent", surface="quota_packet", capture_id="fixture-capture") is payload + assert json.loads((tmp_path / "projection.json").read_bytes()) == payload From 3c3576f00f43a5d53989e148a6b7826f7fe82dc9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:56:29 +0800 Subject: [PATCH 08/29] docs(context): record CLI delivery seam and native host gap Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 21 ++++++++++++++++++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 19 +++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index f15781c186..685f4d436a 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -956,6 +956,25 @@ profiles, shared Turn source content, epoch loss, corrupt delta, unchanged sourc state and zero extra spend. This is a shipped-code proposal, not an enabled native warm adapter or installed App delivery. +The ordinary `quota should-run` capture rendezvous now connects that codec to +JSON stdout, for both quota and `--turn-envelope`. A qualified caller writes a +private `delivery-request.json` beside the held `decision.json`, containing the +existing `scope`, optional `baseline` and `retained` inputs. It opts in by adding +`context_delivery: true` to the existing digest-bound release. The CLI binds the +Goal/Agent to the actual decision, saves immutable `projection.json`, and returns +the shared full/delta presentation with a path and byte digest for full recovery. +Missing, malformed, cross-identity or unavailable optimization input returns full; +it does not rerun admission or change the decision's success status. Legacy +releases and ordinary invocations keep their original output and file behavior. + +Real File/SQLite subprocess tests cover cold/warm output, epoch reset, malformed +request, cross-Agent refusal, source preservation, original receipt and zero +spend. Recovery uses the exact saved projection, including after the candidate +delta was prepared. This closes the CLI producer seam only. The rendezvous is +still before native output processing: a release does not ACK model delivery, +and callers without a qualified continuity observer must not opt into warm reuse. +Both actual solver and installed App lifecycle consumers remain unqualified. + The native transport probe uses the actual shared projector, bounded tool reads and a small `SessionStart(compact)` reference. Its deterministic local provider requests the reads; it does not prove an autonomous solver follows the recovery @@ -963,7 +982,7 @@ route. Preserve the earlier bulk-hook and fail-open negatives. Qualify complete first read, warm delta, compaction, primary-handler failure and full target read from outgoing model inputs. Include every recovery page in cost and call counts; a smaller hook or packet alone is not a saving. The next integration is the -existing ordinary quota capture/detail path plus the Codex lifecycle adapter for +Codex lifecycle adapter consuming the ordinary quota capture/detail path for both phase-one consumers, without a second runner or a new approval flow. Qualification compares matched normal, replan and wait/recovery workloads with diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index e2c2bf49a3..33d2b57afb 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -657,12 +657,27 @@ oracle 检查实际发出的模型输入,而非 hook 日志或 stdout。这只 模式、共享 Turn 来源内容、epoch 丢失、差分损坏、来源状态不变和零额外扣账。这是 产品代码提案,还不是已启用的原生温态 adapter 或安装态 App 交付。 +普通 `quota should-run` 的 capture rendezvous 现已把该 codec 接到 JSON stdout, +同时支持 quota 与 `--turn-envelope`。已获资格的调用方在被保留的 `decision.json` +旁写入私有 `delivery-request.json`,沿用 `scope` 及可选的 `baseline`、`retained` +输入,并在既有摘要绑定的 release 中添加 `context_delivery: true` 显式启用。 +CLI 将 Goal/Agent 绑定到实际决策,保存不可变的 `projection.json`,返回共享全量/ +差分展示及完整恢复文件的路径、字节摘要。优化输入缺失、损坏、身份不符或不可用时 +返回全量,不重跑准入或改变原决策成功状态。旧 release 与普通调用的输出、文件行为 +保持不变。 + +真实 File/SQLite 子进程测试覆盖首次/温态输出、epoch 重置、损坏请求、跨 Agent +拒绝、来源保留、原 receipt 和零扣额。差分准备后仍可从精确保存的投影恢复。 +这只关闭 CLI producer 接缝;rendezvous 仍位于原生输出处理之前,release 不等于 +模型交付 ACK,没有合格连续性观察器的调用方不得启用温态复用。实际 solver 与 +安装态 App 的生命周期消费者仍未获得资格。 + 原生传输 probe 使用实际共享投影、有界工具读取和很小的 `SessionStart(compact)` 引用。确定性的本机 provider 请求相应读取,不证明自主 solver 会遵循恢复路线。 保留之前整包 hook 与 fail-open 负例;从实际发出的模型输入验证首次完整读取、温态 差分、压缩、主 handler 失败和完整目标读回。成本与调用次数须包含每个恢复分段, -hook 或 packet 变小本身不等于节省。下一接入是既有普通 quota capture/detail 路径与 -Codex 生命周期 adapter,覆盖一期两个调用方,不增加第二套 runner 或审批流程。 +hook 或 packet 变小本身不等于节省。下一接入是让 Codex 生命周期 adapter 消费 +既有普通 quota capture/detail 路径,覆盖一期两个调用方,不增加第二套 runner 或审批流程。 在可选能力模式不变的条件下,比较匹配的普通执行、重规划和等待/恢复负载,覆盖首次交付、 温态复用、reset、删除、数组重排、丢失/重复/乱序 delta、部分输出、来源/身份不匹配及迟到 From 394a29adb8697cfa40e6cdf23e20ff9cf1414d11 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:19:35 +0800 Subject: [PATCH 09/29] test(quota): distinguish bound replan from pending closeout Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_quota_decision_capture.py | 18 +++++++++++++++--- .../checkpoint_artifacts.test.ts | 12 +++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/control_plane/test_quota_decision_capture.py b/tests/control_plane/test_quota_decision_capture.py index a3affffaff..20e732d021 100644 --- a/tests/control_plane/test_quota_decision_capture.py +++ b/tests/control_plane/test_quota_decision_capture.py @@ -526,9 +526,21 @@ def typed_host(observed): code, selected, observed, saved, _ = _replan_selection_hop(tmp_path, typed_host) assert observed['decision']['ok'] is True assert observed['decision']['heartbeat_receipt']['status'] == 'upgraded' - # The binding hop is the same Turn's pending closeout, not a new point. - assert observed['intent'] == {'capture': False, 'reason': 'turn_closeout_pending', - 'execution_authority_granted': False} + # Binding a Todo does not discharge the original semantic replan. This + # fixture has not admitted delivery or produced work/closeout evidence. + # Preserve that boundary instead of inferring closeout from "upgraded". + decision = observed['decision'] + receipt = decision['heartbeat_receipt'] + assert receipt.get('closeout_required') is not True + assert decision['interaction_contract']['agent_channel']['delivery_allowed'] is False + assert decision['replan_action_packet']['decision'] == 'replan_required' + assert decision['replan_action_packet'].get('settlement_only') is not True + assert observed['intent'] == { + 'capture': True, + 'obligation_id': receipt['semantic_replan_obligation_id'], + 'settlement_effect_id': receipt['settlement_identity']['effect_id'], + 'execution_authority_granted': False, + } assert code == 0 and selected['ok'] is True, selected assert selected['schema_version'] == 'loopx_turn_envelope_v0' assert len(saved) == 1 and json.loads(saved[0].read_text()) == observed['decision'] diff --git a/tests/control_plane_ts/checkpoint_artifacts.test.ts b/tests/control_plane_ts/checkpoint_artifacts.test.ts index e35aab5512..1c466387e4 100644 --- a/tests/control_plane_ts/checkpoint_artifacts.test.ts +++ b/tests/control_plane_ts/checkpoint_artifacts.test.ts @@ -349,9 +349,9 @@ test("receipt-owned replan settlement returns to its original writer without adm ...quota.replan_action_packet, decision: "replan_required", settlement_only: false}}})).capture, true); }); -test("the selection hop that binds a Todo is released as pending closeout, not an identity failure", async () => { - // Real-CLI shape: the agent selects a Todo on the Turn whose original guard - // owns the replan. The receipt view is "upgraded" with the new binding. +test("a bound selection with explicit closeout is released; binding alone preserves the replan", async () => { + // "upgraded" records the binding, not work completion. Only the explicit + // closeout fact makes this a pending closeout instead of the original replan. const id = "replan-0123456789abcdef", turn = "fixture-turn"; const identity = {goal_id: "fixture-goal", agent_id: "fixture-agent", turn_instance_id: turn, todo_id: "todo_selected", effect_id: `fixture-goal:fixture-agent:todo_selected:${turn}`}; @@ -363,6 +363,12 @@ test("the selection hop that binds a Todo is released as pending closeout, not a assert.deepEqual(await checkpointArtifacts(request), { capture: false, reason: "turn_closeout_pending", execution_authority_granted: false, }); + const {closeout_required: _closeout, ...boundReceipt} = quota.heartbeat_receipt; + assert.deepEqual(await checkpointArtifacts({...request, quota: {...quota, + heartbeat_receipt: boundReceipt}}), { + capture: true, obligation_id: id, settlement_effect_id: identity.effect_id, + execution_authority_granted: false, + }); // Statuses outside the durable receipt views stay refused, now as a typed // request rejection whose reason survives the runtime boundary. for (const status of ["pending", "failed", "uncommitted"]) { From 1c8bfd94a2d4345d85d4342a04ec358d228b42ce Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:20:51 +0800 Subject: [PATCH 10/29] test(context): qualify native CLI delta and capture recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 18 ++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 13 + .../test_codex_context_boundary.py | 7 +- .../test_codex_context_capture.py | 247 ++++++++++++++++++ 4 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_codex_context_capture.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 685f4d436a..017239d81a 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -985,6 +985,24 @@ a smaller hook or packet alone is not a saving. The next integration is the Codex lifecycle adapter consuming the ordinary quota capture/detail path for both phase-one consumers, without a second runner or a new approval flow. +The composed native fixture now executes the real `quota should-run` CLI from +Codex tools, using SQLite, its private rendezvous and the shared projector. It +observes complete paged output in actual outgoing model input before supplying +a warm baseline. Codex exec covers ordinary quota; app-server covers the Turn +envelope. Both recover the same immutable CLI projection after automatic +compaction, including primary recovery-handler failure, without a third guard +or quota debit. This closes a producer-to-native transport test gap; the input +observer is still the deterministic loopback provider, not a production Codex +continuity adapter. The source text, source state and original captures are +checked independently of the delta. All pages, including recovery, remain part +of the cost boundary. Installed adoption and autonomous outcomes remain open. + +Capture regression qualification also distinguishes settlement binding from +closeout: an upgraded receipt with an outstanding semantic replan still permits +an inert replan capture and grants no delivery authority. Explicit pending +closeout suppresses that capture. The real CLI fixture and the typed selector +now test both facts instead of inferring closeout from the receipt status. + Qualification compares matched normal, replan and wait/recovery workloads with optional capability modes unchanged. Include first delivery, warm reuse, reset, deletion, array reordering, lost/duplicated/out-of-order deltas, partial output, diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 33d2b57afb..b1c708a99d 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -679,6 +679,19 @@ CLI 将 Goal/Agent 绑定到实际决策,保存不可变的 `projection.json` hook 或 packet 变小本身不等于节省。下一接入是让 Codex 生命周期 adapter 消费 既有普通 quota capture/detail 路径,覆盖一期两个调用方,不增加第二套 runner 或审批流程。 +组合原生 fixture 现通过 Codex 工具执行真实 `quota should-run` CLI,使用 SQLite、 +私有 rendezvous 和共享投影;在实际发出的模型输入中观察到完整分页输出后,才提供温态 +基线。Codex exec 覆盖普通 quota,app-server 覆盖 Turn envelope。两条路径均在自动 +压缩后恢复同一份不可变 CLI 投影,包含主恢复 handler 失败,且不触发第三次 guard 或 +quota 扣额。这补齐 producer 到原生传输的测试缺口;输入观察器仍是确定性 loopback +provider,并非生产 Codex 连续性 adapter。来源正文、来源状态和原始 capture 独立于 +delta 校验。包括恢复在内的全部分页仍属于成本边界;安装采用和自主成果保持未完成。 + +Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 +重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout +则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 +receipt status 推断 closeout。 + 在可选能力模式不变的条件下,比较匹配的普通执行、重规划和等待/恢复负载,覆盖首次交付、 温态复用、reset、删除、数组重排、丢失/重复/乱序 delta、部分输出、来源/身份不匹配及迟到 要求变化。重建结果须等于完整当前投影,fresh 控制事实始终不得省略。统计首次和补读的模型输入、上下文占用、 diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index 35c87bccf6..a8155bdc31 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -77,7 +77,7 @@ def _input_text(request): return "\n".join(text) -def _app_server(binary, env, workspace, home, trusted, additional_context=None): +def _app_server(binary, env, workspace, home, trusted, additional_context=None, *, sandbox="read-only"): with (home / "stderr.txt").open("w") as stderr, StdioNativeGoalTransport.spawn( [binary, "app-server", "--listen", "stdio://"], cwd=str(workspace), env=env, stderr=stderr, response_timeout_sec=20, @@ -85,7 +85,7 @@ def _app_server(binary, env, workspace, home, trusted, additional_context=None): native.request("initialize", {"clientInfo": {"name": "context_boundary_fixture", "version": "0.1.0"}, "capabilities": {"experimentalApi": True}}) native.notify("initialized", {}) - params = {"cwd": str(workspace), "sandbox": "read-only", "approvalPolicy": "never"} + params = {"cwd": str(workspace), "sandbox": sandbox, "approvalPolicy": "never"} if trusted: # Trust only the exact synthetic handlers created by this test. # Never read/alter the operator's hook trust or treat a global CLI @@ -193,7 +193,8 @@ def projection(context): # Native tool feedback can impose its own cap even when max_output_tokens # is larger. Reconstruct from bounded pages, never a head/tail preview. page_size = 6000 - page_count = lambda name: (len(saved[name].decode()) + page_size - 1) // page_size + def page_count(name): + return (len(saved[name].decode()) + page_size - 1) // page_size base_pages, target_pages = page_count("base"), page_count("target") compact_call = base_pages + 3 final_call = compact_call + target_pages + 1 diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py new file mode 100644 index 0000000000..6368b380bb --- /dev/null +++ b/tests/control_plane/test_codex_context_capture.py @@ -0,0 +1,247 @@ +"""Native tools execute the actual quota CLI and recover its original capture. + +The loopback provider is a deterministic input-boundary observer. This qualifies +the transport composition, not a shipped continuity observer or solver benefit. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shlex +import socket +import subprocess +import sys +import tempfile +import threading + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.quota.turn_envelope import prepare_context_delivery, restore_context_delivery +from test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, _spend_run_count, _write_fixture +from tests.control_plane.codex_checkpoint_fixture import local_inference_provider +from tests.control_plane.test_codex_context_boundary import _app_server, _configure_provider, _input_text, _native_fixture + +pytestmark = pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), + reason="Explicit native Codex binary required; inference uses loopback only") + + +def _delivered(request, name): + pages = {} + for item in request["input"]: + output = item.get("output") + marker = f"CAPTURE_PAGE {name} " + if item.get("type") == "function_call_output" and isinstance(output, str) and marker in output: + index, body = output.split(marker, 1)[1].split("\n", 1) + assert "truncated output" not in output + pages[int(index)] = body.rstrip("\n") + return "".join(pages[i] for i in range(len(pages))) + + +@pytest.mark.parametrize("host,surface", [("exec", "quota_packet"), ("app_server", "turn_envelope")]) +@pytest.mark.parametrize("failed_primary", [False, True]) +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary): + isolate_sqlite_runtime(tmp_path, monkeypatch) + binary, env, workspace, home = _native_fixture(tmp_path) + env.update(NODE_OPTIONS="--experimental-sqlite", LOOPX_USAGE_PING="0") + project, runtime, registry = _write_fixture(workspace) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + body = "\n".join(f"Requirement {i:04d}: preserve this row and its evidence." for i in range(400)) + body += "\nFINAL REQUIREMENT: stop before publication." + todos = read_fixture_todos(state) + todos[0].update(text=body, title=body) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider="sqlite") + original_state = state.read_bytes() + root = workspace / "captures" + root.mkdir() + wire_paths = {name: workspace / f"{name}.json" for name in ("cold", "warm")} + reference = home / "current-reference.json" + identity = home / "session.json" + hook = home / "hook.py" + hook.write_text( + "import json,pathlib,sys\nevent=json.load(sys.stdin)\n" + f"root=pathlib.Path({str(home)!r})\n" + "if event['hook_event_name']=='SessionStart':\n" + " (root/'session.json').write_text(json.dumps({'session_id':event['session_id']}))\n" + " if event.get('source')=='compact':\n" + " (root/'epoch-invalidated').write_text('compact')\n" + " try:\n" + + (" raise RuntimeError('primary handler failure')\n" if failed_primary else " pass\n") + + " except RuntimeError:\n pass\n" + " ref=json.loads((root/'current-reference.json').read_text())\n" + " print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart'," + "'additionalContext':'Baseline invalidated. Read the complete original capture before dependent work; '" + "'do not rerun admission or ask the owner. '+json.dumps(ref)}}))\n" + ) + handler = {"type": "command", "command": shlex.join([sys.executable, str(hook)]), "timeout": 10} + (home / "hooks.json").write_text(json.dumps({"hooks": { + event: [{"hooks": [handler]}] for event in ("SessionStart", "PostToolUse")}})) + snapshots, requests_to_host, errors = [], [], [] + stopped = threading.Event() + stages = {"phase": "start", "pages": [], "baseline_confirmed": False, "compact_response": None} + wires = {} + + with tempfile.TemporaryDirectory(prefix="context-native-", dir="/tmp") as short: + endpoint = Path(short).resolve() / "host.sock" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: + listener.bind(str(endpoint)) + listener.listen(2) + listener.settimeout(0.2) + + def release(): + try: + while len(snapshots) < 2 and not stopped.is_set(): + try: + channel, _ = listener.accept() + except TimeoutError: + continue + index = len(snapshots) + with channel: + channel.settimeout(30) + message = json.loads(channel.makefile("rb").readline(4096)) + path = Path(message["path"]) + raw = path.read_bytes() + assert hashlib.sha256(raw).hexdigest() == message["sha256"] + scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, + session_id=json.loads(identity.read_text())["session_id"], context_epoch="initial") + request = {"scope": scope} + if index: + # This is observed *post-processing* native model input, + # not a release/file/session-id acknowledgement. + assert stages["baseline_confirmed"] is True + request.update(baseline=wires["cold"], + retained={"complete": True, "target": wires["cold"]["target"]}) + request_path = path.parent / "delivery-request.json" + request_path.write_text(json.dumps(request)) + request_path.chmod(0o600) + requests_to_host.append(request) + snapshots.append((path, raw)) + channel.sendall(json.dumps({"released": True, "sha256": message["sha256"], + "context_delivery": True}).encode() + b"\n") + except BaseException as error: + errors.append(error) + + worker = threading.Thread(target=release, daemon=True) + worker.start() + command = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, + "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", + "--decision-output-root", str(root), "--decision-handoff-socket", str(endpoint)] + if surface == "turn_envelope": + command.append("--turn-envelope") + + def invoke(name): + return shlex.join(command) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" + + def pages(name, path): + raw = path.read_text().rstrip("\n") + chunks = [] + for index, start in enumerate(range(0, len(raw), 5000)): + script = ("from pathlib import Path; import sys; " + f"sys.stdout.write('CAPTURE_PAGE {name} {index}\\n' + " + f"Path({str(path)!r}).read_text().rstrip('\\n')[{start}:{start + 5000}])") + chunks.append(shlex.join([sys.executable, "-c", script])) + return chunks + + def response(request, number, message): + phase = stages["phase"] + if stages["pages"]: + cmd = stages["pages"].pop(0) + elif phase == "start": + stages["phase"] = "cold_ready" + cmd = invoke("cold") + elif phase in {"cold_ready", "warm_ready"}: + name = phase.split("_")[0] + wires[name] = json.loads(wire_paths[name].read_text()) + assert wires[name]["mode"] == ("full" if name == "cold" else "delta") + stages["pages"] = pages(name, wire_paths[name]) + stages["phase"] = name + "_read" + cmd = stages["pages"].pop(0) + elif phase == "cold_read": + assert _delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") + stages.update(baseline_confirmed=True, phase="warm_ready") + cmd = invoke("warm") + elif phase == "warm_read": + assert _delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") + assert _delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") + reference.write_text(json.dumps(wires["warm"]["recovery"])) + stages.update(phase="compact", compact_response=number) + cmd = "printf synthetic-work" + elif phase == "compact": + stages["phase"] = "recovery_ready" + message["content"][0]["text"] = "Summary deliberately omitting all packet source bodies." + return message + elif phase == "recovery_ready": + assert (home / "epoch-invalidated").read_text() == "compact" + assert "Baseline invalidated." in _input_text(request) + assert _delivered(request, "cold") == "" and _delivered(request, "warm") == "" + stages["phase"] = "recovered" + stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) + cmd = stages["pages"].pop(0) + else: + assert phase == "recovered" + recovered = _delivered(request, "recovery") + assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") + stages["phase"] = "done" + return message + return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", + "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} + + def usage(request, number): + count = 30000 if number == stages["compact_response"] else 1 + return {"input_tokens": count, "output_tokens": 1, "total_tokens": count + 1} + + def observed_response(request, number, message): + try: + return response(request, number, message) + except Exception as error: + # End the native fixture and report the original assertion, + # rather than making Codex retry a broken HTTP connection. + errors.append(error) + stopped.set() + return message + + try: + with local_inference_provider(observed_response, usage) as (url, calls, observed): + _configure_provider(home, url) + # The real CLI talks to its isolated local TS Effect process. + # Native sandbox networking is disabled by default; allow this + # synthetic workspace's loopback transport explicitly. + with (home / "config.toml").open("a") as config: + config.write("[sandbox_workspace_write]\nnetwork_access=true\n") + if host == "exec": + result = subprocess.run([binary, "exec", "--dangerously-bypass-hook-trust", + "--skip-git-repo-check", "--json", "-s", "workspace-write", "-C", str(workspace), + "Synthetic CLI context-delivery fixture."], env=env, capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stderr + else: + _app_server(binary, env, workspace, home, True, sandbox="workspace-write") + finally: + stopped.set() + worker.join(2) + assert not errors, errors + assert not worker.is_alive() + + assert stages["phase"] == "done" + assert len(snapshots) == 2 # compaction/readback never invokes a third guard + assert wire_paths["warm"].stat().st_size < wire_paths["cold"].stat().st_size + assert len(calls) == len(observed) and stages["baseline_confirmed"] + projection = Path(wires["warm"]["recovery"]["path"]) + assert hashlib.sha256(projection.read_bytes()).hexdigest() == wires["warm"]["recovery"]["sha256"] + current = prepare_context_delivery(json.loads(projection.read_bytes()), scope=requests_to_host[-1]["scope"], + capture_id=wires["warm"]["target"]["capture_id"], surface=surface) + assert restore_context_delivery(wires["warm"], baseline=wires["cold"], current=current)["context"] == current["context"] + for document in (wires["cold"]["context"], current["context"]): + context = (document["work_context"] if surface == "turn_envelope" else + document["interaction_contract"]["agent_channel"]["work_context"]) + selected = next(source["content"]["todo"] for source in context["sources"] + if source["source"] == "selected_todo") + assert selected["todo_id"] == TODO_ID and selected["text"] == body + assert all(path.read_bytes() == raw for path, raw in snapshots) + assert state.read_bytes() == original_state and _spend_run_count(runtime) == 0 From 0447889959507e01fcda5ea338b0dba0bbf384e5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:32:28 +0800 Subject: [PATCH 11/29] feat(quota): read original context captures through bounded CLI pages Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 19 +++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 14 ++- loopx/cli_commands/quota.py | 7 ++ loopx/cli_commands/quota_capture.py | 65 +++++++++++- loopx/cli_commands/quota_registration.py | 8 +- .../test_codex_context_capture.py | 63 +++++++---- .../test_context_capture_read.py | 100 ++++++++++++++++++ 7 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 tests/control_plane/test_context_capture_read.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 017239d81a..34f5489532 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -975,6 +975,21 @@ still before native output processing: a release does not ACK model delivery, and callers without a qualified continuity observer must not opt into warm reuse. Both actual solver and installed App lifecycle consumers remain unqualified. +The recovery reference now carries a runnable `quota context-read` command. +This is local capture IO under the existing quota entrypoint, not another +projection or decision owner. Pass the original `--capture-file` and +`--capture-sha256`; follow each `next_command` until `eof`, combining `content` +pages in byte-offset order. `--page-bytes` bounds UTF-8 content (256–8192 bytes, +4096 by default); JSON metadata and host wrapping also count toward output cost. +Each page verifies the complete file digest and object before returning content. +Replacement, bad offsets or unavailable captures return an error without partial +context or quota side effects. Parent directory aliases remain usable; substituted +final symlinks and non-regular files are refused. The command works without Goal +or runtime initialization and never executes a guard, admission or debit. Page +EOF means only that file's end, not proof of model consumption or permission to +reuse a baseline. This transport-local `context_capture_page_v1` shape does not +extend control-plane lifecycle state. + The native transport probe uses the actual shared projector, bounded tool reads and a small `SessionStart(compact)` reference. Its deterministic local provider requests the reads; it does not prove an autonomous solver follows the recovery @@ -995,7 +1010,9 @@ or quota debit. This closes a producer-to-native transport test gap; the input observer is still the deterministic loopback provider, not a production Codex continuity adapter. The source text, source state and original captures are checked independently of the delta. All pages, including recovery, remain part -of the cost boundary. Installed adoption and autonomous outcomes remain open. +of the cost boundary. These reads now execute `quota context-read`, including +digest checks, rather than ad-hoc file slicing. Installed adoption and autonomous +outcomes remain open. Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index b1c708a99d..cdd9be6d40 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -672,6 +672,17 @@ CLI 将 Goal/Agent 绑定到实际决策,保存不可变的 `projection.json` 模型交付 ACK,没有合格连续性观察器的调用方不得启用温态复用。实际 solver 与 安装态 App 的生命周期消费者仍未获得资格。 +恢复引用现包含可直接执行的 `quota context-read` 命令。这是既有 quota 入口下的 +本地 capture IO,不另建投影或决策 owner。传入原始 `--capture-file` 和 +`--capture-sha256`,依次执行每页 `next_command` 到 `eof`,按字节 offset 顺序组合 +`content`。`--page-bytes` 限制 UTF-8 内容大小(256–8192 字节,默认 4096);JSON +元数据和 Host 包装同样计入输出成本。每页返回内容前校验完整文件摘要及 JSON object。 +文件被替换、offset 错误或 capture 不可用时返回错误,不交付部分上下文或产生 quota +副作用。父目录别名仍可使用;拒绝被替换为符号链接的最终文件及非普通文件。该命令 +无需初始化 Goal 或 runtime,不执行 guard、准入或扣额。分页 EOF 只表示文件结尾, +不证明模型完整消费,也不授予基线复用资格。传输局部的 `context_capture_page_v1` +形状不扩展控制面生命周期状态。 + 原生传输 probe 使用实际共享投影、有界工具读取和很小的 `SessionStart(compact)` 引用。确定性的本机 provider 请求相应读取,不证明自主 solver 会遵循恢复路线。 保留之前整包 hook 与 fail-open 负例;从实际发出的模型输入验证首次完整读取、温态 @@ -685,7 +696,8 @@ hook 或 packet 变小本身不等于节省。下一接入是让 Codex 生命周 压缩后恢复同一份不可变 CLI 投影,包含主恢复 handler 失败,且不触发第三次 guard 或 quota 扣额。这补齐 producer 到原生传输的测试缺口;输入观察器仍是确定性 loopback provider,并非生产 Codex 连续性 adapter。来源正文、来源状态和原始 capture 独立于 -delta 校验。包括恢复在内的全部分页仍属于成本边界;安装采用和自主成果保持未完成。 +delta 校验。包括恢复在内的全部分页仍属于成本边界;这些读取现执行带摘要校验的 +`quota context-read`,不再自行编写文件切片。安装采用和自主成果保持未完成。 Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 3de6f7f8c4..e33553807c 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -8,6 +8,7 @@ from .quota_capture import ( bind_capture_selection_transport, capture_decision, handoff_decision, prepare_decision_capture, project_captured_context, + read_context_capture, ) from ..usage_goal import observe_quota_result @@ -519,6 +520,12 @@ def handle_quota_command( append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, ) -> int: + if args.quota_command == "context-read": + # Presentation recovery reads the original capture, never current Goal + # state. In particular it must not evaluate a guard or append a receipt. + payload = read_context_capture(args) + print_payload(payload, args.format, lambda value: str(value.get("content", value.get("reason", "")))) + return 0 if payload["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 d872e2f846..1f735b048b 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -5,6 +5,7 @@ import hashlib import json import os +import re import shlex import socket import stat @@ -15,6 +16,64 @@ from ..control_plane.turn_driver.decision_capture import validate_handoff_socket +def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096) -> str: + return shlex.join(["loopx", "--format", "json", "quota", "context-read", + "--capture-file", str(path), "--capture-sha256", digest, + "--offset", str(offset), "--page-bytes", str(page_bytes)]) + + +def read_context_capture(args: argparse.Namespace) -> dict: + """Bounded local IO over immutable JSON, not a new context/authority owner. + + Verify the complete byte digest on every page. The cursor addresses bytes + in that exact capture, so a replacement cannot silently mix two decisions. + EOF describes this page, never a model's complete-consumption receipt. + """ + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + + try: + digest = getattr(args, "capture_sha256", None) + value = getattr(args, "capture_file", None) + offset, size = args.offset, args.page_bytes + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None or not value: + raise ValueError("context-read requires the original capture file and its exact SHA-256") + if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= 8192: + raise ValueError("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192") + path = Path(value).expanduser().absolute() + # Parent aliases such as macOS /tmp are normal capture locations. + # Resolve those once, but never follow a substituted final file. + path = path.parent.resolve(strict=True) / path.name + if path.is_symlink(): + raise ValueError("context-read requires the physical original capture path, not a symlink") + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(descriptor, "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_REQUEST_BYTES: + raise ValueError("capture must be a regular JSON file within the existing transport size limit") + raw = stream.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES or hashlib.sha256(raw).hexdigest() != digest: + raise ValueError("capture digest mismatch; restore the original capture, do not rerun admission") + if not isinstance(json.loads(raw.decode("utf-8")), dict): + raise ValueError("capture must contain a complete JSON object") + if offset > len(raw) or (offset < len(raw) and raw[offset] & 0xC0 == 0x80): + raise ValueError("capture offset is outside the file or not a UTF-8 boundary; use the returned next offset") + end = min(offset + size, len(raw)) + while end < len(raw) and raw[end] & 0xC0 == 0x80: + end -= 1 + result = {"ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, + "offset": offset, "next_offset": end if end < len(raw) else None, + "total_bytes": len(raw), "eof": end == len(raw), "content": raw[offset:end].decode("utf-8")} + if end < len(raw): + result["next_command"] = context_capture_read_command(path, digest, offset=end, page_bytes=size) + return result + except (OSError, ValueError, RecursionError) 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", + "reason": str(exc) if isinstance(exc, ValueError) else "original capture could not be read", + "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} + + def prepare_decision_capture(args: argparse.Namespace) -> Path | None: requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) @@ -178,9 +237,11 @@ def project_captured_context( delivery["recovery"] = { "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " - "truncated or uncertain, use this full projection before dependent work. Read bounded pages " - "if necessary; do not rerun admission or ask the owner. This file is not retention proof.", + "truncated or uncertain, use this full projection before dependent work. Run command and follow " + "next_command through eof, combining every content page in offset order. Do not rerun admission " + "or ask the owner. This file and page EOF are not retention proof.", } + delivery["recovery"]["command"] = context_capture_read_command(projection, delivery["recovery"]["sha256"]) return delivery except (OSError, ValueError, TypeError, EffectRuntimeRemoteError, EffectRuntimeStartupError): # The original full payload is already available. An unavailable optional diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 7eed4e01a2..55358d35f1 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", + "context-read", "monitor-poll", "scheduler-ack", "scheduler-ack-current", @@ -35,7 +36,7 @@ def register_quota_command( "void-slot", ], 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.", + help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, 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( "--goal-id", @@ -196,6 +197,11 @@ def register_quota_command( "Requires a capture destination and a same-user Unix socket in a private directory. " "Waits at most 120s for a digest-bound release; failure retains the original receipt."), ) + quota_parser.add_argument("--capture-file", help="For context-read: original captured JSON file; no guard is evaluated.") + quota_parser.add_argument("--capture-sha256", help="For context-read: exact SHA-256 supplied with the capture reference.") + quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") + quota_parser.add_argument("--page-bytes", type=int, default=4096, + help="For context-read: maximum UTF-8 content bytes per page, 256–8192 (default: 4096).") quota_parser.add_argument( "--turn-instance-id", help=( diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 6368b380bb..43d3e8e63b 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -29,16 +29,26 @@ reason="Explicit native Codex binary required; inference uses loopback only") -def _delivered(request, name): +def _delivered(request, digest): pages = {} for item in request["input"]: output = item.get("output") - marker = f"CAPTURE_PAGE {name} " - if item.get("type") == "function_call_output" and isinstance(output, str) and marker in output: - index, body = output.split(marker, 1)[1].split("\n", 1) - assert "truncated output" not in output - pages[int(index)] = body.rstrip("\n") - return "".join(pages[i] for i in range(len(pages))) + if item.get("type") != "function_call_output" or not isinstance(output, str): + continue + try: + page = json.loads(output[output.index("{"):output.rindex("}") + 1]) + except (ValueError, TypeError): + continue + if isinstance(page, dict) and page.get("schema_version") == "context_capture_page_v1" and page.get("sha256") == digest: + assert "truncated output" not in output and page["ok"] + assert page["offset"] not in pages or pages[page["offset"]] == page["content"] + pages[page["offset"]] = page["content"] + offset, parts = 0, [] + for start, body in sorted(pages.items()): + assert start == offset + parts.append(body) + offset += len(body.encode()) + return "".join(parts).rstrip("\n") @pytest.mark.parametrize("host,surface", [("exec", "quota_packet"), ("app_server", "turn_envelope")]) @@ -84,7 +94,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho snapshots, requests_to_host, errors = [], [], [] stopped = threading.Event() stages = {"phase": "start", "pages": [], "baseline_confirmed": False, "compact_response": None} - wires = {} + wires, page_digests = {}, {} with tempfile.TemporaryDirectory(prefix="context-native-", dir="/tmp") as short: endpoint = Path(short).resolve() / "host.sock" @@ -140,14 +150,25 @@ def invoke(name): return shlex.join(command) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" def pages(name, path): - raw = path.read_text().rstrip("\n") - chunks = [] - for index, start in enumerate(range(0, len(raw), 5000)): - script = ("from pathlib import Path; import sys; " - f"sys.stdout.write('CAPTURE_PAGE {name} {index}\\n' + " - f"Path({str(path)!r}).read_text().rstrip('\\n')[{start}:{start + 5000}])") - chunks.append(shlex.join([sys.executable, "-c", script])) - return chunks + raw = path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + page_digests[name] = digest + # Plan only read offsets. Every actual model-facing page must + # come from the public CLI, including digest verification. + offsets, start, count = [0], 0, 0 + for character in raw.decode(): + size = len(character.encode()) + if count + size > 5000: + start += count + offsets.append(start) + count = 0 + count += size + return [shlex.join(command[:5] + ["--format", "json", "quota", "context-read", + "--capture-file", str(path), "--capture-sha256", digest, + "--offset", str(offset), "--page-bytes", "5000"]) for offset in offsets] + + def delivered(request, name): + return _delivered(request, page_digests[name]) def response(request, number, message): phase = stages["phase"] @@ -164,12 +185,12 @@ def response(request, number, message): stages["phase"] = name + "_read" cmd = stages["pages"].pop(0) elif phase == "cold_read": - assert _delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") + assert delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") stages.update(baseline_confirmed=True, phase="warm_ready") cmd = invoke("warm") elif phase == "warm_read": - assert _delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") - assert _delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") + assert delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") + assert delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") reference.write_text(json.dumps(wires["warm"]["recovery"])) stages.update(phase="compact", compact_response=number) cmd = "printf synthetic-work" @@ -180,13 +201,13 @@ def response(request, number, message): elif phase == "recovery_ready": assert (home / "epoch-invalidated").read_text() == "compact" assert "Baseline invalidated." in _input_text(request) - assert _delivered(request, "cold") == "" and _delivered(request, "warm") == "" + assert delivered(request, "cold") == "" and delivered(request, "warm") == "" stages["phase"] = "recovered" stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) cmd = stages["pages"].pop(0) else: assert phase == "recovered" - recovered = _delivered(request, "recovery") + recovered = delivered(request, "recovery") assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") stages["phase"] = "done" return message diff --git a/tests/control_plane/test_context_capture_read.py b/tests/control_plane/test_context_capture_read.py new file mode 100644 index 0000000000..e5f781ac02 --- /dev/null +++ b/tests/control_plane/test_context_capture_read.py @@ -0,0 +1,100 @@ +"""Original-capture paging never consults current authority or reruns a guard.""" +import argparse +import hashlib +import json +import os +import shlex + +import pytest + +from loopx.cli_commands.quota_capture import read_context_capture +from test_quota_settlement_cli import _run_cli + + +def _args(path, raw, **changes): + return argparse.Namespace(capture_file=str(path), capture_sha256=hashlib.sha256(raw).hexdigest(), + **{"offset": 0, "page_bytes": 256, **changes}) + + +def test_cli_pages_reconstruct_unicode_capture_without_goal_or_runtime(tmp_path): + path = tmp_path / "capture with ' quote.json" + original = {"requirements": ("原始要求🙂\n保留权限、停止条件与证据。" * 70), "late": "stop now", "null": None} + raw = (json.dumps(original, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + path.write_bytes(raw) + runtime, registry = tmp_path / "absent-runtime", tmp_path / "absent-registry.json" + args = ["quota", "context-read", "--capture-file", str(path), "--capture-sha256", + hashlib.sha256(raw).hexdigest(), "--page-bytes", "256"] + chunks, offset = [], 0 + while True: + code, page = _run_cli(registry, runtime, *args, cwd=tmp_path) + assert code == 0 and page["ok"], page + assert page["offset"] == offset and page["total_bytes"] == len(raw) + chunk = page["content"].encode() + assert 0 < len(chunk) <= 256 + assert chunk == raw[offset:offset + len(chunk)] + chunks.append(chunk) + offset += len(chunk) + if page["eof"]: + assert page["next_offset"] is None and "next_command" not in page + break + assert page["next_offset"] == offset + command = shlex.split(page["next_command"]) + assert command[:3] == ["loopx", "--format", "json"] + args = command[3:] + assert b"".join(chunks) == raw and json.loads(b"".join(chunks)) == original + assert path.read_bytes() == raw and not runtime.exists() and not registry.exists() + + +@pytest.mark.parametrize("kind", ["digest", "changed_between_pages", "missing", "symlink", "directory", + "not_json", "not_object", "not_utf8", "offset", "utf8_offset", "small_page", "large_page"]) +def test_invalid_capture_never_returns_partial_context(tmp_path, kind): + path = tmp_path / "projection.json" + raw = json.dumps({"source": "原始🙂" * 100}, ensure_ascii=False).encode() + path.write_bytes(raw) + args = _args(path, raw) + if kind == "digest": + args.capture_sha256 = "0" * 64 + elif kind == "changed_between_pages": + first = read_context_capture(args) + assert first["ok"] and not first["eof"] + args.offset = first["next_offset"] + path.write_bytes(raw.replace(b"source", b"target")) + elif kind == "missing": + path.unlink() + elif kind == "symlink": + target = tmp_path / "original.json" + path.rename(target) + path.symlink_to(target) + elif kind == "directory": + path.unlink() + path.mkdir() + elif kind in {"not_json", "not_object", "not_utf8"}: + raw = {"not_json": b'{"missing":', "not_object": b'[]', "not_utf8": b'{"x":"\xff"}'}[kind] + path.write_bytes(raw) + args.capture_sha256 = hashlib.sha256(raw).hexdigest() + elif kind == "offset": + args.offset = len(raw) + 1 + elif kind == "utf8_offset": + args.offset = raw.index("原".encode()) + 1 + else: + args.page_bytes = 255 if kind == "small_page" else 8193 + result = read_context_capture(args) + assert result["ok"] is False and "content" not in result and "next_command" not in result + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX named pipe") +def test_capture_reader_rejects_fifo_without_waiting_for_a_writer(tmp_path): + path = tmp_path / "projection.json" + os.mkfifo(path) + assert read_context_capture(_args(path, b"{}"))["ok"] is False + + +def test_capture_reader_accepts_parent_directory_alias(tmp_path): + original = tmp_path / "original" + original.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(original, target_is_directory=True) + raw = b'{"requirement":"preserve the complete original capture"}' + (original / "projection.json").write_bytes(raw) + result = read_context_capture(_args(alias / "projection.json", raw)) + assert result["ok"] and result["eof"] and result["content"].encode() == raw From 9b41dcb2c3ca770e2764d69ee8e6c60ea11f8c9e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:54:38 +0800 Subject: [PATCH 12/29] feat(quota): cross-check native input before context reuse Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 30 +++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 23 ++- loopx/cli_commands/quota_capture.py | 14 +- .../turn_driver/codex_input_trace.py | 130 +++++++++++++++++ .../test_codex_context_capture.py | 16 +- .../test_codex_input_retention.py | 137 ++++++++++++++++++ .../test_context_delivery_cli.py | 4 + 7 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 tests/control_plane/test_codex_input_retention.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 34f5489532..81cc16d11d 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1007,13 +1007,39 @@ a warm baseline. Codex exec covers ordinary quota; app-server covers the Turn envelope. Both recover the same immutable CLI projection after automatic compaction, including primary recovery-handler failure, without a third guard or quota debit. This closes a producer-to-native transport test gap; the input -observer is still the deterministic loopback provider, not a production Codex -continuity adapter. The source text, source state and original captures are +oracle independently inspects the deterministic loopback provider's input. +The source text, source state and original captures are checked independently of the delta. All pages, including recovery, remain part of the cost boundary. These reads now execute `quota context-read`, including digest checks, rather than ad-hoc file slicing. Installed adoption and autonomous outcomes remain open. +The existing CLI rendezvous also accepts an optional `native_input` cross-check: +`trace_bundle`, `tool_call_id`, `capture_file` and `capture_sha256`. Its native +Codex trace-v1 reader verifies the exact full baseline in the input of the +inference that produced the still-active, model-originated `exec_command` call. +It requires a matching session, ordered trace, completed response containing +that call, and either an intact full tool result or every digest-bound context +page. Earlier inference history, partial pages, previews, other item roles, +completed calls, code-mode children and implicit server-side history do not +qualify. Missing, malformed, oversized or incompatible observations discard the +retention claim and choose full automatically; ordinary callers are unchanged. +The reader only consumes already enabled private traces, with the existing +transport size bound. It reads a bounded event-log tail rather than the whole +session; if the active inference is outside that window it selects full. It +does not enable diagnostics or change native sessions. +Python owns this provider-specific IO; the shared TS projector still validates +baseline identity, freshness and exact reconstruction. + +This check can narrow a caller's retention claim, never mint one or establish +future dispatch ordering. A full baseline may be reused while every original +byte is observable; this reader does not infer a patch chain from saved files. +The native CLI/SQLite composition now uses this reader for warm selection, with +the loopback provider retained as an independent oracle. The recovery hook and +rendezvous controller are still fixtures: production lifecycle installation, +post-publication compaction recovery, trace overhead and autonomous outcomes +remain unqualified. No runtime or owner gate is added, and phase one stays open. + Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits an inert replan capture and grants no delivery authority. Explicit pending diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index cdd9be6d40..4df02851da 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -694,11 +694,30 @@ hook 或 packet 变小本身不等于节省。下一接入是让 Codex 生命周 私有 rendezvous 和共享投影;在实际发出的模型输入中观察到完整分页输出后,才提供温态 基线。Codex exec 覆盖普通 quota,app-server 覆盖 Turn envelope。两条路径均在自动 压缩后恢复同一份不可变 CLI 投影,包含主恢复 handler 失败,且不触发第三次 guard 或 -quota 扣额。这补齐 producer 到原生传输的测试缺口;输入观察器仍是确定性 loopback -provider,并非生产 Codex 连续性 adapter。来源正文、来源状态和原始 capture 独立于 +quota 扣额。这补齐 producer 到原生传输的测试缺口;独立 oracle 检查确定性 loopback +provider 的实际输入。来源正文、来源状态和原始 capture 独立于 delta 校验。包括恢复在内的全部分页仍属于成本边界;这些读取现执行带摘要校验的 `quota context-read`,不再自行编写文件切片。安装采用和自主成果保持未完成。 +既有 CLI rendezvous 还接受可选的 `native_input` 交叉校验,字段为 `trace_bundle`、 +`tool_call_id`、`capture_file`、`capture_sha256`。原生 Codex trace-v1 reader 核对 +产生当前仍在执行的、由模型发起的 `exec_command` 调用的那次推理输入,是否包含精确 +全量基线;要求 session 匹配、trace 顺序有效、已完成 response 包含该调用,并观察到 +完整工具结果或全部摘要绑定的 context 分页。更早的推理历史、部分分页、预览、其他 +消息角色、已完成调用、code-mode 子调用及隐式服务端历史均不具备资格。观察缺失、 +格式错误、超限或不兼容时,丢弃保留声明并自动全量;普通调用方保持不变。 +Reader 只读取已经启用的私有 trace,复用既有传输大小上限;读取有界事件日志尾部, +不读整份 session,当前推理已超出该窗口时自动全量。不启用诊断或改变原生 session。 +Python 持有 provider 特有的 IO;基线身份、新鲜度和精确重建仍由共享 TS +投影器校验。 + +该检查只能收窄调用方的保留声明,不能自行生成声明或证明未来 dispatch 的顺序。 +只有每个原始字节仍可观察时,才可复用全量基线;reader 不从已存文件推断 patch 链。 +原生 CLI/SQLite 组合现使用该 reader 选择温态交付,loopback provider 保留为独立 +oracle。恢复 hook 和 rendezvous controller 仍是 fixture:生产生命周期安装、发布 +后的压缩恢复、trace 开销及自主成果尚未取得资格。不新增 runtime 或 owner 问询门, +一期仍保持开放。 + Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout 则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 1f735b048b..cd084cb052 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -231,9 +231,21 @@ def project_captured_context( scope = request.get("scope") if not isinstance(scope, dict) or scope.get("goal_id") != goal_id or scope.get("agent_id") != agent_id: return payload + retained = request.get("retained") + if "native_input" in request: + from ..control_plane.turn_driver.codex_input_trace import codex_context_in_active_input + + # An optional native observation can only narrow the caller's + # retention claim. It never mints an ACK or enables tracing, and a + # malformed/stale observation must not fall back to that claim. + observation, baseline = request["native_input"], request.get("baseline") + if (not isinstance(observation, dict) or not isinstance(baseline, dict) + or not codex_context_in_active_input(observation, baseline=baseline, + session_id=scope.get("session_id"))): + retained = None delivery = prepare_context_delivery(payload, scope=scope, capture_id=capture_id, surface=surface, - baseline=request.get("baseline"), retained=request.get("retained")) + baseline=request.get("baseline"), retained=retained) delivery["recovery"] = { "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index 2f28de6682..f9ff9bbeb0 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -4,11 +4,141 @@ import hashlib import json import os +import stat from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +def codex_context_in_active_input(observation: dict, *, baseline: dict, session_id: str) -> bool: + """Cross-check a host's retention claim against a native in-flight tool call. + + Native trace v1 is an optional, provider-specific IO boundary. Only an + already enabled trace is read; this neither enables tracing nor modifies a + session. A saved history, an earlier inference or a completed tool is not + current-input evidence. Missing/partial/unknown data returns false. + + This observes the input that produced this tool call, not a future model + dispatch. The caller still owns lifecycle invalidation and same-capture + recovery. It must not use this function as a standalone continuity lease. + """ + from ..effect_runtime import MAX_REQUEST_BYTES + + def read(path: Path, *, tail: bool = False) -> bytes: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode) or (metadata.st_size > MAX_REQUEST_BYTES and not tail): + raise ValueError("native observation is not a bounded regular file") + offset = max(0, metadata.st_size - MAX_REQUEST_BYTES) if tail else 0 + stream.seek(offset) + raw = stream.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES: + raise ValueError("native observation exceeds the transport limit") + # Only the latest in-flight inference is relevant. Bound IO even for a + # long-running trace, discarding its cut first row, never its last row. + return raw.split(b"\n", 1)[-1] if offset else raw + + try: + if baseline.get("mode") != "full" or baseline.get("target", {}).get("session_id") != session_id: + return False + # Python equality conflates booleans and numbers; source observation + # must retain their JSON types, as the shared projection owner does. + expected = json.dumps(baseline, sort_keys=True, ensure_ascii=False, allow_nan=False) + bundle = Path(observation["trace_bundle"]).resolve(strict=True) + manifest = json.loads(read(bundle / "manifest.json")) + if (manifest.get("schema_version") != 1 or manifest.get("root_thread_id") != session_id + or manifest.get("rollout_id") != session_id): + return False + + def payload(ref: dict) -> dict: + relative = Path(ref["path"]) + if (relative.is_absolute() or len(relative.parts) != 2 or relative.parts[0] != "payloads" + or ".." in relative.parts): + raise ValueError("native payload reference escapes its bundle") + path = bundle / relative + path.resolve(strict=True).relative_to(bundle) + return json.loads(read(path)) + + raw_events = read(bundle / "trace.jsonl", tail=True) + if not raw_events.endswith(b"\n"): + return False # a partial writer tail cannot establish the current cursor + rows = [json.loads(line) for line in raw_events.splitlines()] + if any(row.get("schema_version") != 1 or type(row.get("seq")) is not int + or row.get("rollout_id") != session_id for row in rows): + return False + if any(a["seq"] >= b["seq"] for a, b in zip(rows, rows[1:])): + return False + rows = [row for row in rows if row.get("thread_id") == session_id] + starts = [i for i, row in enumerate(rows) if row["payload"].get("type") == "inference_started"] + if not starts: + return False + tail = rows[starts[-1]:] + turn = tail[0]["codex_turn_id"] + inference = tail[0]["payload"]["inference_call_id"] + tool_id = observation["tool_call_id"] + completed = [row for row in tail if row["payload"].get("type") == "inference_completed" + and row["payload"].get("inference_call_id") == inference] + tools = [row for row in tail if row["payload"].get("type") == "tool_call_started" + and row["payload"].get("tool_call_id") == tool_id] + if len(completed) != 1 or len(tools) != 1 or not tail[0]["seq"] < completed[0]["seq"] < tools[0]["seq"]: + return False + tool = tools[0]["payload"] + if (tool.get("requester") != {"type": "model"} or tool.get("kind") != {"type": "exec_command"} + or tool.get("model_visible_call_id") != tool_id or tool.get("code_mode_runtime_tool_id") is not None): + return False + if any(row.get("codex_turn_id") != turn or row["payload"].get("type") == "codex_turn_ended" + or (row["payload"].get("type") == "tool_call_ended" and row["payload"].get("tool_call_id") == tool_id) + for row in tail): + return False + output = payload(completed[0]["payload"]["response_payload"]) + if not any(item.get("type") == "function_call" and item.get("call_id") == tool_id + and item.get("name") == "exec_command" for item in output.get("output_items", [])): + return False + request = payload(tail[0]["payload"]["request_payload"]) + if request.get("previous_response_id"): + return False # implicit server-side history is outside this observer + raw = read(Path(observation["capture_file"])) + digest = hashlib.sha256(raw).hexdigest() + if digest != observation["capture_sha256"] or json.dumps(json.loads(raw), sort_keys=True, + ensure_ascii=False, allow_nan=False) != expected: + return False + pages: dict[int, bytes] = {} + for item in request["input"]: + if item.get("type") != "function_call_output" or not isinstance(item.get("output"), str): + continue + # The qualified exec transport wraps complete output after this + # delimiter. Ignore prose/quoted JSON, previews and other item roles. + text = item["output"].split("\nOutput:\n", 1)[-1] + try: + page = json.loads(text) + except ValueError: + continue + if json.dumps(page, sort_keys=True, ensure_ascii=False, allow_nan=False) == expected: + return True + if not isinstance(page, dict) or page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: + continue + start, content = page.get("offset"), page.get("content") + if type(start) is not int or start < 0 or not isinstance(content, str) or page.get("ok") is not True: + return False + data = content.encode("utf-8") + end = start + len(data) + if (page.get("total_bytes") != len(raw) or end > len(raw) or not data + or page.get("eof") is not (end == len(raw)) + or page.get("next_offset") != (None if end == len(raw) else end) + or raw[start:end] != data or (start in pages and pages[start] != data)): + return False + pages[start] = data + cursor = 0 + for start, data in sorted(pages.items()): + if start != cursor: + return False + cursor += len(data) + return cursor == len(raw) + except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError): + return False + + def record_codex_input_source( trace_root: Path, *, session_id: str, goal_id: str, text: str, prior_bundles: set[str], diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 43d3e8e63b..2c455fe5d9 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -56,6 +56,7 @@ def _delivered(request, digest): def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) + env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") env.update(NODE_OPTIONS="--experimental-sqlite", LOOPX_USAGE_PING="0") project, runtime, registry = _write_fixture(workspace) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" @@ -121,11 +122,16 @@ def release(): session_id=json.loads(identity.read_text())["session_id"], context_epoch="initial") request = {"scope": scope} if index: - # This is observed *post-processing* native model input, - # not a release/file/session-id acknowledgement. - assert stages["baseline_confirmed"] is True + # Production CLI checks the native trace for the + # active guard's model input. The loopback provider + # remains an independent oracle, not its ACK source. + bundle, = (workspace / "traces").glob("trace-*") request.update(baseline=wires["cold"], - retained={"complete": True, "target": wires["cold"]["target"]}) + retained={"complete": True, "target": wires["cold"]["target"]}, + native_input={"trace_bundle": str(bundle), + "tool_call_id": stages["guard_call"], + "capture_file": str(wire_paths["cold"]), + "capture_sha256": page_digests["cold"]}) request_path = path.parent / "delivery-request.json" request_path.write_text(json.dumps(request)) request_path.chmod(0o600) @@ -211,6 +217,8 @@ def response(request, number, message): assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") stages["phase"] = "done" return message + if cmd.startswith(shlex.join(command)): + stages["guard_call"] = f"call_{number}" return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} diff --git a/tests/control_plane/test_codex_input_retention.py b/tests/control_plane/test_codex_input_retention.py new file mode 100644 index 0000000000..32693b36b1 --- /dev/null +++ b/tests/control_plane/test_codex_input_retention.py @@ -0,0 +1,137 @@ +"""Native input observation is narrower than history, release or a future ACK.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver.codex_input_trace import codex_context_in_active_input + + +def fixture(root): + bundle = root / "trace" + (bundle / "payloads").mkdir(parents=True) + baseline = {"schema_version": "context_delivery_v1", "mode": "full", + "target": {"session_id": "session"}, "context": {"enabled": True, "text": "完整🙂 " * 200, "stop": "Stop before publication."}} + raw = json.dumps(baseline, ensure_ascii=False).encode() + path = root / "capture.json" + path.write_bytes(raw) + digest = hashlib.sha256(raw).hexdigest() + # Split at ASCII JSON separators, independently from the production reader. + first = raw.index(b'"context"') + second = raw.index(b'"stop"') + pages = [] + for start, end in ((0, first), (first, second), (second, len(raw))): + page = {"ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, + "offset": start, "next_offset": end if end < len(raw) else None, "total_bytes": len(raw), + "eof": end == len(raw), "content": raw[start:end].decode()} + pages.append({"type": "function_call_output", "output": "Chunk ID: fixture\nOutput:\n" + json.dumps(page)}) + request = {"input": pages} + (bundle / "manifest.json").write_text(json.dumps({"schema_version": 1, "root_thread_id": "session", "rollout_id": "session"})) + (bundle / "payloads/input.json").write_text(json.dumps(request)) + (bundle / "payloads/output.json").write_text(json.dumps({"output_items": [ + {"type": "function_call", "name": "exec_command", "call_id": "guard"}]})) + payloads = [ + {"type": "inference_started", "inference_call_id": "inference", "request_payload": {"path": "payloads/input.json"}}, + {"type": "inference_completed", "inference_call_id": "inference", "response_payload": {"path": "payloads/output.json"}}, + {"type": "tool_call_started", "tool_call_id": "guard", "model_visible_call_id": "guard", + "requester": {"type": "model"}, "kind": {"type": "exec_command"}}, + ] + rows = [{"schema_version": 1, "seq": i, "thread_id": "session", "rollout_id": "session", + "codex_turn_id": "turn", "payload": payload} for i, payload in enumerate(payloads)] + (bundle / "trace.jsonl").write_text("".join(json.dumps(row) + "\n" for row in rows)) + observation = {"trace_bundle": str(bundle), "tool_call_id": "guard", "capture_file": str(path), "capture_sha256": digest} + return observation, baseline, request, rows + + +def test_only_complete_tool_output_in_current_input_is_observed_without_writes(tmp_path): + observation, baseline, request, _ = fixture(tmp_path) + original = {path: path.read_bytes() for path in tmp_path.rglob("*") if path.is_file()} + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + assert all(path.read_bytes() == raw for path, raw in original.items()) + # Identical repeated pages are ordinary history; every byte is still needed. + request["input"].append(request["input"][0]) + path = tmp_path / "trace/payloads/input.json" + path.write_text(json.dumps(request)) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + path.write_text(json.dumps({"input": [{"type": "function_call_output", "output": json.dumps(baseline)}]})) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + + +def test_long_trace_uses_current_window_and_never_old_delivery(tmp_path): + from loopx.control_plane.effect_runtime import MAX_REQUEST_BYTES + + observation, baseline, _, rows = fixture(tmp_path) + trace = tmp_path / "trace/trace.jsonl" + # Old history need not fit in memory or be reread to observe a live call. + old = {**rows[0], "seq": -1, "payload": {"type": "protocol_event_observed", "text": "x" * (MAX_REQUEST_BYTES + 100)}} + trace.write_text(json.dumps(old) + "\n" + trace.read_text()) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + # If the current inference itself fell outside the bounded window, decline. + with trace.open("a") as stream: + stream.write(json.dumps({**old, "seq": 3}) + "\n") + assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") + + +@pytest.mark.parametrize("failure", ["middle_missing", "pruned", "preview", "quoted", "wrong_role", "server_history", + "bad_page", "overlap", "capture_changed", "other_session", "other_tool", "ended", "new_inference", "compacted", + "child", "code_mode", "wrong_response", "partial_trace", "reordered_trace", "missing_trace", "outside_payload", "symlink", "boolean_type"]) +def test_unknown_stale_lossy_or_unbound_observation_never_reuses(tmp_path, failure): + observation, baseline, request, rows = fixture(tmp_path) + bundle = Path(observation["trace_bundle"]) + if failure == "middle_missing": + request["input"].pop(1) + elif failure == "pruned": + request["input"] = [] + elif failure == "preview": + request["input"][1]["output"] = "Warning: truncated output\n" + request["input"][1]["output"][:100] + elif failure == "quoted": + request["input"] = [{"type": "function_call_output", "output": "Example: " + json.dumps(baseline)}] + elif failure == "wrong_role": + request["input"] = [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": json.dumps(baseline)}]}] + elif failure == "boolean_type": + other = json.loads(json.dumps(baseline)) + other["context"]["enabled"] = 1 + request["input"] = [{"type": "function_call_output", "output": json.dumps(other)}] + elif failure == "server_history": + request["previous_response_id"] = "opaque-history" + elif failure == "bad_page": + request["input"][1]["output"] = request["input"][1]["output"].replace('"eof": false', '"eof": true') + elif failure == "overlap": + page = json.loads(request["input"][1]["output"].split("\nOutput:\n")[1]) + page["offset"] -= 1 + request["input"][1]["output"] = json.dumps(page) + elif failure == "capture_changed": + Path(observation["capture_file"]).write_text("{}") + elif failure == "other_session": + baseline["target"]["session_id"] = "other" + elif failure == "other_tool": + observation["tool_call_id"] = "other" + elif failure == "ended": + rows.append({**rows[-1], "seq": 3, "payload": {"type": "tool_call_ended", "tool_call_id": "guard"}}) + elif failure in {"new_inference", "compacted"}: + rows.append({**rows[0], "seq": 3, "payload": {**rows[0]["payload"], "inference_call_id": "later"}}) + elif failure == "child": + rows[-1]["thread_id"] = "child" + elif failure == "code_mode": + rows[-1]["payload"]["code_mode_runtime_tool_id"] = "nested" + elif failure == "wrong_response": + (bundle / "payloads/output.json").write_text(json.dumps({"output_items": []})) + elif failure == "reordered_trace": + rows[0], rows[1] = rows[1], rows[0] + elif failure == "outside_payload": + rows[0]["payload"]["request_payload"]["path"] = "../input.json" + (bundle / "payloads/input.json").write_text(json.dumps(request)) + trace = bundle / "trace.jsonl" + trace.write_text("".join(json.dumps(row) + "\n" for row in rows)) + if failure == "partial_trace": + trace.write_text(trace.read_text() + '{"partial":') + elif failure == "missing_trace": + trace.unlink() + elif failure == "symlink": + original = tmp_path / "original.jsonl" + trace.rename(original) + trace.symlink_to(original) + assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index e4db501085..e63664cad0 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -180,6 +180,10 @@ def release(): reset, _ = invoke({"scope": {**scope, "context_epoch": "compacted"}, "baseline": cold, "retained": {"complete": True, "target": cold["target"]}}) assert reset["mode"] == "full" + unobserved, directory = invoke({"scope": scope, "baseline": cold, + "retained": {"complete": True, "target": cold["target"]}, "native_input": {}}) + assert unobserved["mode"] == "full" and unobserved["reason"] == "baseline_unavailable" + assert unobserved["context"] == json.loads((directory / "projection.json").read_bytes()) for malformed in ["{truncated", "[" * 2000 + "]" * 2000, {"scope": {**scope, "agent_id": "another-agent"}}]: full, directory = invoke(malformed) From 19fafa1dcd90e7ed8e530013c3ff66b0272a3f9f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:18:03 +0800 Subject: [PATCH 13/29] feat(quota): connect native lifecycle context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 46 ++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 32 +- loopx/cli_commands/quota.py | 30 +- loopx/cli_commands/quota_capture.py | 6 + loopx/cli_commands/quota_registration.py | 7 +- .../turn_driver/codex_context.py | 178 ++++++++++ .../turn_driver/codex_input_trace.py | 24 +- .../test_codex_context_capture.py | 320 ++++++++---------- .../test_codex_context_lifecycle.py | 117 +++++++ .../test_codex_input_retention.py | 15 + .../test_context_delivery_cli.py | 29 ++ 11 files changed, 601 insertions(+), 203 deletions(-) create mode 100644 loopx/control_plane/turn_driver/codex_context.py create mode 100644 tests/control_plane/test_codex_context_lifecycle.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 81cc16d11d..43c6029868 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1034,11 +1034,47 @@ baseline identity, freshness and exact reconstruction. This check can narrow a caller's retention claim, never mint one or establish future dispatch ordering. A full baseline may be reused while every original byte is observable; this reader does not infer a patch chain from saved files. -The native CLI/SQLite composition now uses this reader for warm selection, with -the loopback provider retained as an independent oracle. The recovery hook and -rendezvous controller are still fixtures: production lifecycle installation, -post-publication compaction recovery, trace overhead and autonomous outcomes -remain unqualified. No runtime or owner gate is added, and phase one stays open. +The native CLI/SQLite composition uses this reader for warm selection, with +an independent loopback oracle. The production consumer now provides +`quota context-hook --codex-context-dir ` for native events +on stdin. Ordinary `quota should-run` uses the same `--codex-context-dir` with +its existing capture destination and calls the shared projector directly; +a test-side rendezvous controller is no longer required. The Python module owns +only Codex-specific event/private-file IO, not a new capability, runner, Goal +state or decision owner. TS retains delta, binding and fallback semantics. + +The caller prepares a separate private directory per native session/workspace +and registers that hook command for `SessionStart`, `PreToolUse` and `PreCompact` +through the native configuration/trust flow. The CLI does not install hooks, +alter trust or enable tracing. It checks the current `CODEX_THREAD_ID`, working +directory and active call and requires complete input from already enabled +traces; absent, incompatible or uncertain evidence selects full automatically. +Removing the opt-in argument restores the ordinary path. Existing arguments and +legacy rendezvous behavior remain unchanged; selection commands carry the same +transport argument without another human step. + +Startup, resume, fork and compact events invalidate the epoch. Same-session +recovery carries only the latest immutable capture's complete-read command; +a different session never borrows the old reference. Preparation and publication +of the latest recovery reference share a private lock, so post-publication +compaction also recovers that capture. A primary journal write failure still +returns a known recovery reference; a publication that cannot record its reference +returns the original full payload. A candidate file is never an ACK: complete +model input is cross-checked on each reuse. A complete projection read through +`context-read` can establish the baseline body; `1` and `1.0` are both JSON +numbers, while booleans remain distinct. Missing/mismatched baseline, source +permission and freshness rules are unchanged. + +Native exec/quota and app-server/Turn tests now execute this production CLI and +hook rather than a test lifecycle controller. They cover complete cold read, +warm delta, automatic post-publication compaction, primary-handler write failure, +same-capture complete recovery and zero extra guard/debit. The deterministic +loopback provider still does not prove an autonomous model follows recovery or +achieves lower total cost. Mid-session wholly unexecuted/killed hooks, code-mode, +installed adoption, trace/all-read overhead and real solver outcomes need +independent qualification. Ordinary app-server `exec_command` evidence must not +be transferred to Codex App code-mode. No runtime/owner question is added, and +phase one remains open. Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 4df02851da..a972cc4e75 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -713,10 +713,34 @@ Python 持有 provider 特有的 IO;基线身份、新鲜度和精确重建仍 该检查只能收窄调用方的保留声明,不能自行生成声明或证明未来 dispatch 的顺序。 只有每个原始字节仍可观察时,才可复用全量基线;reader 不从已存文件推断 patch 链。 -原生 CLI/SQLite 组合现使用该 reader 选择温态交付,loopback provider 保留为独立 -oracle。恢复 hook 和 rendezvous controller 仍是 fixture:生产生命周期安装、发布 -后的压缩恢复、trace 开销及自主成果尚未取得资格。不新增 runtime 或 owner 问询门, -一期仍保持开放。 +原生 CLI/SQLite 组合使用该 reader 选择温态交付,loopback provider 保留为独立 +oracle。当前生产接入新增 `quota context-hook --codex-context-dir `, +从 stdin 读取原生事件;普通 `quota should-run` 在既有 capture destination 上携带同一 +`--codex-context-dir`,直接调用共享 projector,无需测试侧 rendezvous controller。 +该 Python 模块只负责 Codex 特有的事件和私有文件 IO,不新增 capability、runner、 +Goal 状态或决策 owner;TS 继续持有差分、绑定和回退语义。 + +调用方为每个原生 session/workspace 准备独立的私有目录,并通过原生配置与信任流程 +把上述 hook 命令接到 `SessionStart`、`PreToolUse`、`PreCompact`。CLI 不安装 hook、 +修改信任或启用 tracing。它核对当前 `CODEX_THREAD_ID`、工作目录和 active call,并 +要求已经启用的 trace 提供完整输入;缺失、不可兼容或无法确认时自动全量。移除 opt-in +参数即可恢复原路径;普通参数和旧 rendezvous 行为保持不变。selection 命令继续携带 +同一 transport 参数,不增加人工步骤。 + +启动、resume、fork、压缩事件使 epoch 失效;同一 session 的恢复提示只带最新不可变 +capture 的完整读取命令,跨 session 不借用旧引用。生成候选和保存最新恢复引用使用同一 +私有锁;发布之后的压缩也能恢复同 capture。主 journal 写入失败时,handler 仍返回已知 +恢复引用;生产发布无法记录引用时返回原全量。候选文件从不等于 ACK,完整模型输入需在 +每次复用时重新核对。`context-read` 读回的完整投影可以提供基线正文证据;`1` 与 `1.0` +均属 JSON number,布尔值仍须不同。基线缺失或错配、来源权限与新鲜度规则不变。 + +原生 exec/quota 与 app-server/Turn 测试现执行上述生产 CLI 和 hook,而非测试侧 +生命周期 controller;覆盖完整初读、温态差分、发布后自动压缩、主 handler 写入失败、 +同 capture 完整补读和零额外 guard/扣额。它们仍使用确定性 loopback provider,不证明 +真实模型自主遵循恢复提示或总成本收益。中途整个 hook 未执行/被杀、code-mode、安装 +采用、trace 与全部补读开销以及真实 solver 成果仍须独立验证;不得将普通 app-server +`exec_command` 资格套用于 Codex App 的 code-mode。不开新 runtime/owner 问询门, +一期保持开放。 Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index e33553807c..3cfaf2445b 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -483,7 +483,7 @@ def _emit_quota_result( ) print_payload(failure, args.format, _quota_renderer(args)) return 1 - if delivery_request is not None: + if delivery_request is not None or getattr(args, "codex_context_dir", None): agent = payload.get("agent_identity") decision_identity = (str(payload.get("goal_id") or ""), str(agent.get("agent_id") or "") if isinstance(agent, Mapping) else str(args.agent_id or "")) @@ -507,6 +507,18 @@ def _emit_quota_result( request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet") + if args.format == "json" and succeeded and getattr(args, "codex_context_dir", None) and capture_directory: + import hashlib + from ..control_plane.turn_driver.codex_context import project_codex_context + + try: + capture_id = hashlib.sha256((capture_directory / "decision.json").read_bytes()).hexdigest() + payload = project_codex_context(payload, directory=args.codex_context_dir, + capture_directory=capture_directory, goal_id=decision_identity[0], agent_id=decision_identity[1], + surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet", + capture_id=capture_id) + except OSError: + pass # Lost optional capture: keep the admitted full payload, never rerun its guard. print_payload(payload, args.format, _quota_renderer(args)) return 0 if succeeded else 1 @@ -520,6 +532,21 @@ def handle_quota_command( append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, ) -> int: + if args.quota_command == "context-hook": + import json + import sys + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + from ..control_plane.turn_driver.codex_context import codex_context_hook + + # Native transport only: no registry discovery, guard or quota effects. + try: + raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) + event = json.loads(raw) if len(raw) <= MAX_REQUEST_BYTES else None + payload = codex_context_hook(args.codex_context_dir, event) if isinstance(event, dict) else {} + except (OSError, ValueError, TypeError, RecursionError): + payload = {} + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + return 0 if args.quota_command == "context-read": # Presentation recovery reads the original capture, never current Goal # state. In particular it must not evaluate a guard or append a receipt. @@ -544,6 +571,7 @@ def handle_quota_command( 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 + or getattr(args, "codex_context_dir", None) is not None ): validate_quota_command_context_request(args) capture_directory = prepare_decision_capture(args) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index cd084cb052..d801f407a1 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -78,6 +78,10 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) handoff = getattr(args, "decision_handoff_socket", None) + if getattr(args, "codex_context_dir", None) is not None and ( + args.format != "json" or handoff is not None or (requested is None and root is None)): + raise QuotaCommandValidationError( + "native Codex context delivery needs JSON and a capture destination, without a second handoff socket") if handoff is not None: validate_handoff_socket(handoff) if requested is None and root is None: @@ -149,6 +153,8 @@ def bind_capture_selection_transport(payload: dict, args: argparse.Namespace) -> suffix += " --decision-handoff-socket " + shlex.quote(args.decision_handoff_socket) if getattr(args, "turn_envelope", False): suffix += " --turn-envelope" + if getattr(args, "codex_context_dir", None): + suffix += " --codex-context-dir " + shlex.quote(args.codex_context_dir) selection["command_args_template"] = template + suffix diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 55358d35f1..9c5910fab1 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -28,6 +28,7 @@ def register_quota_command( "plan", "should-run", "context-read", + "context-hook", "monitor-poll", "scheduler-ack", "scheduler-ack-current", @@ -36,7 +37,7 @@ def register_quota_command( "void-slot", ], default="status", - help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, 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.", + help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, context-hook for opt-in native Codex lifecycle IO, 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( "--goal-id", @@ -198,6 +199,10 @@ def register_quota_command( "Waits at most 120s for a digest-bound release; failure retains the original receipt."), ) quota_parser.add_argument("--capture-file", help="For context-read: original captured JSON file; no guard is evaluated.") + quota_parser.add_argument("--codex-context-dir", + help="Opt-in private native Codex lifecycle directory. For context-hook, consume one native hook event on stdin. " + "For should-run, requires JSON and a decision capture destination; unknown retention stays full. " + "Does not install hooks, enable tracing, or change native trust.") quota_parser.add_argument("--capture-sha256", help="For context-read: exact SHA-256 supplied with the capture reference.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") quota_parser.add_argument("--page-bytes", type=int, default=4096, diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py new file mode 100644 index 0000000000..bf061bfe40 --- /dev/null +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -0,0 +1,178 @@ +"""Opt-in native lifecycle IO for the existing quota context projector. + +The private directory belongs to one native session/workspace. Files are only +candidate references: actual native input must independently prove retention. +No hook grants execution authority or acknowledges model understanding. +""" +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +import stat +import uuid + +from .decision_capture import write_decision_capture + + +def _read(path: Path, *, private: bool = True) -> dict: + from ..effect_runtime import MAX_REQUEST_BYTES + + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or (private and info.st_mode & 0o077) or info.st_size > MAX_REQUEST_BYTES): + raise ValueError("context transport needs a bounded private regular file") + raw = stream.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES: + raise ValueError("context transport file is too large") + result = json.loads(raw) + if not isinstance(result, dict): + raise ValueError("context transport file must contain an object") + return result + + +@contextmanager +def _locked(directory: str): + if os.name != "posix": + raise ValueError("native context continuity is unavailable on this platform") + import fcntl + + root = Path(directory).expanduser().absolute() + root = root.parent.resolve(strict=True) / root.name + info = root.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise ValueError("context directory must already exist, be private and belong to this user") + fd = os.open(root / "lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + with os.fdopen(fd, "r+") as lock: + if not stat.S_ISREG(os.fstat(lock.fileno()).st_mode): + raise ValueError("context lock is not a regular file") + fcntl.flock(lock, fcntl.LOCK_EX) + try: + yield root + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +def _state(root: Path) -> dict: + try: + return _read(root / "session.json") + except FileNotFoundError: + return {} + + +def _save(root: Path, state: dict) -> None: + write_decision_capture(root / "session.json", state, publish=os.replace) + + +def codex_context_hook(directory: str, event: dict) -> dict: + """Consume native lifecycle events; emit only same-capture recovery refs. + + SessionStart/PreCompact invalidate, including resume/fork/restart. PreToolUse + supplies the native call id; it is not retention proof. Failure to update the + primary journal still returns the existing recovery ref when available. + Trust/enablement and complete event delivery remain native host concerns. + """ + name, session = event.get("hook_event_name"), event.get("session_id") + if not isinstance(session, str) or not session: + return {} + recovery = None + try: + with _locked(directory) as root: + state = _state(root) + same = state.get("session_id") == session and state.get("cwd") == event.get("cwd") + if same: + recovery = state.get("recovery") + else: + state = {"session_id": session, "cwd": event.get("cwd")} + if name in ("SessionStart", "PreCompact"): + state.update(context_epoch=uuid.uuid4().hex, active_call=None) + state.pop("candidate", None) + elif name == "PreToolUse" and same and state.get("context_epoch"): + state["active_call"] = event.get("tool_use_id") + else: + return {} + _save(root, state) + except (OSError, ValueError, TypeError, RecursionError): + # A failed primary update cannot suppress a known full-read reference. + # It also cannot manufacture a fresh active-call observation. + pass + if name == "SessionStart" and isinstance(recovery, dict) and recovery.get("command"): + return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": + "LoopX context baseline invalidated. Before dependent work, read this original full capture " + "and follow next_command through eof, combining every content page. Do not rerun admission " + "or ask the owner. Capture recovery does not renew authority or source freshness. " + + recovery["command"]}} + return {} + + +def _trace_bundle(session: str) -> str | None: + value = os.environ.get("CODEX_ROLLOUT_TRACE_ROOT") + if not value: + return None # never enable tracing implicitly + matches = [] + # Native diagnostics are optional. Bound discovery instead of scanning a + # long session archive; ambiguity or an unsupported store selects full. + for index, path in enumerate(Path(value).glob("trace-*")): + if index >= 128: + return None + try: + manifest = _read(path / "manifest.json", private=False) + except (OSError, ValueError): + continue + if manifest.get("root_thread_id") == session and manifest.get("rollout_id") == session: + matches.append(str(path)) + return matches[0] if len(matches) == 1 else None + + +def project_codex_context(payload: dict, *, directory: str, capture_directory: Path, + goal_id: str, agent_id: str, surface: str, capture_id: str) -> dict: + """Existing CLI consumer, sharing the rendezvous projector and recovery IO. + + Serialization prevents an invalidation from moving the epoch during + preparation. The current capture reference is durable before publication, + so a later compact event recovers those exact bytes. Full candidates are + never advanced from file existence, hook success or a saved delivery flag. + """ + from ...cli_commands.quota_capture import project_captured_context + from .codex_input_trace import codex_context_in_active_input + + try: + with _locked(directory) as root: + state = _state(root) + session, epoch = state.get("session_id"), state.get("context_epoch") + if (not isinstance(session, str) or not isinstance(epoch, str) + or not session or not epoch or session != os.environ.get("CODEX_THREAD_ID") + or Path(state.get("cwd", "")).resolve() != Path.cwd().resolve()): + return payload + scope = dict(goal_id=goal_id, agent_id=agent_id, session_id=session, context_epoch=epoch) + request = {"scope": scope} + candidate, trace = state.get("candidate"), _trace_bundle(session) + if isinstance(candidate, dict) and trace and isinstance(state.get("active_call"), str): + try: + baseline = _read(Path(candidate["path"])) + observation = dict(trace_bundle=trace, tool_call_id=state["active_call"], + capture_file=candidate["capture_file"], capture_sha256=candidate["capture_sha256"]) + if codex_context_in_active_input(observation, baseline=baseline, session_id=session): + request.update(baseline=baseline, retained={"complete": True, "target": baseline["target"]}, + native_input=observation) + except (OSError, ValueError, KeyError, TypeError, RecursionError): + pass # Missing candidate: publish a new full capture, never an old recovery ref. + result = project_captured_context(payload, directory=capture_directory, request=request, + goal_id=goal_id, agent_id=agent_id, surface=surface, capture_id=capture_id) + if result.get("schema_version") != "context_delivery_v1": + return result + recovery = result["recovery"] + state["recovery"] = {key: recovery[key] for key in ("path", "sha256", "command")} + if result["mode"] == "full": + path = capture_directory / "presentation.json" + write_decision_capture(path, result) + state["candidate"] = dict(path=str(path), capture_file=recovery["path"], + capture_sha256=recovery["sha256"]) + _save(root, state) + return result + except (OSError, ValueError, KeyError, TypeError, RecursionError): + # A broken optional adapter preserves the already admitted full result. + return payload diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index f9ff9bbeb0..9d6840cc24 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -24,6 +24,20 @@ def codex_context_in_active_input(observation: dict, *, baseline: dict, session_ """ from ..effect_runtime import MAX_REQUEST_BYTES + def canonical(value): + # JSON has one number type; native Python projection may spell 1.0 + # where the TS codec spells 1. Preserve booleans and numeric values. + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, list): + return [canonical(item) for item in value] + if isinstance(value, dict): + return {key: canonical(item) for key, item in value.items()} + return value + + def encoded(value): + return json.dumps(canonical(value), sort_keys=True, ensure_ascii=False, allow_nan=False) + def read(path: Path, *, tail: bool = False) -> bytes: descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(descriptor, "rb") as stream: @@ -44,7 +58,8 @@ def read(path: Path, *, tail: bool = False) -> bytes: return False # Python equality conflates booleans and numbers; source observation # must retain their JSON types, as the shared projection owner does. - expected = json.dumps(baseline, sort_keys=True, ensure_ascii=False, allow_nan=False) + expected = encoded(baseline) + context = encoded(baseline.get("context")) bundle = Path(observation["trace_bundle"]).resolve(strict=True) manifest = json.loads(read(bundle / "manifest.json")) if (manifest.get("schema_version") != 1 or manifest.get("root_thread_id") != session_id @@ -100,8 +115,8 @@ def payload(ref: dict) -> dict: return False # implicit server-side history is outside this observer raw = read(Path(observation["capture_file"])) digest = hashlib.sha256(raw).hexdigest() - if digest != observation["capture_sha256"] or json.dumps(json.loads(raw), sort_keys=True, - ensure_ascii=False, allow_nan=False) != expected: + captured = encoded(json.loads(raw)) + if digest != observation["capture_sha256"] or captured not in (expected, context): return False pages: dict[int, bytes] = {} for item in request["input"]: @@ -114,7 +129,8 @@ def payload(ref: dict) -> dict: page = json.loads(text) except ValueError: continue - if json.dumps(page, sort_keys=True, ensure_ascii=False, allow_nan=False) == expected: + document = encoded(page) + if document == expected or (captured == context and document == context): return True if not isinstance(page, dict) or page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: continue diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 2c455fe5d9..9e1c56da73 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -1,7 +1,7 @@ """Native tools execute the actual quota CLI and recover its original capture. The loopback provider is a deterministic input-boundary observer. This qualifies -the transport composition, not a shipped continuity observer or solver benefit. +the opt-in production lifecycle/CLI composition, not installed adoption or solver benefit. """ from __future__ import annotations @@ -10,11 +10,8 @@ import os from pathlib import Path import shlex -import socket import subprocess import sys -import tempfile -import threading import pytest @@ -71,199 +68,146 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho root = workspace / "captures" root.mkdir() wire_paths = {name: workspace / f"{name}.json" for name in ("cold", "warm")} - reference = home / "current-reference.json" - identity = home / "session.json" - hook = home / "hook.py" - hook.write_text( - "import json,pathlib,sys\nevent=json.load(sys.stdin)\n" - f"root=pathlib.Path({str(home)!r})\n" - "if event['hook_event_name']=='SessionStart':\n" - " (root/'session.json').write_text(json.dumps({'session_id':event['session_id']}))\n" - " if event.get('source')=='compact':\n" - " (root/'epoch-invalidated').write_text('compact')\n" - " try:\n" - + (" raise RuntimeError('primary handler failure')\n" if failed_primary else " pass\n") - + " except RuntimeError:\n pass\n" - " ref=json.loads((root/'current-reference.json').read_text())\n" - " print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart'," - "'additionalContext':'Baseline invalidated. Read the complete original capture before dependent work; '" - "'do not rerun admission or ask the owner. '+json.dumps(ref)}}))\n" - ) - handler = {"type": "command", "command": shlex.join([sys.executable, str(hook)]), "timeout": 10} + lifecycle = workspace / "context" + lifecycle.mkdir(mode=0o700) + cli = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, + "-m", "loopx.entrypoint"] + hook_command = shlex.join(cli + ["quota", "context-hook", "--codex-context-dir", str(lifecycle)]) + if failed_primary: + # Fault only the primary journal update. Production code must still + # return the original full-read reference; no test recovery handler. + hook = home / "hook.py" + hook.write_text( + "import json,sys\nfrom unittest.mock import patch\n" + "from loopx.control_plane.turn_driver.codex_context import codex_context_hook\n" + "event=json.load(sys.stdin)\n" + "if event.get('source')=='compact':\n" + " with patch('loopx.control_plane.turn_driver.codex_context._save', side_effect=OSError('synthetic failure')):\n" + f" result=codex_context_hook({str(lifecycle)!r},event)\n" + "else:\n" + f" result=codex_context_hook({str(lifecycle)!r},event)\n" + "print(json.dumps(result))\n") + hook_command = shlex.join(cli[:3] + [str(hook)]) + handler = {"type": "command", "command": hook_command, "timeout": 10} (home / "hooks.json").write_text(json.dumps({"hooks": { - event: [{"hooks": [handler]}] for event in ("SessionStart", "PostToolUse")}})) - snapshots, requests_to_host, errors = [], [], [] - stopped = threading.Event() + event: [{"hooks": [handler]}] for event in ("SessionStart", "PreToolUse")}})) + errors, snapshots = [], [] stages = {"phase": "start", "pages": [], "baseline_confirmed": False, "compact_response": None} - wires, page_digests = {}, {} - - with tempfile.TemporaryDirectory(prefix="context-native-", dir="/tmp") as short: - endpoint = Path(short).resolve() / "host.sock" - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: - listener.bind(str(endpoint)) - listener.listen(2) - listener.settimeout(0.2) - - def release(): - try: - while len(snapshots) < 2 and not stopped.is_set(): - try: - channel, _ = listener.accept() - except TimeoutError: - continue - index = len(snapshots) - with channel: - channel.settimeout(30) - message = json.loads(channel.makefile("rb").readline(4096)) - path = Path(message["path"]) - raw = path.read_bytes() - assert hashlib.sha256(raw).hexdigest() == message["sha256"] - scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, - session_id=json.loads(identity.read_text())["session_id"], context_epoch="initial") - request = {"scope": scope} - if index: - # Production CLI checks the native trace for the - # active guard's model input. The loopback provider - # remains an independent oracle, not its ACK source. - bundle, = (workspace / "traces").glob("trace-*") - request.update(baseline=wires["cold"], - retained={"complete": True, "target": wires["cold"]["target"]}, - native_input={"trace_bundle": str(bundle), - "tool_call_id": stages["guard_call"], - "capture_file": str(wire_paths["cold"]), - "capture_sha256": page_digests["cold"]}) - request_path = path.parent / "delivery-request.json" - request_path.write_text(json.dumps(request)) - request_path.chmod(0o600) - requests_to_host.append(request) - snapshots.append((path, raw)) - channel.sendall(json.dumps({"released": True, "sha256": message["sha256"], - "context_delivery": True}).encode() + b"\n") - except BaseException as error: - errors.append(error) - - worker = threading.Thread(target=release, daemon=True) - worker.start() - command = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, - "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), - "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", - "--decision-output-root", str(root), "--decision-handoff-socket", str(endpoint)] - if surface == "turn_envelope": - command.append("--turn-envelope") - - def invoke(name): - return shlex.join(command) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" - - def pages(name, path): - raw = path.read_bytes() - digest = hashlib.sha256(raw).hexdigest() - page_digests[name] = digest - # Plan only read offsets. Every actual model-facing page must - # come from the public CLI, including digest verification. - offsets, start, count = [0], 0, 0 - for character in raw.decode(): - size = len(character.encode()) - if count + size > 5000: - start += count - offsets.append(start) - count = 0 - count += size - return [shlex.join(command[:5] + ["--format", "json", "quota", "context-read", - "--capture-file", str(path), "--capture-sha256", digest, - "--offset", str(offset), "--page-bytes", "5000"]) for offset in offsets] - - def delivered(request, name): - return _delivered(request, page_digests[name]) - - def response(request, number, message): - phase = stages["phase"] - if stages["pages"]: - cmd = stages["pages"].pop(0) - elif phase == "start": - stages["phase"] = "cold_ready" - cmd = invoke("cold") - elif phase in {"cold_ready", "warm_ready"}: - name = phase.split("_")[0] - wires[name] = json.loads(wire_paths[name].read_text()) - assert wires[name]["mode"] == ("full" if name == "cold" else "delta") - stages["pages"] = pages(name, wire_paths[name]) - stages["phase"] = name + "_read" - cmd = stages["pages"].pop(0) - elif phase == "cold_read": - assert delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") - stages.update(baseline_confirmed=True, phase="warm_ready") - cmd = invoke("warm") - elif phase == "warm_read": - assert delivered(request, "cold") == wire_paths["cold"].read_text().rstrip("\n") - assert delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") - reference.write_text(json.dumps(wires["warm"]["recovery"])) - stages.update(phase="compact", compact_response=number) - cmd = "printf synthetic-work" - elif phase == "compact": - stages["phase"] = "recovery_ready" - message["content"][0]["text"] = "Summary deliberately omitting all packet source bodies." - return message - elif phase == "recovery_ready": - assert (home / "epoch-invalidated").read_text() == "compact" - assert "Baseline invalidated." in _input_text(request) - assert delivered(request, "cold") == "" and delivered(request, "warm") == "" - stages["phase"] = "recovered" - stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) - cmd = stages["pages"].pop(0) - else: - assert phase == "recovered" - recovered = delivered(request, "recovery") - assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") - stages["phase"] = "done" - return message - if cmd.startswith(shlex.join(command)): - stages["guard_call"] = f"call_{number}" - return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", - "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} - - def usage(request, number): - count = 30000 if number == stages["compact_response"] else 1 - return {"input_tokens": count, "output_tokens": 1, "total_tokens": count + 1} - - def observed_response(request, number, message): - try: - return response(request, number, message) - except Exception as error: - # End the native fixture and report the original assertion, - # rather than making Codex retry a broken HTTP connection. - errors.append(error) - stopped.set() - return message - - try: - with local_inference_provider(observed_response, usage) as (url, calls, observed): - _configure_provider(home, url) - # The real CLI talks to its isolated local TS Effect process. - # Native sandbox networking is disabled by default; allow this - # synthetic workspace's loopback transport explicitly. - with (home / "config.toml").open("a") as config: - config.write("[sandbox_workspace_write]\nnetwork_access=true\n") - if host == "exec": - result = subprocess.run([binary, "exec", "--dangerously-bypass-hook-trust", - "--skip-git-repo-check", "--json", "-s", "workspace-write", "-C", str(workspace), - "Synthetic CLI context-delivery fixture."], env=env, capture_output=True, text=True, timeout=90) - assert result.returncode == 0, result.stderr - else: - _app_server(binary, env, workspace, home, True, sandbox="workspace-write") - finally: - stopped.set() - worker.join(2) - assert not errors, errors - assert not worker.is_alive() + wires, page_digests, read_paths = {}, {}, {} + command = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, + "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", + "--decision-output-root", str(root), "--codex-context-dir", str(lifecycle)] + if surface == "turn_envelope": + command.append("--turn-envelope") + + def invoke(name): + return shlex.join(command) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" + + def pages(name, path): + raw = path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + page_digests[name] = digest + # Plan only read offsets. Every actual model-facing page must + # come from the public CLI, including digest verification. + offsets, start, count = [0], 0, 0 + for character in raw.decode(): + size = len(character.encode()) + if count + size > 5000: + start += count + offsets.append(start) + count = 0 + count += size + return [shlex.join(command[:5] + ["--format", "json", "quota", "context-read", + "--capture-file", str(path), "--capture-sha256", digest, + "--offset", str(offset), "--page-bytes", "5000"]) for offset in offsets] + + def delivered(request, name): + return _delivered(request, page_digests[name]) + + def response(request, number, message): + phase = stages["phase"] + if stages["pages"]: + cmd = stages["pages"].pop(0) + elif phase == "start": + stages["phase"] = "cold_ready" + cmd = invoke("cold") + elif phase in {"cold_ready", "warm_ready"}: + name = phase.split("_")[0] + wires[name] = json.loads(wire_paths[name].read_text()) + assert wires[name]["mode"] == ("full" if name == "cold" else "delta") + capture = Path(wires[name]["recovery"]["path"]).parent / "decision.json" + snapshots.append((capture, capture.read_bytes())) + read_paths[name] = Path(wires[name]["recovery"]["path"]) if name == "cold" else wire_paths[name] + stages["pages"] = pages(name, read_paths[name]) + stages["phase"] = name + "_read" + cmd = stages["pages"].pop(0) + elif phase == "cold_read": + assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") + stages.update(baseline_confirmed=True, phase="warm_ready") + cmd = invoke("warm") + elif phase == "warm_read": + assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") + assert delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") + stages.update(phase="compact", compact_response=number) + cmd = "printf synthetic-work" + elif phase == "compact": + stages["phase"] = "recovery_ready" + message["content"][0]["text"] = "Summary deliberately omitting all packet source bodies." + return message + elif phase == "recovery_ready": + assert "LoopX context baseline invalidated." in _input_text(request) + assert delivered(request, "cold") == "" and delivered(request, "warm") == "" + stages["phase"] = "recovered" + stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) + cmd = stages["pages"].pop(0) + else: + assert phase == "recovered" + recovered = delivered(request, "recovery") + assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") + stages["phase"] = "done" + return message + return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", + "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} + + def usage(request, number): + count = 30000 if number == stages["compact_response"] else 1 + return {"input_tokens": count, "output_tokens": 1, "total_tokens": count + 1} + + def observed_response(request, number, message): + try: + return response(request, number, message) + except Exception as error: + # End the native fixture and report the original assertion, + # rather than making Codex retry a broken HTTP connection. + errors.append(error) + return message + + with local_inference_provider(observed_response, usage) as (url, calls, observed): + _configure_provider(home, url) + # The real CLI talks to its isolated local TS Effect process. + # Native sandbox networking is disabled by default; allow this + # synthetic workspace's loopback transport explicitly. + with (home / "config.toml").open("a") as config: + config.write("[sandbox_workspace_write]\nnetwork_access=true\n") + if host == "exec": + result = subprocess.run([binary, "exec", "--dangerously-bypass-hook-trust", + "--skip-git-repo-check", "--json", "-s", "workspace-write", "-C", str(workspace), + "Synthetic CLI context-delivery fixture."], env=env, capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stderr + else: + _app_server(binary, env, workspace, home, True, sandbox="workspace-write") + assert not errors, errors assert stages["phase"] == "done" assert len(snapshots) == 2 # compaction/readback never invokes a third guard + assert len(list(root.glob("*/decision.json"))) == 2 assert wire_paths["warm"].stat().st_size < wire_paths["cold"].stat().st_size assert len(calls) == len(observed) and stages["baseline_confirmed"] projection = Path(wires["warm"]["recovery"]["path"]) assert hashlib.sha256(projection.read_bytes()).hexdigest() == wires["warm"]["recovery"]["sha256"] - current = prepare_context_delivery(json.loads(projection.read_bytes()), scope=requests_to_host[-1]["scope"], + current = prepare_context_delivery(json.loads(projection.read_bytes()), scope={k: wires["warm"]["target"][k] for k in ("goal_id", "agent_id", "session_id", "context_epoch")}, capture_id=wires["warm"]["target"]["capture_id"], surface=surface) assert restore_context_delivery(wires["warm"], baseline=wires["cold"], current=current)["context"] == current["context"] for document in (wires["cold"]["context"], current["context"]): diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py new file mode 100644 index 0000000000..fbb6c6bf5e --- /dev/null +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -0,0 +1,117 @@ +"""Native lifecycle references cannot stand in for retained model input.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from loopx.control_plane.turn_driver import codex_context as context + + +def setup(root, monkeypatch): + root.chmod(0o700) + monkeypatch.chdir(root) + monkeypatch.setenv("CODEX_THREAD_ID", "session") + monkeypatch.delenv("CODEX_ROLLOUT_TRACE_ROOT", raising=False) + event = dict(session_id="session", cwd=str(root), hook_event_name="SessionStart", source="startup") + assert context.codex_context_hook(str(root), event) == {} + return event + + +def project(root, capture_id="one"): + directory = root / capture_id + directory.mkdir(mode=0o700) + payload = {"ok": True, "goal_id": "fixture", "requirements": "Preserve final stop condition."} + result = context.project_codex_context(payload, directory=str(root), capture_directory=directory, + goal_id="fixture", agent_id="agent", surface="quota_packet", capture_id=capture_id) + return payload, result + + +@pytest.mark.parametrize("event_name,source", [("SessionStart", "compact"), ("SessionStart", "resume"), + ("SessionStart", "startup"), ("PreCompact", None)]) +def test_lifecycle_invalidates_and_recovers_current_capture_without_bodies(tmp_path, monkeypatch, event_name, source): + event = setup(tmp_path, monkeypatch) + payload, first = project(tmp_path) + assert first["mode"] == "full" and first["context"] == payload + before = context._state(tmp_path) + result = context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": event_name, "source": source}) + after = context._state(tmp_path) + assert before["context_epoch"] != after["context_epoch"] and "candidate" not in after + assert after["recovery"] == before["recovery"] + if event_name == "SessionStart": + body = result["hookSpecificOutput"]["additionalContext"] + assert first["recovery"]["sha256"] in body and "quota context-read" in body + assert payload["requirements"] not in body and len(body) < 2000 + else: + assert result == {} + _, second = project(tmp_path, "two") + assert second["mode"] == "full" and second["target"]["context_epoch"] == after["context_epoch"] + + +def test_missing_observer_or_hook_never_reuses_candidate_and_other_sessions_do_not_borrow(tmp_path, monkeypatch): + event = setup(tmp_path, monkeypatch) + _, first = project(tmp_path) + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PreToolUse", "tool_use_id": "current"}) + _, second = project(tmp_path, "two") + assert second["mode"] == "full" # a present file plus a hook is not proof + assert second["target"]["capture_id"] != first["target"]["capture_id"] + monkeypatch.setenv("CODEX_THREAD_ID", "another-session") + payload, rejected = project(tmp_path, "other") + assert rejected == payload + assert context.codex_context_hook(str(tmp_path), {**event, "session_id": "another-session", "source": "fork"}) == {} + assert "recovery" not in context._state(tmp_path) and "candidate" not in context._state(tmp_path) + + +def test_primary_write_failure_preserves_small_recovery_and_does_not_create_ack(tmp_path, monkeypatch): + event = setup(tmp_path, monkeypatch) + _, wire = project(tmp_path) + before = (tmp_path / "session.json").read_bytes() + def unavailable(*args, **kwargs): + raise OSError("synthetic journal unavailable") + monkeypatch.setattr(context, "_save", unavailable) + result = context.codex_context_hook(str(tmp_path), {**event, "source": "compact"}) + assert wire["recovery"]["sha256"] in result["hookSpecificOutput"]["additionalContext"] + assert (tmp_path / "session.json").read_bytes() == before + # A primary store failure during publication returns the whole admitted + # payload. It cannot leak an untracked candidate delta. + payload, full = project(tmp_path, "two") + assert full == payload + + +@pytest.mark.parametrize("damage", ["missing", "corrupt", "symlink", "public", "other_workspace"]) +def test_invalid_journal_falls_back_to_original_full(tmp_path, monkeypatch, damage): + setup(tmp_path, monkeypatch) + path = tmp_path / "session.json" + if damage == "missing": + path.unlink() + elif damage == "corrupt": + path.write_text("{partial") + elif damage == "symlink": + other = tmp_path / "other.json" + path.rename(other) + path.symlink_to(other) + elif damage == "public": + path.chmod(0o644) + elif damage == "other_workspace": + state = context._state(tmp_path) + state["cwd"] = str(tmp_path.parent) + context._save(tmp_path, state) + payload, full = project(tmp_path) + assert full == payload + + +def test_real_hook_cli_is_transport_only_without_registry_or_installation(tmp_path): + directory = tmp_path / "native" + directory.mkdir(mode=0o700) + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + cmd = [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), + "quota", "context-hook", "--codex-context-dir", str(directory)] + event = dict(session_id="session", cwd=str(tmp_path), hook_event_name="SessionStart", source="startup") + result = subprocess.run(cmd, input=json.dumps(event), capture_output=True, text=True, timeout=20, + cwd=Path(__file__).resolve().parents[2]) + assert result.returncode == 0 and json.loads(result.stdout) == {}, result.stderr + assert context._state(directory)["session_id"] == "session" + assert not registry.exists() and not runtime.exists() diff --git a/tests/control_plane/test_codex_input_retention.py b/tests/control_plane/test_codex_input_retention.py index 32693b36b1..6c7c3d137e 100644 --- a/tests/control_plane/test_codex_input_retention.py +++ b/tests/control_plane/test_codex_input_retention.py @@ -75,6 +75,21 @@ def test_long_trace_uses_current_window_and_never_old_delivery(tmp_path): assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") +def test_full_projection_read_is_retention_evidence_with_json_number_semantics(tmp_path): + observation, baseline, _, _ = fixture(tmp_path) + baseline["context"]["compute"] = 1 + context = {**baseline["context"], "compute": 1.0} + raw = json.dumps(context).encode() + Path(observation["capture_file"]).write_bytes(raw) + observation["capture_sha256"] = hashlib.sha256(raw).hexdigest() + path = tmp_path / "trace/payloads/input.json" + path.write_text(json.dumps({"input": [{"type": "function_call_output", "output": raw.decode()}]})) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + context["compute"] = True + path.write_text(json.dumps({"input": [{"type": "function_call_output", "output": json.dumps(context)}]})) + assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") + + @pytest.mark.parametrize("failure", ["middle_missing", "pruned", "preview", "quoted", "wrong_role", "server_history", "bad_page", "overlap", "capture_changed", "other_session", "other_tool", "ended", "new_inference", "compacted", "child", "code_mode", "wrong_response", "partial_trace", "reordered_trace", "missing_trace", "outside_payload", "symlink", "boolean_type"]) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index e63664cad0..4559fef067 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -208,3 +208,32 @@ def unavailable(*args, **kwargs): "goal_id": "fixture", "agent_id": "agent", "session_id": "session", "context_epoch": "epoch"}}, goal_id="fixture", agent_id="agent", surface="quota_packet", capture_id="fixture-capture") is payload assert json.loads((tmp_path / "projection.json").read_bytes()) == payload + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_native_context_cli_without_retention_stays_full_and_preserves_receipt(tmp_path, monkeypatch, provider): + from loopx.control_plane.turn_driver.codex_context import codex_context_hook + from test_quota_settlement_cli import _heartbeat_receipt_count + + isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.setenv("CODEX_THREAD_ID", "native-session") + monkeypatch.delenv("CODEX_ROLLOUT_TRACE_ROOT", raising=False) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=read_fixture_todos(state), handoff_mode="soft_claim"), + state_path=state, provider=provider) + original = state.read_bytes() + root, captures = tmp_path / "context", tmp_path / "captures" + root.mkdir(mode=0o700) + captures.mkdir() + event = dict(session_id="native-session", cwd=str(project), hook_event_name="SessionStart", source="startup") + codex_context_hook(str(root), event) + for _ in range(2): + rc, wire = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "native-full", + "--decision-output-root", str(captures), "--codex-context-dir", str(root), cwd=project) + assert rc == 0 and wire["mode"] == "full", wire + assert json.loads(Path(wire["recovery"]["path"]).read_text()) == wire["context"] + assert _heartbeat_receipt_count(runtime, "native-full") == 1 + assert _spend_run_count(runtime) == 0 and state.read_bytes() == original From 44a8ca639924199f108a682bfc2224ce8f05047d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:43:35 +0800 Subject: [PATCH 14/29] feat(benchmark): consume native context delivery through existing workers Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/edgebench/run.py | 6 ++ benchmark/runtime/RUNTIME.md | 31 ++++++++ benchmark/runtime/codex.py | 4 + benchmark/runtime/context_delivery.py | 63 ++++++++++++++++ benchmark/runtime/harbor.py | 6 +- benchmark/runtime/sforge.py | 6 ++ benchmark/runtime/worker.py | 35 ++++++++- benchmark/tests/test_sforge_runtime.py | 17 ++++- benchmark/tests/test_shared_codex_runtime.py | 74 ++++++++++++++++++- .../rfcs/agent-loop-effect-interpreter-v0.md | 18 +++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 13 ++++ loopx/cli_commands/quota_capture.py | 4 +- loopx/cli_commands/support_control.py | 1 + .../support_control_heartbeat_registration.py | 5 ++ loopx/control_plane/heartbeat/builder.py | 6 ++ loopx/control_plane/heartbeat/host.py | 3 + .../test_codex_context_capture.py | 73 +++++++++++++++--- .../test_context_capture_read.py | 13 ++++ .../test_heartbeat_envelope_transport.py | 16 ++++ 19 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 benchmark/runtime/context_delivery.py diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index 3d4c3f3e86..ce786dab97 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -126,6 +126,8 @@ def main(argv=None): help="New-run default: native public feedback; blind/best-only are explicit controls") parser.add_argument("--turn-envelope", action="store_true", help="Opt-in short heartbeat context with same-invocation full captures") + parser.add_argument("--context-delivery", action="store_true", + help="Opt-in native heartbeat subtree reuse with original-capture recovery") parser.add_argument("--native-goal-continue", action="store_true", help="Opt-in: when a native Goal declares completion with budget left, start " "continuation Goals until the budget edge (native-goal worker only)") @@ -190,6 +192,8 @@ def main(argv=None): parser.error("--task-entry loopx-planned requires a heartbeat worker") if args.turn_envelope and args.worker not in {"heartbeat-resume", "heartbeat-explore"}: parser.error("--turn-envelope requires a heartbeat worker") + if args.context_delivery and not args.worker.startswith("heartbeat-"): + parser.error("--context-delivery requires a heartbeat worker") if args.native_goal_continue and args.worker != "native-goal": parser.error("--native-goal-continue requires --worker native-goal") if (args.replan_after_turns is not None or args.replan_after_todos is not None @@ -320,6 +324,7 @@ def main(argv=None): task_objective=task_objective, task_entry=args.task_entry, turn_envelope=args.turn_envelope, + context_delivery=args.context_delivery, native_goal_continue=args.native_goal_continue, replan_after_turns=args.replan_after_turns, replan_after_todos=args.replan_after_todos, @@ -383,6 +388,7 @@ def main(argv=None): "loopx_commit": pins[0], "runner_commit": pins[1], "sforge_source": active_source_receipt(), **turn_envelope_receipt(explicit=args.turn_envelope), + **({"context_delivery": True} if args.context_delivery else {}), **({"native_goal_continue": True} if args.native_goal_continue else {}), "task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(), "feedback": args.feedback, "internet": task.internet, diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 878a49ccf8..80d1d7d3d7 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -45,6 +45,37 @@ fail before execution. Set it back to `false` for the next run to roll back; do not change a running trial's treatment. Captures contain private Goal context. Transport tests do not establish lower token cost or better model decisions. +For an explicitly selected native context-delivery treatment, add +`context_delivery: true` to the same heartbeat agent `kwargs` (default `false`). +The existing EdgeBench/SForge entry exposes the same selection through +`--context-delivery` on a heartbeat worker. +It works with ordinary quota or the explicit TurnEnvelope view. The existing +worker stages two product lifecycle hooks in the trial's private Codex home and +queries native `hooks/list` without invoking a model. Only those exact native +hashes receive per-process trust; workspace/plugin hooks are not trusted and no +bypass flag or operator settings are used. Private native trace-v1 diagnostics +are enabled only in this opted-in trial. Install and wake receipts record the +selection; `context_transport.available` records setup, **not** warm eligibility +or delivery/benefit. Unsupported native setup continues ordinary full output. + +The product heartbeat renderer carries `--codex-context-dir` and the original +capture root into guard/reentry commands. The product observer proves intact +baseline content in the current model input before warm reuse; uncertainty +selects full. Startup/resume/fork/compaction reset the epoch, so this currently +qualifies reuse within a wake, not guaranteed savings between resumed wakes. +The small recovery hook reads the original full capture after compaction, with +no replacement admission or debit. Wholly missing/killed hooks and App code-mode +remain unqualified; deterministic native transport tests are not autonomous +solver outcomes. Count complete input, recovery reads, native discovery and trace +IO in treatment cost, not just the delta bytes. + +A simultaneous `fresh_replan` checkpoint listener retains its existing capture +ownership and reports this context treatment unavailable; it continues the +original checkpoint path. Choose separate newly pinned trials to compare the +features. Do not modify an active/frozen trial. Disable `context_delivery` for a +new trial to roll back; default runs install no hooks, trust or trace settings. +No scorer, budget, task semantics or launch authority changes with this option. + Checkpoint capture is independent of this display experiment. A `fresh_replan` declaration may establish a private capture/handoff socket while `turn_envelope` remains false: the worker keeps the ordinary dispatcher and quota diff --git a/benchmark/runtime/codex.py b/benchmark/runtime/codex.py index 66d670d3f3..ccda035045 100644 --- a/benchmark/runtime/codex.py +++ b/benchmark/runtime/codex.py @@ -28,8 +28,11 @@ class Execution: validation_command: tuple[str, ...] = () task_entry: str | None = None turn_envelope: bool = False + context_delivery: bool = False def __post_init__(self) -> None: + if not isinstance(self.context_delivery, bool) or (self.context_delivery and self.mode != "heartbeat"): + raise ValueError("context_delivery requires heartbeat execution and a boolean opt-in") if not isinstance(self.turn_envelope, bool) or (self.turn_envelope and self.mode != "heartbeat"): raise ValueError("turn_envelope requires heartbeat execution and a boolean opt-in") if self.mode not in MODES or self.context not in CONTEXTS: @@ -182,6 +185,7 @@ def prepare_codex_home( # policy explicitly so model tools cannot silently lose it. "[shell_environment_policy.set]", 'LOOPX_USAGE_PING = "0"', + *(['LOOPX_CONTEXT_DELIVERY = "1"'] if execution.context_delivery else []), "[features]", f"goals = {str(execution.native_goal).lower()}", "unified_exec = true", diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py new file mode 100644 index 0000000000..e5ea21900b --- /dev/null +++ b/benchmark/runtime/context_delivery.py @@ -0,0 +1,63 @@ +"""Explicit trial-local consumer of the product's native context transport. + +No packet semantics live here. The existing worker stages exact product hooks +and trusts only their native hashes; quota owns projection and full recovery. +""" +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +import shlex + + +def prepare_context_delivery(env: dict[str, str], home: Path) -> None: + from loopx.control_plane.turn_driver.codex_stdio import StdioNativeGoalTransport + + root = home / "loopx-context" + traces = home / "context-traces" + for directory in (root, traces): + directory.mkdir(mode=0o700, exist_ok=True) + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise ValueError("context diagnostics require private, physical trial directories") + # Opt-in diagnostics are private to this trial, never global or a retention + # assertion. Include all IO/model-input costs when comparing this treatment. + env["CODEX_ROLLOUT_TRACE_ROOT"] = str(traces) + env["LOOPX_CODEX_CONTEXT_DIR"] = str(root) + command = shlex.join([env["LOOPX_CLI"], "--format", "json", "quota", "context-hook", + "--codex-context-dir", str(root)]) + handler = {"type": "command", "command": command, "timeout": 10} + content = json.dumps({"hooks": {event: [{"hooks": [handler]}] + for event in ("SessionStart", "PreToolUse")}}) + path = home / "hooks.json" + if path.is_symlink() or (path.exists() and path.read_text() != content): + raise ValueError("native context hooks changed within a trial; use a new trial") + if not path.exists(): + path.write_text(content) + path.chmod(0o600) + # The native host is the hash/key owner. Do not duplicate its hashing or + # bypass trust for unrelated workspace hooks. Discovery launches no model. + with (home / "context-hooks-stderr.log").open("a") as stderr: + with StdioNativeGoalTransport.spawn( + [env["CODEX_BIN"], "app-server", "--listen", "stdio://"], + cwd=env["LOOPX_PROJECT"], env=env, stderr=stderr, response_timeout_sec=10, + ) as native: + native.request("initialize", {"clientInfo": {"name": "loopx_context_transport", "version": "0.1.0"}, + "capabilities": {"experimentalApi": True}}) + native.notify("initialized", {}) + listed = native.request("hooks/list", {"cwds": [env["LOOPX_PROJECT"]]}) + hooks = [hook for entry in listed["data"] for hook in entry["hooks"] + if hook.get("sourcePath") == str(path.resolve())] + if len(hooks) != 2 or path.read_text() != content: + raise ValueError("native host did not discover the exact trial context hooks") + entries = [] + for hook in hooks: + key, digest = hook.get("key"), hook.get("currentHash") + if not isinstance(key, str) or not key or not isinstance(digest, str) or not digest: + raise ValueError("native host returned no context-hook trust identity") + entries.append(f"{json.dumps(key)} = {{ trusted_hash = {json.dumps(digest)} }}") + # CLI override keys are dot-separated; native keys contain paths/dots. + # Carry the entire table as TOML so native keys remain literal strings. + env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] = json.dumps(["-c", "hooks.state={" + ",".join(entries) + "}"]) diff --git a/benchmark/runtime/harbor.py b/benchmark/runtime/harbor.py index 4d35d0f0a9..d21099235f 100644 --- a/benchmark/runtime/harbor.py +++ b/benchmark/runtime/harbor.py @@ -72,6 +72,7 @@ def __init__( task_entry=None, planning_timeout_sec=None, turn_envelope=False, + context_delivery=False, native_goal_continue=False, task_objective=None, execution_observation=None, @@ -96,6 +97,7 @@ def __init__( validation_command if validation_command is not None else (), task_entry, turn_envelope, + context_delivery, ) if planning_timeout_sec is not None: raise ValueError( @@ -167,7 +169,8 @@ def __init__( super().__init__(*args, **kwargs) def _turn_envelope_receipt(self) -> dict: - return turn_envelope_receipt(explicit=self.execution.turn_envelope) + return {**turn_envelope_receipt(explicit=self.execution.turn_envelope), + **({"context_delivery": True} if self.execution.context_delivery else {})} def _planning_receipt(self) -> dict: return {"native_goal_continue": True} if self.native_goal_continue else {} @@ -611,6 +614,7 @@ def _worker_env(self, *, cwd: str) -> dict[str, str]: **({"LOOPX_CHECKPOINT_REQUEST": self.checkpoint_request_path} if self.checkpoint_request_path is not None else {}), **({"LOOPX_TURN_ENVELOPE": "1"} if self.execution.turn_envelope else {}), + **({"LOOPX_CONTEXT_DELIVERY": "1"} if self.execution.context_delivery else {}), **({"LOOPX_NATIVE_GOAL_CONTINUE": "1"} if self.native_goal_continue else {}), "LOOPX_ITERATION_CONTEXT": self.execution.context, "LOOPX_CODEX_SANDBOX": self.execution.sandbox, diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index 12e3b0f795..b5d35f6474 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -92,6 +92,7 @@ def __init__(self, config, *, profile: str, cwd: str, task_objective: str | None = None, task_entry: str | None = None, turn_envelope: bool = False, + context_delivery: bool = False, native_goal_continue: bool = False, replan_after_turns: int | None = None, replan_after_todos: int | None = None, @@ -108,6 +109,10 @@ def __init__(self, config, *, profile: str, cwd: str, ): raise ValueError("turn_envelope requires a heartbeat worker") self.turn_envelope = turn_envelope + self.context_delivery = Execution( + mode="heartbeat" if profile.startswith("heartbeat-") else "plain", + context_delivery=context_delivery, + ).context_delivery if timeout_seconds <= PHASE_WORK_RESERVE_SECONDS: raise ValueError("Worker budget must exceed the 160s startup/settlement reserve") if not config.agent_model or not config.agent_effort: @@ -276,6 +281,7 @@ def _install_worker(self, backend, handle, log_dir, logger): scheduler_timeout_sec=self.timeout_seconds, task_entry=self.task_entry, turn_envelope=self.turn_envelope, + context_delivery=self.context_delivery, native_goal_continue=self.native_goal_continue, replan_after_turns=self.replan_after_turns, replan_after_todos=self.replan_after_todos, diff --git a/benchmark/runtime/worker.py b/benchmark/runtime/worker.py index 33aa49869e..d1f1ba9316 100644 --- a/benchmark/runtime/worker.py +++ b/benchmark/runtime/worker.py @@ -92,7 +92,10 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, command += ["--turn-instance-id", turn_id] if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") - if env.get("LOOPX_TURN_ENVELOPE") == "1" or decision_handoff_socket is not None: + context_dir = env.get("LOOPX_CODEX_CONTEXT_DIR") if env.get("LOOPX_CONTEXT_DELIVERY") == "1" else None + if context_dir and decision_handoff_socket is not None: + raise ValueError("native context delivery and checkpoint handoff need separate trials") + if env.get("LOOPX_TURN_ENVELOPE") == "1" or decision_handoff_socket is not None or context_dir: if native_goal: raise ValueError("TurnEnvelope capture requires a host-owned heartbeat Turn") # Wake identity comes from this worker, never from model-authored text. @@ -103,6 +106,8 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, command += ["--turn-envelope"] if decision_handoff_socket is not None: command += ["--decision-handoff-socket", decision_handoff_socket] + if context_dir: + command += ["--codex-context-dir", context_dir] result = subprocess.run( command, cwd=env["LOOPX_PROJECT"], @@ -382,6 +387,8 @@ def native_command(env, execution, stage, wake, session_wake): session_id=session_wake.session_id if session_wake else None, mcp_server=None, ) + if execution.context_delivery: + command[-1:-1] = json.loads(env.get("LOOPX_CODEX_CONTEXT_TRUST_ARGS", "[]")) if stage == "plan": command[-1:-1] = ["-c", "features.goals=false"] return command @@ -409,6 +416,8 @@ def _phase_timeout(timeout: float | None, deadline: float | None) -> tuple[float def run_once(env: dict[str, str]) -> dict: + if env.get("LOOPX_CONTEXT_DELIVERY", "0") not in {"0", "1"}: + raise ValueError("LOOPX_CONTEXT_DELIVERY must be 0 or 1") if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") execution = Execution( @@ -420,6 +429,7 @@ def run_once(env: dict[str, str]) -> dict: validation_command=json.loads(env.get("LOOPX_VALIDATION_COMMAND_JSON", "[]")), task_entry=env.get("LOOPX_TASK_ENTRY"), turn_envelope=env.get("LOOPX_TURN_ENVELOPE", "0") == "1", + context_delivery=env.get("LOOPX_CONTEXT_DELIVERY", "0") == "1", ) stage = env.get("LOOPX_TASK_STAGE", "execute") if stage not in {"plan", "execute"} or (stage == "plan" and execution.task_entry != "loopx-planned"): @@ -435,6 +445,9 @@ def run_once(env: dict[str, str]) -> dict: wake.mkdir(parents=True, exist_ok=False) home = Path(env["LOOPX_CODEX_HOME"]) env = process_environment(home, base=env) + # Internal transport arguments are derived afresh, never ambient authority. + env.pop("LOOPX_CODEX_CONTEXT_DIR", None) + env.pop("LOOPX_CODEX_CONTEXT_TRUST_ARGS", None) env["LOOPX_TURN"] = turn_id receipt = { "turn_id": turn_id, @@ -445,6 +458,7 @@ def run_once(env: dict[str, str]) -> dict: if observation_producer is not None else {}), **({"turn_envelope": True, "turn_envelope_source": "explicit"} if execution.turn_envelope else {}), + **({"context_delivery": True} if execution.context_delivery else {}), "stage": stage, "home_scope": "trial", "ok": False, @@ -496,6 +510,25 @@ def run_once(env: dict[str, str]) -> dict: checkpoint_wake = checkpoint_context.enter_context(CheckpointWake(env, receipt)) else: checkpoint_wake = _InertCheckpointWake() + if execution.context_delivery and checkpoint_wake.socket_path is not None: + receipt["context_transport"] = {"available": False, "reason": "checkpoint_handoff_owns_capture"} + elif execution.context_delivery: + from benchmark.runtime.context_delivery import prepare_context_delivery + + prior_trace = env.get("CODEX_ROLLOUT_TRACE_ROOT") + try: + prepare_context_delivery(env, home) + receipt["context_transport"] = {"available": True} + except Exception as exc: + # Optional presentation cannot block an otherwise admitted + # trial. Unsupported hooks/diagnostics keep ordinary full IO. + env.pop("LOOPX_CODEX_CONTEXT_DIR", None) + env.pop("LOOPX_CODEX_CONTEXT_TRUST_ARGS", None) + if prior_trace is None: + env.pop("CODEX_ROLLOUT_TRACE_ROOT", None) + else: + env["CODEX_ROLLOUT_TRACE_ROOT"] = prior_trace + receipt["context_transport"] = {"available": False, **_error_tail(exc)} body = "Finish the task." if stage == "plan": from benchmark.runtime.planning import render_planning_prompt, task_plan_packet diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index 80d7dcf496..13409a8e73 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -225,7 +225,8 @@ def command(handle, cmd, **kwargs): @pytest.mark.parametrize("capture_path", [None, "/control/checkpoint-request.json"]) -def test_checkpoint_request_reaches_sforge_worker_without_changing_default_profile(tmp_path, monkeypatch, capture_path): +@pytest.mark.parametrize("context_delivery", [False, True]) +def test_checkpoint_request_reaches_sforge_worker_without_changing_default_profile(tmp_path, monkeypatch, capture_path, context_delivery): pytest.importorskip("sforge") pytest.importorskip("harbor") from sforge.harness.config import SForgeConfig @@ -235,7 +236,8 @@ async def installed(self, environment): pass # Exercise the real adapter handoff without starting a trial. monkeypatch.setattr(BenchmarkCodex, "install", installed) config = SForgeConfig(agent_model="fixture", agent_effort="xhigh") - worker = SForgeWorker(config, profile="heartbeat-explore", cwd="/task", checkpoint_request_path=capture_path) + worker = SForgeWorker(config, profile="heartbeat-explore", cwd="/task", checkpoint_request_path=capture_path, + context_delivery=context_delivery) worker.install_stop_hook(None, None, tmp_path, None) env = worker.runtime._worker_env(cwd="/task") profile = json.loads((tmp_path / "worker-profile.json").read_text()) @@ -246,6 +248,8 @@ async def installed(self, environment): assert "turn_envelope" not in profile assert "turn_envelope_source" not in profile assert "LOOPX_TURN_ENVELOPE" not in env + assert env.get("LOOPX_CONTEXT_DELIVERY") == ("1" if context_delivery else None) + assert profile.get("context_delivery") is (True if context_delivery else None) assert profile.get("checkpoint_capture_enabled") is (True if capture_path else None) with pytest.raises(ValueError, match="checkpoint_request_path"): SForgeWorker(config, profile="official", cwd="/task", checkpoint_request_path="/control/request.json") @@ -695,6 +699,7 @@ def test_envelope_rejects_incompatible_sforge_worker(profile): @pytest.mark.parametrize("profile,options", [ ("native-goal", ["--turn-envelope"]), + ("native-goal", ["--context-delivery"]), ("official", ["--replan-after-turns", "3"]), ("single", ["--replan-after-todos", "3"]), ("native-goal", ["--replan-after-todos", "3"]), @@ -1233,7 +1238,8 @@ def stop_before_solver(**kwargs): @pytest.mark.parametrize("capture", [False, True]) -def test_native_checkpoint_storage_guidance_reaches_both_prompt_surfaces(tmp_path, monkeypatch, capture): +@pytest.mark.parametrize("context_delivery", [False, True]) +def test_native_checkpoint_storage_guidance_reaches_both_prompt_surfaces(tmp_path, monkeypatch, capture, context_delivery): pytest.importorskip("sforge") pytest.importorskip("harbor") from types import SimpleNamespace @@ -1253,6 +1259,7 @@ def test_native_checkpoint_storage_guidance_reaches_both_prompt_surfaces(tmp_pat monkeypatch.setattr(run, "RecordingDockerBackend", lambda **k: SimpleNamespace(image_exists=lambda image: True)) def before_solver(**kwargs): worker = kwargs["agent"] + assert worker.context_delivery is context_delivery assert worker.task_objective == "Original acceptance." assert worker.feedback_prompt.startswith(worker.workspace_instructions) assert worker.feedback_prompt.endswith("---\n\nOriginal acceptance.\n") @@ -1267,6 +1274,8 @@ def before_solver(**kwargs): args = ["--task", "fixture", "--tasks-dir", str(tmp_path), "--log-dir", str(tmp_path), "--run-id", "storage", "--worker", "heartbeat-explore", "--model", "fixture", "--effort", "xhigh", "--judge-url", "http://127.0.0.1:9999", "--feedback", "native"] + if context_delivery: + args += ["--context-delivery"] if capture: declaration = tmp_path / "request.json" declaration.write_text(json.dumps({"destination": "/logs/agent/checkpoints", @@ -1274,3 +1283,5 @@ def before_solver(**kwargs): args += ["--checkpoint-request", str(declaration)] with pytest.raises(RuntimeError, match="validated before model execution"): run.main(args) + receipt = json.loads((tmp_path / "runs/storage/fixture/runtime-receipt.json").read_text()) + assert receipt.get("context_delivery") is (True if context_delivery else None) diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index 7cf42f30db..b3b40ceb59 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -780,7 +780,8 @@ def test_default_execution_has_no_independent_turn_deadline(tmp_path): (False, "fresh_replan", None), (True, "fresh_replan", "explicit"), ]) -def test_harbor_install_receipt_keeps_capture_independent_from_display(tmp_path, monkeypatch, enabled, trigger, expected): +@pytest.mark.parametrize("context_delivery", [False, True]) +def test_harbor_install_receipt_keeps_capture_independent_from_display(tmp_path, monkeypatch, enabled, trigger, expected, context_delivery): pytest.importorskip("harbor") from benchmark.runtime.harbor import BenchmarkCodex, CodexOffline from benchmark.runtime.checkpoint_wake import CheckpointWake @@ -798,6 +799,7 @@ def test_harbor_install_receipt_keeps_capture_independent_from_display(tmp_path, checkpoint_args = {"checkpoint_request_path": str(request), "checkpoint_request": request.read_bytes()} agent = BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", turn_envelope=enabled, + context_delivery=context_delivery, **checkpoint_args) with CheckpointWake({"LOOPX_WAKE_LOG_DIR": str(wake_root), **({"LOOPX_CHECKPOINT_REQUEST": str(request)} if trigger else {})}, @@ -841,6 +843,8 @@ async def task_exec(**kwargs): assert agent._worker_env(cwd="/task")["LOOPX_USAGE_PING"] == "0" assert ("turn_envelope" in receipt) is (expected is not None) assert receipt.get("turn_envelope_source") == expected + assert receipt.get("context_delivery") is (True if context_delivery else None) + assert agent._worker_env(cwd="/task").get("LOOPX_CONTEXT_DELIVERY") == ("1" if context_delivery else None) if expected: assert receipt["turn_envelope"] is True @@ -1092,3 +1096,71 @@ def test_horizon_setting_rejects_non_boolean_and_non_loopx_mode(tmp_path, settin from benchmark.runtime.harbor import BenchmarkCodex with pytest.raises(ValueError, match="planning_horizon_goal"): BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", **settings) + + +@pytest.mark.parametrize("mode", ["plain", "native-goal", "turn", "loopx-goal"]) +def test_native_context_opt_in_is_only_a_heartbeat_treatment(mode): + with pytest.raises(ValueError, match="context_delivery"): + Execution(mode=mode, context_delivery=True) + with pytest.raises(ValueError, match="boolean"): + Execution(context_delivery="1") + + +def test_context_transport_unavailable_preserves_ordinary_worker(tmp_path, monkeypatch): + from benchmark.tests.test_resume_sessions import resume_env + env = resume_env(tmp_path, monkeypatch) + env["LOOPX_CONTEXT_DELIVERY"] = "1" + def unavailable(env, home): + env["LOOPX_CODEX_CONTEXT_DIR"] = "not-ready" + env["CODEX_ROLLOUT_TRACE_ROOT"] = "not-ready" + raise ValueError("unsupported native host") + monkeypatch.setattr("benchmark.runtime.context_delivery.prepare_context_delivery", unavailable) + result = run_once(env) + assert result["ok"], result + assert result["context_delivery"] and result["context_transport"]["available"] is False + assert result["session"]["session_id"] + + +def test_context_transport_off_never_installs_or_trusts_ambient_args(tmp_path, monkeypatch): + from benchmark.tests.test_resume_sessions import resume_env + env = resume_env(tmp_path, monkeypatch) + env["LOOPX_CODEX_CONTEXT_DIR"] = "ambient-not-authority" + env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] = '["--invalid-untrusted-override"]' + def unexpected(*args): + pytest.fail("disabled transport must not stage hooks or diagnostics") + monkeypatch.setattr("benchmark.runtime.context_delivery.prepare_context_delivery", unexpected) + result = run_once(env) + assert result["ok"] and "context_delivery" not in result and "context_transport" not in result + assert not (Path(env["LOOPX_CODEX_HOME"]) / "hooks.json").exists() + + +def test_context_selection_is_fixed_by_trial_home(tmp_path): + args = settings(tmp_path, execution=Execution(mode="heartbeat", context_delivery=True)) + prepare_codex_home(**args) + with pytest.raises(ValueError, match="settings changed"): + prepare_codex_home(**(args | {"execution": Execution(mode="heartbeat")})) + + +@pytest.mark.parametrize("kind", ["public_directory", "symlink_directory", "existing_hooks"]) +def test_context_setup_preserves_unsafe_or_unowned_inputs(tmp_path, kind): + from benchmark.runtime.context_delivery import prepare_context_delivery + home = tmp_path / "home" + home.mkdir() + env = {"LOOPX_CLI": "loopx"} + if kind == "existing_hooks": + protected = home / "hooks.json" + protected.write_text('{"operator": "unchanged"}') + else: + protected = home / "context-traces" + if kind == "public_directory": + protected.mkdir(mode=0o755) + protected.chmod(0o755) + else: + target = tmp_path / "outside" + target.mkdir() + protected.symlink_to(target, target_is_directory=True) + before = protected.read_bytes() if protected.is_file() else protected.lstat().st_mode + with pytest.raises(ValueError, match="private|changed within"): + prepare_context_delivery(env, home) + assert (protected.read_bytes() if protected.is_file() else protected.lstat().st_mode) == before + assert "LOOPX_CODEX_CONTEXT_TRUST_ARGS" not in env diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 43c6029868..755ff8f5f1 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1076,6 +1076,24 @@ independent qualification. Ordinary app-server `exec_command` evidence must not be transferred to Codex App code-mode. No runtime/owner question is added, and phase one remains open. +The existing benchmark worker now consumes this production path through an +explicit `context_delivery` heartbeat option, also exposed by EdgeBench/SForge +as `--context-delivery`. Its private trial home stages the product hooks and +uses native `hooks/list` to trust only their exact hashes for that process; +unrelated workspace hooks remain untrusted. Opt-in private traces support the +input observer. Setup failure continues ordinary full output; an existing +checkpoint handoff keeps its original capture ownership. Selection appears in +run/install/wake receipts, while setup availability is not a retention receipt. +Default runs add no hooks, trace or trust configuration, and changing treatment +requires a new trial. The product renderer and recovery pages preserve the +original Turn, capture and pinned CLI route. Deterministic native tests drive +the actual worker with both quota and Turn views through cold read, warm delta, +automatic compaction and complete same-capture recovery without extra guard or +debit. Startup/resume still reset the epoch: this qualifies within-wake reuse, +not savings across resumed wakes. Harbor/container installation, Codex App +code-mode, wholly lost hooks and complete solver/cost qualification remain open. +Native discovery, trace IO and all recovery reads belong in that cost comparison. + Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits an inert replan capture and grants no delivery authority. Explicit pending diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index a972cc4e75..a14ca526cf 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -742,6 +742,19 @@ capture 的完整读取命令,跨 session 不借用旧引用。生成候选和 `exec_command` 资格套用于 Codex App 的 code-mode。不开新 runtime/owner 问询门, 一期保持开放。 +既有 benchmark worker 现通过显式 `context_delivery` heartbeat 选项消费上述生产 +路径;EdgeBench/SForge 以 `--context-delivery` 暴露同一选择。trial 私有 home 放置 +产品 hook,并用原生 `hooks/list` 获取精确 hash,仅在该进程信任这些 hook;无关 +workspace hook 仍不受信任。显式启用的私有 trace 支持输入 observer。设置失败继续 +普通全量,既有 checkpoint handoff 保留原 capture 所有权。run/install/wake receipt +记录选择,设置可用不等于保留回执。默认运行不新增 hook、trace 或 trust 配置;改变 +treatment 须另起 trial。产品 renderer 与恢复分页保留原 Turn、capture 和固定 CLI +路由。确定性原生测试驱动真实 worker,覆盖 quota 与 Turn 两种视图的完整初读、温态 +差分、自动压缩及同 capture 完整恢复,零额外 guard/扣额。启动/resume 仍重置 epoch, +因此只验证单次 wake 内复用,不证明跨 resume 节省。Harbor/container 安装、Codex App +code-mode、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 +trace IO 和所有补读。 + Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout 则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index d801f407a1..04db51274c 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -17,7 +17,9 @@ def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096) -> str: - return shlex.join(["loopx", "--format", "json", "quota", "context-read", + from ..command_invocation import loopx_cli_argv + + return shlex.join([*loopx_cli_argv(), "--format", "json", "quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, "--offset", str(offset), "--page-bytes", str(page_bytes)]) diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 0cc9e4e493..08fabf7c66 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -407,6 +407,7 @@ def handle_support_control_command( turn_instance_id=args.turn_instance_id, decision_output_root=args.decision_output_root, decision_handoff_socket=args.decision_handoff_socket, + codex_context_dir=args.codex_context_dir, turn_envelope=args.turn_envelope, reward_memory_enabled=reward_memory_enabled, ) diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 0c9e506745..9731b37981 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -173,6 +173,11 @@ def register_heartbeat_control_commands( help="Retain the explicit private Host handoff on generated guards; requires --decision-output-root.", ) + heartbeat_prompt_parser.add_argument( + "--codex-context-dir", + help="Carry the opt-in native context directory into guards; requires --decision-output-root. Does not install hooks or enable tracing.", + ) + heartbeat_prequota_parser = subparsers.add_parser( "heartbeat-prequota", help=( diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 4e1845953c..8184991d0d 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -176,6 +176,7 @@ def _heartbeat_prompt_commands( turn_identity_arg: str, decision_output_root: Path | None, decision_handoff_socket: str | None, + codex_context_dir: str | None, turn_envelope: bool, ) -> dict[str, str | None]: quota_guard_command = render_quota_guard_command( @@ -280,6 +281,8 @@ def _heartbeat_prompt_commands( capture_arg = " --decision-output-root " + shlex.quote(str(decision_output_root)) if decision_handoff_socket is not None: capture_arg += " --decision-handoff-socket " + shlex.quote(decision_handoff_socket) + if codex_context_dir is not None: + capture_arg += " --codex-context-dir " + shlex.quote(codex_context_dir) display_arg = " --turn-envelope" if turn_envelope else "" for key in ("quota_guard_command", "task_body_quota_guard_command"): commands[key] = str(commands[key]) + display_arg + capture_arg @@ -315,6 +318,7 @@ def build_heartbeat_prompt( reward_memory_enabled: bool = True, decision_output_root: str | Path | None = None, decision_handoff_socket: str | None = None, + codex_context_dir: str | None = None, turn_envelope: bool = False, ) -> dict[str, Any]: if not (full or compact or brief or thin): @@ -379,6 +383,7 @@ def build_heartbeat_prompt( native_goal_host=native_goal_host, turn_instance_id=normalized_turn_instance_id, decision_handoff_socket=decision_handoff_socket, + codex_context_dir=codex_context_dir, turn_envelope=turn_envelope, ) explicit_agent_scopes = normalize_agent_scopes(agent_scopes) @@ -468,6 +473,7 @@ def build_heartbeat_prompt( turn_identity_arg=turn_identity_arg, decision_output_root=decision_output_root, decision_handoff_socket=decision_handoff_socket, + codex_context_dir=codex_context_dir, turn_envelope=turn_envelope, ) cli_preflight = render_cli_preflight(cli_bin=cli_bin) diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index e930e9c4cf..4bf768786e 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -30,9 +30,12 @@ def resolve_heartbeat_capture_root( native_goal_host: bool, turn_instance_id: str | None, decision_handoff_socket: str | None = None, + codex_context_dir: str | None = None, turn_envelope: bool = False, ) -> Path | None: """Validate capture transport for an already resolved host-owned Turn.""" + if codex_context_dir is not None and (root is None or decision_handoff_socket is not None): + raise ValueError("native context delivery requires a capture root without a second handoff socket") if root is None: if decision_handoff_socket is not None: raise ValueError("decision handoff requires an explicit decision capture root") diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 9e1c56da73..d4d44e2527 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -48,8 +48,10 @@ def _delivered(request, digest): return "".join(parts).rstrip("\n") -@pytest.mark.parametrize("host,surface", [("exec", "quota_packet"), ("app_server", "turn_envelope")]) -@pytest.mark.parametrize("failed_primary", [False, True]) +@pytest.mark.parametrize("host,surface,failed_primary", [ + (host, surface, failed) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope")] + for failed in [False, True] +] + [("benchmark", surface, False) for surface in ("quota_packet", "turn_envelope")]) def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) @@ -89,8 +91,9 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho "print(json.dumps(result))\n") hook_command = shlex.join(cli[:3] + [str(hook)]) handler = {"type": "command", "command": hook_command, "timeout": 10} - (home / "hooks.json").write_text(json.dumps({"hooks": { - event: [{"hooks": [handler]}] for event in ("SessionStart", "PreToolUse")}})) + if host != "benchmark": + (home / "hooks.json").write_text(json.dumps({"hooks": { + event: [{"hooks": [handler]}] for event in ("SessionStart", "PreToolUse")}})) errors, snapshots = [], [] stages = {"phase": "start", "pages": [], "baseline_confirmed": False, "compact_response": None} wires, page_digests, read_paths = {}, {}, {} @@ -119,7 +122,20 @@ def pages(name, path): offsets.append(start) count = 0 count += size - return [shlex.join(command[:5] + ["--format", "json", "quota", "context-read", + if host == "benchmark" and name in {"cold", "recovery"}: + # Execute the shipped original recovery route, including its pinned + # executable; no fixture substitute for installed command routing. + recovery = wires["cold" if name == "cold" else "warm"]["recovery"] + routed = shlex.split(recovery["command"]) + assert routed[:3] == [env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]] + assert routed[routed.index("--capture-file") + 1] == str(path) + routed[routed.index("--page-bytes") + 1] = "5000" + commands = [] + for offset in offsets: + routed[routed.index("--offset") + 1] = str(offset) + commands.append(shlex.join(routed)) + return commands + return [shlex.join(cli + ["--format", "json", "quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, "--offset", str(offset), "--page-bytes", "5000"]) for offset in offsets] @@ -127,10 +143,24 @@ def delivered(request, name): return _delivered(request, page_digests[name]) def response(request, number, message): + nonlocal command, root phase = stages["phase"] if stages["pages"]: cmd = stages["pages"].pop(0) elif phase == "start": + if host == "benchmark": + # Read the actual worker-rendered guard, including its original + # wake/capture identity. No substitute dispatcher or guard. + line = next(line for line in _input_text(request).splitlines() + if " quota should-run " in line and "--codex-context-dir" in line) + native_guard = shlex.split(line) + assert native_guard[0] == env["LOOPX_CLI"] + assignment = next(line for line in _input_text(request).splitlines() + if line.startswith("LOOPX_TURN=")) + turn_id = shlex.split(assignment)[0].split("=", 1)[1] + command = [turn_id if arg == "${LOOPX_TURN:?}" else arg for arg in native_guard] + command += ["--todo-id", TODO_ID] + root = Path(command[command.index("--decision-output-root") + 1]) stages["phase"] = "cold_ready" cmd = invoke("cold") elif phase in {"cold_ready", "warm_ready"}: @@ -158,6 +188,7 @@ def response(request, number, message): return message elif phase == "recovery_ready": assert "LoopX context baseline invalidated." in _input_text(request) + assert wires["warm"]["recovery"]["command"] in _input_text(request) assert delivered(request, "cold") == "" and delivered(request, "warm") == "" stages["phase"] = "recovered" stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) @@ -172,7 +203,7 @@ def response(request, number, message): "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} def usage(request, number): - count = 30000 if number == stages["compact_response"] else 1 + count = (2000000 if host == "benchmark" else 30000) if number == stages["compact_response"] else 1 return {"input_tokens": count, "output_tokens": 1, "total_tokens": count + 1} def observed_response(request, number, message): @@ -185,17 +216,41 @@ def observed_response(request, number, message): return message with local_inference_provider(observed_response, usage) as (url, calls, observed): - _configure_provider(home, url) + if host != "benchmark": + _configure_provider(home, url) # The real CLI talks to its isolated local TS Effect process. # Native sandbox networking is disabled by default; allow this # synthetic workspace's loopback transport explicitly. - with (home / "config.toml").open("a") as config: - config.write("[sandbox_workspace_write]\nnetwork_access=true\n") + if host != "benchmark": + with (home / "config.toml").open("a") as config: + config.write("[sandbox_workspace_write]\nnetwork_access=true\n") if host == "exec": result = subprocess.run([binary, "exec", "--dangerously-bypass-hook-trust", "--skip-git-repo-check", "--json", "-s", "workspace-write", "-C", str(workspace), "Synthetic CLI context-delivery fixture."], env=env, capture_output=True, text=True, timeout=90) assert result.returncode == 0, result.stderr + elif host == "benchmark": + from benchmark.runtime.worker import run_once + skills = workspace / "skills" + skills.mkdir() + unrelated = project / "unrelated-hook-executed" + project_hooks = project / ".codex/hooks.json" + project_hooks.write_text(json.dumps({"hooks": {"SessionStart": [{"hooks": [{ + "type": "command", "command": shlex.join(["touch", str(unrelated)])}]}]}})) + env.update(LOOPX_CONTEXT_DELIVERY="1", LOOPX_TURN_ENVELOPE=str(int(surface == "turn_envelope")), + LOOPX_EXECUTION_MODE="heartbeat", LOOPX_ITERATION_CONTEXT="resume", + LOOPX_TASK_ENTRY="seeded-todo", LOOPX_TASK_STAGE="execute", + LOOPX_CODEX_HOME=str(home), LOOPX_PROJECT=str(project), LOOPX_SHARED_SKILLS=str(skills), + LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), + LOOPX_GOAL_ID=GOAL_ID, LOOPX_AGENT_ID=AGENT_ID, LOOPX_WAKE_LOG_DIR=str(workspace / "wakes"), + LOOPX_CLI=str(Path(sys.executable).with_name("loopx")), CODEX_BIN=binary, + MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=url, + OPENAI_API_KEY="synthetic-loopback-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="90") + result = run_once(env) + assert result["ok"] and result["host_invoked"], result + assert result["context_transport"] == {"available": True} + assert result["session"]["session_id"] + assert not unrelated.exists() # only exact trial-owned hooks received trust else: _app_server(binary, env, workspace, home, True, sandbox="workspace-write") assert not errors, errors diff --git a/tests/control_plane/test_context_capture_read.py b/tests/control_plane/test_context_capture_read.py index e5f781ac02..d529ee0b71 100644 --- a/tests/control_plane/test_context_capture_read.py +++ b/tests/control_plane/test_context_capture_read.py @@ -98,3 +98,16 @@ def test_capture_reader_accepts_parent_directory_alias(tmp_path): (original / "projection.json").write_bytes(raw) result = read_context_capture(_args(alias / "projection.json", raw)) assert result["ok"] and result["eof"] and result["content"].encode() == raw + + +def test_recovery_page_keeps_invocation_executable_and_resets_afterwards(tmp_path): + from loopx.command_invocation import loopx_cli_route + path = tmp_path / "projection.json" + raw = json.dumps({"requirements": "preserve " * 100}).encode() + path.write_bytes(raw) + executable = str(tmp_path / "pinned release" / "loopx") + with loopx_cli_route(executable): + page = read_context_capture(_args(path, raw)) + assert shlex.split(page["next_command"])[:3] == [executable, "--cli-bin", executable] + plain = read_context_capture(_args(path, raw)) + assert shlex.split(plain["next_command"])[:3] == ["loopx", "--format", "json"] diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index c15acf591a..ccacd10fec 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -322,3 +322,19 @@ def test_renderer_retains_owned_handoff_without_enabling_it_by_default(tmp_path) build_heartbeat_prompt(**args, decision_handoff_socket=endpoint) with pytest.raises(ValueError, match='invalid decision handoff socket'): build_heartbeat_prompt(**args, decision_output_root=tmp_path, decision_handoff_socket=endpoint) + + +def test_native_context_prompt_keeps_capture_on_reentry(tmp_path): + args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile="generic_cli", + turn_instance_id=TURN_ID, thin=True, decision_output_root=tmp_path) + plain = build_heartbeat_prompt(**args) + context = str(tmp_path / "private context") + enabled = build_heartbeat_prompt(**args, codex_context_dir=context) + suffix = " --codex-context-dir " + shlex.quote(context) + assert enabled["quota_guard_command"] == plain["quota_guard_command"] + suffix + assert suffix in enabled["thin_prompt_command"] + assert enabled["task_body"].replace(suffix, "") == plain["task_body"] + with pytest.raises(ValueError, match="capture root"): + build_heartbeat_prompt(**(args | {"decision_output_root": None}), codex_context_dir=context) + with pytest.raises(ValueError, match="second handoff"): + build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket="unused") From ba0a4d60983eb77267a019236b9d5113ea32f7b8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:58:30 +0800 Subject: [PATCH 15/29] feat(context): verify native code-mode input retention Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 27 ++++++---- .../agent-loop-effect-interpreter-v0.zh-CN.md | 19 ++++--- .../turn_driver/codex_input_trace.py | 54 ++++++++++++++++--- .../test_codex_context_capture.py | 18 ++++++- .../test_codex_input_retention.py | 43 +++++++++++++++ 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 755ff8f5f1..8f17609a28 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1021,9 +1021,13 @@ inference that produced the still-active, model-originated `exec_command` call. It requires a matching session, ordered trace, completed response containing that call, and either an intact full tool result or every digest-bound context page. Earlier inference history, partial pages, previews, other item roles, -completed calls, code-mode children and implicit server-side history do not -qualify. Missing, malformed, oversized or incompatible observations discard the -retention claim and choose full automatically; ordinary callers are unchanged. +completed calls and implicit server-side history do not qualify. Code-mode +`exec_command` children additionally require one still-live native cell whose +model-visible call id and exact JavaScript match the current inference response. +The complete body/pages must appear in actual model-visible custom-tool text +blocks, directly or in the native exec result envelope; private child results +and unforwarded output never qualify. Missing, malformed, oversized or +incompatible observations discard the retention claim and choose full automatically; ordinary callers are unchanged. The reader only consumes already enabled private traces, with the existing transport size bound. It reads a bounded event-log tail rather than the whole session; if the active inference is outside that window it selects full. It @@ -1070,11 +1074,14 @@ hook rather than a test lifecycle controller. They cover complete cold read, warm delta, automatic post-publication compaction, primary-handler write failure, same-capture complete recovery and zero extra guard/debit. The deterministic loopback provider still does not prove an autonomous model follows recovery or -achieves lower total cost. Mid-session wholly unexecuted/killed hooks, code-mode, -installed adoption, trace/all-read overhead and real solver outcomes need -independent qualification. Ordinary app-server `exec_command` evidence must not -be transferred to Codex App code-mode. No runtime/owner question is added, and -phase one remains open. +achieves lower total cost. The same production path is also exercised through +native app-server code-mode for both quota and Turn views, including the parent +cell/child binding, forwarded output, compaction and primary-write failure. +Missing, mismatched or ended cells and incomplete custom-tool output remain full. +This qualifies the native code-mode transport, not installed desktop adoption. +Mid-session wholly unexecuted/killed hooks, installed adoption, trace/all-read +overhead and real solver outcomes still need qualification. No runtime/owner +question is added, and phase one remains open. The existing benchmark worker now consumes this production path through an explicit `context_delivery` heartbeat option, also exposed by EdgeBench/SForge @@ -1090,8 +1097,8 @@ original Turn, capture and pinned CLI route. Deterministic native tests drive the actual worker with both quota and Turn views through cold read, warm delta, automatic compaction and complete same-capture recovery without extra guard or debit. Startup/resume still reset the epoch: this qualifies within-wake reuse, -not savings across resumed wakes. Harbor/container installation, Codex App -code-mode, wholly lost hooks and complete solver/cost qualification remain open. +not savings across resumed wakes. Harbor/container and Codex App installation, +wholly lost hooks and complete solver/cost qualification remain open. Native discovery, trace IO and all recovery reads belong in that cost comparison. Capture regression qualification also distinguishes settlement binding from diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index a14ca526cf..ee46a8842e 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -704,7 +704,10 @@ delta 校验。包括恢复在内的全部分页仍属于成本边界;这些 产生当前仍在执行的、由模型发起的 `exec_command` 调用的那次推理输入,是否包含精确 全量基线;要求 session 匹配、trace 顺序有效、已完成 response 包含该调用,并观察到 完整工具结果或全部摘要绑定的 context 分页。更早的推理历史、部分分页、预览、其他 -消息角色、已完成调用、code-mode 子调用及隐式服务端历史均不具备资格。观察缺失、 +消息角色、已完成调用及隐式服务端历史均不具备资格。code-mode 的 `exec_command` +子调用还须绑定唯一仍在执行的原生 cell,其模型可见 call id 与完整 JavaScript 必须 +匹配当前推理 response。完整正文/分页须实际出现在模型可见 custom-tool 文本块中, +可以是正文或原生 exec 结果封装;私有子调用结果及未转发的输出不具备资格。观察缺失、 格式错误、超限或不兼容时,丢弃保留声明并自动全量;普通调用方保持不变。 Reader 只读取已经启用的私有 trace,复用既有传输大小上限;读取有界事件日志尾部, 不读整份 session,当前推理已超出该窗口时自动全量。不启用诊断或改变原生 session。 @@ -737,10 +740,12 @@ capture 的完整读取命令,跨 session 不借用旧引用。生成候选和 原生 exec/quota 与 app-server/Turn 测试现执行上述生产 CLI 和 hook,而非测试侧 生命周期 controller;覆盖完整初读、温态差分、发布后自动压缩、主 handler 写入失败、 同 capture 完整补读和零额外 guard/扣额。它们仍使用确定性 loopback provider,不证明 -真实模型自主遵循恢复提示或总成本收益。中途整个 hook 未执行/被杀、code-mode、安装 -采用、trace 与全部补读开销以及真实 solver 成果仍须独立验证;不得将普通 app-server -`exec_command` 资格套用于 Codex App 的 code-mode。不开新 runtime/owner 问询门, -一期保持开放。 +真实模型自主遵循恢复提示或总成本收益。同一生产路径也通过原生 app-server 的 +code-mode 验证 quota 与 Turn 两种视图,覆盖父 cell/子调用绑定、输出转发、压缩和 +主 journal 写入失败。cell 缺失、错配或结束以及输出不完整时仍全量。这验证原生 +code-mode transport,不等于已安装桌面产品采用。中途整个 hook 未执行/被杀、安装 +采用、trace 与全部补读开销及真实 solver 成果仍须独立验证;不开新 runtime/owner +问询门,一期保持开放。 既有 benchmark worker 现通过显式 `context_delivery` heartbeat 选项消费上述生产 路径;EdgeBench/SForge 以 `--context-delivery` 暴露同一选择。trial 私有 home 放置 @@ -751,8 +756,8 @@ workspace hook 仍不受信任。显式启用的私有 trace 支持输入 observ treatment 须另起 trial。产品 renderer 与恢复分页保留原 Turn、capture 和固定 CLI 路由。确定性原生测试驱动真实 worker,覆盖 quota 与 Turn 两种视图的完整初读、温态 差分、自动压缩及同 capture 完整恢复,零额外 guard/扣额。启动/resume 仍重置 epoch, -因此只验证单次 wake 内复用,不证明跨 resume 节省。Harbor/container 安装、Codex App -code-mode、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 +因此只验证单次 wake 内复用,不证明跨 resume 节省。Harbor/container 与 Codex App +安装、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 trace IO 和所有补读。 Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index 9d6840cc24..5fa859ea01 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -99,16 +99,40 @@ def payload(ref: dict) -> dict: if len(completed) != 1 or len(tools) != 1 or not tail[0]["seq"] < completed[0]["seq"] < tools[0]["seq"]: return False tool = tools[0]["payload"] - if (tool.get("requester") != {"type": "model"} or tool.get("kind") != {"type": "exec_command"} - or tool.get("model_visible_call_id") != tool_id or tool.get("code_mode_runtime_tool_id") is not None): + if tool.get("kind") != {"type": "exec_command"}: return False if any(row.get("codex_turn_id") != turn or row["payload"].get("type") == "codex_turn_ended" or (row["payload"].get("type") == "tool_call_ended" and row["payload"].get("tool_call_id") == tool_id) for row in tail): return False output = payload(completed[0]["payload"]["response_payload"]) - if not any(item.get("type") == "function_call" and item.get("call_id") == tool_id - and item.get("name") == "exec_command" for item in output.get("output_items", [])): + if tool.get("requester") == {"type": "model"}: + bound = (tool.get("model_visible_call_id") == tool_id + and tool.get("code_mode_runtime_tool_id") is None + and any(item.get("type") == "function_call" and item.get("call_id") == tool_id + and item.get("name") == "exec_command" for item in output.get("output_items", []))) + else: + # A code-mode child has no model-visible call id of its own. Bind + # the live child to its native cell and that cell's exact model + # output; a child id or a saved source string alone proves nothing. + requester = tool.get("requester", {}) + cell_id = requester.get("runtime_cell_id") + cells = [row for row in tail if row["payload"].get("type") == "code_cell_started" + and row["payload"].get("runtime_cell_id") == cell_id] + bound = (requester.get("type") == "code_cell" and isinstance(cell_id, str) and bool(cell_id) + and isinstance(tool.get("code_mode_runtime_tool_id"), str) + and bool(tool["code_mode_runtime_tool_id"]) + and tool.get("model_visible_call_id") is None and len(cells) == 1 + and completed[0]["seq"] < cells[0]["seq"] < tools[0]["seq"] + and isinstance(cells[0]["payload"].get("source_js"), str) + and isinstance(cells[0]["payload"].get("model_visible_call_id"), str) + and not any(row["payload"].get("type") == "code_cell_ended" + and row["payload"].get("runtime_cell_id") == cell_id for row in tail) + and any(item.get("type") == "custom_tool_call" and item.get("name") == "exec" + and item.get("call_id") == cells[0]["payload"].get("model_visible_call_id") + and item.get("input") == cells[0]["payload"].get("source_js") + for item in output.get("output_items", []))) + if not bound: return False request = payload(tail[0]["payload"]["request_payload"]) if request.get("previous_response_id"): @@ -119,14 +143,28 @@ def payload(ref: dict) -> dict: if digest != observation["capture_sha256"] or captured not in (expected, context): return False pages: dict[int, bytes] = {} - for item in request["input"]: - if item.get("type") != "function_call_output" or not isinstance(item.get("output"), str): - continue + + def texts(): + for item in request["input"]: + if item.get("type") == "function_call_output" and isinstance(item.get("output"), str): + yield item["output"] + elif item.get("type") == "custom_tool_call_output" and isinstance(item.get("output"), list): + for part in item["output"]: + if part.get("type") == "input_text" and isinstance(part.get("text"), str): + yield part["text"] + + for text in texts(): # The qualified exec transport wraps complete output after this # delimiter. Ignore prose/quoted JSON, previews and other item roles. - text = item["output"].split("\nOutput:\n", 1)[-1] + text = text.split("\nOutput:\n", 1)[-1] try: page = json.loads(text) + # text(await tools.exec_command(...)) preserves the native + # result envelope as one text block. Unwrap once, never scan + # arbitrary quoted prose or use the trace's private result. + if (isinstance(page, dict) and type(page.get("exit_code")) is int + and page["exit_code"] == 0 and isinstance(page.get("output"), str)): + page = json.loads(page["output"]) except ValueError: continue document = encoded(page) diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index d4d44e2527..dd4fae79d1 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -30,7 +30,13 @@ def _delivered(request, digest): pages = {} for item in request["input"]: output = item.get("output") - if item.get("type") != "function_call_output" or not isinstance(output, str): + if item.get("type") == "custom_tool_call_output" and isinstance(output, list): + # Native code-mode preserves text() emissions as separate blocks. + output = next((part["text"] for part in output if part.get("type") == "input_text" + and part.get("text", "").startswith('{"chunk_id"')), "") + if output: + output = json.loads(output)["output"] + if not isinstance(output, str): continue try: page = json.loads(output[output.index("{"):output.rindex("}") + 1]) @@ -49,7 +55,8 @@ def _delivered(request, digest): @pytest.mark.parametrize("host,surface,failed_primary", [ - (host, surface, failed) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope")] + (host, surface, failed) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope"), + ("code_mode", "quota_packet"), ("code_mode", "turn_envelope")] for failed in [False, True] ] + [("benchmark", surface, False) for surface in ("quota_packet", "turn_envelope")]) def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary): @@ -199,6 +206,10 @@ def response(request, number, message): assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") stages["phase"] = "done" return message + if host == "code_mode": + return {"type": "custom_tool_call", "id": f"ctc_{number}", "call_id": f"call_{number}", + "name": "exec", "input": "text(await tools.exec_command(" + json.dumps({ + "cmd": cmd, "max_output_tokens": 2000}) + "));"} return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} @@ -218,6 +229,9 @@ def observed_response(request, number, message): with local_inference_provider(observed_response, usage) as (url, calls, observed): if host != "benchmark": _configure_provider(home, url) + if host == "code_mode": + config = home / "config.toml" + config.write_text(config.read_text().replace("[features]\n", "[features]\ncode_mode=true\n")) # The real CLI talks to its isolated local TS Effect process. # Native sandbox networking is disabled by default; allow this # synthetic workspace's loopback transport explicitly. diff --git a/tests/control_plane/test_codex_input_retention.py b/tests/control_plane/test_codex_input_retention.py index 6c7c3d137e..f65f778d75 100644 --- a/tests/control_plane/test_codex_input_retention.py +++ b/tests/control_plane/test_codex_input_retention.py @@ -150,3 +150,46 @@ def test_unknown_stale_lossy_or_unbound_observation_never_reuses(tmp_path, failu trace.rename(original) trace.symlink_to(original) assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") + + +@pytest.mark.parametrize("failure", [None, "parent_missing", "parent_changed", "parent_ended", "parent_wrong_call", + "child_wrong_cell", "child_wrong_tool", "output_missing", "output_preview", "output_wrong_role"]) +def test_code_mode_requires_exact_live_parent_and_complete_model_visible_output(tmp_path, failure): + observation, baseline, request, rows = fixture(tmp_path) + bundle = Path(observation["trace_bundle"]) + source = 'text(await tools.exec_command({cmd:"loopx quota should-run"}));' + output = {"output_items": [{"type": "custom_tool_call", "name": "exec", "call_id": "parent", "input": source}]} + parent = {**rows[-1], "seq": 2, "payload": {"type": "code_cell_started", "runtime_cell_id": "cell", + "model_visible_call_id": "parent", "source_js": source}} + rows[-1]["seq"] = 3 + rows[-1]["payload"].update(requester={"type": "code_cell", "runtime_cell_id": "cell"}, + model_visible_call_id=None, code_mode_runtime_tool_id="tool-1") + rows.insert(2, parent) + request["input"] = [{"type": "custom_tool_call_output", "call_id": f"read-{i}", "output": [ + {"type": "input_text", "text": "Script completed\nOutput:\n"}, + {"type": "input_text", "text": json.dumps({"chunk_id": "fixture", "exit_code": 0, + "output": page["output"].split("\nOutput:\n")[1]})}]} for i, page in enumerate(request["input"])] + if failure == "parent_missing": + rows.remove(parent) + elif failure == "parent_changed": + parent["payload"]["source_js"] += "text('different');" + elif failure == "parent_ended": + rows.append({**parent, "seq": 4, "payload": {"type": "code_cell_ended", "runtime_cell_id": "cell"}}) + elif failure == "parent_wrong_call": + output["output_items"][0]["call_id"] = "different" + elif failure == "child_wrong_cell": + rows[-1]["payload"]["requester"]["runtime_cell_id"] = "other-cell" + elif failure == "child_wrong_tool": + rows[-1]["payload"]["kind"] = {"type": "other"} + elif failure == "output_missing": + request["input"].pop(1) + elif failure == "output_preview": + request["input"][1]["output"][1]["text"] = request["input"][1]["output"][1]["text"][:200] + elif failure == "output_wrong_role": + for item in request["input"]: + item["type"] = "message" + item["role"] = "assistant" + (bundle / "payloads/output.json").write_text(json.dumps(output)) + (bundle / "payloads/input.json").write_text(json.dumps(request)) + (bundle / "trace.jsonl").write_text("".join(json.dumps(row) + "\n" for row in rows)) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") is (failure is None) From 04876a034c5bdb3b7970372ecb726b712f556742 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:18:37 +0800 Subject: [PATCH 16/29] fix(context): prove native retention across resumed wakes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 11 ++-- benchmark/runtime/context_delivery.py | 6 +- .../rfcs/agent-loop-effect-interpreter-v0.md | 21 +++++-- .../agent-loop-effect-interpreter-v0.zh-CN.md | 15 +++-- .../turn_driver/codex_context.py | 54 +++++++++++++----- .../turn_driver/codex_input_trace.py | 43 ++++++++------- .../test_codex_context_boundary.py | 8 ++- .../test_codex_context_capture.py | 39 ++++++++++--- .../test_codex_context_lifecycle.py | 55 ++++++++++++++++++- 9 files changed, 190 insertions(+), 62 deletions(-) diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 80d1d7d3d7..d1921e4dc6 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -61,11 +61,14 @@ or delivery/benefit. Unsupported native setup continues ordinary full output. The product heartbeat renderer carries `--codex-context-dir` and the original capture root into guard/reentry commands. The product observer proves intact baseline content in the current model input before warm reuse; uncertainty -selects full. Startup/resume/fork/compaction reset the epoch, so this currently -qualifies reuse within a wake, not guaranteed savings between resumed wakes. +selects full. Startup/fork/compaction reset the epoch. Same-session resume clears +the active call and keeps only an unconfirmed candidate; reuse requires the new +call's complete actual input, never a saved session or file. Each worker wake +uses a separate private trace directory, avoiding repeated scans of old wakes. The small recovery hook reads the original full capture after compaction, with -no replacement admission or debit. Wholly missing/killed hooks and App code-mode -remain unqualified; deterministic native transport tests are not autonomous +no replacement admission or debit. Native worker and app-server code-mode tests +cover resumed warm reuse for both packet views. Wholly missing/killed hooks and +installed App adoption remain unqualified; deterministic native transport tests are not autonomous solver outcomes. Count complete input, recovery reads, native discovery and trace IO in treatment cost, not just the delta bytes. diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index e5ea21900b..c48badcdec 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -10,6 +10,7 @@ import stat from pathlib import Path import shlex +import tempfile def prepare_context_delivery(env: dict[str, str], home: Path) -> None: @@ -24,7 +25,10 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: raise ValueError("context diagnostics require private, physical trial directories") # Opt-in diagnostics are private to this trial, never global or a retention # assertion. Include all IO/model-input costs when comparing this treatment. - env["CODEX_ROLLOUT_TRACE_ROOT"] = str(traces) + # Keep candidate references across resumed wakes, but inspect only this + # native invocation's diagnostics. Older traces are evidence, not a growing + # hot-path scan or a reason to distrust intact current model input. + env["CODEX_ROLLOUT_TRACE_ROOT"] = tempfile.mkdtemp(prefix="wake-", dir=traces) env["LOOPX_CODEX_CONTEXT_DIR"] = str(root) command = shlex.join([env["LOOPX_CLI"], "--format", "json", "quota", "context-hook", "--codex-context-dir", str(root)]) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 8f17609a28..df4f7f8e6a 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -927,7 +927,7 @@ model request; it adds no owner question, admission or debit. |---|---| | Existing quota/Turn producer | Compute the full authoritative result once and persist the immutable capture before publishing a presentation. Preserve the existing required-source, signature, selection and settlement owners. | | Shared TypeScript context projector | Bind Goal/Agent/session/epoch, capture and digest; compare registered source bodies; keep current control/source facts fresh; choose smaller exact delta or full. Reconstruct against the complete target before release. | -| Codex lifecycle adapter | Observe startup/resume/fork/compaction and invalidate session continuity. Carry a small event and capture recovery reference, never the complete packet. Hook configuration/trust and event coverage must be qualified on each actual host. | +| Codex lifecycle adapter | Observe startup/resume/fork/compaction and invalidate prior continuity observations. Resume may keep an unconfirmed candidate only; fresh actual-input evidence is required for reuse. Carry a small event and capture recovery reference, never the complete packet. Hook configuration/trust and event coverage must be qualified on each actual host. | | Existing local capture/detail IO | Read the original capture in bounded pages, preserving complete text and validating target identity/digest. A preview or successful file write does not establish complete consumption. No new guard or quota operation is needed to recover presentation. | **Sequence and failure handling.** First use or unknown continuity selects full. @@ -1057,8 +1057,14 @@ Removing the opt-in argument restores the ordinary path. Existing arguments and legacy rendezvous behavior remain unchanged; selection commands carry the same transport argument without another human step. -Startup, resume, fork and compact events invalidate the epoch. Same-session -recovery carries only the latest immutable capture's complete-read command; +Startup, fork and compact events invalidate the epoch. A same-session/workspace +resume clears the active call but retains an unconfirmed candidate and its epoch; +this is a lookup hint, never a continuity receipt. The new still-active call must +independently prove the entire baseline in its actual model input. Missing or +pruned input still selects full. Multiple native bundles for a resumed session +are resolved only by a unique valid current-call/input observation, never by +mtime or the latest saved file; ambiguous or excessive discovery selects full. +Same-session recovery carries only the latest immutable capture's complete-read command; a different session never borrows the old reference. Preparation and publication of the latest recovery reference share a private lock, so post-publication compaction also recovers that capture. A primary journal write failure still @@ -1096,8 +1102,13 @@ requires a new trial. The product renderer and recovery pages preserve the original Turn, capture and pinned CLI route. Deterministic native tests drive the actual worker with both quota and Turn views through cold read, warm delta, automatic compaction and complete same-capture recovery without extra guard or -debit. Startup/resume still reset the epoch: this qualifies within-wake reuse, -not savings across resumed wakes. Harbor/container and Codex App installation, +debit. A second real worker invocation and a second code-mode app-server process +now resume the same native session, demonstrate intact prior input and select +warm delta without another cold read, for both quota and Turn views. A subsequent +native compaction still recovers the original full capture. Each benchmark wake +uses a private trace subdirectory so earlier wakes do not enter its hot-path +diagnostic scan. These deterministic checks qualify cross-wake transport, +not net solver savings. Harbor/container and Codex App installation, wholly lost hooks and complete solver/cost qualification remain open. Native discovery, trace IO and all recovery reads belong in that cost comparison. diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index ee46a8842e..d8c50b39b4 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -637,7 +637,7 @@ oracle 检查实际发出的模型输入,而非 hook 日志或 stdout。这只 |---|---| | 既有 quota/Turn producer | 只计算一次完整权威结果,先持久化不可变捕获,再发布展示形式。保留既有必读来源、签名、选择和结算 owner。 | | 共享 TypeScript 上下文投影 | 绑定 Goal/Agent/session/epoch、capture 和摘要;比较已注册来源正文;每次保留新鲜控制与来源事实;选择较小的精确差分或全量,发布前按完整目标重建核对。 | -| Codex 生命周期 adapter | 观察 startup/resume/fork/compaction,使会话连续性失效;只携带小事件和捕获恢复引用,不传整个 packet。每种真实宿主分别验证 hook 配置、信任与事件覆盖。 | +| Codex 生命周期 adapter | 观察 startup/resume/fork/compaction,使先前连续性观察失效;resume 只可保留未确认候选,复用仍须新的实际输入证据。只携带小事件和捕获恢复引用,不传整个 packet。每种真实宿主分别验证 hook 配置、信任与事件覆盖。 | | 既有本地 capture/detail IO | 有边界地分段读取原捕获,保留完整正文,核对目标身份和摘要。预览或写文件成功不证明完整消费。恢复展示不需要新 guard 或 quota 操作。 | **时序与失败处理。** 首次使用或连续性未知时走全量;只有同会话、同 epoch 内 @@ -730,7 +730,11 @@ Goal 状态或决策 owner;TS 继续持有差分、绑定和回退语义。 参数即可恢复原路径;普通参数和旧 rendezvous 行为保持不变。selection 命令继续携带 同一 transport 参数,不增加人工步骤。 -启动、resume、fork、压缩事件使 epoch 失效;同一 session 的恢复提示只带最新不可变 +启动、fork、压缩事件使 epoch 失效。同 session/workspace 的 resume 清除 active call, +只保留未确认候选及其 epoch;这是查找线索,不是连续性回执。新的仍在执行的调用必须 +独立证明完整基线存在于实际模型输入;输入缺失或裁剪仍全量。同 session 的多个原生 +bundle 只由唯一有效的当前调用/输入观察选择,不使用 mtime 或最新文件;歧义或发现 +范围超限仍全量。同一 session 的恢复提示只带最新不可变 capture 的完整读取命令,跨 session 不借用旧引用。生成候选和保存最新恢复引用使用同一 私有锁;发布之后的压缩也能恢复同 capture。主 journal 写入失败时,handler 仍返回已知 恢复引用;生产发布无法记录引用时返回原全量。候选文件从不等于 ACK,完整模型输入需在 @@ -755,8 +759,11 @@ workspace hook 仍不受信任。显式启用的私有 trace 支持输入 observ 记录选择,设置可用不等于保留回执。默认运行不新增 hook、trace 或 trust 配置;改变 treatment 须另起 trial。产品 renderer 与恢复分页保留原 Turn、capture 和固定 CLI 路由。确定性原生测试驱动真实 worker,覆盖 quota 与 Turn 两种视图的完整初读、温态 -差分、自动压缩及同 capture 完整恢复,零额外 guard/扣额。启动/resume 仍重置 epoch, -因此只验证单次 wake 内复用,不证明跨 resume 节省。Harbor/container 与 Codex App +差分、自动压缩及同 capture 完整恢复,零额外 guard/扣额。第二次真实 worker 调用和 +第二个 code-mode app-server 进程现恢复同一原生 session,核验先前完整输入,直接 +选择温态差分而不再初读;quota 与 Turn 两种视图均覆盖。之后的原生压缩仍恢复原始 +全量 capture。benchmark 每次 wake 使用独立私有 trace 子目录,旧 wake 不进入热路径 +诊断扫描。这些确定性检查验证跨唤醒传递,不证明 solver 净收益。Harbor/container 与 Codex App 安装、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 trace IO 和所有补读。 diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index bf061bfe40..d7c4474649 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -70,8 +70,9 @@ def _save(root: Path, state: dict) -> None: def codex_context_hook(directory: str, event: dict) -> dict: """Consume native lifecycle events; emit only same-capture recovery refs. - SessionStart/PreCompact invalidate, including resume/fork/restart. PreToolUse - supplies the native call id; it is not retention proof. Failure to update the + Compaction/fork/restart invalidate. Same-session resume keeps only a candidate: + the new active call's actual input must prove every byte before reuse. + PreToolUse supplies the native call id; it is not retention proof. Failure to update the primary journal still returns the existing recovery ref when available. Trust/enablement and complete event delivery remain native host concerns. """ @@ -87,7 +88,10 @@ def codex_context_hook(directory: str, event: dict) -> dict: recovery = state.get("recovery") else: state = {"session_id": session, "cwd": event.get("cwd")} - if name in ("SessionStart", "PreCompact"): + resumed = name == "SessionStart" and event.get("source") == "resume" and same + if resumed: + state["active_call"] = None + elif name in ("SessionStart", "PreCompact"): state.update(context_epoch=uuid.uuid4().hex, active_call=None) state.pop("candidate", None) elif name == "PreToolUse" and same and state.get("context_epoch"): @@ -100,6 +104,12 @@ def codex_context_hook(directory: str, event: dict) -> dict: # It also cannot manufacture a fresh active-call observation. pass if name == "SessionStart" and isinstance(recovery, dict) and recovery.get("command"): + if event.get("source") == "resume": + return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": + "LoopX resumed context is unconfirmed. Reuse prior context only when complete in current input. " + "If missing before continuing the captured decision, read the original full capture through eof; " + "do not repeat admission or ask the owner. Recovery does not renew authority or freshness. " + + recovery["command"]}} return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": "LoopX context baseline invalidated. Before dependent work, read this original full capture " "and follow next_command through eof, combining every content page. Do not rerun admission " @@ -108,13 +118,16 @@ def codex_context_hook(directory: str, event: dict) -> dict: return {} -def _trace_bundle(session: str) -> str | None: +def _trace_bundle(session: str, tool_id: str) -> str | None: + from .codex_input_trace import read_native_trace_file + value = os.environ.get("CODEX_ROLLOUT_TRACE_ROOT") if not value: return None # never enable tracing implicitly matches = [] # Native diagnostics are optional. Bound discovery instead of scanning a - # long session archive; ambiguity or an unsupported store selects full. + # long session archive. Resume can create another bundle for the same + # session; the active-call/input verifier, never mtime, selects its source. for index, path in enumerate(Path(value).glob("trace-*")): if index >= 128: return None @@ -123,7 +136,20 @@ def _trace_bundle(session: str) -> str | None: except (OSError, ValueError): continue if manifest.get("root_thread_id") == session and manifest.get("rollout_id") == session: - matches.append(str(path)) + try: + raw = read_native_trace_file(path / "trace.jsonl", tail=True) + if not raw.endswith(b"\n"): + return None + events = [json.loads(line)["payload"] for line in raw.splitlines()] + calls = [event for event in events if event.get("tool_call_id") == tool_id + and event.get("type") in ("tool_call_started", "tool_call_ended")] + if calls and calls[-1]["type"] == "tool_call_started": + matches.append(str(path)) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return None + # Count live call identities before checking retained content. Otherwise an + # abandoned old call could win when a resumed call with the same id lost its + # input. The selected bundle still needs the complete independent verifier. return matches[0] if len(matches) == 1 else None @@ -149,15 +175,17 @@ def project_codex_context(payload: dict, *, directory: str, capture_directory: P return payload scope = dict(goal_id=goal_id, agent_id=agent_id, session_id=session, context_epoch=epoch) request = {"scope": scope} - candidate, trace = state.get("candidate"), _trace_bundle(session) - if isinstance(candidate, dict) and trace and isinstance(state.get("active_call"), str): + candidate = state.get("candidate") + if isinstance(candidate, dict) and isinstance(state.get("active_call"), str): try: baseline = _read(Path(candidate["path"])) - observation = dict(trace_bundle=trace, tool_call_id=state["active_call"], - capture_file=candidate["capture_file"], capture_sha256=candidate["capture_sha256"]) - if codex_context_in_active_input(observation, baseline=baseline, session_id=session): - request.update(baseline=baseline, retained={"complete": True, "target": baseline["target"]}, - native_input=observation) + trace = _trace_bundle(session, state["active_call"]) + if trace: + observation = dict(trace_bundle=trace, tool_call_id=state["active_call"], + capture_file=candidate["capture_file"], capture_sha256=candidate["capture_sha256"]) + if codex_context_in_active_input(observation, baseline=baseline, session_id=session): + request.update(baseline=baseline, retained={"complete": True, "target": baseline["target"]}, + native_input=observation) except (OSError, ValueError, KeyError, TypeError, RecursionError): pass # Missing candidate: publish a new full capture, never an old recovery ref. result = project_captured_context(payload, directory=capture_directory, request=request, diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index 5fa859ea01..7d36b924d4 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -10,6 +10,24 @@ from pathlib import Path +def read_native_trace_file(path: Path, *, tail: bool = False) -> bytes: + from ..effect_runtime import MAX_REQUEST_BYTES + + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode) or (metadata.st_size > MAX_REQUEST_BYTES and not tail): + raise ValueError("native observation is not a bounded regular file") + offset = max(0, metadata.st_size - MAX_REQUEST_BYTES) if tail else 0 + stream.seek(offset) + raw = stream.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES: + raise ValueError("native observation exceeds the transport limit") + # Only the latest in-flight inference is relevant. Bound IO even for a + # long-running trace, discarding its cut first row, never its last row. + return raw.split(b"\n", 1)[-1] if offset else raw + + def codex_context_in_active_input(observation: dict, *, baseline: dict, session_id: str) -> bool: """Cross-check a host's retention claim against a native in-flight tool call. @@ -22,8 +40,6 @@ def codex_context_in_active_input(observation: dict, *, baseline: dict, session_ dispatch. The caller still owns lifecycle invalidation and same-capture recovery. It must not use this function as a standalone continuity lease. """ - from ..effect_runtime import MAX_REQUEST_BYTES - def canonical(value): # JSON has one number type; native Python projection may spell 1.0 # where the TS codec spells 1. Preserve booleans and numeric values. @@ -38,21 +54,6 @@ def canonical(value): def encoded(value): return json.dumps(canonical(value), sort_keys=True, ensure_ascii=False, allow_nan=False) - def read(path: Path, *, tail: bool = False) -> bytes: - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - with os.fdopen(descriptor, "rb") as stream: - metadata = os.fstat(stream.fileno()) - if not stat.S_ISREG(metadata.st_mode) or (metadata.st_size > MAX_REQUEST_BYTES and not tail): - raise ValueError("native observation is not a bounded regular file") - offset = max(0, metadata.st_size - MAX_REQUEST_BYTES) if tail else 0 - stream.seek(offset) - raw = stream.read(MAX_REQUEST_BYTES + 1) - if len(raw) > MAX_REQUEST_BYTES: - raise ValueError("native observation exceeds the transport limit") - # Only the latest in-flight inference is relevant. Bound IO even for a - # long-running trace, discarding its cut first row, never its last row. - return raw.split(b"\n", 1)[-1] if offset else raw - try: if baseline.get("mode") != "full" or baseline.get("target", {}).get("session_id") != session_id: return False @@ -61,7 +62,7 @@ def read(path: Path, *, tail: bool = False) -> bytes: expected = encoded(baseline) context = encoded(baseline.get("context")) bundle = Path(observation["trace_bundle"]).resolve(strict=True) - manifest = json.loads(read(bundle / "manifest.json")) + manifest = json.loads(read_native_trace_file(bundle / "manifest.json")) if (manifest.get("schema_version") != 1 or manifest.get("root_thread_id") != session_id or manifest.get("rollout_id") != session_id): return False @@ -73,9 +74,9 @@ def payload(ref: dict) -> dict: raise ValueError("native payload reference escapes its bundle") path = bundle / relative path.resolve(strict=True).relative_to(bundle) - return json.loads(read(path)) + return json.loads(read_native_trace_file(path)) - raw_events = read(bundle / "trace.jsonl", tail=True) + raw_events = read_native_trace_file(bundle / "trace.jsonl", tail=True) if not raw_events.endswith(b"\n"): return False # a partial writer tail cannot establish the current cursor rows = [json.loads(line) for line in raw_events.splitlines()] @@ -137,7 +138,7 @@ def payload(ref: dict) -> dict: request = payload(tail[0]["payload"]["request_payload"]) if request.get("previous_response_id"): return False # implicit server-side history is outside this observer - raw = read(Path(observation["capture_file"])) + raw = read_native_trace_file(Path(observation["capture_file"])) digest = hashlib.sha256(raw).hexdigest() captured = encoded(json.loads(raw)) if digest != observation["capture_sha256"] or captured not in (expected, context): diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index a8155bdc31..8225c735cd 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -77,7 +77,7 @@ def _input_text(request): return "\n".join(text) -def _app_server(binary, env, workspace, home, trusted, additional_context=None, *, sandbox="read-only"): +def _app_server(binary, env, workspace, home, trusted, additional_context=None, *, sandbox="read-only", resume=None): with (home / "stderr.txt").open("w") as stderr, StdioNativeGoalTransport.spawn( [binary, "app-server", "--listen", "stdio://"], cwd=str(workspace), env=env, stderr=stderr, response_timeout_sec=20, @@ -96,7 +96,9 @@ def _app_server(binary, env, workspace, home, trusted, additional_context=None, assert all(h["sourcePath"] == str((home / "hooks.json").resolve()) for h in handlers) params["config"] = {"hooks.state": { h["key"]: {"trusted_hash": h["currentHash"]} for h in handlers}} - source = native.request("thread/start", params)["thread"]["id"] + if resume: + params["threadId"] = resume + source = native.request("thread/resume" if resume else "thread/start", params)["thread"]["id"] turn_params = {"threadId": source, "input": [{"type": "text", "text": "Synthetic context-delivery fixture."}]} if additional_context is not None: @@ -108,7 +110,7 @@ def _app_server(binary, env, workspace, home, trusted, additional_context=None, if event and event.get("method") == "turn/completed": assert event["params"]["turn"]["id"] == turn assert event["params"]["turn"]["status"] == "completed" - return + return source raise AssertionError("Native context fixture did not finish") diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index dd4fae79d1..6ef4816d4e 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -54,12 +54,14 @@ def _delivered(request, digest): return "".join(parts).rstrip("\n") -@pytest.mark.parametrize("host,surface,failed_primary", [ - (host, surface, failed) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope"), +@pytest.mark.parametrize("host,surface,failed_primary,resumed", [ + (host, surface, failed, False) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope"), ("code_mode", "quota_packet"), ("code_mode", "turn_envelope")] for failed in [False, True] -] + [("benchmark", surface, False) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary): +] + [("benchmark", surface, False, False) for surface in ("quota_packet", "turn_envelope")] + + [(host, surface, False, True) for host in ("benchmark", "code_mode") + for surface in ("quota_packet", "turn_envelope")]) +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -154,7 +156,7 @@ def response(request, number, message): phase = stages["phase"] if stages["pages"]: cmd = stages["pages"].pop(0) - elif phase == "start": + elif phase in {"start", "resuming"}: if host == "benchmark": # Read the actual worker-rendered guard, including its original # wake/capture identity. No substitute dispatcher or guard. @@ -168,8 +170,11 @@ def response(request, number, message): command = [turn_id if arg == "${LOOPX_TURN:?}" else arg for arg in native_guard] command += ["--todo-id", TODO_ID] root = Path(command[command.index("--decision-output-root") + 1]) - stages["phase"] = "cold_ready" - cmd = invoke("cold") + name = "cold" if phase == "start" else "warm" + stages["phase"] = name + "_ready" + if phase == "resuming": + assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") + cmd = invoke(name) elif phase in {"cold_ready", "warm_ready"}: name = phase.split("_")[0] wires[name] = json.loads(wire_paths[name].read_text()) @@ -183,6 +188,9 @@ def response(request, number, message): elif phase == "cold_read": assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") stages.update(baseline_confirmed=True, phase="warm_ready") + if resumed: + stages["phase"] = "resuming" + return message cmd = invoke("warm") elif phase == "warm_read": assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") @@ -264,9 +272,18 @@ def observed_response(request, number, message): assert result["ok"] and result["host_invoked"], result assert result["context_transport"] == {"available": True} assert result["session"]["session_id"] + if resumed: + session_id = result["session"]["session_id"] + result = run_once(env) + assert result["ok"] and result["host_invoked"], result + assert result["context_transport"] == {"available": True} + assert result["session"]["session_id"] == session_id assert not unrelated.exists() # only exact trial-owned hooks received trust else: - _app_server(binary, env, workspace, home, True, sandbox="workspace-write") + session_id = _app_server(binary, env, workspace, home, True, sandbox="workspace-write") + if resumed: + assert _app_server(binary, env, workspace, home, True, sandbox="workspace-write", + resume=session_id) == session_id assert not errors, errors assert stages["phase"] == "done" @@ -274,6 +291,12 @@ def observed_response(request, number, message): assert len(list(root.glob("*/decision.json"))) == 2 assert wire_paths["warm"].stat().st_size < wire_paths["cold"].stat().st_size assert len(calls) == len(observed) and stages["baseline_confirmed"] + if host == "benchmark": + trace_roots = list((home / "context-traces").glob("wake-*")) + assert len(trace_roots) == (2 if resumed else 1) + assert all(path.stat().st_mode & 0o077 == 0 for path in trace_roots) + elif resumed: + assert len(list((workspace / "traces").glob("trace-*/manifest.json"))) >= 2 projection = Path(wires["warm"]["recovery"]["path"]) assert hashlib.sha256(projection.read_bytes()).hexdigest() == wires["warm"]["recovery"]["sha256"] current = prepare_context_delivery(json.loads(projection.read_bytes()), scope={k: wires["warm"]["target"][k] for k in ("goal_id", "agent_id", "session_id", "context_epoch")}, diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index fbb6c6bf5e..a25f78222c 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -21,16 +21,17 @@ def setup(root, monkeypatch): return event -def project(root, capture_id="one"): +def project(root, capture_id="one", *, payload=None): directory = root / capture_id directory.mkdir(mode=0o700) - payload = {"ok": True, "goal_id": "fixture", "requirements": "Preserve final stop condition."} + if payload is None: + payload = {"ok": True, "goal_id": "fixture", "requirements": "Preserve final stop condition."} result = context.project_codex_context(payload, directory=str(root), capture_directory=directory, goal_id="fixture", agent_id="agent", surface="quota_packet", capture_id=capture_id) return payload, result -@pytest.mark.parametrize("event_name,source", [("SessionStart", "compact"), ("SessionStart", "resume"), +@pytest.mark.parametrize("event_name,source", [("SessionStart", "compact"), ("SessionStart", "startup"), ("PreCompact", None)]) def test_lifecycle_invalidates_and_recovers_current_capture_without_bodies(tmp_path, monkeypatch, event_name, source): event = setup(tmp_path, monkeypatch) @@ -51,6 +52,54 @@ def test_lifecycle_invalidates_and_recovers_current_capture_without_bodies(tmp_p assert second["mode"] == "full" and second["target"]["context_epoch"] == after["context_epoch"] +def test_resume_keeps_only_an_unconfirmed_candidate_and_never_borrows_old_call(tmp_path, monkeypatch): + event = setup(tmp_path, monkeypatch) + _, first = project(tmp_path) + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PreToolUse", "tool_use_id": "old"}) + before = context._state(tmp_path) + result = context.codex_context_hook(str(tmp_path), {**event, "source": "resume"}) + after = context._state(tmp_path) + assert after["candidate"] == before["candidate"] and after["context_epoch"] == before["context_epoch"] + assert after["active_call"] is None + assert first["recovery"]["command"] in result["hookSpecificOutput"]["additionalContext"] + assert "unconfirmed" in result["hookSpecificOutput"]["additionalContext"] + # No current-input proof: even a complete private candidate remains full. + _, second = project(tmp_path, "two") + assert second["mode"] == "full" + + +@pytest.mark.parametrize("case", ["closed_old", "duplicate_live", "abandoned_old", "missing_input", "discovery_overflow"]) +def test_resumed_bundle_needs_unique_current_input_not_latest_file(tmp_path, monkeypatch, case): + from test_codex_input_retention import fixture + + event = setup(tmp_path, monkeypatch) + requirements = "Preserve the whole source and final stop condition. " * 1000 + payload = {"ok": True, "goal_id": "fixture", "plan_summary": {"requirements": requirements}} + _, first = project(tmp_path, payload=payload) + traces = tmp_path / "traces" + traces.mkdir() + for number in (1, 2): + parent = tmp_path / str(number) + parent.mkdir() + observation, _, _, rows = fixture(parent) + bundle = Path(observation["trace_bundle"]) + if number == 1 and case not in {"duplicate_live", "abandoned_old"}: + rows.append({**rows[-1], "seq": 3, "payload": {"type": "tool_call_ended", "tool_call_id": "guard"}}) + (bundle / "trace.jsonl").write_text("".join(json.dumps(row) + "\n" for row in rows)) + missing = case == "missing_input" or (case == "abandoned_old" and number == 2) + (bundle / "payloads/input.json").write_text(json.dumps({"input": [] if missing else [ + {"type": "function_call_output", "output": json.dumps(first)}]})) + bundle.rename(traces / f"trace-{number}") + if case == "discovery_overflow": + for number in range(3, 130): + (traces / f"trace-{number}").mkdir() + monkeypatch.setenv("CODEX_ROLLOUT_TRACE_ROOT", str(traces)) + context.codex_context_hook(str(tmp_path), {**event, "source": "resume"}) + context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PreToolUse", "tool_use_id": "guard"}) + _, second = project(tmp_path, "two", payload=payload) + assert second["mode"] == ("delta" if case == "closed_old" else "full") + + def test_missing_observer_or_hook_never_reuses_candidate_and_other_sessions_do_not_borrow(tmp_path, monkeypatch): event = setup(tmp_path, monkeypatch) _, first = project(tmp_path) From a524c28ad689ed9357b0e09fbaa3eabdd633d260 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:38:11 +0800 Subject: [PATCH 17/29] docs: qualify context delta and read-policy costs separately Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 21 +++++++++++++++++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 14 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index df4f7f8e6a..bcbddeb5dd 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1112,6 +1112,27 @@ not net solver savings. Harbor/container and Codex App installation, wholly lost hooks and complete solver/cost qualification remain open. Native discovery, trace IO and all recovery reads belong in that cost comparison. +Matched native input replay now separates the subtree codec from the read policy: +compare full and delta under the same page size, forwarding and batching policy, +then compare read policies separately. Count all repeated model input (including +history and tool definitions), model requests, CLI reads and private trace bytes. +A cold-plus-warm pair can save fewer bytes than a long warm series. Smaller pages +batched into fewer model rounds can increase CLI calls while reducing repeated +input; do not attribute that combined gain to the codec alone. Deterministic +replay over captured inputs qualifies transport cost proxies, not autonomous +solver outcomes, billed tokens, cache savings or installed adoption. + +Code-mode callers can forward successful CLI stdout directly without serializing +another exec-result wrapper. They may group bounded reads only when every page +survives the **final model-facing output**, including the host's separate cap. +A larger requested output budget alone is insufficient: the native host can omit +text blocks or truncate inside otherwise valid JSON. Parse success and page EOF +therefore do not establish intact delivery. Verify contiguous UTF-8 offsets and +the original complete digest; reduce a failed batch and reread the same original +capture rather than readmit. No universal batch count or increased default limit +is qualified by a particular replay. Keep truncation negatives alongside the +successful cost comparison, and include recovery in any subsequent promotion. + Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits an inert replan capture and grants no delivery authority. Explicit pending diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index d8c50b39b4..f7761d2190 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -767,6 +767,20 @@ treatment 须另起 trial。产品 renderer 与恢复分页保留原 Turn、capt 安装、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 trace IO 和所有补读。 +同负载原生输入重放现将子树 codec 与读取策略分开:先在相同分页大小、转发与批量 +策略下比较全量和差分,再单独比较读取策略。统计全部重复模型输入(含历史与工具 +定义)、模型请求、CLI 读取和私有 trace 字节。一次初读加一次温态续跑,节省比例 +可能小于长期温态序列。将更小的页合并到更少模型轮次,可能增加 CLI 调用却减少重复 +输入;不能把组合收益全部归给 codec。对已捕获输入的确定性重放只验证传输成本估算, +不证明自主 solver 成果、计费 token、缓存收益或安装采用。 + +code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列化 exec 结果包装。 +只有每一页都完整到达**最终模型输入**时才可合并有界读取;还须考虑宿主的独立上限。 +单独调高请求的输出预算并不足够:原生宿主可能省略文本块,或在仍可解析的 JSON +内部截断内容。因此 JSON 解析成功和分页 EOF 都不证明传递完整。须验证连续 UTF-8 +偏移及原始完整摘要;批次失败时缩小批次并重读同一原 capture,不重新准入。单次 +重放不能资格化通用批次数或提高默认上限。保留截断负例,并在后续推广成本中计入恢复。 + Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout 则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 From 1112bf06b347b7c9e053beb3bc5ae6210bcccfec Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:54:39 +0800 Subject: [PATCH 18/29] fix: recover retained context after bounded page retries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 12 +++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 8 ++ loopx/cli_commands/quota_capture.py | 17 +++- .../turn_driver/codex_context.py | 12 +-- .../turn_driver/codex_input_trace.py | 22 +++-- .../test_codex_context_capture.py | 89 ++++++++++++------- .../test_codex_input_retention.py | 40 ++++++++- 7 files changed, 150 insertions(+), 50 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index bcbddeb5dd..4b890c7e57 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1133,6 +1133,18 @@ capture rather than readmit. No universal batch count or increased default limit is qualified by a particular replay. Keep truncation negatives alongside the successful cost comparison, and include recovery in any subsequent promotion. +The production quota recovery instruction and native resume/compact hooks now +share that bounded-read guidance, including direct successful stdout forwarding, +small adaptive batches and same-capture retries. A malformed model-visible page +contributes no retention evidence. It does not permanently poison a subsequent +complete reread still present in the current input. The native observer accepts +overlapping ranges after a page-size change only when each intact range matches +the original digest-bound bytes and their union covers the complete capture. +Gaps, previews alone and a different capture remain insufficient; no history +clearing, new acknowledgement or admission is needed. Composed code-mode tests +exercise batched stdout, overlapping read boundaries, cross-process resume, +warm delta and native compaction recovery for both quota and Turn views. + Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits an inert replan capture and grants no delivery authority. Explicit pending diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index f7761d2190..9877089b86 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -781,6 +781,14 @@ code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列 偏移及原始完整摘要;批次失败时缩小批次并重读同一原 capture,不重新准入。单次 重放不能资格化通用批次数或提高默认上限。保留截断负例,并在后续推广成本中计入恢复。 +生产 quota 恢复指令与原生 resume/compact hook 现共用这份有界读取指导,包含直接转发 +成功 stdout、小批量自适应读取及同 capture 重试。损坏的模型可见页不贡献保留证据, +也不会永久否决当前输入中随后完整的重读。分页大小改变后,原生 observer 仅在每个 +完整区间都匹配原摘要绑定的字节、且区间并集覆盖整个 capture 时接受重叠读取。 +缺口、只有预览或不同 capture 仍不足以证明保留;无需清空历史、新增 ACK 或重新准入。 +组合 code-mode 测试覆盖批量 stdout、重叠分页边界、跨进程续跑、温态差分及原生压缩 +恢复,包含 quota 和 Turn 两种视图。 + Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout 则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 04db51274c..e83777c06a 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -16,6 +16,18 @@ from ..control_plane.turn_driver.decision_capture import validate_handoff_socket +CONTEXT_CAPTURE_READ_GUIDANCE = ( + "Follow next_command through eof and combine content in byte-offset order. " + "In code-mode, forward successful exec stdout directly with text(result.output); " + "retain errors. Small batches can save model round trips; start with at most two default pages " + "and inspect every output. A host may truncate inside valid JSON or omit a whole output. " + "Check each model-visible content's UTF-8 byte length against next_offset (total_bytes at eof), " + "complete coverage and the original SHA-256. If incomplete, reread the affected range " + "of this same capture with fewer pages or a smaller --page-bytes; do not trust a preview or EOF alone. " + "Do not rerun admission or ask the owner. File existence and page EOF are not retention proof." +) + + def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096) -> str: from ..command_invocation import loopx_cli_argv @@ -257,9 +269,8 @@ def project_captured_context( delivery["recovery"] = { "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " - "truncated or uncertain, use this full projection before dependent work. Run command and follow " - "next_command through eof, combining every content page in offset order. Do not rerun admission " - "or ask the owner. This file and page EOF are not retention proof.", + "truncated or uncertain, use this full projection before dependent work. " + + CONTEXT_CAPTURE_READ_GUIDANCE, } delivery["recovery"]["command"] = context_capture_read_command(projection, delivery["recovery"]["sha256"]) return delivery diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index d7c4474649..876db797c4 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -104,16 +104,18 @@ def codex_context_hook(directory: str, event: dict) -> dict: # It also cannot manufacture a fresh active-call observation. pass if name == "SessionStart" and isinstance(recovery, dict) and recovery.get("command"): + from ...cli_commands.quota_capture import CONTEXT_CAPTURE_READ_GUIDANCE + if event.get("source") == "resume": return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": "LoopX resumed context is unconfirmed. Reuse prior context only when complete in current input. " - "If missing before continuing the captured decision, read the original full capture through eof; " - "do not repeat admission or ask the owner. Recovery does not renew authority or freshness. " + "If missing before continuing the captured decision, read the original full capture. " + "Recovery does not renew authority or freshness. " + CONTEXT_CAPTURE_READ_GUIDANCE + " " + recovery["command"]}} return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": - "LoopX context baseline invalidated. Before dependent work, read this original full capture " - "and follow next_command through eof, combining every content page. Do not rerun admission " - "or ask the owner. Capture recovery does not renew authority or source freshness. " + "LoopX context baseline invalidated. Before dependent work, read this original full capture. " + "Capture recovery does not renew authority or source freshness. " + + CONTEXT_CAPTURE_READ_GUIDANCE + " " + recovery["command"]}} return {} diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index 7d36b924d4..0c0d956110 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -143,7 +143,7 @@ def payload(ref: dict) -> dict: captured = encoded(json.loads(raw)) if digest != observation["capture_sha256"] or captured not in (expected, context): return False - pages: dict[int, bytes] = {} + ranges: list[tuple[int, int]] = [] def texts(): for item in request["input"]: @@ -175,20 +175,26 @@ def texts(): continue start, content = page.get("offset"), page.get("content") if type(start) is not int or start < 0 or not isinstance(content, str) or page.get("ok") is not True: - return False + continue data = content.encode("utf-8") end = start + len(data) if (page.get("total_bytes") != len(raw) or end > len(raw) or not data or page.get("eof") is not (end == len(raw)) or page.get("next_offset") != (None if end == len(raw) else end) - or raw[start:end] != data or (start in pages and pages[start] != data)): - return False - pages[start] = data + or raw[start:end] != data): + # A host preview can remain valid JSON while shortening content + # and retaining the original cursor. It proves no bytes. Keep + # looking for complete rereads in this same current input. + continue + ranges.append((start, end)) cursor = 0 - for start, data in sorted(pages.items()): - if start != cursor: + for start, end in sorted(ranges): + if start > cursor: return False - cursor += len(data) + # Reducing page size after truncation changes boundaries. Exact + # overlapping ranges are safe: each was compared to the immutable + # capture, not another page or a saved delivery acknowledgement. + cursor = max(cursor, end) return cursor == len(raw) except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError): return False diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 6ef4816d4e..e54a958522 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -27,41 +27,48 @@ def _delivered(request, digest): - pages = {} + # Independent byte-map oracle: recover every model-visible byte, including + # separate code-mode emissions and overlapping reads at smaller page sizes. + delivered = {} for item in request["input"]: output = item.get("output") - if item.get("type") == "custom_tool_call_output" and isinstance(output, list): - # Native code-mode preserves text() emissions as separate blocks. - output = next((part["text"] for part in output if part.get("type") == "input_text" - and part.get("text", "").startswith('{"chunk_id"')), "") - if output: - output = json.loads(output)["output"] - if not isinstance(output, str): - continue - try: - page = json.loads(output[output.index("{"):output.rindex("}") + 1]) - except (ValueError, TypeError): - continue - if isinstance(page, dict) and page.get("schema_version") == "context_capture_page_v1" and page.get("sha256") == digest: - assert "truncated output" not in output and page["ok"] - assert page["offset"] not in pages or pages[page["offset"]] == page["content"] - pages[page["offset"]] = page["content"] - offset, parts = 0, [] - for start, body in sorted(pages.items()): - assert start == offset - parts.append(body) - offset += len(body.encode()) - return "".join(parts).rstrip("\n") + texts = ([part["text"] for part in output if part.get("type") == "input_text"] + if item.get("type") == "custom_tool_call_output" and isinstance(output, list) + else [output]) + for output in texts: + if not isinstance(output, str): + continue + try: + page = json.loads(output.split("\nOutput:\n", 1)[-1]) + if isinstance(page, dict) and page.get("exit_code") == 0 and isinstance(page.get("output"), str): + page = json.loads(page["output"]) + except (ValueError, TypeError): + continue + if not isinstance(page, dict) or page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: + continue + assert page["ok"] + data = page["content"].encode() + assert len(data) == (page["total_bytes"] if page["eof"] else page["next_offset"]) - page["offset"] + for offset, value in enumerate(data, page["offset"]): + assert offset not in delivered or delivered[offset] == value + delivered[offset] = value + if not delivered: + return "" + assert set(delivered) == set(range(len(delivered))) + raw = bytes(delivered[i] for i in range(len(delivered))) + assert hashlib.sha256(raw).hexdigest() == digest + return raw.decode().rstrip("\n") -@pytest.mark.parametrize("host,surface,failed_primary,resumed", [ - (host, surface, failed, False) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope"), +@pytest.mark.parametrize("host,surface,failed_primary,resumed,adaptive", [ + (host, surface, failed, False, False) for host, surface in [("exec", "quota_packet"), ("app_server", "turn_envelope"), ("code_mode", "quota_packet"), ("code_mode", "turn_envelope")] for failed in [False, True] -] + [("benchmark", surface, False, False) for surface in ("quota_packet", "turn_envelope")] - + [(host, surface, False, True) for host in ("benchmark", "code_mode") - for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed): +] + [("benchmark", surface, False, False, False) for surface in ("quota_packet", "turn_envelope")] + + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") + for surface in ("quota_packet", "turn_envelope")] + + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -123,10 +130,11 @@ def pages(name, path): page_digests[name] = digest # Plan only read offsets. Every actual model-facing page must # come from the public CLI, including digest verification. + page_size = 4096 if adaptive else 5000 offsets, start, count = [0], 0, 0 for character in raw.decode(): size = len(character.encode()) - if count + size > 5000: + if count + size > page_size: start += count offsets.append(start) count = 0 @@ -144,9 +152,17 @@ def pages(name, path): routed[routed.index("--offset") + 1] = str(offset) commands.append(shlex.join(routed)) return commands - return [shlex.join(cli + ["--format", "json", "quota", "context-read", + commands = [shlex.join(cli + ["--format", "json", "quota", "context-read", "--capture-file", str(path), "--capture-sha256", digest, - "--offset", str(offset), "--page-bytes", "5000"]) for offset in offsets] + "--offset", str(offset), "--page-bytes", str(page_size)]) for offset in offsets] + if adaptive and name == "cold": + # An intact initial short read remains in current native input when + # subsequent reads choose different boundaries. Production must + # accept exact complete coverage without clearing that history. + initial = shlex.split(commands[0]) + initial[-1] = "256" + commands.insert(0, shlex.join(initial)) + return commands def delivered(request, name): return _delivered(request, page_digests[name]) @@ -215,6 +231,15 @@ def response(request, number, message): stages["phase"] = "done" return message if host == "code_mode": + if adaptive: + commands = [cmd] + if stages["pages"]: + commands.append(stages["pages"].pop(0)) + code = "".join("{const r=await tools.exec_command(" + json.dumps({ + "cmd": command, "max_output_tokens": 2400}) + + ");text(r.exit_code === 0 ? r.output : r);}" for command in commands) + return {"type": "custom_tool_call", "id": f"ctc_{number}", "call_id": f"call_{number}", + "name": "exec", "input": code} return {"type": "custom_tool_call", "id": f"ctc_{number}", "call_id": f"call_{number}", "name": "exec", "input": "text(await tools.exec_command(" + json.dumps({ "cmd": cmd, "max_output_tokens": 2000}) + "));"} diff --git a/tests/control_plane/test_codex_input_retention.py b/tests/control_plane/test_codex_input_retention.py index f65f778d75..c84b34747b 100644 --- a/tests/control_plane/test_codex_input_retention.py +++ b/tests/control_plane/test_codex_input_retention.py @@ -75,6 +75,42 @@ def test_long_trace_uses_current_window_and_never_old_delivery(tmp_path): assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") +@pytest.mark.parametrize("code_mode", [False, True]) +def test_smaller_complete_rereads_recover_after_valid_json_preview(tmp_path, code_mode): + observation, baseline, request, _ = fixture(tmp_path) + raw = Path(observation["capture_file"]).read_bytes() + middle = json.loads(request["input"][1]["output"].split("\nOutput:\n")[1]) + # Host truncation inside a string can preserve valid JSON and its old cursor. + preview = {**middle, "content": middle["content"][:30] + "…9 tokens truncated…"} + request["input"][1]["output"] = json.dumps(preview) + path = tmp_path / "trace/payloads/input.json" + path.write_text(json.dumps(request)) + assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") + # Repage the same capture at different boundaries, overlapping intact old + # ranges; no new capture, fake ACK, history clearing or new admission. + split = raw.index("🙂".encode()) + recovered = [] + for start, end in ((0, split), (split, len(raw))): + page = {**middle, "offset": start, "next_offset": end if end < len(raw) else None, + "eof": end == len(raw), "content": raw[start:end].decode()} + recovered.append({"type": "function_call_output", "output": json.dumps(page)}) + request["input"].extend(recovered) + if code_mode: + # One code-mode cell can emit several raw stdout blocks. Every block, + # including the old preview, is actual model-facing input. + request["input"] = [{"type": "custom_tool_call_output", "output": [ + {"type": "input_text", "text": item["output"]} for item in request["input"]]}] + path.write_text(json.dumps(request)) + assert codex_context_in_active_input(observation, baseline=baseline, session_id="session") + # The bad preview never covers the gap when a corrected range is absent. + if code_mode: + request["input"][0]["output"].pop() + else: + request["input"].pop() + path.write_text(json.dumps(request)) + assert not codex_context_in_active_input(observation, baseline=baseline, session_id="session") + + def test_full_projection_read_is_retention_evidence_with_json_number_semantics(tmp_path): observation, baseline, _, _ = fixture(tmp_path) baseline["context"]["compute"] = 1 @@ -91,7 +127,7 @@ def test_full_projection_read_is_retention_evidence_with_json_number_semantics(t @pytest.mark.parametrize("failure", ["middle_missing", "pruned", "preview", "quoted", "wrong_role", "server_history", - "bad_page", "overlap", "capture_changed", "other_session", "other_tool", "ended", "new_inference", "compacted", + "bad_page", "shifted_offset", "capture_changed", "other_session", "other_tool", "ended", "new_inference", "compacted", "child", "code_mode", "wrong_response", "partial_trace", "reordered_trace", "missing_trace", "outside_payload", "symlink", "boolean_type"]) def test_unknown_stale_lossy_or_unbound_observation_never_reuses(tmp_path, failure): observation, baseline, request, rows = fixture(tmp_path) @@ -114,7 +150,7 @@ def test_unknown_stale_lossy_or_unbound_observation_never_reuses(tmp_path, failu request["previous_response_id"] = "opaque-history" elif failure == "bad_page": request["input"][1]["output"] = request["input"][1]["output"].replace('"eof": false', '"eof": true') - elif failure == "overlap": + elif failure == "shifted_offset": page = json.loads(request["input"][1]["output"].split("\nOutput:\n")[1]) page["offset"] -= 1 request["input"][1]["output"] = json.dumps(page) From 9c5ed65dca2a920860e7bd3be07fae5a89b1cc47 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:15:01 +0800 Subject: [PATCH 19/29] fix: recover original context when native hooks are lost Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 14 ++- benchmark/runtime/context_delivery.py | 9 +- .../rfcs/agent-loop-effect-interpreter-v0.md | 38 +++++++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 26 +++++- loopx/cli_commands/quota_capture.py | 10 +++ loopx/cli_commands/quota_registration.py | 3 +- .../turn_driver/codex_context.py | 56 ++++++++++-- .../test_codex_context_capture.py | 86 +++++++++++++++++-- .../test_codex_context_lifecycle.py | 55 ++++++++++++ 9 files changed, 273 insertions(+), 24 deletions(-) diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index d1921e4dc6..3b12298d6d 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -67,9 +67,17 @@ call's complete actual input, never a saved session or file. Each worker wake uses a separate private trace directory, avoiding repeated scans of old wakes. The small recovery hook reads the original full capture after compaction, with no replacement admission or debit. Native worker and app-server code-mode tests -cover resumed warm reuse for both packet views. Wholly missing/killed hooks and -installed App adoption remain unqualified; deterministic native transport tests are not autonomous -solver outcomes. Count complete input, recovery reads, native discovery and trace +cover resumed warm reuse for both packet views. The opted-in worker also carries +a compact native developer instruction that survives compaction independently +of hook output. If context was lost while continuing a captured decision, +`quota context-read --codex-context-dir DIR --goal-id GOAL --agent-id AGENT` +checks the current native session/workspace and selects the original capture. +Follow its digest-pinned next commands; this read never reruns admission or +spends quota. Missing/foreign references refuse lookup. Native tests omit or +kill all subsequent hooks and still recover every byte through this route. +Installed App adoption remains separate; deterministic transport tests are not autonomous +solver outcomes. Include this persistent instruction in subsequent cost comparisons; +earlier input replays did not include it. Count complete input, recovery reads, native discovery and trace IO in treatment cost, not just the delta bytes. A simultaneous `fresh_replan` checkpoint listener retains its existing capture diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index c48badcdec..908c478021 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -64,4 +64,11 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: entries.append(f"{json.dumps(key)} = {{ trusted_hash = {json.dumps(digest)} }}") # CLI override keys are dot-separated; native keys contain paths/dots. # Carry the entire table as TOML so native keys remain literal strings. - env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] = json.dumps(["-c", "hooks.state={" + ",".join(entries) + "}"]) + from loopx.control_plane.turn_driver.codex_context import codex_context_recovery_instructions + + instructions = codex_context_recovery_instructions(str(root), goal_id=env["LOOPX_GOAL_ID"], + agent_id=env["LOOPX_AGENT_ID"], cli_argv=[env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]]) + # This isolated trial's native developer instructions survive compaction; + # a failed SessionStart handler cannot erase the read-only recovery route. + env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] = json.dumps(["-c", "hooks.state={" + ",".join(entries) + "}", + "-c", "developer_instructions=" + json.dumps(instructions)]) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 4b890c7e57..e7510103ff 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1085,8 +1085,8 @@ native app-server code-mode for both quota and Turn views, including the parent cell/child binding, forwarded output, compaction and primary-write failure. Missing, mismatched or ended cells and incomplete custom-tool output remain full. This qualifies the native code-mode transport, not installed desktop adoption. -Mid-session wholly unexecuted/killed hooks, installed adoption, trace/all-read -overhead and real solver outcomes still need qualification. No runtime/owner +The following persistent recovery route qualifies wholly unexecuted/killed hooks; +installed adoption, trace/all-read overhead and real solver outcomes remain separate. No runtime/owner question is added, and phase one remains open. The existing benchmark worker now consumes this production path through an @@ -1108,8 +1108,8 @@ warm delta without another cold read, for both quota and Turn views. A subsequen native compaction still recovers the original full capture. Each benchmark wake uses a private trace subdirectory so earlier wakes do not enter its hot-path diagnostic scan. These deterministic checks qualify cross-wake transport, -not net solver savings. Harbor/container and Codex App installation, -wholly lost hooks and complete solver/cost qualification remain open. +not net solver savings. Harbor/container and Codex App installation and complete solver/cost +qualification remain open; lost-hook recovery is qualified below. Native discovery, trace IO and all recovery reads belong in that cost comparison. Matched native input replay now separates the subtree codec from the read policy: @@ -1145,6 +1145,36 @@ clearing, new acknowledgement or admission is needed. Composed code-mode tests exercise batched stdout, overlapping read boundaries, cross-process resume, warm delta and native compaction recovery for both quota and Turn views. +A lost event handler now has a separate **read-only recovery entry**, not a +second execution/admission path. `quota context-read --codex-context-dir DIR +--goal-id GOAL --agent-id AGENT` selects the latest original capture only when +the private journal matches the ambient native session, current workspace and +requested Goal/Agent. It reads no current Goal, evaluates no guard, spends no +quota and does not change the journal. A missing/foreign/older unscoped journal +refuses lookup; an existing explicit digest-bound reference remains usable. +Lookup starts at offset zero. Every following `next_command` pins the original +path and hash, so a concurrent newer publication cannot mix captures. Missing, +changed or symlinked files retain the ordinary reader's refusal behavior. + +The opt-in benchmark worker carries a compact recovery instruction in native +`developer_instructions`, independently of SessionStart/PreToolUse delivery. +Native code-mode consumers can carry the same product-owned instruction in +their isolated native configuration; append it to existing host instructions +rather than replacing unrelated policy. Its conditional trigger is continuing +a captured decision after context loss, not starting an ordinary new turn. +It directs same-capture reading before dependent work without a user question; +independent authorized work remains possible and recovery renews neither authority +nor freshness. The first lookup page supplies the existing bounded-read guidance. +Default-off workers add no persistent instruction. Deterministic native tests +remove the complete source during real compaction and then omit or kill every +subsequent hook process. The actual worker and code-mode host recover all bytes +through the persistent public CLI route without another guard or debit. Private +files/session identity still establish no retention claim; actual current input +remains the independent reuse criterion. Include the persistent instruction and +all recovery reads in future cost comparisons; prior measurements exclude this +new instruction. This does not claim live desktop installation or autonomous +solver effectiveness. + Capture regression qualification also distinguishes settlement binding from closeout: an upgraded receipt with an outstanding semantic replan still permits an inert replan capture and grants no delivery authority. Explicit pending diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 9877089b86..6b950a70e4 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -747,8 +747,8 @@ capture 的完整读取命令,跨 session 不借用旧引用。生成候选和 真实模型自主遵循恢复提示或总成本收益。同一生产路径也通过原生 app-server 的 code-mode 验证 quota 与 Turn 两种视图,覆盖父 cell/子调用绑定、输出转发、压缩和 主 journal 写入失败。cell 缺失、错配或结束以及输出不完整时仍全量。这验证原生 -code-mode transport,不等于已安装桌面产品采用。中途整个 hook 未执行/被杀、安装 -采用、trace 与全部补读开销及真实 solver 成果仍须独立验证;不开新 runtime/owner +code-mode transport,不等于已安装桌面产品采用。下述常驻恢复入口验证整个 hook 未执行/ +被杀;安装采用、trace 与全部补读开销及真实 solver 成果仍须独立验证;不开新 runtime/owner 问询门,一期保持开放。 既有 benchmark worker 现通过显式 `context_delivery` heartbeat 选项消费上述生产 @@ -764,7 +764,7 @@ treatment 须另起 trial。产品 renderer 与恢复分页保留原 Turn、capt 选择温态差分而不再初读;quota 与 Turn 两种视图均覆盖。之后的原生压缩仍恢复原始 全量 capture。benchmark 每次 wake 使用独立私有 trace 子目录,旧 wake 不进入热路径 诊断扫描。这些确定性检查验证跨唤醒传递,不证明 solver 净收益。Harbor/container 与 Codex App -安装、hook 完全丢失和完整 solver/成本资格仍开放;成本比较须计入原生发现、 +安装与完整 solver/成本资格仍开放;hook 丢失恢复在下文验证。成本比较须计入原生发现、 trace IO 和所有补读。 同负载原生输入重放现将子树 codec 与读取策略分开:先在相同分页大小、转发与批量 @@ -789,6 +789,26 @@ code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列 组合 code-mode 测试覆盖批量 stdout、重叠分页边界、跨进程续跑、温态差分及原生压缩 恢复,包含 quota 和 Turn 两种视图。 +事件 handler 完全丢失时,现有独立的**只读恢复入口**,不新增执行或准入路径。 +`quota context-read --codex-context-dir DIR --goal-id GOAL --agent-id AGENT` +仅在私有 journal 匹配环境中的原生 session、当前工作目录和所请求 Goal/Agent 时, +选取最新的原 capture。它不读取当前 Goal、不执行 guard、不扣额,也不修改 journal。 +缺失、跨身份或旧版未标注范围的 journal 拒绝隐式查找;已有显式摘要绑定引用仍可使用。 +查找从 offset 零开始,后续每个 `next_command` 固定到原路径和 hash,避免并发发布 +更新 capture 时混读。文件缺失、内容变化或符号链接继续沿既有 reader 拒绝。 + +显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, +独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 +同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 +上下文丢失后继续一个已捕获的决策,不是每个普通新 turn 的前置检查。指令要求依赖 +该上下文的工作先读回同 capture,不增加用户问询;独立授权工作仍可继续,恢复不刷新 +权限或新鲜度。首个查找页提供既有的有界读取指导。关闭功能的 worker 不增加常驻 +指令。确定性原生测试在真实压缩中删除完整来源,随后跳过或杀死所有后续 hook 进程, +由真实 worker 与 code-mode 宿主通过常驻公开 CLI 入口读回全部字节,零额外 guard/扣额。 +私有文件与 session 身份仍不构成保留声明;是否可复用仍独立核验实际当前输入。 +后续成本比较须计入常驻指令与全部恢复读取,先前测量不包含这条新指令。此处不宣称 +真实桌面安装采用或自主 solver 效果。 + Capture 回归验证同时区分 settlement 绑定与 closeout:receipt 已升级但仍欠语义 重规划时,可以捕获无执行效果的 replan,且不授予交付权限;显式 pending closeout 则抑制该捕获。真实 CLI fixture 和 typed selector 现分别验证这些事实,不再从 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index e83777c06a..c7135fec41 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -49,6 +49,14 @@ def read_context_capture(args: argparse.Namespace) -> dict: digest = getattr(args, "capture_sha256", None) value = getattr(args, "capture_file", None) offset, size = args.offset, args.page_bytes + context_dir = getattr(args, "codex_context_dir", None) + if context_dir: + if value or digest or offset != 0: + raise ValueError("session lookup starts at offset zero without capture-file or capture-sha256; follow next_command afterwards") + from ..control_plane.turn_driver.codex_context import read_codex_context_reference + + value, digest = read_codex_context_reference(context_dir, + goal_id=getattr(args, "goal_id", None), agent_id=getattr(args, "agent_id", None)) if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None or not value: raise ValueError("context-read requires the original capture file and its exact SHA-256") if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= 8192: @@ -77,6 +85,8 @@ def read_context_capture(args: argparse.Namespace) -> dict: result = {"ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, "offset": offset, "next_offset": end if end < len(raw) else None, "total_bytes": len(raw), "eof": end == len(raw), "content": raw[offset:end].decode("utf-8")} + if context_dir: + result["read_instruction"] = CONTEXT_CAPTURE_READ_GUIDANCE if end < len(raw): result["next_command"] = context_capture_read_command(path, digest, offset=end, page_bytes=size) return result diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 9c5910fab1..1fdcdeb041 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -202,7 +202,8 @@ def register_quota_command( quota_parser.add_argument("--codex-context-dir", help="Opt-in private native Codex lifecycle directory. For context-hook, consume one native hook event on stdin. " "For should-run, requires JSON and a decision capture destination; unknown retention stays full. " - "Does not install hooks, enable tracing, or change native trust.") + "For context-read without a file/hash, select the current native session/workspace capture for --goal-id/--agent-id; " + "start at offset zero, then follow digest-pinned next_command. Does not install hooks, enable tracing, or change native trust.") quota_parser.add_argument("--capture-sha256", help="For context-read: exact SHA-256 supplied with the capture reference.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") quota_parser.add_argument("--page-bytes", type=int, default=4096, diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 876db797c4..97a789e0f1 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -34,17 +34,22 @@ def _read(path: Path, *, private: bool = True) -> dict: return result +def _private_root(directory: str) -> Path: + root = Path(directory).expanduser().absolute() + root = root.parent.resolve(strict=True) / root.name + info = root.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise ValueError("context directory must already exist, be private and belong to this user") + return root + + @contextmanager def _locked(directory: str): if os.name != "posix": raise ValueError("native context continuity is unavailable on this platform") import fcntl - root = Path(directory).expanduser().absolute() - root = root.parent.resolve(strict=True) / root.name - info = root.lstat() - if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: - raise ValueError("context directory must already exist, be private and belong to this user") + root = _private_root(directory) fd = os.open(root / "lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) with os.fdopen(fd, "r+") as lock: if not stat.S_ISREG(os.fstat(lock.fileno()).st_mode): @@ -67,6 +72,44 @@ def _save(root: Path, state: dict) -> None: write_decision_capture(root / "session.json", state, publish=os.replace) +def read_codex_context_reference(directory: str, *, goal_id: str, agent_id: str) -> tuple[str, str]: + """Select an original capture after a lost hook; never assert retention. + + The journal is atomically replaced, so this read needs no lock or write. + Later pages use the returned explicit path/digest, not a moving selector. + """ + state = _state(_private_root(directory)) + session = os.environ.get("CODEX_THREAD_ID") + if (not session or state.get("session_id") != session + or not isinstance(state.get("cwd"), str) + or Path(state["cwd"]).resolve() != Path.cwd().resolve()): + raise ValueError("original capture lookup requires the same native session and workspace") + recovery = state.get("recovery") + if (not goal_id or not agent_id or not isinstance(recovery, dict) + or recovery.get("goal_id") != goal_id or recovery.get("agent_id") != agent_id): + raise ValueError("no original capture for this Goal/Agent; use its explicit capture reference") + path, digest = recovery.get("path"), recovery.get("sha256") + if not isinstance(path, str) or not isinstance(digest, str): + raise ValueError("original capture reference is incomplete") + return path, digest + + +def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_id: str, + cli_argv: list[str]) -> str: + """Host-owned persistent instructions, independent of event-hook delivery.""" + import shlex + + command = shlex.join([*cli_argv, "--format", "json", "quota", "context-read", + "--codex-context-dir", directory, "--goal-id", goal_id, "--agent-id", agent_id]) + return ( + "If continuing a captured LoopX decision and its complete context or recovery reference was lost " + "(including compaction or a failed hook), recover before dependent work with: " + command + ". " + "Follow the returned next_command through eof; verify complete bytes and digest. " + "If lookup fails, restore the original capture/reference; do not guess, readmit or ask the owner. " + "Recovery never renews authority/freshness or proves retention. Independent authorized work can continue." + ) + + def codex_context_hook(directory: str, event: dict) -> dict: """Consume native lifecycle events; emit only same-capture recovery refs. @@ -195,7 +238,8 @@ def project_codex_context(payload: dict, *, directory: str, capture_directory: P if result.get("schema_version") != "context_delivery_v1": return result recovery = result["recovery"] - state["recovery"] = {key: recovery[key] for key in ("path", "sha256", "command")} + state["recovery"] = {**{key: recovery[key] for key in ("path", "sha256", "command")}, + "goal_id": goal_id, "agent_id": agent_id} if result["mode"] == "full": path = capture_directory / "presentation.json" write_decision_capture(path, result) diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index e54a958522..5ad8b62352 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -68,7 +68,7 @@ def _delivered(request, digest): + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") for surface in ("quota_packet", "turn_envelope")] + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive): +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -89,7 +89,10 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho lifecycle = workspace / "context" lifecycle.mkdir(mode=0o700) cli = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, - "-m", "loopx.entrypoint"] + "-m", "loopx.entrypoint", "--cli-bin", str(Path(sys.executable).with_name("loopx"))] + installed_cli = os.environ.get("LOOPX_NATIVE_CONTEXT_CLI") + if installed_cli: + cli = [installed_cli, "--cli-bin", installed_cli] hook_command = shlex.join(cli + ["quota", "context-hook", "--codex-context-dir", str(lifecycle)]) if failed_primary: # Fault only the primary journal update. Production code must still @@ -105,7 +108,20 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho "else:\n" f" result=codex_context_hook({str(lifecycle)!r},event)\n" "print(json.dumps(result))\n") - hook_command = shlex.join(cli[:3] + [str(hook)]) + hook_command = shlex.join(["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, str(hook)]) + loss_marker = home / "lose-context-hook" + if hook_loss: + # Stop the whole hook process before product code runs. All later + # events fail too: recovery must not depend on a second working hook. + wrapper = home / "loopx-fixture" + wrapper.write_text("#!" + sys.executable + "\nimport os,signal,sys\n" + + f"if 'context-hook' in sys.argv and os.path.exists({str(loss_marker)!r}):\n" + + (" os.kill(os.getpid(), signal.SIGKILL)\n" if hook_loss == "killed" else " sys.exit(0)\n") + + (f"os.execv({installed_cli!r}, [{installed_cli!r}, *sys.argv[1:]])\n" if installed_cli else + f"os.environ['PYTHONPATH']={str(Path(__file__).resolve().parents[2])!r}\n" + + f"os.execv({sys.executable!r}, [{sys.executable!r}, '-m', 'loopx.entrypoint', *sys.argv[1:]])\n")) + wrapper.chmod(0o700) + hook_command = shlex.join([str(wrapper), "quota", "context-hook", "--codex-context-dir", str(lifecycle)]) handler = {"type": "command", "command": hook_command, "timeout": 10} if host != "benchmark": (home / "hooks.json").write_text(json.dumps({"hooks": { @@ -113,8 +129,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho errors, snapshots = [], [] stages = {"phase": "start", "pages": [], "baseline_confirmed": False, "compact_response": None} wires, page_digests, read_paths = {}, {}, {} - command = ["env", f"PYTHONPATH={Path(__file__).resolve().parents[2]}", sys.executable, - "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), + command = [*cli, "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", "--decision-output-root", str(root), "--codex-context-dir", str(lifecycle)] @@ -212,24 +227,68 @@ def response(request, number, message): assert delivered(request, "cold") == read_paths["cold"].read_text().rstrip("\n") assert delivered(request, "warm") == wire_paths["warm"].read_text().rstrip("\n") stages.update(phase="compact", compact_response=number) + if hook_loss: + loss_marker.touch() cmd = "printf synthetic-work" elif phase == "compact": stages["phase"] = "recovery_ready" message["content"][0]["text"] = "Summary deliberately omitting all packet source bodies." return message elif phase == "recovery_ready": + if hook_loss: + text = _input_text(request) + assert "LoopX context baseline invalidated." not in text + # The recovery entry must really be present after compaction; + # the fixture may not supply a hidden saved capture path. + prefix = "If continuing a captured LoopX decision" + permanent = text[text.index(prefix):] + cmd = permanent.split("with: ", 1)[1].split(". Follow ", 1)[0] + stages["phase"] = "lookup_read" + assert delivered(request, "cold") == "" and delivered(request, "warm") == "" + return tool(cmd, number) assert "LoopX context baseline invalidated." in _input_text(request) assert wires["warm"]["recovery"]["command"] in _input_text(request) assert delivered(request, "cold") == "" and delivered(request, "warm") == "" stages["phase"] = "recovered" stages["pages"] = pages("recovery", Path(wires["warm"]["recovery"]["path"])) cmd = stages["pages"].pop(0) + elif phase == "lookup_read": + # Product lookup delivered the first page; use only its public + # digest-pinned next command, not the private session journal. + page = None + for item in reversed(request["input"]): + output = item.get("output") + parts = [p.get("text", "") for p in output] if isinstance(output, list) else [output] + for part in parts: + if not isinstance(part, str): + continue + try: + value = json.loads(part.split("\nOutput:\n", 1)[-1]) + if isinstance(value, dict) and value.get("exit_code") == 0: + value = json.loads(value["output"]) + if isinstance(value, dict) and value.get("schema_version") == "context_capture_page_v1": + page = value + break + except (ValueError, KeyError): + continue + if page: + break + assert page and page["ok"], page + assert page["sha256"] == wires["warm"]["recovery"]["sha256"] + page_digests["recovery"] = page["sha256"] + if not page["eof"]: + return tool(page["next_command"], number) + stages["phase"] = "recovered" + return response(request, number, message) else: assert phase == "recovered" recovered = delivered(request, "recovery") assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n") stages["phase"] = "done" return message + return tool(cmd, number) + + def tool(cmd, number): if host == "code_mode": if adaptive: commands = [cmd] @@ -265,6 +324,12 @@ def observed_response(request, number, message): if host == "code_mode": config = home / "config.toml" config.write_text(config.read_text().replace("[features]\n", "[features]\ncode_mode=true\n")) + if hook_loss: + from loopx.control_plane.turn_driver.codex_context import codex_context_recovery_instructions + config = home / "config.toml" + instructions = codex_context_recovery_instructions(str(lifecycle), goal_id=GOAL_ID, + agent_id=AGENT_ID, cli_argv=cli) + config.write_text("developer_instructions=" + json.dumps(instructions) + "\n" + config.read_text()) # The real CLI talks to its isolated local TS Effect process. # Native sandbox networking is disabled by default; allow this # synthetic workspace's loopback transport explicitly. @@ -290,7 +355,7 @@ def observed_response(request, number, message): LOOPX_CODEX_HOME=str(home), LOOPX_PROJECT=str(project), LOOPX_SHARED_SKILLS=str(skills), LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_GOAL_ID=GOAL_ID, LOOPX_AGENT_ID=AGENT_ID, LOOPX_WAKE_LOG_DIR=str(workspace / "wakes"), - LOOPX_CLI=str(Path(sys.executable).with_name("loopx")), CODEX_BIN=binary, + LOOPX_CLI=str(wrapper if hook_loss else Path(sys.executable).with_name("loopx")), CODEX_BIN=binary, MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=url, OPENAI_API_KEY="synthetic-loopback-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="90") result = run_once(env) @@ -335,3 +400,12 @@ def observed_response(request, number, message): assert selected["todo_id"] == TODO_ID and selected["text"] == body assert all(path.read_bytes() == raw for path, raw in snapshots) assert state.read_bytes() == original_state and _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("host,surface,hook_loss", [ + (host, surface, loss) for host in ("code_mode", "benchmark") + for surface, loss in (("quota_packet", "missing"), ("turn_envelope", "killed")) +]) +def test_native_missing_hook_recovers_from_persistent_cli_route(tmp_path, monkeypatch, host, surface, hook_loss): + test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, + host, surface, False, True, True, hook_loss) diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index a25f78222c..33fd4641e7 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -164,3 +164,58 @@ def test_real_hook_cli_is_transport_only_without_registry_or_installation(tmp_pa assert result.returncode == 0 and json.loads(result.stdout) == {}, result.stderr assert context._state(directory)["session_id"] == "session" assert not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("damage", [None, "session", "workspace", "goal", "agent", "missing_scope", "missing", "corrupt", "public", "symlink"]) +def test_lost_hook_reference_is_scoped_read_only_and_pins_later_pages(tmp_path, monkeypatch, damage): + import argparse + from loopx.cli_commands.quota_capture import read_context_capture + + setup(tmp_path, monkeypatch) + _, first = project(tmp_path, payload={"goal_id": "fixture", "requirements": "保留🙂" * 400}) + journal = tmp_path / "session.json" + args = argparse.Namespace(codex_context_dir=str(tmp_path), goal_id="fixture", agent_id="agent", + offset=0, page_bytes=256) + if damage == "session": + monkeypatch.setenv("CODEX_THREAD_ID", "other") + elif damage == "workspace": + monkeypatch.chdir(tmp_path.parent) + elif damage in {"goal", "agent"}: + setattr(args, damage + "_id", "other") + elif damage == "missing_scope": + state = context._state(tmp_path) + state["recovery"].pop("goal_id") + context._save(tmp_path, state) + elif damage == "missing": + journal.unlink() + elif damage == "corrupt": + journal.write_text("{broken") + elif damage == "public": + journal.chmod(0o644) + elif damage == "symlink": + original = tmp_path / "original.json" + journal.rename(original) + journal.symlink_to(original) + before = journal.read_bytes() if journal.exists() else None + page = read_context_capture(args) + assert (journal.read_bytes() if journal.exists() else None) == before + assert page["ok"] is (damage is None) + if damage: + assert "content" not in page + return + assert page["sha256"] == first["recovery"]["sha256"] and page["offset"] == 0 + assert "--codex-context-dir" not in page["next_command"] + assert first["recovery"]["sha256"] in page["next_command"] + # Publication can move the selector; the first page's explicit next command + # must continue the original capture, never silently switch to the new one. + _, second = project(tmp_path, "two", payload={"goal_id": "fixture", "requirements": "changed " * 400}) + assert second["recovery"]["sha256"] != page["sha256"] + args.capture_file = first["recovery"]["path"] + args.capture_sha256 = first["recovery"]["sha256"] + args.codex_context_dir = None + args.offset = page["next_offset"] + rest = read_context_capture(args) + assert rest["ok"] and rest["sha256"] == page["sha256"] + assert rest["content"] == Path(args.capture_file).read_bytes()[args.offset:args.offset + len(rest["content"].encode())].decode() + args.codex_context_dir = str(tmp_path) + assert not read_context_capture(args)["ok"] # no mixed moving and pinned selectors From 956626d3333a7f702013511dcb9bd2b8cdf454d1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:39:35 +0800 Subject: [PATCH 20/29] feat: expose native context setup with session-isolated heartbeat roots Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 9 +++ benchmark/runtime/context_delivery.py | 16 ++--- benchmark/tests/test_shared_codex_runtime.py | 2 +- .../rfcs/agent-loop-effect-interpreter-v0.md | 13 ++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 9 +++ docs/heartbeat-automation-prompt.md | 46 ++++++++++++++ loopx/cli_commands/quota.py | 47 +++++++++++--- loopx/cli_commands/quota_capture.py | 10 ++- loopx/cli_commands/quota_registration.py | 10 ++- loopx/cli_commands/support_control.py | 1 + .../support_control_heartbeat_registration.py | 11 +++- .../heartbeat/bootstrap_prompt.py | 4 ++ loopx/control_plane/heartbeat/builder.py | 6 ++ loopx/control_plane/heartbeat/host.py | 9 ++- .../turn_driver/codex_context.py | 48 +++++++++++++- .../test_codex_context_capture.py | 60 ++++++++++++++---- .../test_codex_context_lifecycle.py | 62 +++++++++++++++++++ .../test_heartbeat_envelope_transport.py | 28 +++++++++ 18 files changed, 350 insertions(+), 41 deletions(-) diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 3b12298d6d..1920b24b3e 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -80,6 +80,15 @@ solver outcomes. Include this persistent instruction in subsequent cost comparis earlier input replays did not include it. Count complete input, recovery reads, native discovery and trace IO in treatment cost, not just the delta bytes. +Codex App setup uses the same product configuration renderer as the worker: +`loopx --format json quota context-config --codex-context-root DIR --goal-id GOAL --agent-id AGENT`. +It returns native hooks and persistent recovery instructions, without installing +files or changing trust. A shared root isolates journals by native session; +the trial-local single-session `--codex-context-dir` remains supported. +See [heartbeat setup](../../docs/heartbeat-automation-prompt.md#opt-in-native-context-delivery) +for activation, diagnostics, readback and rollback. Generated setup and a native +app-server test do not by themselves prove installed desktop adoption. + A simultaneous `fresh_replan` checkpoint listener retains its existing capture ownership and reports this context treatment unavailable; it continues the original checkpoint path. Choose separate newly pinned trials to compare the diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index 908c478021..f129449b9e 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -9,11 +9,11 @@ import os import stat from pathlib import Path -import shlex import tempfile def prepare_context_delivery(env: dict[str, str], home: Path) -> None: + from loopx.control_plane.turn_driver.codex_context import codex_context_configuration from loopx.control_plane.turn_driver.codex_stdio import StdioNativeGoalTransport root = home / "loopx-context" @@ -30,11 +30,9 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: # hot-path scan or a reason to distrust intact current model input. env["CODEX_ROLLOUT_TRACE_ROOT"] = tempfile.mkdtemp(prefix="wake-", dir=traces) env["LOOPX_CODEX_CONTEXT_DIR"] = str(root) - command = shlex.join([env["LOOPX_CLI"], "--format", "json", "quota", "context-hook", - "--codex-context-dir", str(root)]) - handler = {"type": "command", "command": command, "timeout": 10} - content = json.dumps({"hooks": {event: [{"hooks": [handler]}] - for event in ("SessionStart", "PreToolUse")}}) + configuration = codex_context_configuration(str(root), goal_id=env["LOOPX_GOAL_ID"], + agent_id=env["LOOPX_AGENT_ID"], cli_argv=[env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]]) + content = json.dumps({"hooks": configuration["hooks"]}) path = home / "hooks.json" if path.is_symlink() or (path.exists() and path.read_text() != content): raise ValueError("native context hooks changed within a trial; use a new trial") @@ -64,11 +62,7 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: entries.append(f"{json.dumps(key)} = {{ trusted_hash = {json.dumps(digest)} }}") # CLI override keys are dot-separated; native keys contain paths/dots. # Carry the entire table as TOML so native keys remain literal strings. - from loopx.control_plane.turn_driver.codex_context import codex_context_recovery_instructions - - instructions = codex_context_recovery_instructions(str(root), goal_id=env["LOOPX_GOAL_ID"], - agent_id=env["LOOPX_AGENT_ID"], cli_argv=[env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]]) # This isolated trial's native developer instructions survive compaction; # a failed SessionStart handler cannot erase the read-only recovery route. env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] = json.dumps(["-c", "hooks.state={" + ",".join(entries) + "}", - "-c", "developer_instructions=" + json.dumps(instructions)]) + "-c", "developer_instructions=" + json.dumps(configuration["developer_instructions"])]) diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index b3b40ceb59..52cac49001 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -1146,7 +1146,7 @@ def test_context_setup_preserves_unsafe_or_unowned_inputs(tmp_path, kind): from benchmark.runtime.context_delivery import prepare_context_delivery home = tmp_path / "home" home.mkdir() - env = {"LOOPX_CLI": "loopx"} + env = {"LOOPX_CLI": "loopx", "LOOPX_GOAL_ID": "fixture", "LOOPX_AGENT_ID": "agent"} if kind == "existing_hooks": protected = home / "hooks.json" protected.write_text('{"operator": "unchanged"}') diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index e7510103ff..b210b1682f 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1156,6 +1156,19 @@ Lookup starts at offset zero. Every following `next_command` pins the original path and hash, so a concurrent newer publication cannot mix captures. Missing, changed or symlinked files retain the ordinary reader's refusal behavior. +`quota context-config` renders the native hook/recovery configuration through +the CLI; the benchmark worker reuses that same provider helper. Shared Codex App +configuration uses `--codex-context-root` to isolate journals by native session, +while dedicated trial homes retain `--codex-context-dir`. Read-only lookup never +creates an unknown session directory. The heartbeat bootstrap preserves the +root/capture options and uses the existing per-wake `LOOPX_TURN` binding instead +of persisting a Turn ID. Native hook trust and private actual-input diagnostics +remain host-owned; missing diagnostics select full. Generated configuration +installs nothing, changes no trust and grants no execution authority. See +[activation/readback/rollback](../../heartbeat-automation-prompt.md#opt-in-native-context-delivery). +This qualifies generated configuration plus native consumption, not installed +desktop adoption or a new cost measurement. + The opt-in benchmark worker carries a compact recovery instruction in native `developer_instructions`, independently of SessionStart/PreToolUse delivery. Native code-mode consumers can carry the same product-owned instruction in diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 6b950a70e4..633e2defb9 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -797,6 +797,15 @@ code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列 查找从 offset 零开始,后续每个 `next_command` 固定到原路径和 hash,避免并发发布 更新 capture 时混读。文件缺失、内容变化或符号链接继续沿既有 reader 拒绝。 +`quota context-config` 通过 CLI 生成原生 hook/恢复配置,benchmark worker 复用同一 +provider helper。Codex App 共享配置使用 `--codex-context-root` 按原生 session 隔离 +journal,独立 trial home 保留 `--codex-context-dir`。只读查找不创建未知 session +目录。心跳 bootstrap 保留 root/capture 参数,使用既有每次唤醒的 `LOOPX_TURN` +绑定,不持久化固定 Turn ID。原生 hook 信任和私有实际输入诊断仍由宿主管理;缺少 +诊断时使用全量。生成配置不安装文件、不改变信任、不授予执行权限。参见 +[启用/读回/回滚](../../heartbeat-automation-prompt.md#opt-in-native-context-delivery)。 +本段验证生成配置及原生消费,不等于真实桌面安装采用或新的成本测量。 + 显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, 独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 93eba1e99a..ed2e26ab2c 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -421,6 +421,52 @@ loopx-canary heartbeat-prompt \ --goal-id ``` +### Opt-in native context delivery + +For a trusted Codex host, create private context/capture directories and render +the native setup through the installed CLI: + +```sh +loopx --format json quota context-config \ + --codex-context-root --goal-id --agent-id +``` + +The read-only result contains `native_config.hooks` and +`native_config.developer_instructions`. Merge the hooks into the host's existing +`hooks.json` or inline hook configuration, and append the recovery instruction to +its existing developer instructions. Preserve other configuration. Review and +trust only these exact hooks through Codex's native hook trust surface; the CLI +does not install files, approve hashes, enable diagnostics or run a guard. +Use one configuration for the declared Goal/Agent. A shared private root keeps +different native sessions in separate journals, including interleaved sessions. + +Generate the normal bootstrap with `heartbeat-prompt --thin --codex-app +--bootstrap`, its existing Goal/Agent/registry arguments, and +`--decision-output-root --codex-context-root +`; optionally add `--turn-envelope`. The loader preserves +these options without freezing a Turn ID. Each wake loads current instructions +and binds the existing `LOOPX_TURN` before its generated guard. Selection keeps +the same transport. An unset wake identity fails in the shell before admission. + +Warm delta additionally needs the native host process to have +`CODEX_ROLLOUT_TRACE_ROOT` set to private native trace-v1 diagnostics before it +starts. A hook cannot set its parent process environment. If that host cannot +provide the required actual-input observation, ordinary full delivery continues; +do not use a file, session ID or successful setup as a retention acknowledgement. +Scope diagnostics to the authorized host and include their IO/retention cost. + +Read back native hook discovery/trust and the generated guard options, then +verify cold full, warm delta only after complete current-input evidence, +resume and compaction recovery on the actual host. The persistent instruction +names `quota context-read --codex-context-root ...` when an entire hook is lost; +lookup is read-only and subsequent pages pin the original file/hash. It never +readmits or spends. Normal native trust/settings UI remains the activation and +error surface; unsupported diagnostics keep the same full path. +To roll back, remove the context/capture options from this bootstrap and remove +only the generated hooks/recovery instruction from the native configuration. +Existing captures remain private recovery evidence. This opt-in does not grant +execution authority or prove installed App adoption or net token savings. + ## Template Generate the persistent prompt from the installed runtime: diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 3cfaf2445b..c256ff51c0 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -483,7 +483,8 @@ def _emit_quota_result( ) print_payload(failure, args.format, _quota_renderer(args)) return 1 - if delivery_request is not None or getattr(args, "codex_context_dir", None): + if (delivery_request is not None or getattr(args, "codex_context_dir", None) + or getattr(args, "codex_context_root", None)): agent = payload.get("agent_identity") decision_identity = (str(payload.get("goal_id") or ""), str(agent.get("agent_id") or "") if isinstance(agent, Mapping) else str(args.agent_id or "")) @@ -507,17 +508,20 @@ def _emit_quota_result( request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet") - if args.format == "json" and succeeded and getattr(args, "codex_context_dir", None) and capture_directory: + if (args.format == "json" and succeeded and capture_directory + and (getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None))): import hashlib - from ..control_plane.turn_driver.codex_context import project_codex_context + from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context try: capture_id = hashlib.sha256((capture_directory / "decision.json").read_bytes()).hexdigest() - payload = project_codex_context(payload, directory=args.codex_context_dir, + directory = codex_context_directory(directory=args.codex_context_dir, + root=getattr(args, "codex_context_root", None), create=True) + payload = project_codex_context(payload, directory=directory, capture_directory=capture_directory, goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet", capture_id=capture_id) - except OSError: + except (OSError, ValueError): pass # Lost optional capture: keep the admitted full payload, never rerun its guard. print_payload(payload, args.format, _quota_renderer(args)) return 0 if succeeded else 1 @@ -532,17 +536,45 @@ def handle_quota_command( append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, ) -> int: + if args.quota_command == "context-config": + import json + from ..command_invocation import loopx_cli_argv + from ..control_plane.turn_driver.codex_context import codex_context_configuration + + try: + directory = getattr(args, "codex_context_root", None) or args.codex_context_dir + if not directory: + raise ValueError("context-config requires codex-context-root or codex-context-dir") + config = codex_context_configuration(directory, goal_id=args.goal_id, agent_id=args.agent_id, + cli_argv=loopx_cli_argv(), session_root=bool(getattr(args, "codex_context_root", None))) + payload = {"ok": True, "native_config": config, + "activation": "Merge hooks and append developer_instructions to the existing native configuration; " + "review and trust only these exact hooks in Codex. Restart/resume with the resulting configuration. " + "Pass this same context location and a decision-output-root to heartbeat-prompt/should-run. " + "For delta, the native process must already have CODEX_ROLLOUT_TRACE_ROOT pointing to private diagnostics; " + "unavailable diagnostics preserve full delivery. Disabling the context options restores ordinary output. " + "This command installs nothing and grants no execution authority."} + except (OSError, ValueError, TypeError) as exc: + payload = {"ok": False, "reason": str(exc)} + print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 0 if payload["ok"] else 1 if args.quota_command == "context-hook": import json import sys from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - from ..control_plane.turn_driver.codex_context import codex_context_hook + from ..control_plane.turn_driver.codex_context import codex_context_directory, codex_context_hook # Native transport only: no registry discovery, guard or quota effects. try: raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) event = json.loads(raw) if len(raw) <= MAX_REQUEST_BYTES else None - payload = codex_context_hook(args.codex_context_dir, event) if isinstance(event, dict) else {} + if (isinstance(event, dict) and isinstance(event.get("session_id"), str) and event["session_id"] + and event.get("hook_event_name") in ("SessionStart", "PreCompact", "PreToolUse")): + directory = codex_context_directory(directory=args.codex_context_dir, + root=getattr(args, "codex_context_root", None), session=event.get("session_id"), create=True) + payload = codex_context_hook(directory, event) + else: + payload = {} except (OSError, ValueError, TypeError, RecursionError): payload = {} print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) @@ -572,6 +604,7 @@ def handle_quota_command( or getattr(args, "decision_output_root", None) is not None or getattr(args, "decision_handoff_socket", None) is not None or getattr(args, "codex_context_dir", None) is not None + or getattr(args, "codex_context_root", None) is not None ): validate_quota_command_context_request(args) capture_directory = prepare_decision_capture(args) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index c7135fec41..083d5cf54a 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -49,7 +49,10 @@ def read_context_capture(args: argparse.Namespace) -> dict: digest = getattr(args, "capture_sha256", None) value = getattr(args, "capture_file", None) offset, size = args.offset, args.page_bytes - context_dir = getattr(args, "codex_context_dir", None) + from ..control_plane.turn_driver.codex_context import codex_context_directory + + context_dir = codex_context_directory(directory=getattr(args, "codex_context_dir", None), + root=getattr(args, "codex_context_root", None)) if context_dir: if value or digest or offset != 0: raise ValueError("session lookup starts at offset zero without capture-file or capture-sha256; follow next_command afterwards") @@ -102,7 +105,8 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) handoff = getattr(args, "decision_handoff_socket", None) - if getattr(args, "codex_context_dir", None) is not None and ( + if (getattr(args, "codex_context_dir", None) is not None + or getattr(args, "codex_context_root", None) is not None) and ( args.format != "json" or handoff is not None or (requested is None and root is None)): raise QuotaCommandValidationError( "native Codex context delivery needs JSON and a capture destination, without a second handoff socket") @@ -179,6 +183,8 @@ def bind_capture_selection_transport(payload: dict, args: argparse.Namespace) -> suffix += " --turn-envelope" if getattr(args, "codex_context_dir", None): suffix += " --codex-context-dir " + shlex.quote(args.codex_context_dir) + if getattr(args, "codex_context_root", None): + suffix += " --codex-context-root " + shlex.quote(args.codex_context_root) selection["command_args_template"] = template + suffix diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 1fdcdeb041..7a23d6e363 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -29,6 +29,7 @@ def register_quota_command( "should-run", "context-read", "context-hook", + "context-config", "monitor-poll", "scheduler-ack", "scheduler-ack-current", @@ -37,7 +38,7 @@ def register_quota_command( "void-slot", ], default="status", - help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, context-hook for opt-in native Codex lifecycle IO, 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.", + help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, context-hook for opt-in native Codex lifecycle IO, context-config for native hook/recovery configuration without installation or trust changes, 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( "--goal-id", @@ -199,11 +200,16 @@ def register_quota_command( "Waits at most 120s for a digest-bound release; failure retains the original receipt."), ) quota_parser.add_argument("--capture-file", help="For context-read: original captured JSON file; no guard is evaluated.") - quota_parser.add_argument("--codex-context-dir", + context_location = quota_parser.add_mutually_exclusive_group() + context_location.add_argument("--codex-context-dir", help="Opt-in private native Codex lifecycle directory. For context-hook, consume one native hook event on stdin. " "For should-run, requires JSON and a decision capture destination; unknown retention stays full. " "For context-read without a file/hash, select the current native session/workspace capture for --goal-id/--agent-id; " "start at offset zero, then follow digest-pinned next_command. Does not install hooks, enable tracing, or change native trust.") + context_location.add_argument("--codex-context-root", + help="Existing private native context root, with independent journals per native session. " + "Use for shared Codex App hook configuration; cannot combine with --codex-context-dir. " + "context-config renders native configuration for the exact Goal/Agent; no files, trust, guard or spend are changed.") quota_parser.add_argument("--capture-sha256", help="For context-read: exact SHA-256 supplied with the capture reference.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") quota_parser.add_argument("--page-bytes", type=int, default=4096, diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 08fabf7c66..3f5091fea6 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -408,6 +408,7 @@ def handle_support_control_command( decision_output_root=args.decision_output_root, decision_handoff_socket=args.decision_handoff_socket, codex_context_dir=args.codex_context_dir, + codex_context_root=args.codex_context_root, turn_envelope=args.turn_envelope, reward_memory_enabled=reward_memory_enabled, ) diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 9731b37981..73293fc0a2 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -159,7 +159,8 @@ def register_heartbeat_control_commands( heartbeat_prompt_parser.add_argument( "--decision-output-root", - help="Save full local decisions without changing the display; requires thin mode and an explicit host-owned Turn ID.", + help="Save full local decisions without changing the display; requires thin mode and an explicit host-owned Turn ID, " + "or codex-context-root with the normal per-wake LOOPX_TURN binding.", ) heartbeat_prompt_parser.add_argument( @@ -173,10 +174,16 @@ def register_heartbeat_control_commands( help="Retain the explicit private Host handoff on generated guards; requires --decision-output-root.", ) - heartbeat_prompt_parser.add_argument( + context_location = heartbeat_prompt_parser.add_mutually_exclusive_group() + context_location.add_argument( "--codex-context-dir", help="Carry the opt-in native context directory into guards; requires --decision-output-root. Does not install hooks or enable tracing.", ) + context_location.add_argument( + "--codex-context-root", + help="Carry a shared private native context root into guards, isolated per native session; " + "requires --decision-output-root. Use quota context-config to render native setup.", + ) heartbeat_prequota_parser = subparsers.add_parser( "heartbeat-prequota", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 7429da1e3d..7f7d29f02a 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -66,6 +66,8 @@ def _goal_command(args, *, registry: Path, legacy: bool) -> list[str]: ("runtime_profile", "--runtime-profile"), ("visible_goal_host", "--visible-goal-host"), ("host_surface", "--host-surface"), ("scheduler_owner", "--scheduler-owner"), ("execution_mode", "--execution-mode"), + ("decision_output_root", "--decision-output-root"), + ("codex_context_root", "--codex-context-root"), ): value = getattr(args, field, None) if value is not None: @@ -78,6 +80,8 @@ def _goal_command(args, *, registry: Path, legacy: bool) -> list[str]: command += [flag, value] if args.cli_bin != "loopx": command += ["--cli-bin", args.cli_bin] + if getattr(args, "turn_envelope", False): + command.append("--turn-envelope") if legacy: if args.codex_app: command.append("--codex-app") diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 8184991d0d..fe1bfe018a 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -177,6 +177,7 @@ def _heartbeat_prompt_commands( decision_output_root: Path | None, decision_handoff_socket: str | None, codex_context_dir: str | None, + codex_context_root: str | None, turn_envelope: bool, ) -> dict[str, str | None]: quota_guard_command = render_quota_guard_command( @@ -283,6 +284,8 @@ def _heartbeat_prompt_commands( capture_arg += " --decision-handoff-socket " + shlex.quote(decision_handoff_socket) if codex_context_dir is not None: capture_arg += " --codex-context-dir " + shlex.quote(codex_context_dir) + if codex_context_root is not None: + capture_arg += " --codex-context-root " + shlex.quote(codex_context_root) display_arg = " --turn-envelope" if turn_envelope else "" for key in ("quota_guard_command", "task_body_quota_guard_command"): commands[key] = str(commands[key]) + display_arg + capture_arg @@ -319,6 +322,7 @@ def build_heartbeat_prompt( decision_output_root: str | Path | None = None, decision_handoff_socket: str | None = None, codex_context_dir: str | None = None, + codex_context_root: str | None = None, turn_envelope: bool = False, ) -> dict[str, Any]: if not (full or compact or brief or thin): @@ -384,6 +388,7 @@ def build_heartbeat_prompt( turn_instance_id=normalized_turn_instance_id, decision_handoff_socket=decision_handoff_socket, codex_context_dir=codex_context_dir, + codex_context_root=codex_context_root, turn_envelope=turn_envelope, ) explicit_agent_scopes = normalize_agent_scopes(agent_scopes) @@ -474,6 +479,7 @@ def build_heartbeat_prompt( decision_output_root=decision_output_root, decision_handoff_socket=decision_handoff_socket, codex_context_dir=codex_context_dir, + codex_context_root=codex_context_root, turn_envelope=turn_envelope, ) cli_preflight = render_cli_preflight(cli_bin=cli_bin) diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index 4bf768786e..f3d9d73c5e 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -31,10 +31,14 @@ def resolve_heartbeat_capture_root( turn_instance_id: str | None, decision_handoff_socket: str | None = None, codex_context_dir: str | None = None, + codex_context_root: str | None = None, turn_envelope: bool = False, ) -> Path | None: """Validate capture transport for an already resolved host-owned Turn.""" - if codex_context_dir is not None and (root is None or decision_handoff_socket is not None): + if codex_context_dir is not None and codex_context_root is not None: + raise ValueError("choose codex-context-dir or codex-context-root") + if (codex_context_dir is not None or codex_context_root is not None) and ( + root is None or decision_handoff_socket is not None): raise ValueError("native context delivery requires a capture root without a second handoff socket") if root is None: if decision_handoff_socket is not None: @@ -42,7 +46,8 @@ def resolve_heartbeat_capture_root( if turn_envelope: raise ValueError("TurnEnvelope requires an explicit decision capture root") return None - if not thin or full or compact or brief or native_goal_host or not turn_instance_id: + if (not thin or full or compact or brief or native_goal_host + or (not turn_instance_id and codex_context_root is None)): raise ValueError("--decision-output-root requires thin mode and an explicit host-owned Turn ID") directory = Path(root).expanduser().absolute() if directory.is_symlink() or not directory.is_dir(): diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 97a789e0f1..f6837641b6 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -43,6 +43,29 @@ def _private_root(directory: str) -> Path: return root +def codex_context_directory(*, directory: str | None = None, root: str | None = None, + session: str | None = None, create: bool = False) -> str | None: + """Namespace shared host configuration without sharing session journals. + + A native session id selects a directory, never supplies a filesystem path + or proves retained context. Read-only recovery must not create directories. + """ + if directory is not None and root is not None: + raise ValueError("choose codex-context-dir or codex-context-root") + if root is None: + return directory + import hashlib + + parent = _private_root(root) + session = session if session is not None else os.environ.get("CODEX_THREAD_ID") + if not isinstance(session, str) or not session: + raise ValueError("native session context requires CODEX_THREAD_ID or a native hook session") + child = parent / hashlib.sha256(session.encode()).hexdigest() + if create: + child.mkdir(mode=0o700, exist_ok=True) + return str(_private_root(str(child))) + + @contextmanager def _locked(directory: str): if os.name != "posix": @@ -95,12 +118,13 @@ def read_codex_context_reference(directory: str, *, goal_id: str, agent_id: str) def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_id: str, - cli_argv: list[str]) -> str: + cli_argv: list[str], session_root: bool = False) -> str: """Host-owned persistent instructions, independent of event-hook delivery.""" import shlex command = shlex.join([*cli_argv, "--format", "json", "quota", "context-read", - "--codex-context-dir", directory, "--goal-id", goal_id, "--agent-id", agent_id]) + "--codex-context-root" if session_root else "--codex-context-dir", directory, + "--goal-id", goal_id, "--agent-id", agent_id]) return ( "If continuing a captured LoopX decision and its complete context or recovery reference was lost " "(including compaction or a failed hook), recover before dependent work with: " + command + ". " @@ -110,6 +134,26 @@ def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_i ) +def codex_context_configuration(directory: str, *, goal_id: str, agent_id: str, + cli_argv: list[str], session_root: bool = False) -> dict: + """Native configuration data shared by the worker and CLI setup journey. + + This renders no trust hashes and writes no host configuration. The native + host remains responsible for reviewing/trusting the exact discovered hook. + """ + import shlex + + if not goal_id or not agent_id: + raise ValueError("context configuration requires exact goal-id and agent-id") + directory = str(_private_root(directory)) + option = "--codex-context-root" if session_root else "--codex-context-dir" + handler = {"type": "command", "command": shlex.join([ + *cli_argv, "--format", "json", "quota", "context-hook", option, directory]), "timeout": 10} + return {"hooks": {event: [{"hooks": [handler]}] for event in ("SessionStart", "PreToolUse")}, + "developer_instructions": codex_context_recovery_instructions(directory, goal_id=goal_id, + agent_id=agent_id, cli_argv=cli_argv, session_root=session_root)} + + def codex_context_hook(directory: str, event: dict) -> dict: """Consume native lifecycle events; emit only same-capture recovery refs. diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 5ad8b62352..cc36207c9d 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -68,7 +68,7 @@ def _delivered(request, digest): + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") for surface in ("quota_packet", "turn_envelope")] + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None): +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -93,7 +93,16 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho installed_cli = os.environ.get("LOOPX_NATIVE_CONTEXT_CLI") if installed_cli: cli = [installed_cli, "--cli-bin", installed_cli] - hook_command = shlex.join(cli + ["quota", "context-hook", "--codex-context-dir", str(lifecycle)]) + context_option = "--codex-context-root" if shared_root else "--codex-context-dir" + native_configuration = None + if shared_root: + prepared = subprocess.run(cli + ["--format", "json", "quota", "context-config", + context_option, str(lifecycle), "--goal-id", GOAL_ID, "--agent-id", AGENT_ID], + capture_output=True, text=True, timeout=15) + assert prepared.returncode == 0, prepared.stderr + native_configuration = json.loads(prepared.stdout)["native_config"] + hook_command = (native_configuration["hooks"]["SessionStart"][0]["hooks"][0]["command"] + if native_configuration else shlex.join(cli + ["quota", "context-hook", context_option, str(lifecycle)])) if failed_primary: # Fault only the primary journal update. Production code must still # return the original full-read reference; no test recovery handler. @@ -121,7 +130,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho f"os.environ['PYTHONPATH']={str(Path(__file__).resolve().parents[2])!r}\n" + f"os.execv({sys.executable!r}, [{sys.executable!r}, '-m', 'loopx.entrypoint', *sys.argv[1:]])\n")) wrapper.chmod(0o700) - hook_command = shlex.join([str(wrapper), "quota", "context-hook", "--codex-context-dir", str(lifecycle)]) + hook_command = shlex.join([str(wrapper), "quota", "context-hook", context_option, str(lifecycle)]) handler = {"type": "command", "command": hook_command, "timeout": 10} if host != "benchmark": (home / "hooks.json").write_text(json.dumps({"hooks": { @@ -132,12 +141,26 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho command = [*cli, "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", - "--decision-output-root", str(root), "--codex-context-dir", str(lifecycle)] + "--decision-output-root", str(root), context_option, str(lifecycle)] if surface == "turn_envelope": command.append("--turn-envelope") + generated_guard = None + if shared_root: + generated = subprocess.run([*cli, "--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "heartbeat-prompt", "--thin", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--decision-output-root", str(root), + context_option, str(lifecycle), *(["--turn-envelope"] if surface == "turn_envelope" else [])], + capture_output=True, text=True, timeout=15) + assert generated.returncode == 0, generated.stderr + prompt = json.loads(generated.stdout) + assert prompt["ok"], prompt + generated_guard = next(line for line in prompt["task_body"].splitlines() if "quota should-run" in line) + assert '${LOOPX_TURN:?}' in generated_guard and context_option in generated_guard + generated_guard += " --todo-id " + shlex.quote(TODO_ID) + env["LOOPX_TURN"] = "native-capture-turn" def invoke(name): - return shlex.join(command) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" + return (generated_guard or shlex.join(command)) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" def pages(name, path): raw = path.read_bytes() @@ -145,7 +168,7 @@ def pages(name, path): page_digests[name] = digest # Plan only read offsets. Every actual model-facing page must # come from the public CLI, including digest verification. - page_size = 4096 if adaptive else 5000 + page_size = 4096 if adaptive or host == "code_mode" else 5000 offsets, start, count = [0], 0, 0 for character in raw.decode(): size = len(character.encode()) @@ -290,18 +313,21 @@ def response(request, number, message): def tool(cmd, number): if host == "code_mode": + # Exercise the shipped default page size and bounded batching for + # both output forms. One model round per page can exhaust the + # unchanged native turn deadline before same-capture recovery ends. + commands = [cmd] + if stages["pages"]: + commands.append(stages["pages"].pop(0)) if adaptive: - commands = [cmd] - if stages["pages"]: - commands.append(stages["pages"].pop(0)) code = "".join("{const r=await tools.exec_command(" + json.dumps({ "cmd": command, "max_output_tokens": 2400}) + ");text(r.exit_code === 0 ? r.output : r);}" for command in commands) return {"type": "custom_tool_call", "id": f"ctc_{number}", "call_id": f"call_{number}", "name": "exec", "input": code} return {"type": "custom_tool_call", "id": f"ctc_{number}", "call_id": f"call_{number}", - "name": "exec", "input": "text(await tools.exec_command(" + json.dumps({ - "cmd": cmd, "max_output_tokens": 2000}) + "));"} + "name": "exec", "input": "".join("text(await tools.exec_command(" + json.dumps({ + "cmd": command, "max_output_tokens": 2000}) + "));" for command in commands)} return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", "name": "exec_command", "arguments": json.dumps({"cmd": cmd, "max_output_tokens": 2000})} @@ -324,7 +350,11 @@ def observed_response(request, number, message): if host == "code_mode": config = home / "config.toml" config.write_text(config.read_text().replace("[features]\n", "[features]\ncode_mode=true\n")) - if hook_loss: + if native_configuration: + config = home / "config.toml" + config.write_text("developer_instructions=" + json.dumps(native_configuration["developer_instructions"]) + + "\n" + config.read_text()) + elif hook_loss: from loopx.control_plane.turn_driver.codex_context import codex_context_recovery_instructions config = home / "config.toml" instructions = codex_context_recovery_instructions(str(lifecycle), goal_id=GOAL_ID, @@ -409,3 +439,9 @@ def observed_response(request, number, message): def test_native_missing_hook_recovers_from_persistent_cli_route(tmp_path, monkeypatch, host, surface, hook_loss): test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, False, True, True, hook_loss) + + +@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"]) +def test_native_shared_configuration_resume_and_lost_hook(tmp_path, monkeypatch, surface): + test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, + "code_mode", surface, False, True, True, "missing", shared_root=True) diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index 33fd4641e7..0862d2ab06 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os from pathlib import Path import subprocess import sys @@ -11,6 +12,67 @@ from loopx.control_plane.turn_driver import codex_context as context +def test_shared_root_keeps_interleaved_native_sessions_independent(tmp_path, monkeypatch): + tmp_path.chmod(0o700) + monkeypatch.chdir(tmp_path) + original = {} + for session in ("one", "../../two"): + directory = Path(context.codex_context_directory(root=str(tmp_path), session=session, create=True)) + assert directory.parent == tmp_path and directory.stat().st_mode & 0o077 == 0 + monkeypatch.setenv("CODEX_THREAD_ID", session) + event = dict(session_id=session, cwd=str(tmp_path), hook_event_name="SessionStart", source="startup") + context.codex_context_hook(str(directory), event) + _, wire = project(directory) + original[session] = (directory, wire["recovery"], (directory / "session.json").read_bytes()) + assert original["one"][0] != original["../../two"][0] + for session, (directory, recovery, raw) in original.items(): + monkeypatch.setenv("CODEX_THREAD_ID", session) + selected = context.codex_context_directory(root=str(tmp_path)) + assert selected == str(directory) + assert context.read_codex_context_reference(selected, goal_id="fixture", agent_id="agent") == ( + recovery["path"], recovery["sha256"]) + assert (directory / "session.json").read_bytes() == raw + + +def test_shared_root_read_does_not_create_unknown_session_or_follow_alias(tmp_path, monkeypatch): + tmp_path.chmod(0o700) + monkeypatch.setenv("CODEX_THREAD_ID", "unknown") + with pytest.raises(FileNotFoundError): + context.codex_context_directory(root=str(tmp_path)) + assert list(tmp_path.iterdir()) == [] + monkeypatch.delenv("CODEX_THREAD_ID", raising=False) + with pytest.raises(ValueError, match="native session"): + context.codex_context_directory(root=str(tmp_path), create=True) + import hashlib + + alias = tmp_path / hashlib.sha256(b"session").hexdigest() + target = tmp_path / "target" + target.mkdir(mode=0o700) + alias.symlink_to(target, target_is_directory=True) + with pytest.raises(ValueError, match="private"): + context.codex_context_directory(root=str(tmp_path), session="session", create=True) + assert not (target / "session.json").exists() + + +def test_native_configuration_cli_is_read_only_and_usable_without_registry(tmp_path): + tmp_path.chmod(0o700) + repo = Path(__file__).resolve().parents[2] + command = [sys.executable, "-m", "loopx.entrypoint", "--format", "json", "quota", "context-config", + "--codex-context-root", str(tmp_path), "--goal-id", "fixture", "--agent-id", "agent"] + result = subprocess.run(command, cwd=tmp_path, env={**os.environ, "PYTHONPATH": str(repo)}, + capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + native = payload["native_config"] + assert set(native["hooks"]) == {"SessionStart", "PreToolUse"} + assert "--codex-context-root" in native["developer_instructions"] + assert "context-read" in native["developer_instructions"] + assert "hooks.state" not in native and list(tmp_path.iterdir()) == [] + missing_identity = subprocess.run(command[:-2], cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(repo)}, capture_output=True, text=True, timeout=15) + assert missing_identity.returncode == 1 and not json.loads(missing_identity.stdout)["ok"] + + def setup(root, monkeypatch): root.chmod(0o700) monkeypatch.chdir(root) diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index ccacd10fec..e4fc0f15d9 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -23,6 +23,34 @@ ) +def test_shared_native_root_keeps_dynamic_turn_binding_and_bootstrap_options(tmp_path): + from loopx.cli import build_parser + from loopx.control_plane.heartbeat.bootstrap_prompt import goal_bootstrap + + args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile="codex_app_heartbeat", + thin=True, decision_output_root=tmp_path, codex_context_root=str(tmp_path), turn_envelope=True) + prompt = build_heartbeat_prompt(**args) + guard = prompt["quota_guard_command"] + assert '--turn-instance-id "${LOOPX_TURN:?}"' in guard + assert "--begin-turn" not in guard + assert "--codex-context-root" in guard and "--turn-envelope" in guard + assert "LOOPX_TURN=" in prompt["task_body"] + # The shell refuses an unbound wake before the CLI can create a receipt. + result = subprocess.run(guard, shell=True, env={"PATH": os.environ["PATH"]}, + capture_output=True, text=True) + assert result.returncode != 0 and "LOOPX_TURN" in result.stderr + assert list(tmp_path.iterdir()) == [] + parser = build_parser() + cli = ["heartbeat-prompt", "--thin", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--decision-output-root", str(tmp_path), "--codex-context-root", str(tmp_path), "--turn-envelope", "--bootstrap"] + parsed = parser.parse_args(cli) + bootstrap = goal_bootstrap(parsed, registry=tmp_path / "registry.json") + command = shlex.split(bootstrap.split("```sh\n")[1].split("\n```")[0]) + assert "--bootstrap" not in command and "--turn-instance-id" not in command + assert command[command.index("--codex-context-root") + 1] == str(tmp_path) + assert "--turn-envelope" in command + + def test_capture_preserves_full_dispatcher_and_envelope_needs_explicit_opt_in(tmp_path): args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile="generic_cli", turn_instance_id=TURN_ID, thin=True) From 6cb0f8212bcc286545e1f54d2429c132713c4aa5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:02:48 +0800 Subject: [PATCH 21/29] fix(benchmark): compose checkpoint capture with native context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 17 +++++++++++------ benchmark/runtime/checkpoint_wake.py | 2 ++ benchmark/runtime/worker.py | 6 +----- benchmark/tests/test_native_checkpoint.py | 15 ++++++++++++++- .../tests/test_native_checkpoint_handoff.py | 13 ++++++++++--- loopx/cli_commands/quota.py | 10 +++++++--- loopx/cli_commands/quota_capture.py | 4 ++-- loopx/control_plane/heartbeat/host.py | 5 ++--- loopx/hosts/codex_checkpoint.py | 4 +++- .../control_plane/test_codex_context_capture.py | 12 +++++++++++- .../control_plane/test_context_delivery_cli.py | 9 ++++++++- .../test_heartbeat_envelope_transport.py | 11 ++++++++--- 12 files changed, 79 insertions(+), 29 deletions(-) diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 1920b24b3e..161c59163d 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -89,12 +89,17 @@ See [heartbeat setup](../../docs/heartbeat-automation-prompt.md#opt-in-native-co for activation, diagnostics, readback and rollback. Generated setup and a native app-server test do not by themselves prove installed desktop adoption. -A simultaneous `fresh_replan` checkpoint listener retains its existing capture -ownership and reports this context treatment unavailable; it continues the -original checkpoint path. Choose separate newly pinned trials to compare the -features. Do not modify an active/frozen trial. Disable `context_delivery` for a -new trial to roll back; default runs install no hooks, trust or trace settings. -No scorer, budget, task semantics or launch authority changes with this option. +A simultaneous `fresh_replan` checkpoint listener retains the pre-output barrier: +the original full decision is saved and released before native presentation. +The interrupted native cursor also binds the context path. Native projection +uses its own retention proof; a socket consumer's presentation request cannot +wrap that projection or supply its proof. Missing native proof keeps full output, +without guard replay or another debit. Checkpoint capture remains independent of +TurnEnvelope. Qualify the selected native host version before attributing warm +transport to a trial; setup availability alone is insufficient. +Do not modify an active/frozen trial. Disable `context_delivery` for a new trial +to roll back; default runs install no hooks, trust or trace settings. No scorer, +budget, task semantics or launch authority changes with this option. Checkpoint capture is independent of this display experiment. A `fresh_replan` declaration may establish a private capture/handoff socket while diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py index 966d8913f4..9d83fbcbbd 100644 --- a/benchmark/runtime/checkpoint_wake.py +++ b/benchmark/runtime/checkpoint_wake.py @@ -136,6 +136,8 @@ def _capture(self, held, deadline): "turn_envelope": self.receipt.get("turn_envelope", False), "capture_root": str(self.capture_root), "socket_path": self.socket_path, "decision_sha256": held["sha256"]} + if self.env.get("LOOPX_CONTEXT_DELIVERY") == "1" and self.env.get("LOOPX_CODEX_CONTEXT_DIR"): + guard["codex_context_dir"] = self.env["LOOPX_CODEX_CONTEXT_DIR"] reader = readers.enter_context(native_checkpoint_reader(self.env, timeout=max(.1, window - time.time()))) native = native_session_checkpoint(self.env, self.receipt["session"]["session_id"], timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader, diff --git a/benchmark/runtime/worker.py b/benchmark/runtime/worker.py index d1f1ba9316..981818238f 100644 --- a/benchmark/runtime/worker.py +++ b/benchmark/runtime/worker.py @@ -93,8 +93,6 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") context_dir = env.get("LOOPX_CODEX_CONTEXT_DIR") if env.get("LOOPX_CONTEXT_DELIVERY") == "1" else None - if context_dir and decision_handoff_socket is not None: - raise ValueError("native context delivery and checkpoint handoff need separate trials") if env.get("LOOPX_TURN_ENVELOPE") == "1" or decision_handoff_socket is not None or context_dir: if native_goal: raise ValueError("TurnEnvelope capture requires a host-owned heartbeat Turn") @@ -510,9 +508,7 @@ def run_once(env: dict[str, str]) -> dict: checkpoint_wake = checkpoint_context.enter_context(CheckpointWake(env, receipt)) else: checkpoint_wake = _InertCheckpointWake() - if execution.context_delivery and checkpoint_wake.socket_path is not None: - receipt["context_transport"] = {"available": False, "reason": "checkpoint_handoff_owns_capture"} - elif execution.context_delivery: + if execution.context_delivery: from benchmark.runtime.context_delivery import prepare_context_delivery prior_trace = env.get("CODEX_ROLLOUT_TRACE_ROOT") diff --git a/benchmark/tests/test_native_checkpoint.py b/benchmark/tests/test_native_checkpoint.py index c3e85b19e7..7b335e8d81 100644 --- a/benchmark/tests/test_native_checkpoint.py +++ b/benchmark/tests/test_native_checkpoint.py @@ -876,7 +876,8 @@ def forbidden_fork(*args, **kwargs): @pytest.mark.parametrize("turn_envelope", [None, False, True], ids=["legacy", "ordinary", "envelope"]) -def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monkeypatch, turn_envelope): +@pytest.mark.parametrize("context_option", [None, "codex_context_dir", "codex_context_root"]) +def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monkeypatch, turn_envelope, context_option): """A stopped command is eligible only at the declared pre-output boundary.""" import copy import shlex @@ -894,6 +895,9 @@ def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monk guard["turn_envelope"] = turn_envelope if turn_envelope is False: argv.remove("--turn-envelope") + if context_option: + guard[context_option] = "/fixture/context" + argv += ["--" + context_option.replace("_", "-"), guard[context_option]] command = shlex.join(["/bin/bash", "-lc", shlex.join(argv)]) item = {"type": "commandExecution", "id": "guard-call", "command": command, "cwd": str(tmp_path), "status": "failed", "exitCode": 137, "aggregatedOutput": None} @@ -925,6 +929,15 @@ def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monk with pytest.raises(ValueError, match="must be boolean"): _verify_interrupted_guard(turns, {**guard, "turn_envelope": invalid}) variants = [] + for option in ("--codex-context-dir", "--codex-context-root"): + altered = list(argv) + if option in altered: + altered[altered.index(option) + 1] = "/foreign/context" + else: + altered += [option, "/foreign/context"] + changed = copy.deepcopy(turns) + changed[-1]["items"][-1]["command"] = shlex.join(["/bin/bash", "-lc", shlex.join(altered)]) + variants.append(changed) for field, value in [("aggregatedOutput", "score already exposed"), ("cwd", "/elsewhere"), ("status", "completed"), ("command", shlex.join(["/bin/bash", "-lc", shlex.join(argv) + "; touch later"]))]: changed = copy.deepcopy(turns) diff --git a/benchmark/tests/test_native_checkpoint_handoff.py b/benchmark/tests/test_native_checkpoint_handoff.py index adfa2c5ace..a798613a1b 100644 --- a/benchmark/tests/test_native_checkpoint_handoff.py +++ b/benchmark/tests/test_native_checkpoint_handoff.py @@ -593,7 +593,8 @@ def settle_work(result, expected): @pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN") or os.name != "posix", reason="Requires the real native CLI and private Unix transport") @pytest.mark.parametrize("capture", ["files", "native", "full", "off", "controller"]) -def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay(tmp_path, capture): +@pytest.mark.parametrize("context_delivery", [False, True]) +def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay(tmp_path, capture, context_delivery): """Use run_once, its renderer/listener/session owner and the actual quota CLI.""" import sys import tomllib @@ -601,12 +602,13 @@ def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay from benchmark.runtime.worker import run_once from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TURN_ID, _write_fixture, _configure_autonomous_replan_fixture, - _heartbeat_receipt_count, _run_cli, + _heartbeat_receipt_count, _run_cli, _initialize_fixture_authority, ) if capture == "controller" and os.environ.get("LOOPX_CHECKPOINT_EXTERNAL_CONTROLLER") != "1": pytest.skip("Requires an explicitly attached host checkpoint supervisor") project, runtime, registry = _write_fixture(tmp_path / "authority") + _initialize_fixture_authority(project, runtime) logs = Path("/logs/agent/wakes") if capture == "controller" else tmp_path / "trial" / "wakes" candidate = project / "candidate.txt" candidate.write_text("original candidate") @@ -702,10 +704,15 @@ def response(payload, index, message): OPENAI_API_KEY="synthetic-local-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="60") deadline = str(time.time() + 600) env["LOOPX_PHASE_DEADLINE_EPOCH"] = deadline + if context_delivery: + env["LOOPX_CONTEXT_DELIVERY"] = "1" if capture != "off": env["LOOPX_CHECKPOINT_REQUEST"] = str(request) result = run_once(env) assert result["ok"] and not result["timed_out"], result + if context_delivery: + assert result["context_transport"] == {"available": True} + assert "--codex-context-dir" in guards[0] assert _heartbeat_receipt_count(runtime, TURN_ID) == 1 # Checkpoint preloading keeps the existing heartbeat contract without # injecting a Host countdown or runtime guidance. @@ -717,7 +724,7 @@ def response(payload, index, message): if capture == "off": assert "checkpoint_handoffs" not in result assert "--decision-handoff-socket" not in guards[0] - assert not list(logs.glob("*/decisions")) + assert bool(list(logs.glob("*/decisions"))) is context_delivery return assert len(result["checkpoint_handoffs"]) == 1 assert '--turn-envelope' not in shlex.split(guards[0]) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index c256ff51c0..11dfe9ac14 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -503,13 +503,17 @@ def _emit_quota_result( registry_path=registry_path, ) succeeded = bool(payload.get("ok")) - if args.format == "json" and succeeded and delivery_request is not None: + native_context = bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) + # The checkpoint handoff holds the original decision before any display. + # An explicit native consumer owns presentation after release: never nest + # the socket consumer's envelope or borrow its retention assertion. If the + # native proof is unavailable, retain the ordinary full projection. + if args.format == "json" and succeeded and delivery_request is not None and not native_context: payload = project_captured_context(payload, directory=capture_directory, request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet") - if (args.format == "json" and succeeded and capture_directory - and (getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None))): + if args.format == "json" and succeeded and capture_directory and native_context: import hashlib from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 083d5cf54a..821bffeef0 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -107,9 +107,9 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: handoff = getattr(args, "decision_handoff_socket", None) if (getattr(args, "codex_context_dir", None) is not None or getattr(args, "codex_context_root", None) is not None) and ( - args.format != "json" or handoff is not None or (requested is None and root is None)): + args.format != "json" or (requested is None and root is None)): raise QuotaCommandValidationError( - "native Codex context delivery needs JSON and a capture destination, without a second handoff socket") + "native Codex context delivery needs JSON and a capture destination") if handoff is not None: validate_handoff_socket(handoff) if requested is None and root is None: diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index f3d9d73c5e..4167b3be3b 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -37,9 +37,8 @@ def resolve_heartbeat_capture_root( """Validate capture transport for an already resolved host-owned Turn.""" if codex_context_dir is not None and codex_context_root is not None: raise ValueError("choose codex-context-dir or codex-context-root") - if (codex_context_dir is not None or codex_context_root is not None) and ( - root is None or decision_handoff_socket is not None): - raise ValueError("native context delivery requires a capture root without a second handoff socket") + if (codex_context_dir is not None or codex_context_root is not None) and root is None: + raise ValueError("native context delivery requires a capture root") if root is None: if decision_handoff_socket is not None: raise ValueError("decision handoff requires an explicit decision capture root") diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 76475ea0a2..8258d1a65f 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -116,7 +116,9 @@ def arguments(command): "goal_id": guard["goal_id"], "agent_id": guard["agent_id"], "turn_instance_id": guard["turn_instance_id"], "turn_envelope": turn_envelope, "decision_output_root": guard["capture_root"], - "decision_output_dir": None, "decision_handoff_socket": guard["socket_path"]} + "decision_output_dir": None, "decision_handoff_socket": guard["socket_path"], + "codex_context_dir": guard.get("codex_context_dir"), + "codex_context_root": guard.get("codex_context_root")} if any(args.get(key) != value for key, value in expected.items()): raise ValueError("native pending guard differs from the held packet routes or identity") # A second matching guard after a tool yield cannot stand in for the first diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index cc36207c9d..48e629c18b 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -21,6 +21,7 @@ from test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, _spend_run_count, _write_fixture from tests.control_plane.codex_checkpoint_fixture import local_inference_provider from tests.control_plane.test_codex_context_boundary import _app_server, _configure_provider, _input_text, _native_fixture +from test_heartbeat_envelope_transport import private_handoff # noqa: F401 -- real release barrier fixture pytestmark = pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="Explicit native Codex binary required; inference uses loopback only") @@ -68,7 +69,7 @@ def _delivered(request, digest): + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") for surface in ("quota_packet", "turn_envelope")] + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False): +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, handoff_socket=None): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -144,6 +145,8 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho "--decision-output-root", str(root), context_option, str(lifecycle)] if surface == "turn_envelope": command.append("--turn-envelope") + if handoff_socket: + command += ["--decision-handoff-socket", handoff_socket] generated_guard = None if shared_root: generated = subprocess.run([*cli, "--registry", str(registry), "--runtime-root", str(runtime), @@ -432,6 +435,13 @@ def observed_response(request, number, message): assert state.read_bytes() == original_state and _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("private_handoff", [True], indirect=True) +@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"]) +def test_native_capture_release_preserves_delta_and_recovery(tmp_path, monkeypatch, private_handoff, surface): # noqa: F811 -- pytest fixture injection + test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, + "code_mode", surface, False, True, True, handoff_socket=private_handoff) + + @pytest.mark.parametrize("host,surface,hook_loss", [ (host, surface, loss) for host in ("code_mode", "benchmark") for surface, loss in (("quota_packet", "missing"), ("turn_envelope", "killed")) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index 4559fef067..1bbfa157c5 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -119,7 +119,7 @@ def test_real_cli_host_rendezvous_sends_delta_and_keeps_same_capture_full(tmp_pa scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id="host-session", context_epoch="epoch-1") saved = [] - def invoke(host_request, *, opt_in=True): + def invoke(host_request, *, opt_in=True, native_context=False): with tempfile.TemporaryDirectory(prefix="context-host-", dir="/tmp") as short: endpoint = Path(short).resolve() / "host.sock" with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: @@ -150,6 +150,7 @@ def release(): "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "host-context-delivery", "--decision-output-root", str(root), "--decision-handoff-socket", str(endpoint), + *(["--codex-context-dir", str(tmp_path / "unavailable-native-state")] if native_context else []), *(["--turn-envelope"] if surface == "turn_envelope" else []), cwd=project) path, raw = future.result(timeout=30) assert code == 0, wire @@ -171,6 +172,12 @@ def release(): assert warm["recovery"]["path"] == str(second / "projection.json") assert warm["target"]["capture_id"] == hashlib.sha256((second / "decision.json").read_bytes()).hexdigest() assert warm["recovery"]["sha256"] == hashlib.sha256((second / "projection.json").read_bytes()).hexdigest() + # Two consumers cannot compose nested envelopes or transfer retention + # authority. The explicitly selected native consumer has no proof here. + no_native_proof, _ = invoke({"scope": scope, "baseline": cold, + "retained": {"complete": True, "target": cold["target"]}}, native_context=True) + assert "target" not in no_native_proof and "context" not in no_native_proof + assert no_native_proof["ok"] and no_native_proof.get("schema_version") != "context_delivery_v1" before_receipts = _heartbeat_receipt_count(runtime, "host-context-delivery") # Compaction between preparation and dispatch: the Host can select the exact # full projection without a second CLI call, admission or state mutation. diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index e4fc0f15d9..670eaa69fd 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -214,8 +214,9 @@ def release(): @pytest.mark.parametrize('provider', ['file', 'sqlite']) @pytest.mark.parametrize('enabled', [False, True]) @pytest.mark.parametrize('private_handoff', [False, True], indirect=True) +@pytest.mark.parametrize('context_delivery', [False, True]) def test_real_worker_generated_guard_selection_reentry_and_settle_once( - tmp_path, monkeypatch, provider, enabled, private_handoff, + tmp_path, monkeypatch, provider, enabled, private_handoff, context_delivery, ): from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -237,6 +238,10 @@ def test_real_worker_generated_guard_selection_reentry_and_settle_once( LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_PROJECT=str(project), LOOPX_GOAL_ID=GOAL_ID, LOOPX_AGENT_ID=AGENT_ID, LOOPX_WAKE_LOG_DIR=str(logs), LOOPX_TURN_ENVELOPE='1' if enabled else '0') + if context_delivery: + # No native proof in this CLI fixture: composed capture must still + # release ordinary full output and preserve reentry and settlement. + env.update(LOOPX_CONTEXT_DELIVERY='1', LOOPX_CODEX_CONTEXT_DIR=str(tmp_path / 'context')) body = heartbeat_body(env, TURN_ID, native_goal=False, decision_handoff_socket=private_handoff) # Execute the renderer's actual guard, preserving its route and flags. block = body.split('```sh\n', 1)[1].split('```', 1)[0] @@ -247,7 +252,7 @@ def test_real_worker_generated_guard_selection_reentry_and_settle_once( first = json.loads(result.stdout) assert result.returncode == 0, first root = logs / TURN_ID / 'decisions' - captured = enabled or private_handoff is not None + captured = enabled or private_handoff is not None or context_delivery if captured: captures = list(root.glob('*/decision.json')) assert len(captures) == 1 @@ -364,5 +369,5 @@ def test_native_context_prompt_keeps_capture_on_reentry(tmp_path): assert enabled["task_body"].replace(suffix, "") == plain["task_body"] with pytest.raises(ValueError, match="capture root"): build_heartbeat_prompt(**(args | {"decision_output_root": None}), codex_context_dir=context) - with pytest.raises(ValueError, match="second handoff"): + with pytest.raises(ValueError, match="invalid decision handoff socket"): build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket="unused") From fc95366968c152470b15c29e871aaa0548ec2ef4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:06:06 +0800 Subject: [PATCH 22/29] fix: compose native context delivery after checkpoint handoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 23 +++-- benchmark/runtime/checkpoint_wake.py | 2 + benchmark/runtime/worker.py | 6 +- benchmark/tests/test_native_checkpoint.py | 9 +- .../tests/test_native_checkpoint_handoff.py | 34 ++++++- .../rfcs/agent-loop-effect-interpreter-v0.md | 13 +++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 9 ++ docs/heartbeat-automation-prompt.md | 4 + loopx/cli_commands/quota.py | 8 +- loopx/cli_commands/quota_capture.py | 4 +- loopx/control_plane/heartbeat/host.py | 5 +- loopx/hosts/codex_checkpoint.py | 4 +- .../test_codex_context_capture.py | 95 +++++++++++++++++-- .../test_context_delivery_cli.py | 31 ++++++ .../test_heartbeat_envelope_transport.py | 12 ++- .../test_quota_decision_capture.py | 12 ++- 16 files changed, 239 insertions(+), 32 deletions(-) diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 1920b24b3e..3050521549 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -89,12 +89,23 @@ See [heartbeat setup](../../docs/heartbeat-automation-prompt.md#opt-in-native-co for activation, diagnostics, readback and rollback. Generated setup and a native app-server test do not by themselves prove installed desktop adoption. -A simultaneous `fresh_replan` checkpoint listener retains its existing capture -ownership and reports this context treatment unavailable; it continues the -original checkpoint path. Choose separate newly pinned trials to compare the -features. Do not modify an active/frozen trial. Disable `context_delivery` for a -new trial to roll back; default runs install no hooks, trust or trace settings. -No scorer, budget, task semantics or launch authority changes with this option. +A simultaneous `fresh_replan` checkpoint listener retains ownership of the same +original capture. It validates and holds the original full decision before any +context projection, then releases its exact digest. Only afterward may the +native observer project full/delta for an uninterrupted tool. A refused/lost +release remains the original typed transport failure, without publishing an +optimized result or rerunning the guard. An interrupted guard continues with +the original full packet; no delta is required for checkpoint recovery. Native +cursor verification also binds the context-directory/root option. The existing +Darwin native-checkpoint refusal remains in force. + +The host-side checkpoint preflight still preloads full input; this composition +does not reduce that initial read or establish autonomous solver benefit. +Across distinct worker wakes, settle the prior Turn before selecting new work; +reading an old prompt from resumed history cannot supply the next wake's guard. +Do not modify an active/frozen trial. Disable `context_delivery` for a new trial +to roll back; default runs install no hooks, trust or trace settings. No scorer, +budget, task semantics or launch authority changes with this option. Checkpoint capture is independent of this display experiment. A `fresh_replan` declaration may establish a private capture/handoff socket while diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py index 966d8913f4..0b0f84936b 100644 --- a/benchmark/runtime/checkpoint_wake.py +++ b/benchmark/runtime/checkpoint_wake.py @@ -136,6 +136,8 @@ def _capture(self, held, deadline): "turn_envelope": self.receipt.get("turn_envelope", False), "capture_root": str(self.capture_root), "socket_path": self.socket_path, "decision_sha256": held["sha256"]} + if self.env.get("LOOPX_CODEX_CONTEXT_DIR"): + guard["codex_context_dir"] = self.env["LOOPX_CODEX_CONTEXT_DIR"] reader = readers.enter_context(native_checkpoint_reader(self.env, timeout=max(.1, window - time.time()))) native = native_session_checkpoint(self.env, self.receipt["session"]["session_id"], timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader, diff --git a/benchmark/runtime/worker.py b/benchmark/runtime/worker.py index d1f1ba9316..981818238f 100644 --- a/benchmark/runtime/worker.py +++ b/benchmark/runtime/worker.py @@ -93,8 +93,6 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") context_dir = env.get("LOOPX_CODEX_CONTEXT_DIR") if env.get("LOOPX_CONTEXT_DELIVERY") == "1" else None - if context_dir and decision_handoff_socket is not None: - raise ValueError("native context delivery and checkpoint handoff need separate trials") if env.get("LOOPX_TURN_ENVELOPE") == "1" or decision_handoff_socket is not None or context_dir: if native_goal: raise ValueError("TurnEnvelope capture requires a host-owned heartbeat Turn") @@ -510,9 +508,7 @@ def run_once(env: dict[str, str]) -> dict: checkpoint_wake = checkpoint_context.enter_context(CheckpointWake(env, receipt)) else: checkpoint_wake = _InertCheckpointWake() - if execution.context_delivery and checkpoint_wake.socket_path is not None: - receipt["context_transport"] = {"available": False, "reason": "checkpoint_handoff_owns_capture"} - elif execution.context_delivery: + if execution.context_delivery: from benchmark.runtime.context_delivery import prepare_context_delivery prior_trace = env.get("CODEX_ROLLOUT_TRACE_ROOT") diff --git a/benchmark/tests/test_native_checkpoint.py b/benchmark/tests/test_native_checkpoint.py index c3e85b19e7..d6f3a719cf 100644 --- a/benchmark/tests/test_native_checkpoint.py +++ b/benchmark/tests/test_native_checkpoint.py @@ -876,7 +876,8 @@ def forbidden_fork(*args, **kwargs): @pytest.mark.parametrize("turn_envelope", [None, False, True], ids=["legacy", "ordinary", "envelope"]) -def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monkeypatch, turn_envelope): +@pytest.mark.parametrize("context_route", [None, "dir", "root"]) +def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monkeypatch, turn_envelope, context_route): """A stopped command is eligible only at the declared pre-output boundary.""" import copy import shlex @@ -894,6 +895,9 @@ def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monk guard["turn_envelope"] = turn_envelope if turn_envelope is False: argv.remove("--turn-envelope") + if context_route: + guard["codex_context_" + context_route] = "/fixture/context" + argv += ["--codex-context-" + context_route, "/fixture/context"] command = shlex.join(["/bin/bash", "-lc", shlex.join(argv)]) item = {"type": "commandExecution", "id": "guard-call", "command": command, "cwd": str(tmp_path), "status": "failed", "exitCode": 137, "aggregatedOutput": None} @@ -924,6 +928,9 @@ def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monk for invalid in [None, 0, 1, "false"]: with pytest.raises(ValueError, match="must be boolean"): _verify_interrupted_guard(turns, {**guard, "turn_envelope": invalid}) + for route in ("dir", "root"): + with pytest.raises(ValueError, match="routes or identity"): + _verify_interrupted_guard(turns, {**guard, "codex_context_" + route: "/another/context"}) variants = [] for field, value in [("aggregatedOutput", "score already exposed"), ("cwd", "/elsewhere"), ("status", "completed"), ("command", shlex.join(["/bin/bash", "-lc", shlex.join(argv) + "; touch later"]))]: diff --git a/benchmark/tests/test_native_checkpoint_handoff.py b/benchmark/tests/test_native_checkpoint_handoff.py index adfa2c5ace..fc8d9c380b 100644 --- a/benchmark/tests/test_native_checkpoint_handoff.py +++ b/benchmark/tests/test_native_checkpoint_handoff.py @@ -593,7 +593,7 @@ def settle_work(result, expected): @pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN") or os.name != "posix", reason="Requires the real native CLI and private Unix transport") @pytest.mark.parametrize("capture", ["files", "native", "full", "off", "controller"]) -def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay(tmp_path, capture): +def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay(tmp_path, capture, context_delivery=False): """Use run_once, its renderer/listener/session owner and the actual quota CLI.""" import sys import tomllib @@ -601,12 +601,13 @@ def test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay from benchmark.runtime.worker import run_once from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TURN_ID, _write_fixture, _configure_autonomous_replan_fixture, - _heartbeat_receipt_count, _run_cli, + _heartbeat_receipt_count, _run_cli, _initialize_fixture_authority, ) if capture == "controller" and os.environ.get("LOOPX_CHECKPOINT_EXTERNAL_CONTROLLER") != "1": pytest.skip("Requires an explicitly attached host checkpoint supervisor") project, runtime, registry = _write_fixture(tmp_path / "authority") + _initialize_fixture_authority(project, runtime) logs = Path("/logs/agent/wakes") if capture == "controller" else tmp_path / "trial" / "wakes" candidate = project / "candidate.txt" candidate.write_text("original candidate") @@ -654,6 +655,23 @@ def response(payload, index, message): index_path = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" prior_runs = index_path.read_text() if index_path.exists() else "" _configure_autonomous_replan_fixture(project, runtime, registry) + # This fixture's later scenario is canonical too. Do not reseed a + # promoted store or make Markdown authoritative after cutover. + from tests.control_plane.canonical_authority_fixture import read_fixture_todos + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, + todos=read_fixture_todos(project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md"), + handoff_mode="soft_claim") + provider = Path(__file__).resolve().parents[2] / "loopx/control_plane/coordination/local_authority_provider.ts" + script = (f"import {{openLocalAuthorityStore}} from {json.dumps(provider.as_uri())};" + "let raw='';for await(const part of process.stdin)raw+=part;const v=JSON.parse(raw);" + "const s=await openLocalAuthorityStore(v.root,v.goal);const h=await s.loadAuthority();" + "const r=await s.commitAuthority({expected_provider_revision:h.provider_revision," + "operation_id:'fixture-midwake-scenario',events:[],receipts:[],next_projection:v.projection});" + "if(r.status!=='applied')throw Error(JSON.stringify(r));") + subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + input=json.dumps({"root": str(runtime), "goal": GOAL_ID, "projection": projection}), + text=True, capture_output=True, check=True, timeout=30) # Preserve the actual preflight settlement evidence. The reusable # fixture replaces the index; its two later synthetic observations # must not erase the already settled original Host Turn. @@ -700,12 +718,16 @@ def response(payload, index, message): LOOPX_CODEX_HOME=str(tmp_path / "worker-home"), LOOPX_SHARED_SKILLS=str(skills), MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=base_url, OPENAI_API_KEY="synthetic-local-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="60") + if context_delivery: + env["LOOPX_CONTEXT_DELIVERY"] = "1" deadline = str(time.time() + 600) env["LOOPX_PHASE_DEADLINE_EPOCH"] = deadline if capture != "off": env["LOOPX_CHECKPOINT_REQUEST"] = str(request) result = run_once(env) assert result["ok"] and not result["timed_out"], result + if context_delivery: + assert result["context_transport"] == {"available": True} assert _heartbeat_receipt_count(runtime, TURN_ID) == 1 # Checkpoint preloading keeps the existing heartbeat contract without # injecting a Host countdown or runtime guidance. @@ -785,3 +807,11 @@ def response(payload, index, message): # The private endpoint belongs to this wake and is gone after readback. argv = shlex.split(guards[0]) assert not Path(argv[argv.index("--decision-handoff-socket") + 1]).exists() + + +@pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN") or os.name != "posix", + reason="Requires the real native CLI and private Unix transport") +@pytest.mark.parametrize("capture", ["files", "native"]) +def test_context_delivery_preserves_actual_midwake_checkpoint(tmp_path, capture): + test_actual_worker_handles_midwake_replan_and_duplicate_without_guard_replay( + tmp_path, capture, context_delivery=True) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index b210b1682f..db5cf5b81f 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1169,6 +1169,19 @@ installs nothing, changes no trust and grants no execution authority. See This qualifies generated configuration plus native consumption, not installed desktop adoption or a new cost measurement. +Native context delivery can also share the existing checkpoint handoff capture. +The checkpoint owner holds/verifies the original full decision and releases its +exact digest before native projection. Lost/refused release publishes no optimized +result; interruption retains original full continuation, without another guard. +Native interrupted-cursor verification binds the context route as well as the +existing identity/capture routes. The host-side preflight remains full, and the +Darwin native-checkpoint limitation is unchanged. Deterministic native tests +cover ordinary held release, warm reuse, lost-hook compaction recovery and +midwake capture/refusal. Resumed worker tests use the latest generated guard and +settle the independently checked prior readback before a new Turn; old history +cannot stand in for a new admission. This is transport composition, not a new +cost/outcome qualification or permission to modify frozen trials. + The opt-in benchmark worker carries a compact recovery instruction in native `developer_instructions`, independently of SessionStart/PreToolUse delivery. Native code-mode consumers can carry the same product-owned instruction in diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 633e2defb9..9427cfae90 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -806,6 +806,15 @@ journal,独立 trial home 保留 `--codex-context-dir`。只读查找不创建 [启用/读回/回滚](../../heartbeat-automation-prompt.md#opt-in-native-context-delivery)。 本段验证生成配置及原生消费,不等于真实桌面安装采用或新的成本测量。 +原生上下文交付也可复用既有 checkpoint handoff 的同一 capture。checkpoint owner +先持有并核验原始完整决策,按精确摘要释放后才执行原生投影。释放丢失或拒绝不发布 +优化结果;中断时保留原始全量 continuation,不再次 guard。原生中断 cursor 核验 +除既有身份/capture 路由外,也绑定 context 路由。宿主侧 preflight 仍使用全量, +Darwin 原生 checkpoint 限制保持不变。确定性原生测试覆盖普通持有释放、warm 复用、 +hook 丢失后的压缩恢复,以及唤醒中途捕获/拒绝。续跑 worker 测试读取最新生成的 +guard,在新 Turn 前通过正常入口结算独立核验的前次读回;旧历史不能替代新准入。 +这只验证传输组合,不构成新的成本/结果资格,也不授予修改冻结 trial 的权限。 + 显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, 独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index ed2e26ab2c..e1b556fdf8 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -462,6 +462,10 @@ names `quota context-read --codex-context-root ...` when an entire hook is lost; lookup is read-only and subsequent pages pin the original file/hash. It never readmits or spends. Normal native trust/settings UI remains the activation and error surface; unsupported diagnostics keep the same full path. +An existing decision-handoff socket may use this same capture. Handoff validates +and releases the immutable original first; native projection follows release. +It does not create a second checkpoint or change the handoff's failure/recovery +rules. If interrupted while held, consume the original full continuation packet. To roll back, remove the context/capture options from this bootstrap and remove only the generated hooks/recovery instruction from the native configuration. Existing captures remain private recovery evidence. This opt-in does not grant diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index c256ff51c0..a78586400c 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -503,13 +503,17 @@ def _emit_quota_result( registry_path=registry_path, ) succeeded = bool(payload.get("ok")) - if args.format == "json" and succeeded and delivery_request is not None: + # Handoff releases the immutable original before any presentation. Native + # actual-input observation owns its projection when explicitly selected; + # never wrap one context-delivery wire in a second projection. + native_context = bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) + if args.format == "json" and succeeded and delivery_request is not None and not native_context: payload = project_captured_context(payload, directory=capture_directory, request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet") if (args.format == "json" and succeeded and capture_directory - and (getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None))): + and native_context): import hashlib from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 083d5cf54a..821bffeef0 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -107,9 +107,9 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: handoff = getattr(args, "decision_handoff_socket", None) if (getattr(args, "codex_context_dir", None) is not None or getattr(args, "codex_context_root", None) is not None) and ( - args.format != "json" or handoff is not None or (requested is None and root is None)): + args.format != "json" or (requested is None and root is None)): raise QuotaCommandValidationError( - "native Codex context delivery needs JSON and a capture destination, without a second handoff socket") + "native Codex context delivery needs JSON and a capture destination") if handoff is not None: validate_handoff_socket(handoff) if requested is None and root is None: diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index f3d9d73c5e..4167b3be3b 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -37,9 +37,8 @@ def resolve_heartbeat_capture_root( """Validate capture transport for an already resolved host-owned Turn.""" if codex_context_dir is not None and codex_context_root is not None: raise ValueError("choose codex-context-dir or codex-context-root") - if (codex_context_dir is not None or codex_context_root is not None) and ( - root is None or decision_handoff_socket is not None): - raise ValueError("native context delivery requires a capture root without a second handoff socket") + if (codex_context_dir is not None or codex_context_root is not None) and root is None: + raise ValueError("native context delivery requires a capture root") if root is None: if decision_handoff_socket is not None: raise ValueError("decision handoff requires an explicit decision capture root") diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 76475ea0a2..8258d1a65f 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -116,7 +116,9 @@ def arguments(command): "goal_id": guard["goal_id"], "agent_id": guard["agent_id"], "turn_instance_id": guard["turn_instance_id"], "turn_envelope": turn_envelope, "decision_output_root": guard["capture_root"], - "decision_output_dir": None, "decision_handoff_socket": guard["socket_path"]} + "decision_output_dir": None, "decision_handoff_socket": guard["socket_path"], + "codex_context_dir": guard.get("codex_context_dir"), + "codex_context_root": guard.get("codex_context_root")} if any(args.get(key) != value for key, value in expected.items()): raise ValueError("native pending guard differs from the held packet routes or identity") # A second matching guard after a tool yield cannot stand in for the first diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index cc36207c9d..422143af71 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -68,7 +68,7 @@ def _delivered(request, digest): + [(host, surface, False, True, False) for host in ("benchmark", "code_mode") for surface in ("quota_packet", "turn_envelope")] + [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")]) -def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False): +def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, checkpoint_handoff=False): isolate_sqlite_runtime(tmp_path, monkeypatch) binary, env, workspace, home = _native_fixture(tmp_path) env["CODEX_ROLLOUT_TRACE_ROOT"] = str(workspace / "traces") @@ -159,8 +159,13 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho generated_guard += " --todo-id " + shlex.quote(TODO_ID) env["LOOPX_TURN"] = "native-capture-turn" + checkpoint_wakes, held_captures = [], [] + def invoke(name): - return (generated_guard or shlex.join(command)) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" + argv = list(command) + if checkpoint_handoff: + assert argv[argv.index("--decision-handoff-socket") + 1] == checkpoint_wakes[-1].socket_path + return (generated_guard or shlex.join(argv)) + " > " + shlex.quote(str(wire_paths[name])) + " && printf WIRE_READY" def pages(name, path): raw = path.read_bytes() @@ -214,12 +219,12 @@ def response(request, number, message): if host == "benchmark": # Read the actual worker-rendered guard, including its original # wake/capture identity. No substitute dispatcher or guard. - line = next(line for line in _input_text(request).splitlines() - if " quota should-run " in line and "--codex-context-dir" in line) + line = [line for line in _input_text(request).splitlines() + if " quota should-run " in line and "--codex-context-dir" in line][-1] native_guard = shlex.split(line) assert native_guard[0] == env["LOOPX_CLI"] - assignment = next(line for line in _input_text(request).splitlines() - if line.startswith("LOOPX_TURN=")) + assignment = [line for line in _input_text(request).splitlines() + if line.startswith("LOOPX_TURN=")][-1] turn_id = shlex.split(assignment)[0].split("=", 1)[1] command = [turn_id if arg == "${LOOPX_TURN:?}" else arg for arg in native_guard] command += ["--todo-id", TODO_ID] @@ -244,6 +249,8 @@ def response(request, number, message): stages.update(baseline_confirmed=True, phase="warm_ready") if resumed: stages["phase"] = "resuming" + stages["cold_readback_sha256"] = hashlib.sha256(delivered(request, "cold").encode()).hexdigest() + message["content"][0]["text"] = stages["cold_readback_sha256"] return message cmd = invoke("warm") elif phase == "warm_read": @@ -388,17 +395,74 @@ def observed_response(request, number, message): LOOPX_CLI=str(wrapper if hook_loss else Path(sys.executable).with_name("loopx")), CODEX_BIN=binary, MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=url, OPENAI_API_KEY="synthetic-loopback-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="90") + if checkpoint_handoff: + from contextlib import contextmanager + from benchmark.runtime import checkpoint_wake + original_enter = checkpoint_wake.CheckpointWake.__enter__ + original_hold = checkpoint_wake.held_quota_decision + + def enter(wake): + value = original_enter(wake) + assert value.listener is not None and value.socket_path + checkpoint_wakes.append(value) + return value + + @contextmanager + def held(*args, **kwargs): + with original_hold(*args, **kwargs) as capture: + # The checkpoint owner sees the original admitted + # decision before projection; it releases those bytes. + raw = capture["path"].read_bytes() + assert hashlib.sha256(raw).hexdigest() == capture["sha256"] + assert json.loads(raw)["ok"] is True + assert json.loads(raw).get("schema_version") != "context_delivery_v1" + held_captures.append((capture["path"], raw)) + yield capture + assert capture["path"].read_bytes() == raw + + monkeypatch.setattr(checkpoint_wake.CheckpointWake, "__enter__", enter) + monkeypatch.setattr(checkpoint_wake, "held_quota_decision", held) + declaration = workspace / "checkpoint-request.json" + declaration.write_text(json.dumps({"trigger": "fresh_replan", + "destination": str(workspace / "checkpoints"), + "components": [{"id": "task", "source_path": str(state)}], "point_metadata": {}})) + env["LOOPX_CHECKPOINT_REQUEST"] = str(declaration) result = run_once(env) assert result["ok"] and result["host_invoked"], result assert result["context_transport"] == {"available": True} assert result["session"]["session_id"] if resumed: session_id = result["session"]["session_id"] + # A later worker wake owns a new Turn. Settle the independently + # checked first readback through the real CLI, rather than + # reusing a stale guard/Turn from resumed history to obtain an + # artificially eligible warm packet. + from test_quota_settlement_cli import _run_cli + expected = hashlib.sha256(read_paths["cold"].read_text().rstrip("\n").encode()).hexdigest() + assert stages["cold_readback_sha256"] == expected + artifact = project / "native-context-readback.txt" + artifact.write_text(stages["cold_readback_sha256"]) + assert artifact.read_text() == expected + cold_turn = json.loads(snapshots[0][1])["heartbeat_receipt"]["turn_instance_id"] + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", cold_turn) + rc, written = _run_cli(registry, runtime, "refresh-state", *binding, + "--classification", "native_context_readback_verified", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--no-global-sync", "--suppress-external-sinks", cwd=project) + assert rc == 0, written + rc, spent = _run_cli(registry, runtime, "quota", "spend-slot", *binding, + "--slots", "1", "--source", "heartbeat", "--execute", cwd=project) + assert rc == 0 and spent["appended"], spent result = run_once(env) assert result["ok"] and result["host_invoked"], result assert result["context_transport"] == {"available": True} assert result["session"]["session_id"] == session_id assert not unrelated.exists() # only exact trial-owned hooks received trust + if checkpoint_handoff: + assert len(held_captures) == 2 + assert all(path.read_bytes() == raw for path, raw in held_captures) + assert result["checkpoint"]["captured"] is False # no replan was requested else: session_id = _app_server(binary, env, workspace, home, True, sandbox="workspace-write") if resumed: @@ -408,7 +472,9 @@ def observed_response(request, number, message): assert stages["phase"] == "done" assert len(snapshots) == 2 # compaction/readback never invokes a third guard - assert len(list(root.glob("*/decision.json"))) == 2 + captured = (list((workspace / "wakes").glob("*/decisions/*/decision.json")) + if host == "benchmark" else list(root.glob("*/decision.json"))) + assert len(captured) == 2 assert wire_paths["warm"].stat().st_size < wire_paths["cold"].stat().st_size assert len(calls) == len(observed) and stages["baseline_confirmed"] if host == "benchmark": @@ -429,7 +495,14 @@ def observed_response(request, number, message): if source["source"] == "selected_todo") assert selected["todo_id"] == TODO_ID and selected["text"] == body assert all(path.read_bytes() == raw for path, raw in snapshots) - assert state.read_bytes() == original_state and _spend_run_count(runtime) == 0 + if host == "benchmark" and resumed: + assert _spend_run_count(runtime) == 1 + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + retained = next(todo for todo in read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id=GOAL_ID)["todos"] if todo["todo_id"] == TODO_ID) + assert retained["text"] == body and retained["done"] is False + else: + assert state.read_bytes() == original_state and _spend_run_count(runtime) == 0 @pytest.mark.parametrize("host,surface,hook_loss", [ @@ -445,3 +518,9 @@ def test_native_missing_hook_recovers_from_persistent_cli_route(tmp_path, monkey def test_native_shared_configuration_resume_and_lost_hook(tmp_path, monkeypatch, surface): test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, "code_mode", surface, False, True, True, "missing", shared_root=True) + + +@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"]) +def test_native_benchmark_context_keeps_checkpoint_handoff(tmp_path, monkeypatch, surface): + test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, + "benchmark", surface, False, True, True, "missing", checkpoint_handoff=True) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index 4559fef067..093d7d152a 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -237,3 +237,34 @@ def test_native_context_cli_without_retention_stays_full_and_preserves_receipt(t assert json.loads(Path(wire["recovery"]["path"]).read_text()) == wire["context"] assert _heartbeat_receipt_count(runtime, "native-full") == 1 assert _spend_run_count(runtime) == 0 and state.read_bytes() == original + + +@pytest.mark.parametrize("native_failure", [False, True]) +def test_native_projection_never_layers_host_projection(tmp_path, monkeypatch, native_failure): + from loopx.cli import build_parser + from loopx.cli_commands import quota + from loopx.control_plane.turn_driver import codex_context + + args = build_parser().parse_args(["--format", "json", "quota", "should-run", + "--goal-id", "fixture", "--agent-id", "worker", "--codex-context-dir", str(tmp_path)]) + original = {"ok": True, "goal_id": "fixture", "agent_identity": {"agent_id": "worker"}} + monkeypatch.setattr(quota, "handoff_decision", lambda *args: ({"scope": "host-request"}, "digest")) + monkeypatch.setattr(quota, "_project_quota_cli_payload", lambda payload, *args, **kwargs: payload) + + def competing(*args, **kwargs): + pytest.fail("explicit native observation must not compose two projection owners") + + def native(payload, **kwargs): + assert payload == original + if native_failure: + raise OSError("native observation unavailable") + return {"native_projection": payload} + + monkeypatch.setattr(quota, "project_captured_context", competing) + monkeypatch.setattr(codex_context, "project_codex_context", native) + printed = [] + assert quota._emit_quota_result(original, args, context=None, registry_path=tmp_path / "absent.json", + heartbeat_turn_id=None, usage_quota_started=0, capture_directory=tmp_path, + detail_sections=frozenset(), print_payload=lambda value, *args: printed.append(value)) == 0 + assert printed == [original if native_failure else {"native_projection": original}] + assert json.loads((tmp_path / "decision.json").read_text()) == original diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index e4fc0f15d9..41e03e3a34 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -364,5 +364,15 @@ def test_native_context_prompt_keeps_capture_on_reentry(tmp_path): assert enabled["task_body"].replace(suffix, "") == plain["task_body"] with pytest.raises(ValueError, match="capture root"): build_heartbeat_prompt(**(args | {"decision_output_root": None}), codex_context_dir=context) - with pytest.raises(ValueError, match="second handoff"): + with pytest.raises(ValueError, match="invalid decision handoff socket"): build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket="unused") + with tempfile.TemporaryDirectory(prefix="context-handoff-", dir="/tmp") as short: + endpoint = str(Path(short).resolve() / "host.sock") + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: + listener.bind(endpoint) + packet = build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket=endpoint) + for field in ("quota_guard_command", "thin_prompt_command"): + argv = shlex.split(packet[field]) + assert argv[argv.index("--decision-output-root") + 1] == str(tmp_path) + assert argv[argv.index("--decision-handoff-socket") + 1] == endpoint + assert argv[argv.index("--codex-context-dir") + 1] == context diff --git a/tests/control_plane/test_quota_decision_capture.py b/tests/control_plane/test_quota_decision_capture.py index 20e732d021..39ca5bfd9e 100644 --- a/tests/control_plane/test_quota_decision_capture.py +++ b/tests/control_plane/test_quota_decision_capture.py @@ -311,7 +311,8 @@ def test_capture_root_allocates_private_distinct_children(tmp_path): @pytest.mark.skipif(os.name != 'posix', reason='Unix local Host transport') @pytest.mark.parametrize('release', ['exact', 'wrong_digest', 'disconnect', 'changed_capture']) -def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): +@pytest.mark.parametrize('native_context', [False, True]) +def test_real_cli_handoff_preserves_original_receipt(tmp_path, release, native_context): import hashlib import socket import sys @@ -322,6 +323,9 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): state = _configure_read_only_todo(project) initialize_canonical_source_fixture(runtime, GOAL_ID, state_path=state) capture = tmp_path / 'handoff-capture' + context = tmp_path / 'native-context' + if native_context: + context.mkdir(mode=0o700) with tempfile.TemporaryDirectory(prefix='quota-host-', dir='/tmp') as short: endpoint = Path(short).resolve() / 'host.sock' with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: @@ -335,6 +339,8 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): '--runtime-profile', 'generic_cli', '--available-capability', 'shell', '--available-capability', 'filesystem_read', '--decision-output-dir', str(capture), '--decision-handoff-socket', str(endpoint)] + if native_context: + command += ['--codex-context-dir', str(context)] with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env={**os.environ, 'PYTHONPATH': str(Path(__file__).resolve().parents[2])}) as process: try: @@ -352,6 +358,8 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): # successful output while the Host is taking its snapshot. import select assert not select.select([process.stdout], [], [], 0)[0] + if native_context: + assert list(context.iterdir()) == [] # no projection before release if release == 'changed_capture': (capture / 'decision.json').write_bytes(original + b'\n') if release != 'disconnect': @@ -364,6 +372,8 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release): else: assert process.returncode != 0 assert 'Do not blindly rerun' in stderr + stdout + if native_context: + assert list(context.iterdir()) == [] assert _heartbeat_receipt_count(runtime, TURN_ID) == receipts assert json.loads(original)['heartbeat_receipt']['turn_instance_id'] == TURN_ID finally: From abf599769a874a589e822d7ad05d63791e02dc4d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:46:21 +0800 Subject: [PATCH 23/29] fix: project saved preflight through native context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/RUNTIME.md | 14 +- benchmark/runtime/checkpoint.py | 10 +- benchmark/runtime/context_delivery.py | 25 ++ .../rfcs/agent-loop-effect-interpreter-v0.md | 18 +- .../agent-loop-effect-interpreter-v0.zh-CN.md | 11 +- docs/heartbeat-automation-prompt.md | 22 ++ loopx/cli_commands/quota.py | 6 +- loopx/cli_commands/quota_capture.py | 109 ++++++-- loopx/cli_commands/quota_registration.py | 7 +- .../test_saved_context_project.py | 243 ++++++++++++++++++ 10 files changed, 436 insertions(+), 29 deletions(-) create mode 100644 tests/control_plane/test_saved_context_project.py diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 3050521549..ec5109da60 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -99,8 +99,18 @@ the original full packet; no delta is required for checkpoint recovery. Native cursor verification also binds the context-directory/root option. The existing Darwin native-checkpoint refusal remains in force. -The host-side checkpoint preflight still preloads full input; this composition -does not reduce that initial read or establish autonomous solver benefit. +The host-side checkpoint preflight still captures the complete original decision. +With context delivery enabled, its prompt now supplies `quota context-project` +with that original path/hash, Goal/Agent/Turn and private cache location. The +native tool presents the saved decision using the same current-input observer; +no guard, checkpoint, admission or debit is repeated. Cold delivery supplies the +full body and its target; qualified warm delivery supplies the exact delta. +The small response points to bounded reads and an original full-read fallback. +No native setup, invalid cache or uncertain retention keeps full input. A bad +source hash/scope is refused before publication. This does not renew decision +freshness or establish autonomous solver benefit. Default-off preloading is +unchanged. Include the projection command, descriptor, recovery and page reads +in subsequent total-cost comparisons. Across distinct worker wakes, settle the prior Turn before selecting new work; reading an old prompt from resumed history cannot supply the next wake's guard. Do not modify an active/frozen trial. Disable `context_delivery` for a new trial diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py index 4fa3a93299..2cdef2a08d 100644 --- a/benchmark/runtime/checkpoint.py +++ b/benchmark/runtime/checkpoint.py @@ -153,8 +153,14 @@ def capture_before_replan(env: dict[str, str], receipt: dict[str, Any], body: st preflight_seen = guard_path.exists() guard_path.write_text(guard_run.stdout, encoding="utf-8") guard_path.chmod(0o600) - preloaded_body = (f"Host checkpoint preflight has already executed this wake's original quota guard once. " - f"Read the complete packet at {guard_path} before work, then follow its interaction_contract. " + reading = f"Read the complete packet at {guard_path} before work, then follow its interaction_contract. " + if env.get("LOOPX_CONTEXT_DELIVERY") == "1" and env.get("LOOPX_CODEX_CONTEXT_DIR"): + from .context_delivery import preflight_context_instruction + + reading = preflight_context_instruction(env, guard_path, + hashlib.sha256(guard_path.read_bytes()).hexdigest(), receipt["turn_id"]) + preloaded_body = ("Host checkpoint preflight has already executed this wake's original quota guard once. " + + reading + "Do not repeat the initial guard below; use its selection or recovery command when required.\n\n" + body) receipt["checkpoint"] = {"captured": False, "research_point_qualified": False, "phase": "before_replan"} try: diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index f129449b9e..ebbf75d7af 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -8,10 +8,35 @@ import json import os import stat +import shlex from pathlib import Path import tempfile +def preflight_context_instruction(env: dict[str, str], path: Path, digest: str, turn_id: str) -> str: + """Delay presentation of the original preflight until its native tool call. + + No model input exists at host preflight time. Keep the original source/hash + and let the product's current-input observer choose full/delta later. + """ + from loopx.command_invocation import loopx_cli_argv + + prefix = [*loopx_cli_argv(env["LOOPX_CLI"]), "--format", "json", "quota"] + source = ["--capture-file", str(path), "--capture-sha256", digest] + project = [*prefix, "context-project", *source, + "--goal-id", env["LOOPX_GOAL_ID"], "--agent-id", env["LOOPX_AGENT_ID"], + "--turn-instance-id", turn_id, "--codex-context-dir", env["LOOPX_CODEX_CONTEXT_DIR"], + "--decision-output-root", str(path.parent / "decisions")] + full = [*prefix, "context-read", *source] + return ("Present that same immutable packet with:\n```sh\n" + shlex.join(project) + + "\n```\nFollow the returned command and all next_command pages; reconstruct any delta against " + "its exact retained baseline before following the original interaction_contract. " + "If projection is unavailable, the baseline is absent/uncertain, or a response is incomplete, " + "follow its full recovery reference. If the projection command itself fails, read the original " + "complete packet with:\n```sh\n" + shlex.join(full) + "\n```\nFollow every next_command. " + "Neither presentation nor recovery renews this decision's authority or freshness. ") + + def prepare_context_delivery(env: dict[str, str], home: Path) -> None: from loopx.control_plane.turn_driver.codex_context import codex_context_configuration from loopx.control_plane.turn_driver.codex_stdio import StdioNativeGoalTransport diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index db5cf5b81f..6d546e2760 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1174,14 +1174,28 @@ The checkpoint owner holds/verifies the original full decision and releases its exact digest before native projection. Lost/refused release publishes no optimized result; interruption retains original full continuation, without another guard. Native interrupted-cursor verification binds the context route as well as the -existing identity/capture routes. The host-side preflight remains full, and the -Darwin native-checkpoint limitation is unchanged. Deterministic native tests +existing identity/capture routes. The host-side preflight retains the complete +original capture; the Darwin native-checkpoint limitation is unchanged. Deterministic native tests cover ordinary held release, warm reuse, lost-hook compaction recovery and midwake capture/refusal. Resumed worker tests use the latest generated guard and settle the independently checked prior readback before a new Turn; old history cannot stand in for a new admission. This is transport composition, not a new cost/outcome qualification or permission to modify frozen trials. +Saved preflight presentation now uses public `quota context-project`: the host +pins the original path/hash and Goal/Agent/Turn, then the native tool invokes the +same projector/actual-input observer without another guard or debit. A compact +descriptor supplies full/delta read commands, the available target and an +original full fallback. Cold complete context remains verifiable; uncertain +native state or optional cache failure selects full. Source hash/scope failure +refuses before publication. Bounded physical-file/hash IO is shared with the +existing reader. Native tests prove complete warm reconstruction through the +real checkpoint worker's generated saved-read entry across resumed wakes, +without guard replay. Existing +decision freshness, checkpoint ordering and default-off full preloading remain; +total-cost/solver-outcome qualification must include the added descriptor and +tool reads, not only delta bytes. + The opt-in benchmark worker carries a compact recovery instruction in native `developer_instructions`, independently of SessionStart/PreToolUse delivery. Native code-mode consumers can carry the same product-owned instruction in diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 9427cfae90..7bbcf559a9 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -809,12 +809,21 @@ journal,独立 trial home 保留 `--codex-context-dir`。只读查找不创建 原生上下文交付也可复用既有 checkpoint handoff 的同一 capture。checkpoint owner 先持有并核验原始完整决策,按精确摘要释放后才执行原生投影。释放丢失或拒绝不发布 优化结果;中断时保留原始全量 continuation,不再次 guard。原生中断 cursor 核验 -除既有身份/capture 路由外,也绑定 context 路由。宿主侧 preflight 仍使用全量, +除既有身份/capture 路由外,也绑定 context 路由。宿主侧 preflight 保留完整原始捕获, Darwin 原生 checkpoint 限制保持不变。确定性原生测试覆盖普通持有释放、warm 复用、 hook 丢失后的压缩恢复,以及唤醒中途捕获/拒绝。续跑 worker 测试读取最新生成的 guard,在新 Turn 前通过正常入口结算独立核验的前次读回;旧历史不能替代新准入。 这只验证传输组合,不构成新的成本/结果资格,也不授予修改冻结 trial 的权限。 +已保存 preflight 的交付现走公开 `quota context-project`:宿主固定原始路径/摘要和 +Goal/Agent/Turn,原生工具调用同一 projector/实际输入 observer,不再次 guard 或扣额。 +简短描述返回 full/delta 读取命令、可用的 target 和原始全量回退。冷启动全文仍可核验; +原生状态不确定或可选 cache 失败选择全量,来源摘要/作用域失败则在发布前拒绝。 +物理文件和摘要的有界 IO 与既有 reader 复用。原生测试通过真实 checkpoint worker +跨唤醒恢复后生成的 saved-read 入口验证 warm 完整重建及零 guard 重放。原决策新鲜度、checkpoint +顺序和默认关闭时的全量预载保持;完整成本/solver 结果资格须计入新增描述及工具读取, +不能只计算 delta 字节。 + 显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, 独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index e1b556fdf8..eb80b8c407 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -466,6 +466,28 @@ An existing decision-handoff socket may use this same capture. Handoff validates and releases the immutable original first; native projection follows release. It does not create a second checkpoint or change the handoff's failure/recovery rules. If interrupted while held, consume the original full continuation packet. + +For a host that already saved the quota decision before invoking Codex, present +that immutable snapshot from the native tool instead of running its guard again: + +```sh +loopx --format json quota context-project \ + --capture-file --capture-sha256 \ + --goal-id --agent-id --turn-instance-id \ + --codex-context-root --decision-output-root +``` + +The JSON response supplies `mode`, the digest-pinned read `command`, the target +when available, and an original full `recovery` reference. Cold mode delivers +complete context plus its target; a warm delta still requires actual current-input +proof. Follow every page and reconstruct before dependent work. Invalid optional +transport/cache falls back to that same full capture; invalid source hash or +Goal/Agent/Turn refuses publication. This is integrity-checked presentation of +the caller's snapshot, not fresh authority, registry lookup, installation, +admission or accounting. The original file stays unchanged; generated caches +remain private. A missing projection command can use `quota context-read` with +the original file/hash. Disable the existing context opt-in to restore the host's +ordinary full-read instruction; no stored decision or receipt is rewritten. To roll back, remove the context/capture options from this bootstrap and remove only the generated hooks/recovery instruction from the native configuration. Existing captures remain private recovery evidence. This opt-in does not grant diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index a78586400c..9a44d9b51c 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -8,7 +8,7 @@ from .quota_capture import ( bind_capture_selection_transport, capture_decision, handoff_decision, prepare_decision_capture, project_captured_context, - read_context_capture, + read_context_capture, project_saved_context_capture, ) from ..usage_goal import observe_quota_result @@ -583,6 +583,10 @@ def handle_quota_command( payload = {} print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) return 0 + if args.quota_command == "context-project": + payload = project_saved_context_capture(args) + print_payload(payload, args.format, lambda value: str(value.get("command", value.get("reason", "")))) + return 0 if payload["ok"] else 1 if args.quota_command == "context-read": # Presentation recovery reads the original capture, never current Goal # state. In particular it must not evaluate a guard or append a receipt. diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 821bffeef0..a3f46634fc 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -36,6 +36,32 @@ def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, pa "--offset", str(offset), "--page-bytes", str(page_bytes)]) +def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict]: + """One bounded physical-file/hash check for recovery and saved projection.""" + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None or not value: + raise ValueError("context-read requires the original capture file and its exact SHA-256") + path = Path(value).expanduser().absolute() + # Parent aliases such as macOS /tmp are normal capture locations. + # Resolve those once, but never follow a substituted final file. + path = path.parent.resolve(strict=True) / path.name + if path.is_symlink(): + raise ValueError("context-read requires the physical original capture path, not a symlink") + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(descriptor, "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_REQUEST_BYTES: + raise ValueError("capture must be a regular JSON file within the existing transport size limit") + raw = stream.read(MAX_REQUEST_BYTES + 1) + if len(raw) > MAX_REQUEST_BYTES or hashlib.sha256(raw).hexdigest() != digest: + raise ValueError("capture digest mismatch; restore the original capture, do not rerun admission") + payload = json.loads(raw.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("capture must contain a complete JSON object") + return path, raw, payload + + def read_context_capture(args: argparse.Namespace) -> dict: """Bounded local IO over immutable JSON, not a new context/authority owner. @@ -43,8 +69,6 @@ def read_context_capture(args: argparse.Namespace) -> dict: in that exact capture, so a replacement cannot silently mix two decisions. EOF describes this page, never a model's complete-consumption receipt. """ - from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - try: digest = getattr(args, "capture_sha256", None) value = getattr(args, "capture_file", None) @@ -64,22 +88,7 @@ def read_context_capture(args: argparse.Namespace) -> dict: raise ValueError("context-read requires the original capture file and its exact SHA-256") if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= 8192: raise ValueError("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192") - path = Path(value).expanduser().absolute() - # Parent aliases such as macOS /tmp are normal capture locations. - # Resolve those once, but never follow a substituted final file. - path = path.parent.resolve(strict=True) / path.name - if path.is_symlink(): - raise ValueError("context-read requires the physical original capture path, not a symlink") - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) - with os.fdopen(descriptor, "rb") as stream: - metadata = os.fstat(stream.fileno()) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_REQUEST_BYTES: - raise ValueError("capture must be a regular JSON file within the existing transport size limit") - raw = stream.read(MAX_REQUEST_BYTES + 1) - if len(raw) > MAX_REQUEST_BYTES or hashlib.sha256(raw).hexdigest() != digest: - raise ValueError("capture digest mismatch; restore the original capture, do not rerun admission") - if not isinstance(json.loads(raw.decode("utf-8")), dict): - raise ValueError("capture must contain a complete JSON object") + path, raw, _ = _read_capture(value, digest) if offset > len(raw) or (offset < len(raw) and raw[offset] & 0xC0 == 0x80): raise ValueError("capture offset is outside the file or not a UTF-8 boundary; use the returned next offset") end = min(offset + size, len(raw)) @@ -101,6 +110,70 @@ def read_context_capture(args: argparse.Namespace) -> dict: "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} +def project_saved_context_capture(args: argparse.Namespace) -> dict: + """Present an immutable already-decided quota capture, without authority IO. + + Publication is delayed until this native tool runs, so the same actual-input + observer can qualify a resumed baseline. Any optional transport failure + returns the original full read reference, never a replacement guard. + """ + try: + path, raw, payload = _read_capture(args.capture_file, args.capture_sha256) + identity = payload.get("agent_identity") + receipt = payload.get("heartbeat_receipt") + if (payload.get("ok") is not True or not isinstance(identity, dict) or not isinstance(receipt, dict) + or not args.goal_id or not args.agent_id or not args.turn_instance_id + or payload.get("goal_id") != args.goal_id or identity.get("agent_id") != args.agent_id + or receipt.get("turn_instance_id") != args.turn_instance_id): + raise ValueError("context-project requires the captured quota decision's exact Goal, Agent and Turn") + if args.turn_envelope or args.decision_output_dir or args.decision_handoff_socket: + raise ValueError("context-project presents the saved quota view; it does not change view or handoff") + except (OSError, ValueError, TypeError, RecursionError) as exc: + return {"ok": False, "reason": str(exc) if isinstance(exc, ValueError) else "original capture could not be read", + "recovery": "Restore the original digest-bound decision; no guard, admission or spend was performed."} + + reference = {"path": str(path), "sha256": args.capture_sha256, + "command": context_capture_read_command(path, args.capture_sha256)} + full_reference = dict(reference) + mode = "full" + target = None + try: + from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context + from ..control_plane.turn_driver.decision_capture import write_decision_capture + + root = Path(args.decision_output_root).expanduser().absolute() + metadata = root.lstat() + if root.resolve(strict=True) != root or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or metadata.st_mode & 0o077: + raise ValueError("saved projection needs an existing private physical capture root") + directory = codex_context_directory(directory=args.codex_context_dir, + root=args.codex_context_root, create=True) + if not directory: + raise ValueError("native context is unavailable") + cache = Path(tempfile.mkdtemp(prefix="context-", dir=root)) + projected = project_codex_context(payload, directory=directory, capture_directory=cache, + goal_id=args.goal_id, agent_id=args.agent_id, surface="quota_packet", + capture_id=hashlib.sha256(raw).hexdigest()) + wire = cache / "delivery.json" + write_decision_capture(wire, projected) + digest = hashlib.sha256(wire.read_bytes()).hexdigest() + reference = {"path": str(wire), "sha256": digest, "command": context_capture_read_command(wire, digest)} + if projected.get("schema_version") == "context_delivery_v1": + mode = projected["mode"] + target = projected["target"] + if mode == "full": + # The native observer verifies paged complete context bytes. + # Deliver that exact full body plus its target in this small + # descriptor, rather than paging another wrapper around it. + reference = {key: projected["recovery"][key] for key in ("path", "sha256", "command")} + except (OSError, ValueError, TypeError, RecursionError): + pass # Optional presentation unavailable: the original full reference is already verified. + return {"ok": True, "mode": mode, **reference, **({"target": target} if target else {}), + "recovery": full_reference, + "capture_sha256": args.capture_sha256, + "instruction": "Presentation of the original decision only; authority and freshness are unchanged. " + + CONTEXT_CAPTURE_READ_GUIDANCE} + + def prepare_decision_capture(args: argparse.Namespace) -> Path | None: requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 7a23d6e363..bfde4ab7dc 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -28,6 +28,7 @@ def register_quota_command( "plan", "should-run", "context-read", + "context-project", "context-hook", "context-config", "monitor-poll", @@ -38,7 +39,7 @@ def register_quota_command( "void-slot", ], default="status", - help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, context-hook for opt-in native Codex lifecycle IO, context-config for native hook/recovery configuration without installation or trust changes, 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.", + help="Use status for all groups, plan for next-turn groups, should-run for one goal, context-read for bounded original-capture recovery without a guard, context-project to present an existing digest-bound quota capture with native full/delta and a full-read fallback, context-hook for opt-in native Codex lifecycle IO, context-config for native hook/recovery configuration without installation or trust changes, 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( "--goal-id", @@ -199,7 +200,7 @@ def register_quota_command( "Requires a capture destination and a same-user Unix socket in a private directory. " "Waits at most 120s for a digest-bound release; failure retains the original receipt."), ) - quota_parser.add_argument("--capture-file", help="For context-read: original captured JSON file; no guard is evaluated.") + quota_parser.add_argument("--capture-file", help="For context-read/context-project: original captured JSON file; no guard is evaluated.") context_location = quota_parser.add_mutually_exclusive_group() context_location.add_argument("--codex-context-dir", help="Opt-in private native Codex lifecycle directory. For context-hook, consume one native hook event on stdin. " @@ -210,7 +211,7 @@ def register_quota_command( help="Existing private native context root, with independent journals per native session. " "Use for shared Codex App hook configuration; cannot combine with --codex-context-dir. " "context-config renders native configuration for the exact Goal/Agent; no files, trust, guard or spend are changed.") - quota_parser.add_argument("--capture-sha256", help="For context-read: exact SHA-256 supplied with the capture reference.") + quota_parser.add_argument("--capture-sha256", help="For context-read/context-project: exact SHA-256 supplied with the capture reference.") quota_parser.add_argument("--offset", type=int, default=0, help="For context-read: byte offset returned by the previous page.") quota_parser.add_argument("--page-bytes", type=int, default=4096, help="For context-read: maximum UTF-8 content bytes per page, 256–8192 (default: 4096).") diff --git a/tests/control_plane/test_saved_context_project.py b/tests/control_plane/test_saved_context_project.py new file mode 100644 index 0000000000..73871cfe57 --- /dev/null +++ b/tests/control_plane/test_saved_context_project.py @@ -0,0 +1,243 @@ +"""Saved quota presentation never reevaluates the Goal or renews authority.""" +import hashlib +import json +import os +from pathlib import Path +import shlex +import sys + +import pytest + +from tests.control_plane.test_quota_settlement_cli import _run_cli + + +@pytest.mark.parametrize("failure", [None, "digest", "goal", "agent", "turn", "symlink", "object"]) +def test_saved_projection_checks_original_before_any_publication(tmp_path, failure): + original = {"ok": True, "goal_id": "saved-goal", "agent_identity": {"agent_id": "saved-agent"}, + "heartbeat_receipt": {"turn_instance_id": "saved-turn"}, "requirement": "保留完整要求🙂" * 100} + path = tmp_path / "original.json" + path.write_text(json.dumps(original, ensure_ascii=False)) + if failure == "object": + path.write_text("[]") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if failure == "symlink": + source = tmp_path / "source.json" + path.rename(source) + path.symlink_to(source) + args = ["quota", "context-project", "--capture-file", str(path), "--capture-sha256", digest, + "--goal-id", "saved-goal", "--agent-id", "saved-agent", "--turn-instance-id", "saved-turn"] + for name in ("digest", "goal", "agent", "turn"): + if failure == name: + option = {"digest": "--capture-sha256", "goal": "--goal-id", "agent": "--agent-id", "turn": "--turn-instance-id"}[name] + args[args.index(option) + 1] = "0" * 64 if name == "digest" else "foreign" + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + code, result = _run_cli(registry, runtime, *args, cwd=tmp_path) + assert not registry.exists() and not runtime.exists() + if failure: + assert code != 0 and not result["ok"] and "command" not in result + else: + assert code == 0 and result["ok"] and result["mode"] == "full" + assert result["path"] == str(path) and result["sha256"] == digest + assert result["recovery"]["path"] == str(path) + command = shlex.split(result["command"]) + code, page = _run_cli(registry, runtime, *command[3:], cwd=tmp_path) + assert code == 0 and page["content"].encode() == path.read_bytes()[:page["next_offset"]] + + +@pytest.mark.parametrize("root_kind", ["unsafe", "symlink", "missing"]) +def test_optional_cache_failure_returns_original_full_reference(tmp_path, root_kind): + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import project_saved_context_capture + + path, root, context = tmp_path / "original.json", tmp_path / "cache", tmp_path / "native" + raw = json.dumps({"ok": True, "goal_id": "saved-goal", "agent_identity": {"agent_id": "saved-agent"}, + "heartbeat_receipt": {"turn_instance_id": "saved-turn"}}).encode() + path.write_bytes(raw) + context.mkdir(mode=0o700) + if root_kind == "unsafe": + root.mkdir(mode=0o755) + root.chmod(0o755) + elif root_kind == "symlink": + root.symlink_to(context, target_is_directory=True) + digest = hashlib.sha256(raw).hexdigest() + args = build_parser().parse_args(["--format", "json", "quota", "context-project", + "--capture-file", str(path), "--capture-sha256", digest, "--goal-id", "saved-goal", + "--agent-id", "saved-agent", "--turn-instance-id", "saved-turn", + "--decision-output-root", str(root), "--codex-context-dir", str(context)]) + result = project_saved_context_capture(args) + assert result["ok"] and result["mode"] == "full" + assert result["path"] == str(path) and result["sha256"] == digest + assert path.read_bytes() == raw and list(context.iterdir()) == [] + + +def test_preflight_instruction_pins_both_projection_and_full_fallback(tmp_path): + from benchmark.runtime.context_delivery import preflight_context_instruction + + source = tmp_path / "saved packet.json" + env = {"LOOPX_CLI": "/fixture/bin/loopx", "LOOPX_GOAL_ID": "goal", + "LOOPX_AGENT_ID": "agent", "LOOPX_CODEX_CONTEXT_DIR": str(tmp_path / "private native")} + body = preflight_context_instruction(env, source, "a" * 64, "original-turn") + commands = [shlex.split(block.split("\n```", 1)[0]) for block in body.split("```sh\n")[1:]] + assert len(commands) == 2 + assert commands[0][6] == "context-project" and commands[1][6] == "context-read" + for argv in commands: + assert argv[:3] == [env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]] + assert argv[argv.index("--capture-file") + 1] == str(source) + assert argv[argv.index("--capture-sha256") + 1] == "a" * 64 + assert "should-run" not in argv and "spend-slot" not in argv + assert commands[0][commands[0].index("--turn-instance-id") + 1] == "original-turn" + + +@pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="Explicit native Codex; loopback inference only") +@pytest.mark.parametrize("host", ["app_server", "benchmark"]) +def test_native_saved_preflight_uses_current_input_without_guard_replay(tmp_path, monkeypatch, host): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from loopx.control_plane.quota.turn_envelope import prepare_context_delivery, restore_context_delivery + from tests.control_plane.codex_checkpoint_fixture import local_inference_provider + from tests.control_plane.test_codex_context_boundary import _app_server, _configure_provider, _native_fixture, _input_text + from tests.control_plane.test_codex_context_capture import _delivered + from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, _write_fixture, _spend_run_count, _heartbeat_receipt_count + + isolate_sqlite_runtime(tmp_path, monkeypatch) + binary, env, workspace, home = _native_fixture(tmp_path) + env.update(NODE_OPTIONS="--experimental-sqlite", LOOPX_USAGE_PING="0", CODEX_ROLLOUT_TRACE_ROOT=str(workspace / "traces")) + project, runtime, registry = _write_fixture(workspace) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todos = read_fixture_todos(state) + todos[0]["text"] = todos[0]["title"] = "Exact requirement with retained provenance.\n" * 240 + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider="sqlite") + sources = [] + for name in (("cold", "warm") if host == "app_server" else ()): + code, decision = _run_cli(registry, runtime, "quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "saved-native-turn", "--codex-app", cwd=project) + assert code == 0, decision + source = workspace / (name + "-source.json") + source.write_text(json.dumps(decision, ensure_ascii=False)) + sources.append((source, source.read_bytes())) + receipts = _heartbeat_receipt_count(runtime, "saved-native-turn") + context, cache = workspace / "context", workspace / "cache" + context.mkdir(mode=0o700) + cache.mkdir(mode=0o700) + program = os.environ.get("LOOPX_NATIVE_CONTEXT_CLI") or str(Path(sys.executable).with_name("loopx")) + cli = [program, "--cli-bin", program] + from loopx.control_plane.turn_driver.codex_context import codex_context_configuration + configuration = codex_context_configuration(str(context), goal_id=GOAL_ID, agent_id=AGENT_ID, cli_argv=cli) + if host == "app_server": + (home / "hooks.json").write_text(json.dumps({"hooks": configuration["hooks"]})) + phase, queue, observed, errors = "cold", [], {}, [] + actual_turn = None + + def response(request, number, message): + nonlocal phase, queue, actual_turn, receipts + try: + if queue: + command = queue.pop(0) + elif phase in ("cold", "warm"): + if host == "benchmark": + command = [line for line in _input_text(request).splitlines() if " quota context-project " in line][-1] + argv = shlex.split(command) + actual_turn = argv[argv.index("--turn-instance-id") + 1] + source = Path(argv[argv.index("--capture-file") + 1]) + sources.append((source, source.read_bytes())) + receipts = _heartbeat_receipt_count(runtime, actual_turn) + assert hashlib.sha256(source.read_bytes()).hexdigest() == argv[argv.index("--capture-sha256") + 1] + else: + source, raw = sources[0 if phase == "cold" else 1] + command = shlex.join([*cli, "--format", "json", "quota", "context-project", + "--capture-file", str(source), "--capture-sha256", hashlib.sha256(raw).hexdigest(), + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "saved-native-turn", + "--codex-context-dir", str(context), "--decision-output-root", str(cache)]) + phase += "_ready" + elif phase.endswith("_ready"): + name = phase.split("_")[0] + output = [item["output"] for item in request["input"] if item.get("type") == "function_call_output"][-1] + descriptor = json.loads(output.split("\nOutput:\n", 1)[-1]) + assert descriptor["ok"] and descriptor["mode"] == ("full" if name == "cold" else "delta") + wire = json.loads((Path(descriptor["path"]).parent / "presentation.json").read_text()) if name == "cold" else json.loads(Path(descriptor["path"]).read_text()) + assert descriptor["target"] == wire["target"] + observed[name] = (wire, descriptor) + data = Path(descriptor["path"]).read_bytes() + for offset in range(0, len(data), 4096): + # Synthetic captures are ASCII JSON; public reader owns every byte. + queue.append(shlex.join([*cli, "--format", "json", "quota", "context-read", + "--capture-file", descriptor["path"], "--capture-sha256", descriptor["sha256"], + "--offset", str(offset)])) + phase = name + "_read" + command = queue.pop(0) + else: + name = phase.split("_")[0] + descriptor = observed[name][1] + assert _delivered(request, descriptor["sha256"]) == Path(descriptor["path"]).read_text().rstrip("\n") + if name == "warm": + phase = "done" + return message + # Full wire context is observed exactly, then reuse in the next call. + phase = "warm" + if host == "benchmark": + return message # A new worker wake owns its own original guard. + return response(request, number, message) + return {"type": "function_call", "id": f"fc_{number}", "call_id": f"call_{number}", + "name": "exec_command", "arguments": json.dumps({"cmd": command, "max_output_tokens": 3000})} + except Exception as error: + errors.append(error) + return message + + with local_inference_provider(response) as (url, _, _): + if host == "benchmark": + from benchmark.runtime.worker import run_once + skills = workspace / "skills" + skills.mkdir() + declaration = workspace / "checkpoint-request.json" + declaration.write_text(json.dumps({"trigger": "fresh_replan", "destination": str(workspace / "checkpoints"), + "components": [{"id": "task", "source_path": str(state)}], "point_metadata": {}})) + env.update(LOOPX_CONTEXT_DELIVERY="1", LOOPX_EXECUTION_MODE="heartbeat", LOOPX_ITERATION_CONTEXT="resume", + LOOPX_TASK_ENTRY="seeded-todo", LOOPX_TASK_STAGE="execute", LOOPX_CHECKPOINT_REQUEST=str(declaration), + LOOPX_CODEX_HOME=str(home), LOOPX_PROJECT=str(project), LOOPX_SHARED_SKILLS=str(skills), + LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_GOAL_ID=GOAL_ID, LOOPX_AGENT_ID=AGENT_ID, + LOOPX_WAKE_LOG_DIR=str(workspace / "wakes"), LOOPX_CLI=program, CODEX_BIN=binary, + MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=url, OPENAI_API_KEY="synthetic-loopback-only", + LOOPX_CODEX_TURN_TIMEOUT_SEC="60") + result = run_once(env) + assert result["ok"] and result["context_transport"] == {"available": True}, result + session_id = result["session"]["session_id"] + assert phase == "warm" and sources[0][0].read_bytes() == sources[0][1] + # Settle this isolated, independently verified readback once before + # a new wake. Presentation itself still performs no debit or guard. + cold_turn = json.loads(sources[0][1])["heartbeat_receipt"]["turn_instance_id"] + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", cold_turn) + code, written = _run_cli(registry, runtime, "refresh-state", *binding, + "--classification", "saved_context_readback_verified", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--no-global-sync", "--suppress-external-sinks", cwd=project) + assert code == 0, written + code, spent = _run_cli(registry, runtime, "quota", "spend-slot", *binding, + "--slots", "1", "--source", "heartbeat", "--execute", cwd=project) + assert code == 0 and spent["appended"], spent + result = run_once(env) + assert result["ok"] and result["context_transport"] == {"available": True}, result + assert result["session"]["session_id"] == session_id + else: + _configure_provider(home, url) + with (home / "config.toml").open("a") as config: + config.write("[sandbox_workspace_write]\nnetwork_access=true\n") + _app_server(binary, env, workspace, home, True, sandbox="workspace-write") + assert not errors and phase == "done", errors + if host == "benchmark": + turns = [json.loads(raw)["heartbeat_receipt"]["turn_instance_id"] for _, raw in sources] + assert len(set(turns)) == 2 + assert all(_heartbeat_receipt_count(runtime, turn) == 1 for turn in turns) + assert _spend_run_count(runtime) == 1 # only the explicit inter-wake settlement above + else: + assert _heartbeat_receipt_count(runtime, "saved-native-turn") == receipts and _spend_run_count(runtime) == 0 + cold, warm = observed["cold"][0], observed["warm"][0] + assert cold["context"] == json.loads(sources[0][1]) + original = json.loads(sources[1][1]) + current = prepare_context_delivery(original, + scope={key: warm["target"][key] for key in ("goal_id", "agent_id", "session_id", "context_epoch")}, + capture_id=warm["target"]["capture_id"], surface="quota_packet") + assert restore_context_delivery(warm, baseline=cold, current=current)["context"] == original + assert all(path.read_bytes() == raw for path, raw in sources) From 08449d43cac5573f2c9ecdf02cf4856d88a38896 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:05:59 +0800 Subject: [PATCH 24/29] docs: qualify generated worker input costs and cold overhead Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 10 ++++++++++ .../rfcs/agent-loop-effect-interpreter-v0.zh-CN.md | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 6d546e2760..9587739fa8 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1196,6 +1196,16 @@ decision freshness, checkpoint ordering and default-off full preloading remain; total-cost/solver-outcome qualification must include the added descriptor and tool reads, not only delta bytes. +A matched deterministic native worker diagnostic now includes the generated +preflight, resumed history and every page, using the same synthetic requirement +and default function-tool read policy in both execution orders. Across two +two-wake trials per arm, serialized-input token estimates fall 35.83% and reader +calls fall from 72 to 48. Cold input increases about 11%; savings come from warm +reuse. Elapsed ranges overlap, so this establishes no latency improvement. +Both arms enable diagnostic capture; it does not qualify tracing cost against +uninstrumented execution, autonomous solver outcomes or exact billing. The +separate real-capture replay remains a different workload and read policy. + The opt-in benchmark worker carries a compact recovery instruction in native `developer_instructions`, independently of SessionStart/PreToolUse delivery. Native code-mode consumers can carry the same product-owned instruction in diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 7bbcf559a9..2517287aa8 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -824,6 +824,13 @@ Goal/Agent/Turn,原生工具调用同一 projector/实际输入 observer,不 顺序和默认关闭时的全量预载保持;完整成本/solver 结果资格须计入新增描述及工具读取, 不能只计算 delta 字节。 +新增匹配的确定性原生 worker 诊断,计入生成的 preflight、跨唤醒历史和全部分页; +两侧使用相同合成要求及默认 function-tool 读取策略,并覆盖正反执行顺序。每侧两组 +双唤醒中,序列化输入 token 估算下降 35.83%,reader 调用从 72 次降为 48 次。 +冷启动输入约增加 11%,收益来自 warm 复用;耗时区间重叠,未证明延迟改善。 +两侧均开启诊断捕获,不据此认定相对未采集诊断时的追踪成本、自主 solver 成果或精确 +计费。既有真实捕获回放是另一种负载及读取策略,分别保留。 + 显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, 独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 From 912d56036bd9e19736fdb2030f52c3ac0df56e68 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:45:47 +0800 Subject: [PATCH 25/29] refactor(context): keep capture IO in its Core owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 11 ++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 7 + loopx/cli_commands/quota.py | 178 +++++++---------- loopx/cli_commands/quota_capture.py | 143 +++++++------- loopx/control_plane/quota/context_delivery.ts | 17 +- loopx/control_plane/quota/turn_envelope.ts | 7 +- .../turn_driver/codex_context.py | 31 +-- .../turn_driver/codex_input_trace.py | 184 ++++++++++-------- .../turn_driver/decision_capture.py | 78 +++++++- .../test_context_delivery_cli.py | 2 +- .../control_plane_ts/context_delivery.test.ts | 22 +++ 11 files changed, 392 insertions(+), 288 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 9587739fa8..b64c4ed36a 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1206,6 +1206,17 @@ Both arms enable diagnostic capture; it does not qualify tracing cost against uninstrumented execution, autonomous solver outcomes or exact billing. The separate real-capture replay remains a different workload and read policy. +The native adapter now uses the existing Core original-capture IO owner for +shared read guidance and projection, removing the reverse Core-to-CLI dependency. +CLI presentation dispatch remains ahead of authority IO; event recording, +settlement readback and memory ingestion keep their original order. Native +input parsing, call binding and digest-bound page validation have separate +responsibilities without weakening lifecycle or complete-coverage checks. +The existing legacy quota adapter supplies its own field binding to both codec +operations; caller JSON cannot nominate additional reusable controls. Unknown +fields stay fresh. These changes preserve the measured runtime contract rather +than introduce another projection or compatibility reader. + The opt-in benchmark worker carries a compact recovery instruction in native `developer_instructions`, independently of SessionStart/PreToolUse delivery. Native code-mode consumers can carry the same product-owned instruction in diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 2517287aa8..63f98d71bd 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -831,6 +831,13 @@ Goal/Agent/Turn,原生工具调用同一 projector/实际输入 observer,不 两侧均开启诊断捕获,不据此认定相对未采集诊断时的追踪成本、自主 solver 成果或精确 计费。既有真实捕获回放是另一种负载及读取策略,分别保留。 +原生 adapter 改为复用既有 Core 原始 capture IO owner 的共享读取指导与投影,消除 +Core 反向依赖 CLI。CLI 展示命令仍在权限 IO 之前分派;事件记录、settlement 回读与 +memory ingest 保持原顺序。原生输入解析、调用绑定和摘要绑定分页核验分清职责, +不削弱生命周期或完整覆盖检查。既有旧 quota adapter 向编码与恢复两侧提供自己拥有 +的字段绑定;调用方 JSON 不能指定更多可复用控制字段,未知字段仍为 fresh。这些调整 +保持已测运行契约,不新增另一套投影或兼容读取入口。 + 显式启用的 benchmark worker 将简短恢复指令放入原生 `developer_instructions`, 独立于 SessionStart/PreToolUse 的交付。原生 code-mode 消费者也可在隔离配置中携带 同一产品 owner 生成的指令;应追加到既有宿主指令,不替换无关规则。触发条件是 diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 9a44d9b51c..5d3cd2e1d0 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -4,12 +4,13 @@ import time from collections.abc import Callable, Mapping from pathlib import Path +from typing import Any from .quota_capture import ( bind_capture_selection_transport, capture_decision, handoff_decision, - prepare_decision_capture, project_captured_context, - read_context_capture, project_saved_context_capture, + prepare_decision_capture, handle_native_context_command, ) +from ..control_plane.turn_driver.decision_capture import project_captured_context from ..usage_goal import observe_quota_result from ..capabilities.explore.composition_frontier import ( @@ -531,6 +532,67 @@ def _emit_quota_result( return 0 if succeeded else 1 +def _record_non_guard_quota_event( + payload: dict[str, Any], args: argparse.Namespace, *, registry_path: Path, + runtime_root_arg: str | None, context: QuotaCommandContext | None, goal_ref: dict[str, str] | None, + rollout_todo_id: str | None, rollout_replan_obligation_id: str | None, + spend_turn_instance_id: str | None, rollout_details: dict[str, Any], + append_cli_rollout_event: RolloutEventAppender, +) -> None: + """Record the CLI event before settlement readback and optional memory ingest.""" + append_cli_rollout_event( + payload, + registry_path=registry_path, + runtime_root_arg=runtime_root_arg, + event_kind=QUOTA_EVENT_KINDS[args.quota_command], + goal_ref=goal_ref, + agent_id=args.agent_id, + todo_id=rollout_todo_id, + run_id=( + spend_turn_instance_id + if args.quota_command == "spend-slot" + else None + ), + status=str( + payload.get("effective_action") + or payload.get("decision") + or payload.get("mode") + or args.quota_command + ), + summary=( + f"quota {args.quota_command} decision=" + f"{payload.get('decision') or payload.get('mode')} " + f"state={payload.get('state') or ''}" + ), + details=rollout_details, + allow_failed=args.quota_command == "should-run", + ) + if args.quota_command == "spend-slot" and spend_turn_instance_id: + assert context is not None # A settlement requires a prepared authority context. + attach_spend_settlement_result( + payload, + runtime_root=context.runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + todo_id=rollout_todo_id, + turn_instance_id=spend_turn_instance_id, + replan_obligation_id=rollout_replan_obligation_id, + goal_ref=goal_ref, + ) + attach_reward_memory_ingest_after_spend( + payload, + execute=bool(args.execute), + runtime_root=context.runtime_root, + registry_path=registry_path, + goal_id=args.goal_id, + agent_id=args.agent_id, + todo_id=rollout_todo_id, + turn_instance_id=spend_turn_instance_id, + replan_obligation_id=rollout_replan_obligation_id, + goal_ref=goal_ref, + ) + + def handle_quota_command( args: argparse.Namespace, *, @@ -540,59 +602,9 @@ def handle_quota_command( append_cli_rollout_event: RolloutEventAppender, capture_directory: Path | None = None, ) -> int: - if args.quota_command == "context-config": - import json - from ..command_invocation import loopx_cli_argv - from ..control_plane.turn_driver.codex_context import codex_context_configuration - - try: - directory = getattr(args, "codex_context_root", None) or args.codex_context_dir - if not directory: - raise ValueError("context-config requires codex-context-root or codex-context-dir") - config = codex_context_configuration(directory, goal_id=args.goal_id, agent_id=args.agent_id, - cli_argv=loopx_cli_argv(), session_root=bool(getattr(args, "codex_context_root", None))) - payload = {"ok": True, "native_config": config, - "activation": "Merge hooks and append developer_instructions to the existing native configuration; " - "review and trust only these exact hooks in Codex. Restart/resume with the resulting configuration. " - "Pass this same context location and a decision-output-root to heartbeat-prompt/should-run. " - "For delta, the native process must already have CODEX_ROLLOUT_TRACE_ROOT pointing to private diagnostics; " - "unavailable diagnostics preserve full delivery. Disabling the context options restores ordinary output. " - "This command installs nothing and grants no execution authority."} - except (OSError, ValueError, TypeError) as exc: - payload = {"ok": False, "reason": str(exc)} - print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) - return 0 if payload["ok"] else 1 - if args.quota_command == "context-hook": - import json - import sys - from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - from ..control_plane.turn_driver.codex_context import codex_context_directory, codex_context_hook - - # Native transport only: no registry discovery, guard or quota effects. - try: - raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) - event = json.loads(raw) if len(raw) <= MAX_REQUEST_BYTES else None - if (isinstance(event, dict) and isinstance(event.get("session_id"), str) and event["session_id"] - and event.get("hook_event_name") in ("SessionStart", "PreCompact", "PreToolUse")): - directory = codex_context_directory(directory=args.codex_context_dir, - root=getattr(args, "codex_context_root", None), session=event.get("session_id"), create=True) - payload = codex_context_hook(directory, event) - else: - payload = {} - except (OSError, ValueError, TypeError, RecursionError): - payload = {} - print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) - return 0 - if args.quota_command == "context-project": - payload = project_saved_context_capture(args) - print_payload(payload, args.format, lambda value: str(value.get("command", value.get("reason", "")))) - return 0 if payload["ok"] else 1 - if args.quota_command == "context-read": - # Presentation recovery reads the original capture, never current Goal - # state. In particular it must not evaluate a guard or append a receipt. - payload = read_context_capture(args) - print_payload(payload, args.format, lambda value: str(value.get("content", value.get("reason", "")))) - return 0 if payload["ok"] else 1 + native_result = handle_native_context_command(args, print_payload=print_payload) + if native_result is not None: + return native_result usage_quota_started = time.time_ns() // 1_000_000 heartbeat_turn_id: str | None = None heartbeat_receipt_existing: dict[str, object] | None = None @@ -1006,56 +1018,14 @@ def handle_quota_command( ), ) else: - append_cli_rollout_event( - payload, - registry_path=registry_path, - runtime_root_arg=runtime_root_arg, - event_kind=QUOTA_EVENT_KINDS[args.quota_command], - goal_ref=goal_ref, - agent_id=args.agent_id, - todo_id=rollout_todo_id, - run_id=( - spend_turn_instance_id - if args.quota_command == "spend-slot" - else None - ), - status=str( - payload.get("effective_action") - or payload.get("decision") - or payload.get("mode") - or args.quota_command - ), - summary=( - f"quota {args.quota_command} decision=" - f"{payload.get('decision') or payload.get('mode')} " - f"state={payload.get('state') or ''}" - ), - details=rollout_details, - allow_failed=args.quota_command == "should-run", + _record_non_guard_quota_event( + payload, args, registry_path=registry_path, + runtime_root_arg=runtime_root_arg, context=context, goal_ref=goal_ref, + rollout_todo_id=rollout_todo_id, + rollout_replan_obligation_id=rollout_replan_obligation_id, + spend_turn_instance_id=spend_turn_instance_id, rollout_details=rollout_details, + append_cli_rollout_event=append_cli_rollout_event, ) - if args.quota_command == "spend-slot" and spend_turn_instance_id: - attach_spend_settlement_result( - payload, - runtime_root=runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, - todo_id=rollout_todo_id, - turn_instance_id=spend_turn_instance_id, - replan_obligation_id=rollout_replan_obligation_id, - goal_ref=goal_ref, - ) - attach_reward_memory_ingest_after_spend( - payload, - execute=bool(args.execute), - runtime_root=runtime_root, - registry_path=registry_path, - goal_id=args.goal_id, - agent_id=args.agent_id, - todo_id=rollout_todo_id, - turn_instance_id=spend_turn_instance_id, - replan_obligation_id=rollout_replan_obligation_id, - goal_ref=goal_ref, - ) attach_reward_memory_recall_after_should_run( payload, quota_command=args.quota_command, diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index a3f46634fc..14ac29b4ba 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -11,32 +11,16 @@ import stat import tempfile from pathlib import Path +from typing import Any +from collections.abc import Callable from ..control_plane.quota.error_codes import QuotaCommandValidationError, QuotaDecisionTransportError -from ..control_plane.turn_driver.decision_capture import validate_handoff_socket - - -CONTEXT_CAPTURE_READ_GUIDANCE = ( - "Follow next_command through eof and combine content in byte-offset order. " - "In code-mode, forward successful exec stdout directly with text(result.output); " - "retain errors. Small batches can save model round trips; start with at most two default pages " - "and inspect every output. A host may truncate inside valid JSON or omit a whole output. " - "Check each model-visible content's UTF-8 byte length against next_offset (total_bytes at eof), " - "complete coverage and the original SHA-256. If incomplete, reread the affected range " - "of this same capture with fewer pages or a smaller --page-bytes; do not trust a preview or EOF alone. " - "Do not rerun admission or ask the owner. File existence and page EOF are not retention proof." +from ..control_plane.turn_driver.decision_capture import ( + validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, context_capture_read_command, ) -def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096) -> str: - from ..command_invocation import loopx_cli_argv - - return shlex.join([*loopx_cli_argv(), "--format", "json", "quota", "context-read", - "--capture-file", str(path), "--capture-sha256", digest, - "--offset", str(offset), "--page-bytes", str(page_bytes)]) - - -def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict]: +def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict[str, Any]]: """One bounded physical-file/hash check for recovery and saved projection.""" from ..control_plane.effect_runtime import MAX_REQUEST_BYTES @@ -62,7 +46,7 @@ def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict]: return path, raw, payload -def read_context_capture(args: argparse.Namespace) -> dict: +def read_context_capture(args: argparse.Namespace) -> dict[str, Any]: """Bounded local IO over immutable JSON, not a new context/authority owner. Verify the complete byte digest on every page. The cursor addresses bytes @@ -110,7 +94,7 @@ def read_context_capture(args: argparse.Namespace) -> dict: "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} -def project_saved_context_capture(args: argparse.Namespace) -> dict: +def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: """Present an immutable already-decided quota capture, without authority IO. Publication is delayed until this native tool runs, so the same actual-input @@ -232,7 +216,7 @@ def capture_decision(directory: Path | None, payload: dict[str, object]) -> None ) from exc -def bind_capture_selection_transport(payload: dict, args: argparse.Namespace) -> None: +def bind_capture_selection_transport(payload: dict[str, Any], args: argparse.Namespace) -> None: """Retain caller display/capture options in the existing selection command. Only transport argv is added here; the typed quota owner still selects the @@ -261,7 +245,7 @@ def bind_capture_selection_transport(payload: dict, args: argparse.Namespace) -> selection["command_args_template"] = template + suffix -def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[dict, str] | None: +def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[dict[str, Any], str] | None: """Withhold the original output until the Host releases these exact bytes. No second guard, admission, settlement or retry is performed here. A lost @@ -269,7 +253,7 @@ def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[d recovery; it must never be silently replaced by a later observation. """ if socket_path is None: - return + return None if directory is None: raise ValueError("decision handoff requires a captured decision") path = directory / "decision.json" @@ -316,54 +300,59 @@ def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[d return {}, digest -def project_captured_context( - payload: dict, *, directory: Path | None, request: dict | None, - goal_id: str, agent_id: str, surface: str, capture_id: str, -) -> dict: - """Adapt an admitted capture to the shared TS presentation owner. - - The caller-owned rendezvous may supply a continuity observation. It must - still recheck the candidate at final model-input assembly and ACK only intact - delivery. Unsupported ordinary CLI clients never supply this request. Every - opt-in attempt saves the complete projected JSON for same-capture recovery; - neither malformed requests nor optimization failures rerun the guard. - """ - if request is None or directory is None: - return payload - from ..control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError - from ..control_plane.quota.turn_envelope import prepare_context_delivery - from ..control_plane.turn_driver.decision_capture import write_decision_capture - - try: - projection = directory / "projection.json" - write_decision_capture(projection, payload) - scope = request.get("scope") - if not isinstance(scope, dict) or scope.get("goal_id") != goal_id or scope.get("agent_id") != agent_id: - return payload - retained = request.get("retained") - if "native_input" in request: - from ..control_plane.turn_driver.codex_input_trace import codex_context_in_active_input - - # An optional native observation can only narrow the caller's - # retention claim. It never mints an ACK or enables tracing, and a - # malformed/stale observation must not fall back to that claim. - observation, baseline = request["native_input"], request.get("baseline") - if (not isinstance(observation, dict) or not isinstance(baseline, dict) - or not codex_context_in_active_input(observation, baseline=baseline, - session_id=scope.get("session_id"))): - retained = None - delivery = prepare_context_delivery(payload, scope=scope, - capture_id=capture_id, surface=surface, - baseline=request.get("baseline"), retained=retained) - delivery["recovery"] = { - "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), - "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " - "truncated or uncertain, use this full projection before dependent work. " - + CONTEXT_CAPTURE_READ_GUIDANCE, - } - delivery["recovery"]["command"] = context_capture_read_command(projection, delivery["recovery"]["sha256"]) - return delivery - except (OSError, ValueError, TypeError, EffectRuntimeRemoteError, EffectRuntimeStartupError): - # The original full payload is already available. An unavailable optional - # codec/capture must not convert safe full delivery into an execution gate. - return payload +def handle_native_context_command(args: argparse.Namespace, *, print_payload: Callable[..., None]) -> int | None: + """Dispatch native presentation/lifecycle IO before any quota authority read.""" + if args.quota_command == "context-config": + import json + from ..command_invocation import loopx_cli_argv + from ..control_plane.turn_driver.codex_context import codex_context_configuration + + try: + directory = getattr(args, "codex_context_root", None) or args.codex_context_dir + if not directory: + raise ValueError("context-config requires codex-context-root or codex-context-dir") + config = codex_context_configuration(directory, goal_id=args.goal_id, agent_id=args.agent_id, + cli_argv=loopx_cli_argv(), session_root=bool(getattr(args, "codex_context_root", None))) + payload = {"ok": True, "native_config": config, + "activation": "Merge hooks and append developer_instructions to the existing native configuration; " + "review and trust only these exact hooks in Codex. Restart/resume with the resulting configuration. " + "Pass this same context location and a decision-output-root to heartbeat-prompt/should-run. " + "For delta, the native process must already have CODEX_ROLLOUT_TRACE_ROOT pointing to private diagnostics; " + "unavailable diagnostics preserve full delivery. Disabling the context options restores ordinary output. " + "This command installs nothing and grants no execution authority."} + except (OSError, ValueError, TypeError) as exc: + payload = {"ok": False, "reason": str(exc)} + print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 0 if payload["ok"] else 1 + if args.quota_command == "context-hook": + import json + import sys + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + from ..control_plane.turn_driver.codex_context import codex_context_directory, codex_context_hook + + # Native transport only: no registry discovery, guard or quota effects. + try: + raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) + event = json.loads(raw) if len(raw) <= MAX_REQUEST_BYTES else None + if (isinstance(event, dict) and isinstance(event.get("session_id"), str) and event["session_id"] + and event.get("hook_event_name") in ("SessionStart", "PreCompact", "PreToolUse")): + directory = codex_context_directory(directory=args.codex_context_dir, + root=getattr(args, "codex_context_root", None), session=event.get("session_id"), create=True) + payload = codex_context_hook(directory, event) + else: + payload = {} + except (OSError, ValueError, TypeError, RecursionError): + payload = {} + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + return 0 + if args.quota_command == "context-project": + payload = project_saved_context_capture(args) + print_payload(payload, args.format, lambda value: str(value.get("command", value.get("reason", "")))) + return 0 if payload["ok"] else 1 + if args.quota_command == "context-read": + # Presentation recovery reads the original capture, never current Goal + # state. In particular it must not evaluate a guard or append a receipt. + payload = read_context_capture(args) + print_payload(payload, args.format, lambda value: str(value.get("content", value.get("reason", "")))) + return 0 if payload["ok"] else 1 + return None diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index e3d7161061..593db81aeb 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -137,7 +137,7 @@ const pathKey = (path: Path): string => JSON.stringify(path); // obligation or make a historical source authoritative. const REUSABLE_PACKET_FIELDS = new Set([ "registry", "runtime_root", "source", "project_asset_source", "long_task_cadence_hint", - "plan_summary", "reason", "recommended_action", "heartbeat_recommendation", + "plan_summary", "reason", "recommended_action", "goal_frontier_projection", "todo_write_hint", "vision_continuation_audit", "agent_lane_next_action", "user_todo_summary", "agent_todo_summary", "blocked_priority_fallback", "autonomous_backlog_candidates", "autonomous_monitor_candidates", "next_action_projection_warning", @@ -151,7 +151,7 @@ const REUSABLE_PACKET_FIELDS = new Set([ /** Current controls are forced into the change tree even when equal. This avoids * duplicating them in both a fresh frame and a patch. Static authoring guidance * remains binding via exact reuse; commands, conditions and identity stay fresh. */ -function freshPaths(context: JsonObject, surface: Surface): {paths: Path[]; valid: boolean} { +function freshPaths(context: JsonObject, surface: Surface, legacyQuotaFields: readonly string[]): {paths: Path[]; valid: boolean} { const paths: Path[] = []; let valid = true; const leaves = (value: unknown, path: Path) => { @@ -212,7 +212,10 @@ function freshPaths(context: JsonObject, surface: Surface): {paths: Path[]; vali } } else { for (const [key, value] of Object.entries(context)) { - if (key !== "interaction_contract") { if (!REUSABLE_PACKET_FIELDS.has(key)) paths.push([key]); continue; } + if (key !== "interaction_contract") { + if (!REUSABLE_PACKET_FIELDS.has(key) && !legacyQuotaFields.includes(key)) paths.push([key]); + continue; + } const contract = jsonObject(value); if (!contract) { paths.push([key]); continue; } for (const [channel, fields] of Object.entries(contract)) { @@ -307,7 +310,7 @@ function surfaceOf(value: unknown): Surface { /** Final-input host boundary only. Unsupported/uncertain retention returns full; * the host ACKs intact delivery, never file creation or an Agent assertion. */ -export function prepareContextDelivery(request: JsonObject): JsonObject { +export function prepareContextDelivery(request: JsonObject, legacyQuotaFields: readonly string[] = []): JsonObject { const context = requireJsonObject(request.context, "current projection"); const surface = surfaceOf(request.surface ?? "work_context"), target = targetBinding(request, context, surface); const full = (reason: string): JsonObject => ({schema_version: VERSION, mode: "full", target, @@ -319,7 +322,7 @@ export function prepareContextDelivery(request: JsonObject): JsonObject { || binding.context_digest !== digest(prior)) return full("baseline_unavailable"); const depth = baseline?.ancestry; if (!Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) >= MAX_ANCESTRY) return full("full_refresh_due"); - const before = freshPaths(prior, surface), after = freshPaths(context, surface); + const before = freshPaths(prior, surface, legacyQuotaFields), after = freshPaths(context, surface, legacyQuotaFields); if (!before.valid || !after.valid) return full("source_not_reusable"); const changes = changeTree(prior, context, after.paths); const delta: JsonObject = {schema_version: VERSION, mode: "delta", target, base: binding, @@ -333,7 +336,7 @@ export function prepareContextDelivery(request: JsonObject): JsonObject { /** Validate the exact change tree against the immutable CURRENT capture before * applying it. Wire keys never choose freshness, scope or arbitrary write paths. * Recovery neither reruns admission nor mutates Goal/quota state. */ -export function restoreContextDelivery(request: JsonObject): JsonObject { +export function restoreContextDelivery(request: JsonObject, legacyQuotaFields: readonly string[] = []): JsonObject { const current = requireJsonObject(request.current, "current full capture"); const currentContext = requireJsonObject(current.context, "current full context"); const target = requireJsonObject(current.target, "current capture binding"); @@ -349,7 +352,7 @@ export function restoreContextDelivery(request: JsonObject): JsonObject { || !equal(delivery.base, base) || !compatible(base, target) || base.context_digest !== digest(context) || !Number.isSafeInteger(baseline.ancestry) || Number(baseline.ancestry) < 0 || delivery.ancestry !== Number(baseline.ancestry) + 1 || Number(delivery.ancestry) > MAX_ANCESTRY) return fallback(); - const before = freshPaths(context, surface), after = freshPaths(currentContext, surface); + const before = freshPaths(context, surface, legacyQuotaFields), after = freshPaths(currentContext, surface, legacyQuotaFields); if (!before.valid || !after.valid) return fallback(); const expected = changeTree(context, currentContext, after.paths); // Also rejects omitted unchanged controls, redundant/conflicting edits, diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 06f8de318b..105e21117f 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -45,6 +45,9 @@ export function readMonitorHeartbeatRecommendation(decision: JsonObject): JsonOb export function monitorHeartbeatDecisionFields(decision: JsonObject): JsonObject { return {heartbeat_recommendation: readMonitorHeartbeatRecommendation(decision)}; } +// The legacy adapter owns its field names. The codec receives this local binding +// from the adapter, never from request data or a second legacy-field reader. +const LEGACY_QUOTA_CONTEXT_FIELDS = Object.keys(monitorHeartbeatDecisionFields({})); /** Keep legacy heartbeat fields at their existing quota projection boundary; * the interaction owner still decides required reads and scheduler arbitration. */ @@ -999,8 +1002,8 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; if (operation === "build") return buildTurnEnvelope(request); - if (operation === "context_delivery") return prepareContextDelivery(request); - if (operation === "context_recovery") return restoreContextDelivery(request); + if (operation === "context_delivery") return prepareContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS); + if (operation === "context_recovery") return restoreContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS); if (operation === "bind_capture") return bindTurnDecisionCapture(request); if (operation === "replan_settlement_identity") return {identity: turnEnvelopeReplanSettlementIdentity( requireJsonObject(request.envelope, "turn_envelope"), request.turn_instance_id, diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index f6837641b6..a14ac58799 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -7,16 +7,18 @@ from __future__ import annotations from contextlib import contextmanager +from collections.abc import Iterator +from typing import Any import json import os from pathlib import Path import stat import uuid -from .decision_capture import write_decision_capture +from .decision_capture import write_decision_capture, project_captured_context, CONTEXT_CAPTURE_READ_GUIDANCE -def _read(path: Path, *, private: bool = True) -> dict: +def _read(path: Path, *, private: bool = True) -> dict[str, Any]: from ..effect_runtime import MAX_REQUEST_BYTES descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) @@ -67,7 +69,7 @@ def codex_context_directory(*, directory: str | None = None, root: str | None = @contextmanager -def _locked(directory: str): +def _locked(directory: str) -> Iterator[Path]: if os.name != "posix": raise ValueError("native context continuity is unavailable on this platform") import fcntl @@ -84,18 +86,18 @@ def _locked(directory: str): fcntl.flock(lock, fcntl.LOCK_UN) -def _state(root: Path) -> dict: +def _state(root: Path) -> dict[str, Any]: try: return _read(root / "session.json") except FileNotFoundError: return {} -def _save(root: Path, state: dict) -> None: +def _save(root: Path, state: dict[str, Any]) -> None: write_decision_capture(root / "session.json", state, publish=os.replace) -def read_codex_context_reference(directory: str, *, goal_id: str, agent_id: str) -> tuple[str, str]: +def read_codex_context_reference(directory: str, *, goal_id: str | None, agent_id: str | None) -> tuple[str, str]: """Select an original capture after a lost hook; never assert retention. The journal is atomically replaced, so this read needs no lock or write. @@ -135,7 +137,7 @@ def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_i def codex_context_configuration(directory: str, *, goal_id: str, agent_id: str, - cli_argv: list[str], session_root: bool = False) -> dict: + cli_argv: list[str], session_root: bool = False) -> dict[str, Any]: """Native configuration data shared by the worker and CLI setup journey. This renders no trust hashes and writes no host configuration. The native @@ -154,7 +156,7 @@ def codex_context_configuration(directory: str, *, goal_id: str, agent_id: str, agent_id=agent_id, cli_argv=cli_argv, session_root=session_root)} -def codex_context_hook(directory: str, event: dict) -> dict: +def codex_context_hook(directory: str | None, event: dict[str, Any]) -> dict[str, Any]: """Consume native lifecycle events; emit only same-capture recovery refs. Compaction/fork/restart invalidate. Same-session resume keeps only a candidate: @@ -163,6 +165,8 @@ def codex_context_hook(directory: str, event: dict) -> dict: primary journal still returns the existing recovery ref when available. Trust/enablement and complete event delivery remain native host concerns. """ + if directory is None: + return {} name, session = event.get("hook_event_name"), event.get("session_id") if not isinstance(session, str) or not session: return {} @@ -191,8 +195,6 @@ def codex_context_hook(directory: str, event: dict) -> dict: # It also cannot manufacture a fresh active-call observation. pass if name == "SessionStart" and isinstance(recovery, dict) and recovery.get("command"): - from ...cli_commands.quota_capture import CONTEXT_CAPTURE_READ_GUIDANCE - if event.get("source") == "resume": return {"hookSpecificOutput": {"hookEventName": name, "additionalContext": "LoopX resumed context is unconfirmed. Reuse prior context only when complete in current input. " @@ -242,8 +244,8 @@ def _trace_bundle(session: str, tool_id: str) -> str | None: return matches[0] if len(matches) == 1 else None -def project_codex_context(payload: dict, *, directory: str, capture_directory: Path, - goal_id: str, agent_id: str, surface: str, capture_id: str) -> dict: +def project_codex_context(payload: dict[str, Any], *, directory: str | None, capture_directory: Path, + goal_id: str, agent_id: str, surface: str, capture_id: str) -> dict[str, Any]: """Existing CLI consumer, sharing the rendezvous projector and recovery IO. Serialization prevents an invalidation from moving the epoch during @@ -251,9 +253,10 @@ def project_codex_context(payload: dict, *, directory: str, capture_directory: P so a later compact event recovers those exact bytes. Full candidates are never advanced from file existence, hook success or a saved delivery flag. """ - from ...cli_commands.quota_capture import project_captured_context from .codex_input_trace import codex_context_in_active_input + if directory is None: + return payload try: with _locked(directory) as root: state = _state(root) @@ -263,7 +266,7 @@ def project_codex_context(payload: dict, *, directory: str, capture_directory: P or Path(state.get("cwd", "")).resolve() != Path.cwd().resolve()): return payload scope = dict(goal_id=goal_id, agent_id=agent_id, session_id=session, context_epoch=epoch) - request = {"scope": scope} + request: dict[str, Any] = {"scope": scope} candidate = state.get("candidate") if isinstance(candidate, dict) and isinstance(state.get("active_call"), str): try: diff --git a/loopx/control_plane/turn_driver/codex_input_trace.py b/loopx/control_plane/turn_driver/codex_input_trace.py index 0c0d956110..9452ab5f0b 100644 --- a/loopx/control_plane/turn_driver/codex_input_trace.py +++ b/loopx/control_plane/turn_driver/codex_input_trace.py @@ -8,6 +8,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from typing import Any def read_native_trace_file(path: Path, *, tail: bool = False) -> bytes: @@ -28,7 +29,95 @@ def read_native_trace_file(path: Path, *, tail: bool = False) -> bytes: return raw.split(b"\n", 1)[-1] if offset else raw -def codex_context_in_active_input(observation: dict, *, baseline: dict, session_id: str) -> bool: +def _canonical_native_json(value: object) -> object: + # JSON has one number type; native Python projection may spell 1.0 + # where the TS codec spells 1. Preserve booleans and numeric values. + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, list): + return [_canonical_native_json(item) for item in value] + if isinstance(value, dict): + return {key: _canonical_native_json(item) for key, item in value.items()} + return value + + +def _encoded_native_document(value: object) -> str: + return json.dumps(_canonical_native_json(value), sort_keys=True, ensure_ascii=False, allow_nan=False) + + +def _native_tool_is_bound(tail: list[dict[str, Any]], completed: list[dict[str, Any]], tools: list[dict[str, Any]], output: dict[str, Any], tool_id: str) -> bool: + tool = tools[0]["payload"] + if tool.get("requester") == {"type": "model"}: + bound = (tool.get("model_visible_call_id") == tool_id + and tool.get("code_mode_runtime_tool_id") is None + and any(item.get("type") == "function_call" and item.get("call_id") == tool_id + and item.get("name") == "exec_command" for item in output.get("output_items", []))) + else: + # A code-mode child has no model-visible call id of its own. Bind + # the live child to its native cell and that cell's exact model + # output; a child id or a saved source string alone proves nothing. + requester = tool.get("requester", {}) + cell_id = requester.get("runtime_cell_id") + cells = [row for row in tail if row["payload"].get("type") == "code_cell_started" + and row["payload"].get("runtime_cell_id") == cell_id] + bound = (requester.get("type") == "code_cell" and isinstance(cell_id, str) and bool(cell_id) + and isinstance(tool.get("code_mode_runtime_tool_id"), str) + and bool(tool["code_mode_runtime_tool_id"]) + and tool.get("model_visible_call_id") is None and len(cells) == 1 + and completed[0]["seq"] < cells[0]["seq"] < tools[0]["seq"] + and isinstance(cells[0]["payload"].get("source_js"), str) + and isinstance(cells[0]["payload"].get("model_visible_call_id"), str) + and not any(row["payload"].get("type") == "code_cell_ended" + and row["payload"].get("runtime_cell_id") == cell_id for row in tail) + and any(item.get("type") == "custom_tool_call" and item.get("name") == "exec" + and item.get("call_id") == cells[0]["payload"].get("model_visible_call_id") + and item.get("input") == cells[0]["payload"].get("source_js") + for item in output.get("output_items", []))) + return bound + + +def _model_input_texts(request: dict[str, Any]) -> Iterator[str]: + for item in request["input"]: + if item.get("type") == "function_call_output" and isinstance(item.get("output"), str): + yield item["output"] + elif item.get("type") == "custom_tool_call_output" and isinstance(item.get("output"), list): + for part in item["output"]: + if part.get("type") == "input_text" and isinstance(part.get("text"), str): + yield part["text"] + + +def _read_bundle_payload(bundle: Path, ref: dict[str, Any]) -> dict[str, Any]: + relative = Path(ref["path"]) + if (relative.is_absolute() or len(relative.parts) != 2 or relative.parts[0] != "payloads" + or ".." in relative.parts): + raise ValueError("native payload reference escapes its bundle") + path = bundle / relative + path.resolve(strict=True).relative_to(bundle) + value = json.loads(read_native_trace_file(path)) + if not isinstance(value, dict): + raise ValueError("native payload must be an object") + return value + + +def _capture_page_range(page: dict[str, Any], *, raw: bytes, digest: str) -> tuple[int, int] | None: + if page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: + return None + start, content = page.get("offset"), page.get("content") + if type(start) is not int or start < 0 or not isinstance(content, str) or page.get("ok") is not True: + return None + data = content.encode("utf-8") + end = start + len(data) + if (page.get("total_bytes") != len(raw) or end > len(raw) or not data + or page.get("eof") is not (end == len(raw)) + or page.get("next_offset") != (None if end == len(raw) else end) + or raw[start:end] != data): + # A host preview can remain valid JSON while shortening content + # and retaining the original cursor. It proves no bytes. + return None + return start, end + + +def codex_context_in_active_input(observation: dict[str, Any], *, baseline: dict[str, Any], session_id: str) -> bool: """Cross-check a host's retention claim against a native in-flight tool call. Native trace v1 is an optional, provider-specific IO boundary. Only an @@ -40,42 +129,19 @@ def codex_context_in_active_input(observation: dict, *, baseline: dict, session_ dispatch. The caller still owns lifecycle invalidation and same-capture recovery. It must not use this function as a standalone continuity lease. """ - def canonical(value): - # JSON has one number type; native Python projection may spell 1.0 - # where the TS codec spells 1. Preserve booleans and numeric values. - if isinstance(value, float) and value.is_integer(): - return int(value) - if isinstance(value, list): - return [canonical(item) for item in value] - if isinstance(value, dict): - return {key: canonical(item) for key, item in value.items()} - return value - - def encoded(value): - return json.dumps(canonical(value), sort_keys=True, ensure_ascii=False, allow_nan=False) - try: if baseline.get("mode") != "full" or baseline.get("target", {}).get("session_id") != session_id: return False # Python equality conflates booleans and numbers; source observation # must retain their JSON types, as the shared projection owner does. - expected = encoded(baseline) - context = encoded(baseline.get("context")) + expected = _encoded_native_document(baseline) + context = _encoded_native_document(baseline.get("context")) bundle = Path(observation["trace_bundle"]).resolve(strict=True) manifest = json.loads(read_native_trace_file(bundle / "manifest.json")) if (manifest.get("schema_version") != 1 or manifest.get("root_thread_id") != session_id or manifest.get("rollout_id") != session_id): return False - def payload(ref: dict) -> dict: - relative = Path(ref["path"]) - if (relative.is_absolute() or len(relative.parts) != 2 or relative.parts[0] != "payloads" - or ".." in relative.parts): - raise ValueError("native payload reference escapes its bundle") - path = bundle / relative - path.resolve(strict=True).relative_to(bundle) - return json.loads(read_native_trace_file(path)) - raw_events = read_native_trace_file(bundle / "trace.jsonl", tail=True) if not raw_events.endswith(b"\n"): return False # a partial writer tail cannot establish the current cursor @@ -106,55 +172,21 @@ def payload(ref: dict) -> dict: or (row["payload"].get("type") == "tool_call_ended" and row["payload"].get("tool_call_id") == tool_id) for row in tail): return False - output = payload(completed[0]["payload"]["response_payload"]) - if tool.get("requester") == {"type": "model"}: - bound = (tool.get("model_visible_call_id") == tool_id - and tool.get("code_mode_runtime_tool_id") is None - and any(item.get("type") == "function_call" and item.get("call_id") == tool_id - and item.get("name") == "exec_command" for item in output.get("output_items", []))) - else: - # A code-mode child has no model-visible call id of its own. Bind - # the live child to its native cell and that cell's exact model - # output; a child id or a saved source string alone proves nothing. - requester = tool.get("requester", {}) - cell_id = requester.get("runtime_cell_id") - cells = [row for row in tail if row["payload"].get("type") == "code_cell_started" - and row["payload"].get("runtime_cell_id") == cell_id] - bound = (requester.get("type") == "code_cell" and isinstance(cell_id, str) and bool(cell_id) - and isinstance(tool.get("code_mode_runtime_tool_id"), str) - and bool(tool["code_mode_runtime_tool_id"]) - and tool.get("model_visible_call_id") is None and len(cells) == 1 - and completed[0]["seq"] < cells[0]["seq"] < tools[0]["seq"] - and isinstance(cells[0]["payload"].get("source_js"), str) - and isinstance(cells[0]["payload"].get("model_visible_call_id"), str) - and not any(row["payload"].get("type") == "code_cell_ended" - and row["payload"].get("runtime_cell_id") == cell_id for row in tail) - and any(item.get("type") == "custom_tool_call" and item.get("name") == "exec" - and item.get("call_id") == cells[0]["payload"].get("model_visible_call_id") - and item.get("input") == cells[0]["payload"].get("source_js") - for item in output.get("output_items", []))) + output = _read_bundle_payload(bundle, completed[0]["payload"]["response_payload"]) + bound = _native_tool_is_bound(tail, completed, tools, output, tool_id) if not bound: return False - request = payload(tail[0]["payload"]["request_payload"]) + request = _read_bundle_payload(bundle, tail[0]["payload"]["request_payload"]) if request.get("previous_response_id"): return False # implicit server-side history is outside this observer raw = read_native_trace_file(Path(observation["capture_file"])) digest = hashlib.sha256(raw).hexdigest() - captured = encoded(json.loads(raw)) + captured = _encoded_native_document(json.loads(raw)) if digest != observation["capture_sha256"] or captured not in (expected, context): return False ranges: list[tuple[int, int]] = [] - def texts(): - for item in request["input"]: - if item.get("type") == "function_call_output" and isinstance(item.get("output"), str): - yield item["output"] - elif item.get("type") == "custom_tool_call_output" and isinstance(item.get("output"), list): - for part in item["output"]: - if part.get("type") == "input_text" and isinstance(part.get("text"), str): - yield part["text"] - - for text in texts(): + for text in _model_input_texts(request): # The qualified exec transport wraps complete output after this # delimiter. Ignore prose/quoted JSON, previews and other item roles. text = text.split("\nOutput:\n", 1)[-1] @@ -168,25 +200,13 @@ def texts(): page = json.loads(page["output"]) except ValueError: continue - document = encoded(page) + document = _encoded_native_document(page) if document == expected or (captured == context and document == context): return True - if not isinstance(page, dict) or page.get("schema_version") != "context_capture_page_v1" or page.get("sha256") != digest: - continue - start, content = page.get("offset"), page.get("content") - if type(start) is not int or start < 0 or not isinstance(content, str) or page.get("ok") is not True: - continue - data = content.encode("utf-8") - end = start + len(data) - if (page.get("total_bytes") != len(raw) or end > len(raw) or not data - or page.get("eof") is not (end == len(raw)) - or page.get("next_offset") != (None if end == len(raw) else end) - or raw[start:end] != data): - # A host preview can remain valid JSON while shortening content - # and retaining the original cursor. It proves no bytes. Keep - # looking for complete rereads in this same current input. - continue - ranges.append((start, end)) + if isinstance(page, dict): + page_range = _capture_page_range(page, raw=raw, digest=digest) + if page_range is not None: + ranges.append(page_range) cursor = 0 for start, end in sorted(ranges): if start > cursor: diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index d7d122868f..79d078107d 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -1,7 +1,9 @@ -"""Private observation IO for the real Turn host; TS owns binding validation.""" +"""Private original-capture and presentation IO; TS owns binding validation.""" from __future__ import annotations +import hashlib import json +import shlex import os import socket import stat @@ -91,3 +93,77 @@ def validate_handoff_socket(value: str) -> Path: except OSError as exc: raise QuotaCommandValidationError(f"invalid decision handoff socket: {exc}") from exc return path + + +CONTEXT_CAPTURE_READ_GUIDANCE = ( + "Follow next_command through eof and combine content in byte-offset order. " + "In code-mode, forward successful exec stdout directly with text(result.output); " + "retain errors. Small batches can save model round trips; start with at most two default pages " + "and inspect every output. A host may truncate inside valid JSON or omit a whole output. " + "Check each model-visible content's UTF-8 byte length against next_offset (total_bytes at eof), " + "complete coverage and the original SHA-256. If incomplete, reread the affected range " + "of this same capture with fewer pages or a smaller --page-bytes; do not trust a preview or EOF alone. " + "Do not rerun admission or ask the owner. File existence and page EOF are not retention proof." +) + + +def context_capture_read_command(path: Path, digest: str, *, offset: int = 0, page_bytes: int = 4096) -> str: + from ...command_invocation import loopx_cli_argv + + return shlex.join([*loopx_cli_argv(), "--format", "json", "quota", "context-read", + "--capture-file", str(path), "--capture-sha256", digest, + "--offset", str(offset), "--page-bytes", str(page_bytes)]) + + +def project_captured_context( + payload: dict[str, Any], *, directory: Path | None, request: dict[str, Any] | None, + goal_id: str, agent_id: str, surface: str, capture_id: str, +) -> dict[str, Any]: + """Adapt an admitted capture to the shared TS presentation owner. + + The caller-owned rendezvous may supply a continuity observation. It must + still recheck the candidate at final model-input assembly and ACK only intact + delivery. Unsupported ordinary CLI clients never supply this request. Every + opt-in attempt saves the complete projected JSON for same-capture recovery; + neither malformed requests nor optimization failures rerun the guard. + """ + if request is None or directory is None: + return payload + from ..effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError + from ..quota.turn_envelope import prepare_context_delivery + + try: + projection = directory / "projection.json" + write_decision_capture(projection, payload) + scope = request.get("scope") + if not isinstance(scope, dict) or scope.get("goal_id") != goal_id or scope.get("agent_id") != agent_id: + return payload + retained = request.get("retained") + if "native_input" in request: + from .codex_input_trace import codex_context_in_active_input + + # An optional native observation can only narrow the caller's + # retention claim. It never mints an ACK or enables tracing, and a + # malformed/stale observation must not fall back to that claim. + observation, baseline = request["native_input"], request.get("baseline") + native_session = scope.get("session_id") + if (not isinstance(observation, dict) or not isinstance(baseline, dict) + or not isinstance(native_session, str) + or not codex_context_in_active_input(observation, baseline=baseline, + session_id=native_session)): + retained = None + delivery = prepare_context_delivery(payload, scope=scope, + capture_id=capture_id, surface=surface, + baseline=request.get("baseline"), retained=retained) + delivery["recovery"] = { + "path": str(projection), "sha256": hashlib.sha256(projection.read_bytes()).hexdigest(), + "instruction": "Complete JSON for this original capture. If the baseline is missing, compacted, " + "truncated or uncertain, use this full projection before dependent work. " + + CONTEXT_CAPTURE_READ_GUIDANCE, + } + delivery["recovery"]["command"] = context_capture_read_command(projection, delivery["recovery"]["sha256"]) + return delivery + except (OSError, ValueError, TypeError, EffectRuntimeRemoteError, EffectRuntimeStartupError): + # The original full payload is already available. An unavailable optional + # codec/capture must not convert safe full delivery into an execution gate. + return payload diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index 26209aa945..12ca98ec15 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -203,7 +203,7 @@ def release(): def test_optional_capture_projection_failure_falls_back_without_hiding_source(tmp_path, monkeypatch): - from loopx.cli_commands.quota_capture import project_captured_context + from loopx.control_plane.turn_driver.decision_capture import project_captured_context from loopx.control_plane.quota import turn_envelope from loopx.control_plane.effect_runtime import EffectRuntimeStartupError payload = {"ok": True, "goal_id": "fixture", "requirements": "Keep late stop conditions."} diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index 7ff92ce858..1a9116702a 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {compactSelectedTodoSource, prepareContextDelivery, restoreContextDelivery} from "../../loopx/control_plane/quota/context_delivery.ts"; +import {evaluateTurnEnvelope} from "../../loopx/control_plane/quota/turn_envelope.ts"; + const scope = {goal_id: "goal", agent_id: "agent", session_id: "session", context_epoch: "epoch-1"}; const long = "Keep the original acceptance and stop before publication. ".repeat(400); function context(): JsonObject { @@ -315,3 +317,23 @@ test("quota and Turn use one codec but cannot borrow another surface's baseline" assert.deepEqual(restoreContextDelivery({delivery: delta, baseline, current: packetFull(turn, "capture-b", "turn_envelope")}).context, turn); }); + + +test("legacy quota fields are bound by the existing adapter, never caller data", () => { + const value = {...packet(), heartbeat_recommendation: {reason: long}}; + const baseline = evaluateTurnEnvelope({operation: "context_delivery", context: value, + scope, capture_id: "capture-a", surface: "quota_packet"}); + const request = {context: value, scope, capture_id: "capture-b", surface: "quota_packet", baseline, + retained: {complete: true, target: baseline.target}, legacyQuotaFields: ["unknown_future_control"]}; + const delta = evaluateTurnEnvelope({...request, operation: "context_delivery"}); + assert.equal(delta.mode, "delta"); + assert.equal(inline(delta, ["heartbeat_recommendation"]), undefined); + assert.deepEqual(inline(delta, ["unknown_future_control"]), value.unknown_future_control); + const current = evaluateTurnEnvelope({operation: "context_delivery", context: value, + scope, capture_id: "capture-b", surface: "quota_packet"}); + const restored = evaluateTurnEnvelope({operation: "context_recovery", current, delivery: delta, baseline}); + assert.equal(restored.mode, "delta"); + assert.deepEqual(restored.context, value); + // A direct codec call has no legacy binding and conservatively refreshes it. + assert.deepEqual(inline(prepareContextDelivery(request), ["heartbeat_recommendation"]), value.heartbeat_recommendation); +}); From 48b6ee9ecf8ce504293560dea850c07339d1fa95 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:40:49 +0800 Subject: [PATCH 26/29] fix(benchmark): isolate native hook discovery from plugin startup Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/context_delivery.py | 7 ++- benchmark/tests/test_shared_codex_runtime.py | 41 +++++++++++++++++ .../rfcs/agent-loop-effect-interpreter-v0.md | 25 ++++++++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 16 +++++++ .../test_saved_context_project.py | 46 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index ebbf75d7af..9414ff8acd 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -68,7 +68,12 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: # bypass trust for unrelated workspace hooks. Discovery launches no model. with (home / "context-hooks-stderr.log").open("a") as stderr: with StdioNativeGoalTransport.spawn( - [env["CODEX_BIN"], "app-server", "--listen", "stdio://"], + # This process only reads native hook identities. Catalog/plugin + # startup can clone repositories into an otherwise private home; + # it is unrelated to discovery and must not affect solver config. + [env["CODEX_BIN"], "-c", "features.plugins=false", + "-c", "features.remote_plugin=false", "-c", "features.plugin_sharing=false", + "app-server", "--listen", "stdio://"], cwd=env["LOOPX_PROJECT"], env=env, stderr=stderr, response_timeout_sec=10, ) as native: native.request("initialize", {"clientInfo": {"name": "loopx_context_transport", "version": "0.1.0"}, diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index 52cac49001..b24bf4dbae 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -1164,3 +1164,44 @@ def test_context_setup_preserves_unsafe_or_unowned_inputs(tmp_path, kind): prepare_context_delivery(env, home) assert (protected.read_bytes() if protected.is_file() else protected.lstat().st_mode) == before assert "LOOPX_CODEX_CONTEXT_TRUST_ARGS" not in env + + +def test_context_discovery_keeps_solver_configuration_and_unrelated_hooks(tmp_path, monkeypatch): + from contextlib import contextmanager + from benchmark.runtime.context_delivery import prepare_context_delivery + from loopx.control_plane.turn_driver.codex_stdio import StdioNativeGoalTransport + + home = tmp_path / "home" + home.mkdir() + config = home / "config.toml" + original_config = b"[features]\nplugins=true\nremote_plugin=true\nplugin_sharing=true\n" + config.write_bytes(original_config) + methods, commands = [], [] + + @contextmanager + def discovery(argv, **kwargs): + commands.append(argv) + def request(method, params): + methods.append(method) + if method == "initialize": + return {} + assert method == "hooks/list", "discovery must not start model work" + own = [{"sourcePath": str((home / "hooks.json").resolve()), + "key": f"own-{i}", "currentHash": f"native-hash-{i}"} for i in range(2)] + return {"data": [{"hooks": [*own, {"sourcePath": str(tmp_path / "other-hooks.json"), + "key": "unrelated", "currentHash": "never-trust-this"}]}]} + yield SimpleNamespace(request=request, notify=lambda *args: None) + + monkeypatch.setattr(StdioNativeGoalTransport, "spawn", discovery) + env = {"LOOPX_CLI": "fixture-loopx", "LOOPX_GOAL_ID": "fixture", "LOOPX_AGENT_ID": "agent", + "CODEX_BIN": "native-codex", "LOOPX_PROJECT": str(tmp_path)} + prepare_context_delivery(env, home) + assert methods == ["initialize", "hooks/list"] + assignments = {commands[0][i + 1] for i, value in enumerate(commands[0]) if value == "-c"} + assert assignments == {"features.plugins=false", "features.remote_plugin=false", "features.plugin_sharing=false"} + trusted = json.loads(env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"]) + assert "native-hash-0" in trusted[1] and "native-hash-1" in trusted[1] + assert "unrelated" not in trusted[1] and "never-trust-this" not in trusted[1] + assert all("features.plugins" not in value and "features.remote_plugin" not in value + and "features.plugin_sharing" not in value for value in trusted) + assert config.read_bytes() == original_config diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index b64c4ed36a..6eac1da0fe 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1206,6 +1206,31 @@ Both arms enable diagnostic capture; it does not qualify tracing cost against uninstrumented execution, autonomous solver outcomes or exact billing. The separate real-capture replay remains a different workload and read policy. +A subsequent three-arm diagnostic at runtime source `912d56036` compares +ordinary tracing-off, ordinary tracing-on and opt-in delta/tracing-on execution. +Three counterbalanced two-wake trials per arm include generated preflight, +resumed history, descriptors, persistent instructions and every 4096-byte serial +read. Against ordinary tracing-off, delta reduces the summed serialized-input +o200k proxy from 5,482,641 to 3,499,312 (36.17%), whole-request proxy from +5,892,161 to 3,801,600 (35.48%), and requests from 114 to 84. Reader calls fall +from 108 to 72, with six additional projection calls. All trials verify complete +original bytes, independent reconstruction, session continuity and unchanged +guard/debit counts. Tokenization runs outside timing; synthetic loopback usage +does not establish billing or autonomous solver outcomes. + +The default-off comparison exposes an IO tradeoff: delta retains about 5.77 MB +of native diagnostics per two-wake trial, versus zero without tracing. Native +background plugin-catalog clones dominate the variable total retained footprint; +endpoint file sizes are not cumulative writes. Timing, CPU and net IO savings +remain unqualified. Hook identity discovery now disables plugin features only +in its temporary native process, avoiding unrelated catalog startup. It still +asks native `hooks/list` for hashes and trusts only the exact owned hooks; +solver configuration, default-off execution and model settings are unchanged. +Real native config readback verifies this isolation, repeated discovery returns +the same trust identities, and discovery sends no inference request. The same +test detects the old helper's enabled plugin features. The cost figures above +precede this helper fix and do not measure its latency or disk savings. + The native adapter now uses the existing Core original-capture IO owner for shared read guidance and projection, removing the reverse Core-to-CLI dependency. CLI presentation dispatch remains ahead of authority IO; event recording, diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 63f98d71bd..b8b85ffa43 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -831,6 +831,22 @@ Goal/Agent/Turn,原生工具调用同一 projector/实际输入 observer,不 两侧均开启诊断捕获,不据此认定相对未采集诊断时的追踪成本、自主 solver 成果或精确 计费。既有真实捕获回放是另一种负载及读取策略,分别保留。 +后续以运行时源 `912d56036` 对照普通关闭 trace、普通开启 trace、显式差分并开启 +trace 三组路径,每组按交叉顺序执行三次双唤醒。计入生成 preflight、恢复历史、描述、 +常驻指令及全部 4096 字节串行读取。相对普通关闭 trace,差分累计序列化输入 o200k +代理值从 5,482,641 降为 3,499,312(36.17%),整请求代理值从 5,892,161 降为 +3,801,600(35.48%),请求数从 114 降为 84;reader 调用从 108 降为 72,另增六次 +投影调用。全部样本核验原包完整字节、独立重建、session 连续性及不变的 guard/扣额 +次数。分词在计时之外完成;合成 loopback usage 不证明计费或自主 solver 成果。 + +默认关闭对照也揭示 IO 代价:差分每次双唤醒保留约 5.77 MB 原生诊断,未追踪时为零。 +原生后台插件目录克隆占据大部分波动的总保留量;终点文件大小不等于累计写入。 +延迟、CPU 和净 IO 收益仍未验收。hook 身份查询现仅在临时原生进程关闭插件功能, +避免启动无关目录;仍由原生 `hooks/list` 生成哈希,且只信任精确自有 hook。 +solver 配置、默认关闭路径和模型设置保持不变。真实原生配置回读核验该隔离,重复查询 +返回相同信任身份,且不发送推理请求;同一测试能够发现旧 helper 仍开启插件功能。 +上述成本数据采于本次 helper 修复之前,不代表其延迟或落盘收益。 + 原生 adapter 改为复用既有 Core 原始 capture IO owner 的共享读取指导与投影,消除 Core 反向依赖 CLI。CLI 展示命令仍在权限 IO 之前分派;事件记录、settlement 回读与 memory ingest 保持原顺序。原生输入解析、调用绑定和摘要绑定分页核验分清职责, diff --git a/tests/control_plane/test_saved_context_project.py b/tests/control_plane/test_saved_context_project.py index 73871cfe57..57cd5835df 100644 --- a/tests/control_plane/test_saved_context_project.py +++ b/tests/control_plane/test_saved_context_project.py @@ -241,3 +241,49 @@ def response(request, number, message): capture_id=warm["target"]["capture_id"], surface="quota_packet") assert restore_context_delivery(warm, baseline=cold, current=current)["context"] == original assert all(path.read_bytes() == raw for path, raw in sources) + + +@pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="Explicit native Codex; loopback inference only") +def test_native_discovery_keeps_plugins_out_of_probe_without_changing_solver(tmp_path, monkeypatch): + from contextlib import contextmanager + from benchmark.runtime.context_delivery import prepare_context_delivery + from loopx.control_plane.turn_driver.codex_stdio import StdioNativeGoalTransport + from tests.control_plane.codex_checkpoint_fixture import local_inference_provider + from tests.control_plane.test_codex_context_boundary import _configure_provider, _native_fixture + + binary, env, workspace, home = _native_fixture(tmp_path) + observed = [] + original_spawn = StdioNativeGoalTransport.spawn + + @contextmanager + def inspect_probe(argv, **kwargs): + with original_spawn(argv, **kwargs) as native: + def request(method, params): + result = native.request(method, params) + if method == "hooks/list": + effective = native.request("config/read", {"cwd": str(workspace), "includeLayers": False}) + observed.append(effective["config"]["features"]) + return result + from types import SimpleNamespace + yield SimpleNamespace(request=request, notify=native.notify) + + monkeypatch.setattr(StdioNativeGoalTransport, "spawn", inspect_probe) + with local_inference_provider() as (url, calls, _): + _configure_provider(home, url) + config = home / "config.toml" + config.write_text(config.read_text().replace("plugins=false", "plugins=true") + .replace("remote_plugin=false", "remote_plugin=true") + .replace("plugin_sharing=false", "plugin_sharing=true")) + original_config = config.read_bytes() + env.update(CODEX_BIN=binary, LOOPX_PROJECT=str(workspace), LOOPX_GOAL_ID="fixture", + LOOPX_AGENT_ID="agent", LOOPX_CLI=str(Path(sys.executable).with_name("loopx"))) + prepare_context_delivery(env, home) + first_trust = env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] + prepare_context_delivery(env, home) + assert env["LOOPX_CODEX_CONTEXT_TRUST_ARGS"] == first_trust + assert calls == [] # Hook identity lookup never starts inference. + assert len(observed) == 2 + for features in observed: + assert all(features[key] is False for key in ("plugins", "remote_plugin", "plugin_sharing")) + assert config.read_bytes() == original_config + assert not list(home.rglob("*.pack")), "discovery must not clone a plugin catalog" From c5353ed19d73f78654faa7583242370dc41bb065 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:06:42 +0800 Subject: [PATCH 27/29] perf(context): avoid discarded clones and equal-string encoding Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 11 ++++++++++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 7 +++++++ loopx/control_plane/quota/context_delivery.ts | 19 +++++++++++------ .../control_plane_ts/context_delivery.test.ts | 21 +++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 6eac1da0fe..5249c9623c 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1231,6 +1231,17 @@ the same trust identities, and discovery sends no inference request. The same test detects the old helper's enabled plugin features. The cost figures above precede this helper fix and do not measure its latency or disk savings. +The shared codec also avoids copying unchanged/discarded replacement subtrees +and canonicalizing identical requirement strings. A V8 capture identifies +comparison, sizing and cloning work; a controlled intervention replays 31 fixed +real captures through the loaded owner. Four counterbalanced process pairs, +each with 12 measured replays after warmup, reduce mean prepare/recovery time +from 347.30 to 303.06 ms (12.74%). Every emitted delivery remains byte-identical, +and reconstruction plus detached-value checks preserve both source captures. +This measures the loaded codec on one host, excluding startup, CLI/backend IO, +native host, model and whole-Turn time. It changes neither packet bytes nor the +input-token result; complete-cost and solver-outcome acceptance remain open. + The native adapter now uses the existing Core original-capture IO owner for shared read guidance and projection, removing the reverse Core-to-CLI dependency. CLI presentation dispatch remains ahead of authority IO; event recording, diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index b8b85ffa43..48ba300868 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -847,6 +847,13 @@ solver 配置、默认关闭路径和模型设置保持不变。真实原生配 返回相同信任身份,且不发送推理请求;同一测试能够发现旧 helper 仍开启插件功能。 上述成本数据采于本次 helper 修复之前,不代表其延迟或落盘收益。 +共享 codec 同时避免复制未变化或未选中的替换子树,以及对相同要求字符串重复规范化。 +V8 采样定位比较、尺寸计算及复制开销;受控修改使用 31 份固定真实 capture 回放已加载 +owner。四组交叉进程对照,每进程预热后测 12 次回放,prepare/recovery 平均耗时从 +347.30 降至 303.06 ms(12.74%)。所有交付输出逐字节一致;重建与值隔离核验保持两份 +来源不被修改。这只测单机已加载 codec,不含启动、CLI/backend IO、原生宿主、模型或 +完整 Turn;不改变 packet 字节或输入 token 结果,完整成本和 solver 成果验收仍开放。 + 原生 adapter 改为复用既有 Core 原始 capture IO owner 的共享读取指导与投影,消除 Core 反向依赖 CLI。CLI 展示命令仍在权限 IO 之前分派;事件记录、settlement 回读与 memory ingest 保持原顺序。原生输入解析、调用绑定和摘要绑定分页核验分清职责, diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index 593db81aeb..6ee18026bc 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -11,8 +11,13 @@ const MAX_ANCESTRY = 32; type Path = (string | number)[]; type Split = {fresh: JsonObject; valid: boolean}; const clone = (value: T): T => structuredClone(value); -const equal = (a: unknown, b: unknown): boolean => a === undefined || b === undefined ? a === b - : JSON.stringify(canonicalAuthorityJson(a)) === JSON.stringify(canonicalAuthorityJson(b)); +const equal = (a: unknown, b: unknown): boolean => { + // Identical requirement strings need no canonical JSON allocation. Keep the + // existing type-sensitive comparison for every other value. + if (typeof a === "string" && a === b) return true; + return a === undefined || b === undefined ? a === b + : JSON.stringify(canonicalAuthorityJson(a)) === JSON.stringify(canonicalAuthorityJson(b)); +}; const digest = (value: unknown): string => `sha256:${canonicalAuthoritySha256(value)}`; const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8"); @@ -239,8 +244,8 @@ function changeTree(before: unknown, after: unknown, forced: Path[]): Delta | nu const exact = new Set(forced.map(pathKey)), ancestors = new Set(); for (const path of forced) for (let i = 0; i < path.length; i++) ancestors.add(pathKey(path.slice(0, i))); const visit = (a: unknown, b: unknown, path: Path): Delta | null => { - const key = pathKey(path), replacement: Delta = {value: clone(b)}; - if (exact.has(key)) return replacement; + const key = pathKey(path); + if (exact.has(key)) return {value: clone(b)}; if (!ancestors.has(key) && equal(a, b)) return null; let delta: Delta; if (jsonObject(a) && jsonObject(b)) { @@ -259,8 +264,10 @@ function changeTree(before: unknown, after: unknown, forced: Path[]): Delta | nu if (child) items.push([String(i), child]); }); delta = {items: Object.fromEntries(items), ...(a.length !== b.length ? {length: b.length} : {})}; - } else return replacement; - return bytes(delta) < bytes(replacement) ? delta : replacement; + } else return {value: clone(b)}; + // Sizing does not publish a value. Copy only a replacement that is chosen, + // rather than every unchanged subtree and every discarded ancestor value. + return bytes(delta) < bytes({value: b}) ? delta : {value: clone(b)}; }; return visit(before, after, []); } diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index 1a9116702a..d6394e157e 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -5,6 +5,27 @@ import {compactSelectedTodoSource, prepareContextDelivery, restoreContextDeliver import {evaluateTurnEnvelope} from "../../loopx/control_plane/quota/turn_envelope.ts"; +test("replacements and sparse edits own their values without mutating either capture", () => { + const original = {registry: {body: "Retain every original requirement. ".repeat(400), changed: {value: 1}}, + current_control: {labels: ["original"]}}; + const current = {...structuredClone(original), current_control: {labels: ["current"]}}; + current.registry.changed.value = 2; + const before = structuredClone(original), after = structuredClone(current); + const initial = prepareContextDelivery({context: original, scope, capture_id: "original", surface: "quota_packet"}); + const args = {context: current, scope, capture_id: "current", surface: "quota_packet"}; + const delta = prepareContextDelivery({...args, baseline: initial, retained: {complete: true, target: initial.target}}); + assert.equal(delta.mode, "delta"); + assert.deepEqual(inline(delta, ["current_control"]), {labels: ["current"]}); + const restored = restoreContextDelivery({delivery: delta, baseline: initial, current: prepareContextDelivery(args)}); + assert.deepEqual(restored.context, after); + // Publication must detach both a forced whole value and a sparse replacement. + (inline(delta, ["current_control"]) as {labels: string[]}).labels.push("wire-only"); + (restored.context as typeof current).registry.changed.value = 3; + assert.deepEqual(original, before); + assert.deepEqual(initial.context, before); + assert.deepEqual(current, after); +}); + const scope = {goal_id: "goal", agent_id: "agent", session_id: "session", context_epoch: "epoch-1"}; const long = "Keep the original acceptance and stop before publication. ".repeat(400); function context(): JsonObject { From 8cd5d5853abfdf3cde4739fc75427c42d094b4ea Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:46:26 +0800 Subject: [PATCH 28/29] feat(context): gate native packet reuse with a default-off host-aware switch Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/edgebench/run.py | 8 +- benchmark/runtime/RUNTIME.md | 16 +++- benchmark/runtime/checkpoint.py | 2 +- benchmark/runtime/checkpoint_wake.py | 1 + benchmark/runtime/codex.py | 8 +- benchmark/runtime/context_delivery.py | 4 +- benchmark/runtime/harbor.py | 6 +- benchmark/runtime/sforge.py | 2 +- benchmark/runtime/worker.py | 16 ++-- benchmark/tests/test_native_checkpoint.py | 7 +- .../tests/test_native_checkpoint_handoff.py | 2 +- benchmark/tests/test_sforge_runtime.py | 16 ++-- benchmark/tests/test_shared_codex_runtime.py | 20 +++-- .../rfcs/agent-loop-effect-interpreter-v0.md | 21 ++++- .../agent-loop-effect-interpreter-v0.zh-CN.md | 17 ++++- docs/heartbeat-automation-prompt.md | 34 +++++++-- loopx/cli_commands/quota.py | 13 +++- loopx/cli_commands/quota_capture.py | 52 +++++++++++-- loopx/cli_commands/quota_registration.py | 7 ++ loopx/cli_commands/support_control.py | 1 + .../support_control_heartbeat_registration.py | 7 ++ .../heartbeat/bootstrap_prompt.py | 9 ++- loopx/control_plane/heartbeat/builder.py | 18 +++++ loopx/control_plane/heartbeat/host.py | 4 + .../turn_driver/codex_context.py | 50 +++++++++++- loopx/hosts/codex_checkpoint.py | 3 +- .../test_codex_context_capture.py | 12 +-- .../test_codex_context_lifecycle.py | 76 ++++++++++++++++++- .../test_context_delivery_cli.py | 51 ++++++++++++- .../test_heartbeat_envelope_transport.py | 24 +++--- .../test_quota_decision_capture.py | 4 +- .../test_saved_context_project.py | 28 ++++++- 32 files changed, 439 insertions(+), 100 deletions(-) diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index ce786dab97..eb692b9d19 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -126,8 +126,8 @@ def main(argv=None): help="New-run default: native public feedback; blind/best-only are explicit controls") parser.add_argument("--turn-envelope", action="store_true", help="Opt-in short heartbeat context with same-invocation full captures") - parser.add_argument("--context-delivery", action="store_true", - help="Opt-in native heartbeat subtree reuse with original-capture recovery") + parser.add_argument("--context-delivery", choices=("off", "on"), default="off", + help="Experimental native Codex benchmark heartbeat reuse only; off by default") parser.add_argument("--native-goal-continue", action="store_true", help="Opt-in: when a native Goal declares completion with budget left, start " "continuation Goals until the budget edge (native-goal worker only)") @@ -192,7 +192,7 @@ def main(argv=None): parser.error("--task-entry loopx-planned requires a heartbeat worker") if args.turn_envelope and args.worker not in {"heartbeat-resume", "heartbeat-explore"}: parser.error("--turn-envelope requires a heartbeat worker") - if args.context_delivery and not args.worker.startswith("heartbeat-"): + if args.context_delivery != "off" and not args.worker.startswith("heartbeat-"): parser.error("--context-delivery requires a heartbeat worker") if args.native_goal_continue and args.worker != "native-goal": parser.error("--native-goal-continue requires --worker native-goal") @@ -388,7 +388,7 @@ def main(argv=None): "loopx_commit": pins[0], "runner_commit": pins[1], "sforge_source": active_source_receipt(), **turn_envelope_receipt(explicit=args.turn_envelope), - **({"context_delivery": True} if args.context_delivery else {}), + **({"context_delivery": "on"} if args.context_delivery != "off" else {}), **({"native_goal_continue": True} if args.native_goal_continue else {}), "task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(), "feedback": args.feedback, "internet": task.internet, diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index ec5109da60..2680162dbc 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -46,9 +46,17 @@ do not change a running trial's treatment. Captures contain private Goal context Transport tests do not establish lower token cost or better model decisions. For an explicitly selected native context-delivery treatment, add -`context_delivery: true` to the same heartbeat agent `kwargs` (default `false`). +`context_delivery: "on"` to the same heartbeat agent `kwargs` (default `"off"`). +Only the existing Codex heartbeat worker accepts `"on"`; booleans, other modes and +scenario names are rejected. This is an experimental transport setting, separate +from `turn_envelope`. The existing EdgeBench/SForge entry exposes the same selection through -`--context-delivery` on a heartbeat worker. +`--context-delivery on` on a heartbeat worker (`off` is the default). +The worker preserves its existing `generic_cli` scheduler profile. Its execution +owner requires `mode=heartbeat` and explicitly propagates `LOOPX_EXECUTION_MODE=heartbeat` +and `LOOPX_CONTEXT_DELIVERY=on` into the native tool environment. Generic CLI +without these worker declarations stays full. These are caller declarations, +not host authentication or a new admission grant. It works with ordinary quota or the explicit TurnEnvelope view. The existing worker stages two product lifecycle hooks in the trial's private Codex home and queries native `hooks/list` without invoking a model. Only those exact native @@ -81,7 +89,7 @@ earlier input replays did not include it. Count complete input, recovery reads, IO in treatment cost, not just the delta bytes. Codex App setup uses the same product configuration renderer as the worker: -`loopx --format json quota context-config --codex-context-root DIR --goal-id GOAL --agent-id AGENT`. +`loopx --format json quota context-config --context-delivery on --codex-app --codex-context-root DIR --goal-id GOAL --agent-id AGENT`. It returns native hooks and persistent recovery instructions, without installing files or changing trust. A shared root isolates journals by native session; the trial-local single-session `--codex-context-dir` remains supported. @@ -113,7 +121,7 @@ unchanged. Include the projection command, descriptor, recovery and page reads in subsequent total-cost comparisons. Across distinct worker wakes, settle the prior Turn before selecting new work; reading an old prompt from resumed history cannot supply the next wake's guard. -Do not modify an active/frozen trial. Disable `context_delivery` for a new trial +Do not modify an active/frozen trial. Set `context_delivery: "off"` for a new trial to roll back; default runs install no hooks, trust or trace settings. No scorer, budget, task semantics or launch authority changes with this option. diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py index 2cdef2a08d..dc6c796ea2 100644 --- a/benchmark/runtime/checkpoint.py +++ b/benchmark/runtime/checkpoint.py @@ -154,7 +154,7 @@ def capture_before_replan(env: dict[str, str], receipt: dict[str, Any], body: st guard_path.write_text(guard_run.stdout, encoding="utf-8") guard_path.chmod(0o600) reading = f"Read the complete packet at {guard_path} before work, then follow its interaction_contract. " - if env.get("LOOPX_CONTEXT_DELIVERY") == "1" and env.get("LOOPX_CODEX_CONTEXT_DIR"): + if env.get("LOOPX_CONTEXT_DELIVERY") == "on" and env.get("LOOPX_CODEX_CONTEXT_DIR"): from .context_delivery import preflight_context_instruction reading = preflight_context_instruction(env, guard_path, diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py index 0b0f84936b..e0dc0ee2be 100644 --- a/benchmark/runtime/checkpoint_wake.py +++ b/benchmark/runtime/checkpoint_wake.py @@ -138,6 +138,7 @@ def _capture(self, held, deadline): "decision_sha256": held["sha256"]} if self.env.get("LOOPX_CODEX_CONTEXT_DIR"): guard["codex_context_dir"] = self.env["LOOPX_CODEX_CONTEXT_DIR"] + guard["context_delivery"] = self.env.get("LOOPX_CONTEXT_DELIVERY", "off") reader = readers.enter_context(native_checkpoint_reader(self.env, timeout=max(.1, window - time.time()))) native = native_session_checkpoint(self.env, self.receipt["session"]["session_id"], timeout=max(.1, window - time.time()), interrupted_guard=guard, transport=reader, diff --git a/benchmark/runtime/codex.py b/benchmark/runtime/codex.py index ccda035045..3d60db2ed8 100644 --- a/benchmark/runtime/codex.py +++ b/benchmark/runtime/codex.py @@ -28,11 +28,11 @@ class Execution: validation_command: tuple[str, ...] = () task_entry: str | None = None turn_envelope: bool = False - context_delivery: bool = False + context_delivery: str = "off" def __post_init__(self) -> None: - if not isinstance(self.context_delivery, bool) or (self.context_delivery and self.mode != "heartbeat"): - raise ValueError("context_delivery requires heartbeat execution and a boolean opt-in") + if self.context_delivery not in ("off", "on") or (self.context_delivery != "off" and self.mode != "heartbeat"): + raise ValueError("context_delivery supports only off or on with heartbeat execution") if not isinstance(self.turn_envelope, bool) or (self.turn_envelope and self.mode != "heartbeat"): raise ValueError("turn_envelope requires heartbeat execution and a boolean opt-in") if self.mode not in MODES or self.context not in CONTEXTS: @@ -185,7 +185,7 @@ def prepare_codex_home( # policy explicitly so model tools cannot silently lose it. "[shell_environment_policy.set]", 'LOOPX_USAGE_PING = "0"', - *(['LOOPX_CONTEXT_DELIVERY = "1"'] if execution.context_delivery else []), + *(['LOOPX_CONTEXT_DELIVERY = "on"', 'LOOPX_EXECUTION_MODE = "heartbeat"'] if execution.context_delivery != "off" else []), "[features]", f"goals = {str(execution.native_goal).lower()}", "unified_exec = true", diff --git a/benchmark/runtime/context_delivery.py b/benchmark/runtime/context_delivery.py index 9414ff8acd..bf0fe1ce63 100644 --- a/benchmark/runtime/context_delivery.py +++ b/benchmark/runtime/context_delivery.py @@ -25,7 +25,7 @@ def preflight_context_instruction(env: dict[str, str], path: Path, digest: str, source = ["--capture-file", str(path), "--capture-sha256", digest] project = [*prefix, "context-project", *source, "--goal-id", env["LOOPX_GOAL_ID"], "--agent-id", env["LOOPX_AGENT_ID"], - "--turn-instance-id", turn_id, "--codex-context-dir", env["LOOPX_CODEX_CONTEXT_DIR"], + "--turn-instance-id", turn_id, "--context-delivery", "on", "--runtime-profile", "generic_cli", "--codex-context-dir", env["LOOPX_CODEX_CONTEXT_DIR"], "--decision-output-root", str(path.parent / "decisions")] full = [*prefix, "context-read", *source] return ("Present that same immutable packet with:\n```sh\n" + shlex.join(project) @@ -56,7 +56,7 @@ def prepare_context_delivery(env: dict[str, str], home: Path) -> None: env["CODEX_ROLLOUT_TRACE_ROOT"] = tempfile.mkdtemp(prefix="wake-", dir=traces) env["LOOPX_CODEX_CONTEXT_DIR"] = str(root) configuration = codex_context_configuration(str(root), goal_id=env["LOOPX_GOAL_ID"], - agent_id=env["LOOPX_AGENT_ID"], cli_argv=[env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]]) + agent_id=env["LOOPX_AGENT_ID"], scenario="codex_benchmark_heartbeat", cli_argv=[env["LOOPX_CLI"], "--cli-bin", env["LOOPX_CLI"]]) content = json.dumps({"hooks": configuration["hooks"]}) path = home / "hooks.json" if path.is_symlink() or (path.exists() and path.read_text() != content): diff --git a/benchmark/runtime/harbor.py b/benchmark/runtime/harbor.py index d21099235f..568c0c2dfa 100644 --- a/benchmark/runtime/harbor.py +++ b/benchmark/runtime/harbor.py @@ -72,7 +72,7 @@ def __init__( task_entry=None, planning_timeout_sec=None, turn_envelope=False, - context_delivery=False, + context_delivery="off", native_goal_continue=False, task_objective=None, execution_observation=None, @@ -170,7 +170,7 @@ def __init__( def _turn_envelope_receipt(self) -> dict: return {**turn_envelope_receipt(explicit=self.execution.turn_envelope), - **({"context_delivery": True} if self.execution.context_delivery else {})} + **({"context_delivery": "on"} if self.execution.context_delivery != "off" else {})} def _planning_receipt(self) -> dict: return {"native_goal_continue": True} if self.native_goal_continue else {} @@ -614,7 +614,7 @@ def _worker_env(self, *, cwd: str) -> dict[str, str]: **({"LOOPX_CHECKPOINT_REQUEST": self.checkpoint_request_path} if self.checkpoint_request_path is not None else {}), **({"LOOPX_TURN_ENVELOPE": "1"} if self.execution.turn_envelope else {}), - **({"LOOPX_CONTEXT_DELIVERY": "1"} if self.execution.context_delivery else {}), + **({"LOOPX_CONTEXT_DELIVERY": "on"} if self.execution.context_delivery != "off" else {}), **({"LOOPX_NATIVE_GOAL_CONTINUE": "1"} if self.native_goal_continue else {}), "LOOPX_ITERATION_CONTEXT": self.execution.context, "LOOPX_CODEX_SANDBOX": self.execution.sandbox, diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index b5d35f6474..62c9d2a943 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -92,7 +92,7 @@ def __init__(self, config, *, profile: str, cwd: str, task_objective: str | None = None, task_entry: str | None = None, turn_envelope: bool = False, - context_delivery: bool = False, + context_delivery: str = "off", native_goal_continue: bool = False, replan_after_turns: int | None = None, replan_after_todos: int | None = None, diff --git a/benchmark/runtime/worker.py b/benchmark/runtime/worker.py index 981818238f..df34425088 100644 --- a/benchmark/runtime/worker.py +++ b/benchmark/runtime/worker.py @@ -92,7 +92,7 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, command += ["--turn-instance-id", turn_id] if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") - context_dir = env.get("LOOPX_CODEX_CONTEXT_DIR") if env.get("LOOPX_CONTEXT_DELIVERY") == "1" else None + context_dir = env.get("LOOPX_CODEX_CONTEXT_DIR") if env.get("LOOPX_CONTEXT_DELIVERY") == "on" else None if env.get("LOOPX_TURN_ENVELOPE") == "1" or decision_handoff_socket is not None or context_dir: if native_goal: raise ValueError("TurnEnvelope capture requires a host-owned heartbeat Turn") @@ -105,7 +105,7 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, if decision_handoff_socket is not None: command += ["--decision-handoff-socket", decision_handoff_socket] if context_dir: - command += ["--codex-context-dir", context_dir] + command += ["--context-delivery", "on", "--codex-context-dir", context_dir] result = subprocess.run( command, cwd=env["LOOPX_PROJECT"], @@ -385,7 +385,7 @@ def native_command(env, execution, stage, wake, session_wake): session_id=session_wake.session_id if session_wake else None, mcp_server=None, ) - if execution.context_delivery: + if execution.context_delivery != "off": command[-1:-1] = json.loads(env.get("LOOPX_CODEX_CONTEXT_TRUST_ARGS", "[]")) if stage == "plan": command[-1:-1] = ["-c", "features.goals=false"] @@ -414,8 +414,8 @@ def _phase_timeout(timeout: float | None, deadline: float | None) -> tuple[float def run_once(env: dict[str, str]) -> dict: - if env.get("LOOPX_CONTEXT_DELIVERY", "0") not in {"0", "1"}: - raise ValueError("LOOPX_CONTEXT_DELIVERY must be 0 or 1") + if env.get("LOOPX_CONTEXT_DELIVERY", "off") not in {"off", "on"}: + raise ValueError("LOOPX_CONTEXT_DELIVERY supports only off or on") if env.get("LOOPX_TURN_ENVELOPE", "0") not in {"0", "1"}: raise ValueError("LOOPX_TURN_ENVELOPE must be 0 or 1") execution = Execution( @@ -427,7 +427,7 @@ def run_once(env: dict[str, str]) -> dict: validation_command=json.loads(env.get("LOOPX_VALIDATION_COMMAND_JSON", "[]")), task_entry=env.get("LOOPX_TASK_ENTRY"), turn_envelope=env.get("LOOPX_TURN_ENVELOPE", "0") == "1", - context_delivery=env.get("LOOPX_CONTEXT_DELIVERY", "0") == "1", + context_delivery=env.get("LOOPX_CONTEXT_DELIVERY", "off"), ) stage = env.get("LOOPX_TASK_STAGE", "execute") if stage not in {"plan", "execute"} or (stage == "plan" and execution.task_entry != "loopx-planned"): @@ -456,7 +456,7 @@ def run_once(env: dict[str, str]) -> dict: if observation_producer is not None else {}), **({"turn_envelope": True, "turn_envelope_source": "explicit"} if execution.turn_envelope else {}), - **({"context_delivery": True} if execution.context_delivery else {}), + **({"context_delivery": "on"} if execution.context_delivery != "off" else {}), "stage": stage, "home_scope": "trial", "ok": False, @@ -508,7 +508,7 @@ def run_once(env: dict[str, str]) -> dict: checkpoint_wake = checkpoint_context.enter_context(CheckpointWake(env, receipt)) else: checkpoint_wake = _InertCheckpointWake() - if execution.context_delivery: + if execution.context_delivery != "off": from benchmark.runtime.context_delivery import prepare_context_delivery prior_trace = env.get("CODEX_ROLLOUT_TRACE_ROOT") diff --git a/benchmark/tests/test_native_checkpoint.py b/benchmark/tests/test_native_checkpoint.py index 7b335e8d81..cf378f3532 100644 --- a/benchmark/tests/test_native_checkpoint.py +++ b/benchmark/tests/test_native_checkpoint.py @@ -943,10 +943,13 @@ def test_interrupted_cursor_requires_exact_unreleased_final_guard(tmp_path, monk changed = copy.deepcopy(turns) changed[-1]["items"][-1][field] = value variants.append(changed) - for option, value in [("--turn-instance-id", "wrong-turn"), ("--decision-handoff-socket", "/tmp/other"), + for option, value in [("--context-delivery", "on"), ("--turn-instance-id", "wrong-turn"), ("--decision-handoff-socket", "/tmp/other"), ("--registry", "/other.json"), ("--goal-id", "other")]: altered = list(argv) - altered[altered.index(option) + 1] = value + if option in altered: + altered[altered.index(option) + 1] = value + else: + altered += [option, value] changed = copy.deepcopy(turns) changed[-1]["items"][-1]["command"] = shlex.join(["/bin/bash", "-lc", shlex.join(altered)]) variants.append(changed) diff --git a/benchmark/tests/test_native_checkpoint_handoff.py b/benchmark/tests/test_native_checkpoint_handoff.py index df908297b6..17411246b0 100644 --- a/benchmark/tests/test_native_checkpoint_handoff.py +++ b/benchmark/tests/test_native_checkpoint_handoff.py @@ -720,7 +720,7 @@ def response(payload, index, message): MODEL_NAME="fixture", REASONING_EFFORT="high", OPENAI_BASE_URL=base_url, OPENAI_API_KEY="synthetic-local-only", LOOPX_CODEX_TURN_TIMEOUT_SEC="60") if context_delivery: - env["LOOPX_CONTEXT_DELIVERY"] = "1" + env["LOOPX_CONTEXT_DELIVERY"] = "on" deadline = str(time.time() + 600) env["LOOPX_PHASE_DEADLINE_EPOCH"] = deadline if capture != "off": diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index 13409a8e73..caae5d1417 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -225,7 +225,7 @@ def command(handle, cmd, **kwargs): @pytest.mark.parametrize("capture_path", [None, "/control/checkpoint-request.json"]) -@pytest.mark.parametrize("context_delivery", [False, True]) +@pytest.mark.parametrize("context_delivery", ["off", "on"]) def test_checkpoint_request_reaches_sforge_worker_without_changing_default_profile(tmp_path, monkeypatch, capture_path, context_delivery): pytest.importorskip("sforge") pytest.importorskip("harbor") @@ -248,8 +248,8 @@ async def installed(self, environment): assert "turn_envelope" not in profile assert "turn_envelope_source" not in profile assert "LOOPX_TURN_ENVELOPE" not in env - assert env.get("LOOPX_CONTEXT_DELIVERY") == ("1" if context_delivery else None) - assert profile.get("context_delivery") is (True if context_delivery else None) + assert env.get("LOOPX_CONTEXT_DELIVERY") == ("on" if context_delivery != "off" else None) + assert profile.get("context_delivery") == (context_delivery if context_delivery != "off" else None) assert profile.get("checkpoint_capture_enabled") is (True if capture_path else None) with pytest.raises(ValueError, match="checkpoint_request_path"): SForgeWorker(config, profile="official", cwd="/task", checkpoint_request_path="/control/request.json") @@ -352,7 +352,7 @@ async def installed(self, environment): assert profile.get("native_goal_continue") is (True if enabled else None) with pytest.raises(ValueError, match="native-goal"): SForgeWorker(config, profile="heartbeat-resume", cwd="/task", native_goal_continue=True) - with pytest.raises(ValueError, match="boolean"): + with pytest.raises(ValueError, match="context_delivery"): SForgeWorker(config, profile="native-goal", cwd="/task", native_goal_continue="1") @@ -1238,7 +1238,7 @@ def stop_before_solver(**kwargs): @pytest.mark.parametrize("capture", [False, True]) -@pytest.mark.parametrize("context_delivery", [False, True]) +@pytest.mark.parametrize("context_delivery", ["off", "on"]) def test_native_checkpoint_storage_guidance_reaches_both_prompt_surfaces(tmp_path, monkeypatch, capture, context_delivery): pytest.importorskip("sforge") pytest.importorskip("harbor") @@ -1274,8 +1274,8 @@ def before_solver(**kwargs): args = ["--task", "fixture", "--tasks-dir", str(tmp_path), "--log-dir", str(tmp_path), "--run-id", "storage", "--worker", "heartbeat-explore", "--model", "fixture", "--effort", "xhigh", "--judge-url", "http://127.0.0.1:9999", "--feedback", "native"] - if context_delivery: - args += ["--context-delivery"] + if context_delivery != "off": + args += ["--context-delivery", "on"] if capture: declaration = tmp_path / "request.json" declaration.write_text(json.dumps({"destination": "/logs/agent/checkpoints", @@ -1284,4 +1284,4 @@ def before_solver(**kwargs): with pytest.raises(RuntimeError, match="validated before model execution"): run.main(args) receipt = json.loads((tmp_path / "runs/storage/fixture/runtime-receipt.json").read_text()) - assert receipt.get("context_delivery") is (True if context_delivery else None) + assert receipt.get("context_delivery") == (context_delivery if context_delivery != "off" else None) diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index b24bf4dbae..09ec53859e 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -780,7 +780,7 @@ def test_default_execution_has_no_independent_turn_deadline(tmp_path): (False, "fresh_replan", None), (True, "fresh_replan", "explicit"), ]) -@pytest.mark.parametrize("context_delivery", [False, True]) +@pytest.mark.parametrize("context_delivery", ["off", "on"]) def test_harbor_install_receipt_keeps_capture_independent_from_display(tmp_path, monkeypatch, enabled, trigger, expected, context_delivery): pytest.importorskip("harbor") from benchmark.runtime.harbor import BenchmarkCodex, CodexOffline @@ -843,8 +843,8 @@ async def task_exec(**kwargs): assert agent._worker_env(cwd="/task")["LOOPX_USAGE_PING"] == "0" assert ("turn_envelope" in receipt) is (expected is not None) assert receipt.get("turn_envelope_source") == expected - assert receipt.get("context_delivery") is (True if context_delivery else None) - assert agent._worker_env(cwd="/task").get("LOOPX_CONTEXT_DELIVERY") == ("1" if context_delivery else None) + assert receipt.get("context_delivery") == (context_delivery if context_delivery != "off" else None) + assert agent._worker_env(cwd="/task").get("LOOPX_CONTEXT_DELIVERY") == ("on" if context_delivery != "off" else None) if expected: assert receipt["turn_envelope"] is True @@ -1101,15 +1101,15 @@ def test_horizon_setting_rejects_non_boolean_and_non_loopx_mode(tmp_path, settin @pytest.mark.parametrize("mode", ["plain", "native-goal", "turn", "loopx-goal"]) def test_native_context_opt_in_is_only_a_heartbeat_treatment(mode): with pytest.raises(ValueError, match="context_delivery"): - Execution(mode=mode, context_delivery=True) - with pytest.raises(ValueError, match="boolean"): + Execution(mode=mode, context_delivery="on") + with pytest.raises(ValueError, match="context_delivery"): Execution(context_delivery="1") def test_context_transport_unavailable_preserves_ordinary_worker(tmp_path, monkeypatch): from benchmark.tests.test_resume_sessions import resume_env env = resume_env(tmp_path, monkeypatch) - env["LOOPX_CONTEXT_DELIVERY"] = "1" + env["LOOPX_CONTEXT_DELIVERY"] = "on" def unavailable(env, home): env["LOOPX_CODEX_CONTEXT_DIR"] = "not-ready" env["CODEX_ROLLOUT_TRACE_ROOT"] = "not-ready" @@ -1135,7 +1135,7 @@ def unexpected(*args): def test_context_selection_is_fixed_by_trial_home(tmp_path): - args = settings(tmp_path, execution=Execution(mode="heartbeat", context_delivery=True)) + args = settings(tmp_path, execution=Execution(mode="heartbeat", context_delivery="on")) prepare_codex_home(**args) with pytest.raises(ValueError, match="settings changed"): prepare_codex_home(**(args | {"execution": Execution(mode="heartbeat")})) @@ -1205,3 +1205,9 @@ def request(method, params): assert all("features.plugins" not in value and "features.remote_plugin" not in value and "features.plugin_sharing" not in value for value in trusted) assert config.read_bytes() == original_config + + +@pytest.mark.parametrize("value", [True, False, "1", "codex_benchmark_heartbeat", "codex_app_heartbeat", "all"]) +def test_context_benchmark_setting_rejects_broad_or_other_host_activation(value): + with pytest.raises(ValueError, match="context_delivery"): + Execution(context_delivery=value) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 5249c9623c..5f3360d5e6 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1090,8 +1090,8 @@ installed adoption, trace/all-read overhead and real solver outcomes remain sepa question is added, and phase one remains open. The existing benchmark worker now consumes this production path through an -explicit `context_delivery` heartbeat option, also exposed by EdgeBench/SForge -as `--context-delivery`. Its private trial home stages the product hooks and +explicit `context_delivery: "on"` heartbeat option (default `"off"`), also exposed by EdgeBench/SForge +as `--context-delivery on`. Its private trial home stages the product hooks and uses native `hooks/list` to trust only their exact hashes for that process; unrelated workspace hooks remain untrusted. Opt-in private traces support the input observer. Setup failure continues ordinary full output; an existing @@ -1156,7 +1156,22 @@ Lookup starts at offset zero. Every following `next_command` pins the original path and hash, so a concurrent newer publication cannot mix captures. Missing, changed or symlinked files retain the ordinary reader's refusal behavior. -`quota context-config` renders the native hook/recovery configuration through +The experimental switch is now `--context-delivery {off,on}`, default `off`. +It reuses the existing declared host context: `--codex-app` / the equivalent +`codex_app_heartbeat` profile, or the benchmark worker's existing `generic_cli` +profile plus its explicit `LOOPX_EXECUTION_MODE=heartbeat` and +`LOOPX_CONTEXT_DELIVERY=on` declarations. The worker's existing execution owner +rejects non-heartbeat modes and propagates these settings to native tools; its +scheduler declaration is unchanged. No process or ambient native-session +heuristic authenticates a host. Unsupported, absent or +contradictory declarations keep full presentation with a reason; they do not +block otherwise authorized work. Hooks, configuration, saved presentation and +reentry preserve this binary selection. Directory options or a host handoff +alone cannot turn on deltas. Full original-capture recovery remains usable when +off. These caller declarations select a supported adapter path, grant no +execution authority and do not replace actual-input retention verification. + +`quota context-config --context-delivery on --codex-app` renders the native hook/recovery configuration through the CLI; the benchmark worker reuses that same provider helper. Shared Codex App configuration uses `--codex-context-root` to isolate journals by native session, while dedicated trial homes retain `--codex-context-dir`. Read-only lookup never diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 48ba300868..f369add24e 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -751,8 +751,8 @@ code-mode transport,不等于已安装桌面产品采用。下述常驻恢复 被杀;安装采用、trace 与全部补读开销及真实 solver 成果仍须独立验证;不开新 runtime/owner 问询门,一期保持开放。 -既有 benchmark worker 现通过显式 `context_delivery` heartbeat 选项消费上述生产 -路径;EdgeBench/SForge 以 `--context-delivery` 暴露同一选择。trial 私有 home 放置 +既有 benchmark worker 现通过显式 `context_delivery: "on"` heartbeat 选项消费上述生产 +路径(默认 `"off"`);EdgeBench/SForge 以 `--context-delivery on` 暴露同一选择。trial 私有 home 放置 产品 hook,并用原生 `hooks/list` 获取精确 hash,仅在该进程信任这些 hook;无关 workspace hook 仍不受信任。显式启用的私有 trace 支持输入 observer。设置失败继续 普通全量,既有 checkpoint handoff 保留原 capture 所有权。run/install/wake receipt @@ -797,7 +797,18 @@ code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列 查找从 offset 零开始,后续每个 `next_command` 固定到原路径和 hash,避免并发发布 更新 capture 时混读。文件缺失、内容变化或符号链接继续沿既有 reader 拒绝。 -`quota context-config` 通过 CLI 生成原生 hook/恢复配置,benchmark worker 复用同一 +实验开关现为 `--context-delivery {off,on}`,默认 `off`。复用已有声明的宿主上下文: +`--codex-app` / 等价 `codex_app_heartbeat` profile,或 benchmark worker 既有 +`generic_cli` profile 加显式 `LOOPX_EXECUTION_MODE=heartbeat` 与 +`LOOPX_CONTEXT_DELIVERY=on` 声明。worker 既有执行 owner 拒绝非 heartbeat 模式, +并把这些设置传递给原生工具;scheduler 声明不变。不通过进程名或环境中的原生 +session 启发式认证宿主。声明缺失、 +矛盾或不受支持时保留全量并说明原因,不阻塞已有权限内的工作。hook、配置、已保存 +包展示和重入均传递这一二态选择。目录参数或 host handoff 本身不能开启差分。关闭时 +仍可回查原 capture 全量。这些调用方声明只选择受支持 adapter 路径,不授予执行权限, +也不替代实际输入保留验证。 + +`quota context-config --context-delivery on --codex-app` 通过 CLI 生成原生 hook/恢复配置,benchmark worker 复用同一 provider helper。Codex App 共享配置使用 `--codex-context-root` 按原生 session 隔离 journal,独立 trial home 保留 `--codex-context-dir`。只读查找不创建未知 session 目录。心跳 bootstrap 保留 root/capture 参数,使用既有每次唤醒的 `LOOPX_TURN` diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index eb80b8c407..4734a8e6e2 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -423,11 +423,32 @@ loopx-canary heartbeat-prompt \ ### Opt-in native context delivery +`--context-delivery {off,on}` is experimental and defaults to `off`. It reuses +existing scheduler-owned host declarations and the benchmark worker's explicit +execution settings; it does not guess a host from process names or ambient native +session variables. Only two declared run modes can activate it: + +| Supported entry | Existing host declaration | +| --- | --- | +| Codex App heartbeat, same-session resume and recovery | `--codex-app`, equivalently `--runtime-profile codex_app_heartbeat` (or its complete explicit host tuple) | +| Codex benchmark heartbeat worker | Existing `generic_cli` profile plus the worker-owned `LOOPX_EXECUTION_MODE=heartbeat` and `LOOPX_CONTEXT_DELIVERY=on` declarations; its execution owner rejects other modes | + +`--codex-app` is a shortcut for a complete runtime profile. `--host-surface` alone +is only one field; incomplete, contradictory or unsupported declarations do not +activate reuse. `on` with no supported declaration returns full delivery and an +activation reason. `off` creates no native journal, hook configuration or input +observation; directories alone never turn it on. This declaration check is not +host authentication, execution authority or proof of retained input. Every warm +delta still needs independently verified complete current-input evidence. +Generic CLI, visible native Goals, Claude and other hosts remain outside this +opt-in. Harbor/container and installed desktop adoption retain their separate +unqualified boundaries; listing an entry does not certify live adoption. + For a trusted Codex host, create private context/capture directories and render the native setup through the installed CLI: ```sh -loopx --format json quota context-config \ +loopx --format json quota context-config --context-delivery on --codex-app \ --codex-context-root --goal-id --agent-id ``` @@ -442,7 +463,7 @@ different native sessions in separate journals, including interleaved sessions. Generate the normal bootstrap with `heartbeat-prompt --thin --codex-app --bootstrap`, its existing Goal/Agent/registry arguments, and -`--decision-output-root --codex-context-root +`--context-delivery on --decision-output-root --codex-context-root `; optionally add `--turn-envelope`. The loader preserves these options without freezing a Turn ID. Each wake loads current instructions and binds the existing `LOOPX_TURN` before its generated guard. Selection keeps @@ -471,7 +492,7 @@ For a host that already saved the quota decision before invoking Codex, present that immutable snapshot from the native tool instead of running its guard again: ```sh -loopx --format json quota context-project \ +loopx --format json quota context-project --context-delivery on --codex-app \ --capture-file --capture-sha256 \ --goal-id --agent-id --turn-instance-id \ --codex-context-root --decision-output-root @@ -486,8 +507,11 @@ Goal/Agent/Turn refuses publication. This is integrity-checked presentation of the caller's snapshot, not fresh authority, registry lookup, installation, admission or accounting. The original file stays unchanged; generated caches remain private. A missing projection command can use `quota context-read` with -the original file/hash. Disable the existing context opt-in to restore the host's -ordinary full-read instruction; no stored decision or receipt is rewritten. +the original file/hash. Set `--context-delivery off` and remove context options from generated host +configuration to restore ordinary full-read instructions. Remove only these generated hook entries and +appended recovery instruction from native configuration, and disable their +owned diagnostics if enabled. Preserve unrelated hooks/settings; the CLI does +not edit host configuration. No stored decision or receipt is rewritten. To roll back, remove the context/capture options from this bootstrap and remove only the generated hooks/recovery instruction from the native configuration. Existing captures remain private recovery evidence. This opt-in does not grant diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 5d3cd2e1d0..f8e55fe621 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -468,13 +468,16 @@ def _emit_quota_result( turn_id=_effective_spend_turn_instance_id(payload, heartbeat_turn_id=heartbeat_turn_id), started_at=usage_quota_started, ) + from .quota_capture import native_context_activation + + activation = native_context_activation(args) bind_capture_selection_transport(payload, args) delivery_request = None decision_identity = ("", "") try: capture_decision(capture_directory, payload) if payload.get("ok") is True: - delivery_request = handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None)) + delivery_request = handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None), activation["enabled"]) except QuotaDecisionTransportError as exc: # The guard already ran. Report that typed fact with the durable receipt # and saved decision, never a new collection failure that could replace @@ -507,8 +510,9 @@ def _emit_quota_result( # Handoff releases the immutable original before any presentation. Native # actual-input observation owns its projection when explicitly selected; # never wrap one context-delivery wire in a second projection. - native_context = bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) - if args.format == "json" and succeeded and delivery_request is not None and not native_context: + native_context = activation["enabled"] and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) + if (args.format == "json" and succeeded and delivery_request is not None and not native_context + and activation["enabled"]): payload = project_captured_context(payload, directory=capture_directory, request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], @@ -528,6 +532,8 @@ def _emit_quota_result( capture_id=capture_id) except (OSError, ValueError): pass # Lost optional capture: keep the admitted full payload, never rerun its guard. + if activation["requested"] == "on" and not activation["enabled"]: + payload = {**payload, "context_delivery": activation} print_payload(payload, args.format, _quota_renderer(args)) return 0 if succeeded else 1 @@ -625,6 +631,7 @@ def handle_quota_command( or getattr(args, "decision_handoff_socket", None) is not None or getattr(args, "codex_context_dir", None) is not None or getattr(args, "codex_context_root", None) is not None + or getattr(args, "context_delivery", "off") != "off" ): validate_quota_command_context_request(args) capture_directory = prepare_decision_capture(args) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 14ac29b4ba..754b17457f 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -20,6 +20,22 @@ ) +def native_context_activation(args: argparse.Namespace) -> dict[str, Any]: + from ..control_plane.turn_driver.codex_context import context_delivery_activation + + fields = {key: getattr(args, key, None) for key in ("host_surface", "scheduler_owner", "execution_mode")} + profiles = [value for value in (getattr(args, "runtime_profile", None), + "codex_app_heartbeat" if getattr(args, "codex_app", False) else None, + "trae_app" if getattr(args, "trae_app", False) else None) if value is not None] + if len(profiles) > 1: + return context_delivery_activation(getattr(args, "context_delivery", "off")) + return context_delivery_activation(getattr(args, "context_delivery", "off"), + runtime_profile=profiles[0] if profiles else None, + scheduler_execution_context=fields if any(fields.values()) else None, + benchmark_mode=os.environ.get("LOOPX_EXECUTION_MODE") + if os.environ.get("LOOPX_CONTEXT_DELIVERY") == "on" else None) + + def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict[str, Any]]: """One bounded physical-file/hash check for recovery and saved projection.""" from ..control_plane.effect_runtime import MAX_REQUEST_BYTES @@ -125,6 +141,8 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context from ..control_plane.turn_driver.decision_capture import write_decision_capture + if not native_context_activation(args)["enabled"]: + raise ValueError("context delivery is off") root = Path(args.decision_output_root).expanduser().absolute() metadata = root.lstat() if root.resolve(strict=True) != root or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or metadata.st_mode & 0o077: @@ -154,15 +172,20 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: return {"ok": True, "mode": mode, **reference, **({"target": target} if target else {}), "recovery": full_reference, "capture_sha256": args.capture_sha256, + **({"context_delivery": native_context_activation(args)} if getattr(args, "context_delivery", "off") == "on" else {}), "instruction": "Presentation of the original decision only; authority and freshness are unchanged. " + CONTEXT_CAPTURE_READ_GUIDANCE} def prepare_decision_capture(args: argparse.Namespace) -> Path | None: + enabled = native_context_activation(args)["enabled"] + if enabled and not (getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None) + or getattr(args, "decision_handoff_socket", None)): + raise QuotaCommandValidationError("context-delivery on requires a native context location or explicit host handoff") requested = getattr(args, "decision_output_dir", None) root = getattr(args, "decision_output_root", None) handoff = getattr(args, "decision_handoff_socket", None) - if (getattr(args, "codex_context_dir", None) is not None + if enabled and (getattr(args, "codex_context_dir", None) is not None or getattr(args, "codex_context_root", None) is not None) and ( args.format != "json" or (requested is None and root is None)): raise QuotaCommandValidationError( @@ -189,6 +212,7 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: if parent.is_symlink() or not parent.is_dir(): raise OSError("capture root must be an existing directory, not a symlink") return Path(tempfile.mkdtemp(prefix="decision-", dir=parent)) + assert requested is not None # root is absent; exactly one destination was checked above. directory = Path(requested).expanduser().absolute() # A new directory per invocation prevents overwriting a prior observation, # including a prior invocation in the same Turn. Existing symlinks fail too. @@ -238,6 +262,8 @@ def bind_capture_selection_transport(payload: dict[str, Any], args: argparse.Nam suffix += " --decision-handoff-socket " + shlex.quote(args.decision_handoff_socket) if getattr(args, "turn_envelope", False): suffix += " --turn-envelope" + if getattr(args, "context_delivery", "off") != "off": + suffix += " --context-delivery " + shlex.quote(args.context_delivery) if getattr(args, "codex_context_dir", None): suffix += " --codex-context-dir " + shlex.quote(args.codex_context_dir) if getattr(args, "codex_context_root", None): @@ -245,7 +271,8 @@ def bind_capture_selection_transport(payload: dict[str, Any], args: argparse.Nam selection["command_args_template"] = template + suffix -def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[dict[str, Any], str] | None: +def handoff_decision(directory: Path | None, socket_path: str | None, + context_delivery_enabled: bool = False) -> tuple[dict[str, Any], str] | None: """Withhold the original output until the Host releases these exact bytes. No second guard, admission, settlement or retry is performed here. A lost @@ -277,9 +304,10 @@ def handoff_decision(directory: Path | None, socket_path: str | None) -> tuple[d "Decision handoff failed after guard evaluation; inspect the original " f"Turn receipt and {path}. Do not blindly rerun the guard." ) from exc - if response.get("context_delivery") is not True: + if response.get("context_delivery") is not True or not context_delivery_enabled: return None - # Only an explicit Host request opts in. The release itself does not prove + # Both the supported caller opt-in and explicit Host request are required. + # The release itself does not prove # delivery/retention. Read one private snapshot, never an arbitrary pathname # supplied by a response or a model. Invalid optimization input selects full. request_path = directory / "delivery-request.json" @@ -308,17 +336,22 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca from ..control_plane.turn_driver.codex_context import codex_context_configuration try: + activation = native_context_activation(args) + if not activation["enabled"]: + payload = {"ok": True, "context_delivery": activation} + print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 0 directory = getattr(args, "codex_context_root", None) or args.codex_context_dir if not directory: raise ValueError("context-config requires codex-context-root or codex-context-dir") config = codex_context_configuration(directory, goal_id=args.goal_id, agent_id=args.agent_id, - cli_argv=loopx_cli_argv(), session_root=bool(getattr(args, "codex_context_root", None))) - payload = {"ok": True, "native_config": config, + cli_argv=loopx_cli_argv(), scenario=activation["scenario"], session_root=bool(getattr(args, "codex_context_root", None))) + payload = {"ok": True, "context_delivery": activation, "native_config": config, "activation": "Merge hooks and append developer_instructions to the existing native configuration; " "review and trust only these exact hooks in Codex. Restart/resume with the resulting configuration. " - "Pass this same context location and a decision-output-root to heartbeat-prompt/should-run. " + "Pass --context-delivery on with the same declared host mode, context location and a decision-output-root to heartbeat-prompt/should-run. " "For delta, the native process must already have CODEX_ROLLOUT_TRACE_ROOT pointing to private diagnostics; " - "unavailable diagnostics preserve full delivery. Disabling the context options restores ordinary output. " + "unavailable diagnostics preserve full delivery. Set --context-delivery off and remove context options to restore ordinary output. " "This command installs nothing and grants no execution authority."} except (OSError, ValueError, TypeError) as exc: payload = {"ok": False, "reason": str(exc)} @@ -332,6 +365,9 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca # Native transport only: no registry discovery, guard or quota effects. try: + if not native_context_activation(args)["enabled"]: + print("{}") + return 0 raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) event = json.loads(raw) if len(raw) <= MAX_REQUEST_BYTES else None if (isinstance(event, dict) and isinstance(event.get("session_id"), str) and event["session_id"] diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index bfde4ab7dc..6cd6a5ace9 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -201,6 +201,13 @@ def register_quota_command( "Waits at most 120s for a digest-bound release; failure retains the original receipt."), ) quota_parser.add_argument("--capture-file", help="For context-read/context-project: original captured JSON file; no guard is evaluated.") + from ..control_plane.turn_driver.codex_context import CONTEXT_DELIVERY_SETTINGS + + quota_parser.add_argument("--context-delivery", choices=CONTEXT_DELIVERY_SETTINGS, default="off", + help="Experimental native packet reuse; off by default. On uses existing host declarations: " + "Codex App heartbeat/resume/recovery (--codex-app), or the Codex benchmark heartbeat worker " + "(generic_cli plus its explicit heartbeat worker settings). Other or missing modes keep full delivery. " + "A directory alone never enables it; declarations are not retention proof.") context_location = quota_parser.add_mutually_exclusive_group() context_location.add_argument("--codex-context-dir", help="Opt-in private native Codex lifecycle directory. For context-hook, consume one native hook event on stdin. " diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 3f5091fea6..e8f3a33f2f 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -409,6 +409,7 @@ def handle_support_control_command( decision_handoff_socket=args.decision_handoff_socket, codex_context_dir=args.codex_context_dir, codex_context_root=args.codex_context_root, + context_delivery=args.context_delivery, turn_envelope=args.turn_envelope, reward_memory_enabled=reward_memory_enabled, ) diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 73293fc0a2..b0080ddc05 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -174,6 +174,13 @@ def register_heartbeat_control_commands( help="Retain the explicit private Host handoff on generated guards; requires --decision-output-root.", ) + from ..control_plane.turn_driver.codex_context import CONTEXT_DELIVERY_SETTINGS + + heartbeat_prompt_parser.add_argument("--context-delivery", choices=CONTEXT_DELIVERY_SETTINGS, default="off", + help="Experimental native packet reuse; off by default. On uses existing host declarations: " + "Codex App heartbeat/resume/recovery (--codex-app), or the Codex benchmark heartbeat worker " + "(generic_cli plus its explicit heartbeat worker settings). Other or missing modes keep full delivery. " + "A directory alone never enables it; declarations are not retention proof.") context_location = heartbeat_prompt_parser.add_mutually_exclusive_group() context_location.add_argument( "--codex-context-dir", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 7f7d29f02a..a633d02c7f 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -68,9 +68,10 @@ def _goal_command(args, *, registry: Path, legacy: bool) -> list[str]: ("execution_mode", "--execution-mode"), ("decision_output_root", "--decision-output-root"), ("codex_context_root", "--codex-context-root"), + ("context_delivery", "--context-delivery"), ): value = getattr(args, field, None) - if value is not None: + if value is not None and not (field == "context_delivery" and value == "off"): if field == "active_state": value = str(Path(value).expanduser().resolve()) command += [flag, value] @@ -116,17 +117,19 @@ def host_bootstrap_binding(prompt: str) -> dict | None: return None values = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, trae_app=False, - full=False, compact=False, brief=False, thin=False, + full=False, compact=False, brief=False, thin=False, turn_envelope=False, visible_goal_host=None, available_capabilities=[], agent_scopes=[], runtime_root=None) singles = {"--" + name.replace("_", "-"): name for name in ( "registry", "runtime_root", "goal_id", "agent_id", "active_state", "material_rule", "permission_rule", "runtime_profile", "visible_goal_host", - "host_surface", "scheduler_owner", "execution_mode", "cli_bin")} + "host_surface", "scheduler_owner", "execution_mode", "cli_bin", + "decision_output_root", "codex_context_root", "context_delivery")} repeated = {"--agent-scope": "agent_scopes", "--available-capability": "available_capabilities"} booleans = { "--codex-app": "codex_app", "--trae_app": "trae_app", + "--turn-envelope": "turn_envelope", **{"--" + name.replace("_", "-"): name for name in ("full", "compact", "brief", "thin")}, } index = 3 diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index fe1bfe018a..36425f1df2 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -178,6 +178,7 @@ def _heartbeat_prompt_commands( decision_handoff_socket: str | None, codex_context_dir: str | None, codex_context_root: str | None, + context_delivery: str, turn_envelope: bool, ) -> dict[str, str | None]: quota_guard_command = render_quota_guard_command( @@ -282,6 +283,8 @@ def _heartbeat_prompt_commands( capture_arg = " --decision-output-root " + shlex.quote(str(decision_output_root)) if decision_handoff_socket is not None: capture_arg += " --decision-handoff-socket " + shlex.quote(decision_handoff_socket) + if context_delivery != "off": + capture_arg += " --context-delivery " + shlex.quote(context_delivery) if codex_context_dir is not None: capture_arg += " --codex-context-dir " + shlex.quote(codex_context_dir) if codex_context_root is not None: @@ -323,6 +326,7 @@ def build_heartbeat_prompt( decision_handoff_socket: str | None = None, codex_context_dir: str | None = None, codex_context_root: str | None = None, + context_delivery: str = "off", turn_envelope: bool = False, ) -> dict[str, Any]: if not (full or compact or brief or thin): @@ -381,6 +385,17 @@ def build_heartbeat_prompt( runtime_profile=runtime_profile, scheduler_execution_context=scheduler_execution_context, ) + from ..turn_driver.codex_context import context_delivery_activation + + import os + + activation = context_delivery_activation(context_delivery, runtime_profile=runtime_profile, + scheduler_execution_context=scheduler_execution_context, + benchmark_mode=os.environ.get("LOOPX_EXECUTION_MODE") + if os.environ.get("LOOPX_CONTEXT_DELIVERY") == "on" else None) + if not activation["enabled"]: + codex_context_dir = codex_context_root = None + effective_context_delivery = "on" if activation["enabled"] else "off" decision_output_root = resolve_heartbeat_capture_root( decision_output_root, thin=thin, full=full, compact=compact, brief=brief, @@ -389,6 +404,7 @@ def build_heartbeat_prompt( decision_handoff_socket=decision_handoff_socket, codex_context_dir=codex_context_dir, codex_context_root=codex_context_root, + context_delivery=effective_context_delivery, turn_envelope=turn_envelope, ) explicit_agent_scopes = normalize_agent_scopes(agent_scopes) @@ -480,6 +496,7 @@ def build_heartbeat_prompt( decision_handoff_socket=decision_handoff_socket, codex_context_dir=codex_context_dir, codex_context_root=codex_context_root, + context_delivery=effective_context_delivery, turn_envelope=turn_envelope, ) cli_preflight = render_cli_preflight(cli_bin=cli_bin) @@ -558,6 +575,7 @@ def build_heartbeat_prompt( "agent_profile": agent_profile_prompt_projection(agent_profile), "registered_agents": normalized_registered_agents, "runtime_profile": runtime_profile, + **({"context_delivery": activation} if context_delivery == "on" else {}), "reward_memory_enabled": reward_memory_enabled, "scheduler_execution_context": scheduler_execution_context, **( diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index 4167b3be3b..e7544a736c 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -32,9 +32,13 @@ def resolve_heartbeat_capture_root( decision_handoff_socket: str | None = None, codex_context_dir: str | None = None, codex_context_root: str | None = None, + context_delivery: str = "off", turn_envelope: bool = False, ) -> Path | None: """Validate capture transport for an already resolved host-owned Turn.""" + enabled = context_delivery == "on" + if enabled and not (codex_context_dir or codex_context_root): + raise ValueError("context-delivery requires a native context location") if codex_context_dir is not None and codex_context_root is not None: raise ValueError("choose codex-context-dir or codex-context-root") if (codex_context_dir is not None or codex_context_root is not None) and root is None: diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index a14ac58799..19c0d54d14 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -17,6 +17,47 @@ from .decision_capture import write_decision_capture, project_captured_context, CONTEXT_CAPTURE_READ_GUIDANCE +# Native adapter activation reuses scheduler-owned host declarations. These +# settings are presentation opt-ins, never host authentication or authority. +CONTEXT_DELIVERY_SETTINGS = ("off", "on") +CONTEXT_DELIVERY_SCENARIOS = ("codex_benchmark_heartbeat", "codex_app_heartbeat") + + +def context_delivery_activation(setting: str, *, runtime_profile: str | None = None, + scheduler_execution_context: dict[str, Any] | None = None, + benchmark_mode: str | None = None) -> dict[str, Any]: + from ..scheduler.execution_context import ( + ExecutionMode, HostSurface, SchedulerOwner, resolve_scheduler_execution_context, + scheduler_execution_context_for_runtime_profile, + ) + + if setting not in CONTEXT_DELIVERY_SETTINGS: + raise ValueError("context-delivery must be off or on") + scenario = None + if setting == "on": + # Conflicting declarations cannot activate transport. The scheduler + # owner still validates execution; this adapter does not repair it. + if runtime_profile is not None and scheduler_execution_context is not None: + resolution = resolve_scheduler_execution_context(None) + elif runtime_profile is not None: + resolution = scheduler_execution_context_for_runtime_profile(runtime_profile) + else: + resolution = resolve_scheduler_execution_context(scheduler_execution_context) + context = resolution.context if resolution.ok else None + if context is not None: + if (context.host_surface is HostSurface.CODEX_APP + and context.scheduler_owner is SchedulerOwner.HOST_AUTOMATION + and context.execution_mode is ExecutionMode.HOSTED_AUTOMATION): + scenario = "codex_app_heartbeat" + elif (context.host_surface is HostSurface.GENERIC_CLI + and context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP + and context.execution_mode is ExecutionMode.INTERACTIVE + and benchmark_mode == "heartbeat"): + scenario = "codex_benchmark_heartbeat" + return {"requested": setting, "enabled": scenario is not None, "scenario": scenario, + "reason": "supported_declared_run_mode" if scenario else "disabled" if setting == "off" else "unsupported_or_missing_run_mode", + "supported_scenarios": list(CONTEXT_DELIVERY_SCENARIOS)} + def _read(path: Path, *, private: bool = True) -> dict[str, Any]: from ..effect_runtime import MAX_REQUEST_BYTES @@ -137,7 +178,7 @@ def codex_context_recovery_instructions(directory: str, *, goal_id: str, agent_i def codex_context_configuration(directory: str, *, goal_id: str, agent_id: str, - cli_argv: list[str], session_root: bool = False) -> dict[str, Any]: + cli_argv: list[str], scenario: str, session_root: bool = False) -> dict[str, Any]: """Native configuration data shared by the worker and CLI setup journey. This renders no trust hashes and writes no host configuration. The native @@ -145,12 +186,17 @@ def codex_context_configuration(directory: str, *, goal_id: str, agent_id: str, """ import shlex + if scenario not in CONTEXT_DELIVERY_SCENARIOS: + raise ValueError("unsupported native context configuration scenario") if not goal_id or not agent_id: raise ValueError("context configuration requires exact goal-id and agent-id") directory = str(_private_root(directory)) option = "--codex-context-root" if session_root else "--codex-context-dir" handler = {"type": "command", "command": shlex.join([ - *cli_argv, "--format", "json", "quota", "context-hook", option, directory]), "timeout": 10} + *cli_argv, "--format", "json", "quota", "context-hook", "--context-delivery", "on", + *(["--codex-app"] if scenario == "codex_app_heartbeat" else + ["--runtime-profile", "generic_cli"]), + option, directory]), "timeout": 10} return {"hooks": {event: [{"hooks": [handler]}] for event in ("SessionStart", "PreToolUse")}, "developer_instructions": codex_context_recovery_instructions(directory, goal_id=goal_id, agent_id=agent_id, cli_argv=cli_argv, session_root=session_root)} diff --git a/loopx/hosts/codex_checkpoint.py b/loopx/hosts/codex_checkpoint.py index 8258d1a65f..37fba19ddc 100644 --- a/loopx/hosts/codex_checkpoint.py +++ b/loopx/hosts/codex_checkpoint.py @@ -118,7 +118,8 @@ def arguments(command): "decision_output_root": guard["capture_root"], "decision_output_dir": None, "decision_handoff_socket": guard["socket_path"], "codex_context_dir": guard.get("codex_context_dir"), - "codex_context_root": guard.get("codex_context_root")} + "codex_context_root": guard.get("codex_context_root"), + "context_delivery": guard.get("context_delivery", "off")} if any(args.get(key) != value for key, value in expected.items()): raise ValueError("native pending guard differs from the held packet routes or identity") # A second matching guard after a tool yield cannot stand in for the first diff --git a/tests/control_plane/test_codex_context_capture.py b/tests/control_plane/test_codex_context_capture.py index 9b4485e6ec..ee2f3c1a36 100644 --- a/tests/control_plane/test_codex_context_capture.py +++ b/tests/control_plane/test_codex_context_capture.py @@ -98,12 +98,12 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho native_configuration = None if shared_root: prepared = subprocess.run(cli + ["--format", "json", "quota", "context-config", - context_option, str(lifecycle), "--goal-id", GOAL_ID, "--agent-id", AGENT_ID], + "--context-delivery", "on", "--codex-app", context_option, str(lifecycle), "--goal-id", GOAL_ID, "--agent-id", AGENT_ID], capture_output=True, text=True, timeout=15) assert prepared.returncode == 0, prepared.stderr native_configuration = json.loads(prepared.stdout)["native_config"] hook_command = (native_configuration["hooks"]["SessionStart"][0]["hooks"][0]["command"] - if native_configuration else shlex.join(cli + ["quota", "context-hook", context_option, str(lifecycle)])) + if native_configuration else shlex.join(cli + ["quota", "context-hook", "--context-delivery", "on", "--codex-app", context_option, str(lifecycle)])) if failed_primary: # Fault only the primary journal update. Production code must still # return the original full-read reference; no test recovery handler. @@ -131,7 +131,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho f"os.environ['PYTHONPATH']={str(Path(__file__).resolve().parents[2])!r}\n" + f"os.execv({sys.executable!r}, [{sys.executable!r}, '-m', 'loopx.entrypoint', *sys.argv[1:]])\n")) wrapper.chmod(0o700) - hook_command = shlex.join([str(wrapper), "quota", "context-hook", context_option, str(lifecycle)]) + hook_command = shlex.join([str(wrapper), "quota", "context-hook", "--context-delivery", "on", "--codex-app", context_option, str(lifecycle)]) handler = {"type": "command", "command": hook_command, "timeout": 10} if host != "benchmark": (home / "hooks.json").write_text(json.dumps({"hooks": { @@ -142,7 +142,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho command = [*cli, "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "native-capture-turn", "--codex-app", - "--decision-output-root", str(root), context_option, str(lifecycle)] + "--decision-output-root", str(root), "--context-delivery", "on", "--codex-app", context_option, str(lifecycle)] if handoff_socket: command += ["--decision-handoff-socket", handoff_socket] if surface == "turn_envelope": @@ -152,7 +152,7 @@ def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, ho generated = subprocess.run([*cli, "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "heartbeat-prompt", "--thin", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--decision-output-root", str(root), - context_option, str(lifecycle), *(["--turn-envelope"] if surface == "turn_envelope" else [])], + "--context-delivery", "on", "--codex-app", context_option, str(lifecycle), *(["--turn-envelope"] if surface == "turn_envelope" else [])], capture_output=True, text=True, timeout=15) assert generated.returncode == 0, generated.stderr prompt = json.loads(generated.stdout) @@ -389,7 +389,7 @@ def observed_response(request, number, message): project_hooks = project / ".codex/hooks.json" project_hooks.write_text(json.dumps({"hooks": {"SessionStart": [{"hooks": [{ "type": "command", "command": shlex.join(["touch", str(unrelated)])}]}]}})) - env.update(LOOPX_CONTEXT_DELIVERY="1", LOOPX_TURN_ENVELOPE=str(int(surface == "turn_envelope")), + env.update(LOOPX_CONTEXT_DELIVERY="on", LOOPX_TURN_ENVELOPE=str(int(surface == "turn_envelope")), LOOPX_EXECUTION_MODE="heartbeat", LOOPX_ITERATION_CONTEXT="resume", LOOPX_TASK_ENTRY="seeded-todo", LOOPX_TASK_STAGE="execute", LOOPX_CODEX_HOME=str(home), LOOPX_PROJECT=str(project), LOOPX_SHARED_SKILLS=str(skills), diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index 0862d2ab06..0c7afc856a 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -58,7 +58,7 @@ def test_native_configuration_cli_is_read_only_and_usable_without_registry(tmp_p tmp_path.chmod(0o700) repo = Path(__file__).resolve().parents[2] command = [sys.executable, "-m", "loopx.entrypoint", "--format", "json", "quota", "context-config", - "--codex-context-root", str(tmp_path), "--goal-id", "fixture", "--agent-id", "agent"] + "--context-delivery", "on", "--codex-app", "--codex-context-root", str(tmp_path), "--goal-id", "fixture", "--agent-id", "agent"] result = subprocess.run(command, cwd=tmp_path, env={**os.environ, "PYTHONPATH": str(repo)}, capture_output=True, text=True, timeout=15) assert result.returncode == 0, result.stderr @@ -219,7 +219,7 @@ def test_real_hook_cli_is_transport_only_without_registry_or_installation(tmp_pa directory.mkdir(mode=0o700) registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" cmd = [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), "--runtime-root", str(runtime), - "quota", "context-hook", "--codex-context-dir", str(directory)] + "quota", "context-hook", "--context-delivery", "on", "--codex-app", "--codex-context-dir", str(directory)] event = dict(session_id="session", cwd=str(tmp_path), hook_event_name="SessionStart", source="startup") result = subprocess.run(cmd, input=json.dumps(event), capture_output=True, text=True, timeout=20, cwd=Path(__file__).resolve().parents[2]) @@ -281,3 +281,75 @@ def test_lost_hook_reference_is_scoped_read_only_and_pins_later_pages(tmp_path, assert rest["content"] == Path(args.capture_file).read_bytes()[args.offset:args.offset + len(rest["content"].encode())].decode() args.codex_context_dir = str(tmp_path) assert not read_context_capture(args)["ok"] # no mixed moving and pinned selectors + + + +@pytest.mark.parametrize("setting", [None, "off", "on"]) +@pytest.mark.parametrize("host_args,expected", [ + ([], None), + (["--codex-app"], "codex_app_heartbeat"), + (["--runtime-profile", "codex_app_heartbeat"], "codex_app_heartbeat"), + (["--host-surface", "codex_app", "--scheduler-owner", "host_automation", "--execution-mode", "hosted_automation"], "codex_app_heartbeat"), + (["--host-surface", "codex_cli", "--scheduler-owner", "outer_controller", "--execution-mode", "isolated_headless"], None), + (["--runtime-profile", "generic_cli"], None), + (["--runtime-profile", "claude_code"], None), + (["--runtime-profile", "codex_cli"], None), + (["--host-surface", "codex_app"], None), + (["--codex-app", "--host-surface", "codex_cli"], None), + (["--codex-app", "--runtime-profile", "generic_cli"], None), + (["--codex-app", "--trae_app"], None), +]) +def test_activation_reuses_declared_host_mode_and_off_is_inert(tmp_path, monkeypatch, setting, host_args, expected): + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import handle_native_context_command + + # Ambient native environment is not an independent activation or mode proof. + monkeypatch.setenv("CODEX_THREAD_ID", "ambient-native-session") + monkeypatch.delenv("LOOPX_EXECUTION_MODE", raising=False) + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) + tmp_path.chmod(0o700) + args = ["--format", "json", "quota", "context-config", "--codex-context-dir", str(tmp_path), + "--goal-id", "fixture", "--agent-id", "agent", *host_args] + if setting is not None: + args += ["--context-delivery", setting] + results = [] + parsed = build_parser().parse_args(args) + assert handle_native_context_command(parsed, print_payload=lambda value, *_: results.append(value)) == 0 + result = results[0] + activation = result["context_delivery"] + expected = expected if setting == "on" else None + assert activation["requested"] == (setting or "off") + assert activation["enabled"] is (expected is not None) and activation["scenario"] == expected + assert activation["supported_scenarios"] == ["codex_benchmark_heartbeat", "codex_app_heartbeat"] + if expected is None: + assert "native_config" not in result + else: + for entries in result["native_config"]["hooks"].values(): + assert "--context-delivery on" in entries[0]["hooks"][0]["command"] + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("setting", ["true", "generic_cli", "claude_code", "all", "codex_app_heartbeat"]) +def test_context_activation_rejects_non_binary_settings(setting): + from loopx.cli import build_parser + + with pytest.raises(SystemExit): + build_parser().parse_args(["quota", "context-config", "--context-delivery", setting]) + + +@pytest.mark.parametrize("mode,setting,expected", [("heartbeat", "on", True), ("plain", "on", False), + (None, "on", False), ("heartbeat", "off", False)]) +def test_benchmark_activation_uses_existing_explicit_worker_declaration(monkeypatch, mode, setting, expected): + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import native_context_activation + + monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "on") + if mode is not None: + monkeypatch.setenv("LOOPX_EXECUTION_MODE", mode) + else: + monkeypatch.delenv("LOOPX_EXECUTION_MODE", raising=False) + args = build_parser().parse_args(["quota", "context-config", "--runtime-profile", "generic_cli", + "--context-delivery", setting]) + result = native_context_activation(args) + assert result["enabled"] is expected + assert result["scenario"] == ("codex_benchmark_heartbeat" if expected else None) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index 12ca98ec15..554b2dfb98 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -119,7 +119,7 @@ def test_real_cli_host_rendezvous_sends_delta_and_keeps_same_capture_full(tmp_pa scope = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id="host-session", context_epoch="epoch-1") saved = [] - def invoke(host_request, *, opt_in=True, native_context=False): + def invoke(host_request, *, opt_in=True, native_context=False, setting="on"): with tempfile.TemporaryDirectory(prefix="context-host-", dir="/tmp") as short: endpoint = Path(short).resolve() / "host.sock" with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: @@ -149,6 +149,7 @@ def release(): code, wire = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "host-context-delivery", + "--context-delivery", setting, "--decision-output-root", str(root), "--decision-handoff-socket", str(endpoint), *(["--codex-context-dir", str(tmp_path / "unavailable-native-state")] if native_context else []), *(["--turn-envelope"] if surface == "turn_envelope" else []), cwd=project) @@ -196,6 +197,10 @@ def release(): full, directory = invoke(malformed) assert "target" not in full # Automatic ordinary full output, not a gate. assert full == json.loads((directory / "projection.json").read_bytes()) + # A host-side request cannot enable this feature when the caller is off. + disabled, disabled_directory = invoke({"scope": scope, "baseline": cold, + "retained": {"complete": True, "target": cold["target"]}}, setting="off") + assert "target" not in disabled and not (disabled_directory / "projection.json").exists() ordinary, directory = invoke(None, opt_in=False) assert "target" not in ordinary and not (directory / "projection.json").exists() assert state.read_bytes() == before_source and _spend_run_count(runtime) == 0 @@ -239,7 +244,7 @@ def test_native_context_cli_without_retention_stays_full_and_preserves_receipt(t for _ in range(2): rc, wire = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "native-full", - "--decision-output-root", str(captures), "--codex-context-dir", str(root), cwd=project) + "--decision-output-root", str(captures), "--context-delivery", "on", "--codex-context-dir", str(root), cwd=project) assert rc == 0 and wire["mode"] == "full", wire assert json.loads(Path(wire["recovery"]["path"]).read_text()) == wire["context"] assert _heartbeat_receipt_count(runtime, "native-full") == 1 @@ -253,7 +258,7 @@ def test_native_projection_never_layers_host_projection(tmp_path, monkeypatch, n from loopx.control_plane.turn_driver import codex_context args = build_parser().parse_args(["--format", "json", "quota", "should-run", - "--goal-id", "fixture", "--agent-id", "worker", "--codex-context-dir", str(tmp_path)]) + "--goal-id", "fixture", "--agent-id", "worker", "--context-delivery", "on", "--codex-app", "--codex-context-dir", str(tmp_path)]) original = {"ok": True, "goal_id": "fixture", "agent_identity": {"agent_id": "worker"}} monkeypatch.setattr(quota, "handoff_decision", lambda *args: ({"scope": "host-request"}, "digest")) monkeypatch.setattr(quota, "_project_quota_cli_payload", lambda payload, *args, **kwargs: payload) @@ -275,3 +280,43 @@ def native(payload, **kwargs): detail_sections=frozenset(), print_payload=lambda value, *args: printed.append(value)) == 0 assert printed == [original if native_failure else {"native_projection": original}] assert json.loads((tmp_path / "decision.json").read_text()) == original + + +@pytest.mark.parametrize("scenario", [None, "off"]) +def test_plain_quota_context_default_does_not_touch_transport(tmp_path, monkeypatch, scenario): + from loopx.control_plane.turn_driver import codex_context + from loopx.cli import build_parser + + def unexpected(*args, **kwargs): + pytest.fail("off must not observe native context or create transport state") + monkeypatch.setattr(codex_context, "project_codex_context", unexpected) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=read_fixture_todos(state), handoff_mode="soft_claim"), + state_path=state, provider="file") + args = ["quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "off-scenario"] + if scenario: + args += ["--context-delivery", scenario] + code, result = _run_cli(registry, runtime, *args, cwd=project) + assert code == 0 and result["ok"] and result.get("schema_version") != "context_delivery_v1", result + assert build_parser().parse_args(["quota", "should-run"]).context_delivery == "off" + + +@pytest.mark.parametrize("activation", [[], ["--context-delivery", "off"], ["--context-delivery", "on"]]) +def test_unsupported_or_off_delivery_keeps_full_and_does_not_touch_native_cache(tmp_path, activation): + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=read_fixture_todos(state), handoff_mode="soft_claim"), + state_path=state, provider="file") + root = tmp_path / "never-create" + code, result = _run_cli(registry, runtime, "quota", "should-run", "--runtime-profile", "generic_cli", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "unsupported", + "--codex-context-dir", str(root), *activation, cwd=project) + assert code == 0 and result["ok"] and result.get("schema_version") != "context_delivery_v1", result + assert not root.exists() + if activation == ["--context-delivery", "on"]: + assert result["context_delivery"]["enabled"] is False + assert result["context_delivery"]["reason"] == "unsupported_or_missing_run_mode" diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index 4b80a87117..7eacbee9df 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -25,10 +25,10 @@ def test_shared_native_root_keeps_dynamic_turn_binding_and_bootstrap_options(tmp_path): from loopx.cli import build_parser - from loopx.control_plane.heartbeat.bootstrap_prompt import goal_bootstrap + from loopx.control_plane.heartbeat.bootstrap_prompt import goal_bootstrap, host_bootstrap_binding args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile="codex_app_heartbeat", - thin=True, decision_output_root=tmp_path, codex_context_root=str(tmp_path), turn_envelope=True) + thin=True, decision_output_root=tmp_path, codex_context_root=str(tmp_path), context_delivery="on", turn_envelope=True) prompt = build_heartbeat_prompt(**args) guard = prompt["quota_guard_command"] assert '--turn-instance-id "${LOOPX_TURN:?}"' in guard @@ -42,13 +42,15 @@ def test_shared_native_root_keeps_dynamic_turn_binding_and_bootstrap_options(tmp assert list(tmp_path.iterdir()) == [] parser = build_parser() cli = ["heartbeat-prompt", "--thin", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - "--decision-output-root", str(tmp_path), "--codex-context-root", str(tmp_path), "--turn-envelope", "--bootstrap"] + "--decision-output-root", str(tmp_path), "--context-delivery", "on", "--codex-app", "--codex-context-root", str(tmp_path), "--turn-envelope", "--bootstrap"] parsed = parser.parse_args(cli) bootstrap = goal_bootstrap(parsed, registry=tmp_path / "registry.json") command = shlex.split(bootstrap.split("```sh\n")[1].split("\n```")[0]) assert "--bootstrap" not in command and "--turn-instance-id" not in command assert command[command.index("--codex-context-root") + 1] == str(tmp_path) assert "--turn-envelope" in command + binding = host_bootstrap_binding(bootstrap) + assert binding is not None and binding["context_delivery"] == "on" and binding["turn_envelope"] is True def test_capture_preserves_full_dispatcher_and_envelope_needs_explicit_opt_in(tmp_path): @@ -241,7 +243,7 @@ def test_real_worker_generated_guard_selection_reentry_and_settle_once( if context_delivery: # No native proof in this CLI fixture: composed capture must still # release ordinary full output and preserve reentry and settlement. - env.update(LOOPX_CONTEXT_DELIVERY='1', LOOPX_CODEX_CONTEXT_DIR=str(tmp_path / 'context')) + env.update(LOOPX_CONTEXT_DELIVERY='on', LOOPX_EXECUTION_MODE='heartbeat', LOOPX_CODEX_CONTEXT_DIR=str(tmp_path / 'context')) body = heartbeat_body(env, TURN_ID, native_goal=False, decision_handoff_socket=private_handoff) # Execute the renderer's actual guard, preserving its route and flags. block = body.split('```sh\n', 1)[1].split('```', 1)[0] @@ -357,25 +359,27 @@ def test_renderer_retains_owned_handoff_without_enabling_it_by_default(tmp_path) build_heartbeat_prompt(**args, decision_output_root=tmp_path, decision_handoff_socket=endpoint) -def test_native_context_prompt_keeps_capture_on_reentry(tmp_path): +def test_native_context_prompt_keeps_capture_on_reentry(tmp_path, monkeypatch): + monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "on") + monkeypatch.setenv("LOOPX_EXECUTION_MODE", "heartbeat") args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile="generic_cli", turn_instance_id=TURN_ID, thin=True, decision_output_root=tmp_path) plain = build_heartbeat_prompt(**args) context = str(tmp_path / "private context") - enabled = build_heartbeat_prompt(**args, codex_context_dir=context) - suffix = " --codex-context-dir " + shlex.quote(context) + enabled = build_heartbeat_prompt(**args, context_delivery="on", codex_context_dir=context) + suffix = " --context-delivery on --codex-context-dir " + shlex.quote(context) assert enabled["quota_guard_command"] == plain["quota_guard_command"] + suffix assert suffix in enabled["thin_prompt_command"] assert enabled["task_body"].replace(suffix, "") == plain["task_body"] with pytest.raises(ValueError, match="capture root"): - build_heartbeat_prompt(**(args | {"decision_output_root": None}), codex_context_dir=context) + build_heartbeat_prompt(**(args | {"decision_output_root": None}), context_delivery="on", codex_context_dir=context) with pytest.raises(ValueError, match="invalid decision handoff socket"): - build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket="unused") + build_heartbeat_prompt(**args, context_delivery="on", codex_context_dir=context, decision_handoff_socket="unused") with tempfile.TemporaryDirectory(prefix="context-handoff-", dir="/tmp") as short: endpoint = str(Path(short).resolve() / "host.sock") with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: listener.bind(endpoint) - packet = build_heartbeat_prompt(**args, codex_context_dir=context, decision_handoff_socket=endpoint) + packet = build_heartbeat_prompt(**args, context_delivery="on", codex_context_dir=context, decision_handoff_socket=endpoint) for field in ("quota_guard_command", "thin_prompt_command"): argv = shlex.split(packet[field]) assert argv[argv.index("--decision-output-root") + 1] == str(tmp_path) diff --git a/tests/control_plane/test_quota_decision_capture.py b/tests/control_plane/test_quota_decision_capture.py index 39ca5bfd9e..993a345a72 100644 --- a/tests/control_plane/test_quota_decision_capture.py +++ b/tests/control_plane/test_quota_decision_capture.py @@ -340,9 +340,9 @@ def test_real_cli_handoff_preserves_original_receipt(tmp_path, release, native_c '--available-capability', 'filesystem_read', '--decision-output-dir', str(capture), '--decision-handoff-socket', str(endpoint)] if native_context: - command += ['--codex-context-dir', str(context)] + command += ['--context-delivery', 'on', '--codex-context-dir', str(context)] with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, env={**os.environ, 'PYTHONPATH': str(Path(__file__).resolve().parents[2])}) as process: + text=True, env={**os.environ, 'PYTHONPATH': str(Path(__file__).resolve().parents[2]), **({'LOOPX_CONTEXT_DELIVERY': 'on', 'LOOPX_EXECUTION_MODE': 'heartbeat'} if native_context else {})}) as process: try: channel, _ = listener.accept() with channel: diff --git a/tests/control_plane/test_saved_context_project.py b/tests/control_plane/test_saved_context_project.py index 57cd5835df..c06c60f60f 100644 --- a/tests/control_plane/test_saved_context_project.py +++ b/tests/control_plane/test_saved_context_project.py @@ -63,7 +63,7 @@ def test_optional_cache_failure_returns_original_full_reference(tmp_path, root_k args = build_parser().parse_args(["--format", "json", "quota", "context-project", "--capture-file", str(path), "--capture-sha256", digest, "--goal-id", "saved-goal", "--agent-id", "saved-agent", "--turn-instance-id", "saved-turn", - "--decision-output-root", str(root), "--codex-context-dir", str(context)]) + "--decision-output-root", str(root), "--context-delivery", "on", "--codex-app", "--codex-context-dir", str(context)]) result = project_saved_context_capture(args) assert result["ok"] and result["mode"] == "full" assert result["path"] == str(path) and result["sha256"] == digest @@ -124,7 +124,7 @@ def test_native_saved_preflight_uses_current_input_without_guard_replay(tmp_path program = os.environ.get("LOOPX_NATIVE_CONTEXT_CLI") or str(Path(sys.executable).with_name("loopx")) cli = [program, "--cli-bin", program] from loopx.control_plane.turn_driver.codex_context import codex_context_configuration - configuration = codex_context_configuration(str(context), goal_id=GOAL_ID, agent_id=AGENT_ID, cli_argv=cli) + configuration = codex_context_configuration(str(context), goal_id=GOAL_ID, agent_id=AGENT_ID, cli_argv=cli, scenario="codex_app_heartbeat") if host == "app_server": (home / "hooks.json").write_text(json.dumps({"hooks": configuration["hooks"]})) phase, queue, observed, errors = "cold", [], {}, [] @@ -149,7 +149,7 @@ def response(request, number, message): command = shlex.join([*cli, "--format", "json", "quota", "context-project", "--capture-file", str(source), "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "saved-native-turn", - "--codex-context-dir", str(context), "--decision-output-root", str(cache)]) + "--context-delivery", "on", "--codex-app", "--codex-context-dir", str(context), "--decision-output-root", str(cache)]) phase += "_ready" elif phase.endswith("_ready"): name = phase.split("_")[0] @@ -193,7 +193,7 @@ def response(request, number, message): declaration = workspace / "checkpoint-request.json" declaration.write_text(json.dumps({"trigger": "fresh_replan", "destination": str(workspace / "checkpoints"), "components": [{"id": "task", "source_path": str(state)}], "point_metadata": {}})) - env.update(LOOPX_CONTEXT_DELIVERY="1", LOOPX_EXECUTION_MODE="heartbeat", LOOPX_ITERATION_CONTEXT="resume", + env.update(LOOPX_CONTEXT_DELIVERY="on", LOOPX_EXECUTION_MODE="heartbeat", LOOPX_ITERATION_CONTEXT="resume", LOOPX_TASK_ENTRY="seeded-todo", LOOPX_TASK_STAGE="execute", LOOPX_CHECKPOINT_REQUEST=str(declaration), LOOPX_CODEX_HOME=str(home), LOOPX_PROJECT=str(project), LOOPX_SHARED_SKILLS=str(skills), LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_GOAL_ID=GOAL_ID, LOOPX_AGENT_ID=AGENT_ID, @@ -287,3 +287,23 @@ def request(method, params): assert all(features[key] is False for key in ("plugins", "remote_plugin", "plugin_sharing")) assert config.read_bytes() == original_config assert not list(home.rglob("*.pack")), "discovery must not clone a plugin catalog" + + +def test_saved_projection_default_off_ignores_available_native_cache(tmp_path, monkeypatch): + from loopx.cli import build_parser + from loopx.cli_commands.quota_capture import project_saved_context_capture + from loopx.control_plane.turn_driver import codex_context + + path, root = tmp_path / "original.json", tmp_path / "uncreated" + raw = json.dumps({"ok": True, "goal_id": "g", "agent_identity": {"agent_id": "a"}, + "heartbeat_receipt": {"turn_instance_id": "t"}}).encode() + path.write_bytes(raw) + def unexpected(*args, **kwargs): + pytest.fail("default-off saved presentation must not inspect/create native state") + monkeypatch.setattr(codex_context, "codex_context_directory", unexpected) + args = build_parser().parse_args(["quota", "context-project", "--capture-file", str(path), + "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--goal-id", "g", "--agent-id", "a", + "--turn-instance-id", "t", "--codex-context-dir", str(root), "--decision-output-root", str(root)]) + result = project_saved_context_capture(args) + assert result["ok"] and result["mode"] == "full" and result["path"] == str(path) + assert not root.exists() and path.read_bytes() == raw From 6176a290dbc2e6d05bc2ea5089061d4080db1bce Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:07:08 +0800 Subject: [PATCH 29/29] fix(context): reject unsupported explicit packet reuse modes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 9 +++-- .../agent-loop-effect-interpreter-v0.zh-CN.md | 6 ++- docs/heartbeat-automation-prompt.md | 9 +++-- loopx/cli_commands/quota.py | 10 ++--- loopx/cli_commands/quota_capture.py | 37 ++++++++++++------- loopx/cli_commands/quota_registration.py | 2 +- .../support_control_heartbeat_registration.py | 2 +- .../turn_driver/codex_context.py | 9 ++++- .../test_codex_context_lifecycle.py | 13 ++++++- .../test_context_delivery_cli.py | 32 +++++++++++++--- .../test_heartbeat_envelope_transport.py | 16 ++++++++ 11 files changed, 109 insertions(+), 36 deletions(-) diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 5f3360d5e6..b5c08ce3d1 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -1163,9 +1163,12 @@ profile plus its explicit `LOOPX_EXECUTION_MODE=heartbeat` and `LOOPX_CONTEXT_DELIVERY=on` declarations. The worker's existing execution owner rejects non-heartbeat modes and propagates these settings to native tools; its scheduler declaration is unchanged. No process or ambient native-session -heuristic authenticates a host. Unsupported, absent or -contradictory declarations keep full presentation with a reason; they do not -block otherwise authorized work. Hooks, configuration, saved presentation and +heuristic authenticates a host. With `on`, unsupported, absent, incomplete or +contradictory run-mode declarations fail the invocation with a nonzero exit and +an actionable reason before admission or transport writes, including hooks. +Correct the declaration or use `off`; this adds no owner question or approval. +Within a supported run mode, missing or invalid retained-input evidence still +automatically restores the same full capture. Hooks, configuration, saved presentation and reentry preserve this binary selection. Directory options or a host handoff alone cannot turn on deltas. Full original-capture recovery remains usable when off. These caller declarations select a supported adapter path, grant no diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index f369add24e..0bf7b7a00b 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -802,8 +802,10 @@ code-mode 调用方可以直接转发成功的 CLI stdout,避免再次序列 `generic_cli` profile 加显式 `LOOPX_EXECUTION_MODE=heartbeat` 与 `LOOPX_CONTEXT_DELIVERY=on` 声明。worker 既有执行 owner 拒绝非 heartbeat 模式, 并把这些设置传递给原生工具;scheduler 声明不变。不通过进程名或环境中的原生 -session 启发式认证宿主。声明缺失、 -矛盾或不受支持时保留全量并说明原因,不阻塞已有权限内的工作。hook、配置、已保存 +session 启发式认证宿主。`on` 时,跑法声明缺失、不完整、 +矛盾或不受支持会在准入与 transport 写入前以非零退出码和可操作原因报错,hook 也遵守 +此规则。应修正声明或改为 `off`,不新增 owner 问询或审批。受支持跑法内,输入保留证据 +缺失或失效仍自动恢复同一 capture 的全量。hook、配置、已保存 包展示和重入均传递这一二态选择。目录参数或 host handoff 本身不能开启差分。关闭时 仍可回查原 capture 全量。这些调用方声明只选择受支持 adapter 路径,不授予执行权限, 也不替代实际输入保留验证。 diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 4734a8e6e2..7788a2ee6b 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -434,9 +434,12 @@ session variables. Only two declared run modes can activate it: | Codex benchmark heartbeat worker | Existing `generic_cli` profile plus the worker-owned `LOOPX_EXECUTION_MODE=heartbeat` and `LOOPX_CONTEXT_DELIVERY=on` declarations; its execution owner rejects other modes | `--codex-app` is a shortcut for a complete runtime profile. `--host-surface` alone -is only one field; incomplete, contradictory or unsupported declarations do not -activate reuse. `on` with no supported declaration returns full delivery and an -activation reason. `off` creates no native journal, hook configuration or input +is only one field. With `on`, missing, incomplete, contradictory or unsupported +run-mode declarations are configuration errors: the CLI exits nonzero with a +reason before admission or transport writes, including in native hooks. Correct +the declaration or select `off`; there is no owner question or new approval. +In a supported run mode, missing or invalid retained-input evidence still +recovers the same original full capture automatically. `off` creates no native journal, hook configuration or input observation; directories alone never turn it on. This declaration check is not host authentication, execution authority or proof of retained input. Every warm delta still needs independently verified complete current-input evidence. diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index f8e55fe621..df42d85377 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -470,14 +470,14 @@ def _emit_quota_result( ) from .quota_capture import native_context_activation - activation = native_context_activation(args) + context_delivery_enabled = payload.get("ok") is True and native_context_activation(args)["enabled"] bind_capture_selection_transport(payload, args) delivery_request = None decision_identity = ("", "") try: capture_decision(capture_directory, payload) if payload.get("ok") is True: - delivery_request = handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None), activation["enabled"]) + delivery_request = handoff_decision(capture_directory, getattr(args, "decision_handoff_socket", None), context_delivery_enabled) except QuotaDecisionTransportError as exc: # The guard already ran. Report that typed fact with the durable receipt # and saved decision, never a new collection failure that could replace @@ -510,9 +510,9 @@ def _emit_quota_result( # Handoff releases the immutable original before any presentation. Native # actual-input observation owns its projection when explicitly selected; # never wrap one context-delivery wire in a second projection. - native_context = activation["enabled"] and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) + native_context = context_delivery_enabled and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) if (args.format == "json" and succeeded and delivery_request is not None and not native_context - and activation["enabled"]): + and context_delivery_enabled): payload = project_captured_context(payload, directory=capture_directory, request=delivery_request[0], capture_id=delivery_request[1], goal_id=decision_identity[0], agent_id=decision_identity[1], @@ -532,8 +532,6 @@ def _emit_quota_result( capture_id=capture_id) except (OSError, ValueError): pass # Lost optional capture: keep the admitted full payload, never rerun its guard. - if activation["requested"] == "on" and not activation["enabled"]: - payload = {**payload, "context_delivery": activation} print_payload(payload, args.format, _quota_renderer(args)) return 0 if succeeded else 1 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 754b17457f..42c6d99923 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -27,13 +27,16 @@ def native_context_activation(args: argparse.Namespace) -> dict[str, Any]: profiles = [value for value in (getattr(args, "runtime_profile", None), "codex_app_heartbeat" if getattr(args, "codex_app", False) else None, "trae_app" if getattr(args, "trae_app", False) else None) if value is not None] - if len(profiles) > 1: - return context_delivery_activation(getattr(args, "context_delivery", "off")) - return context_delivery_activation(getattr(args, "context_delivery", "off"), - runtime_profile=profiles[0] if profiles else None, - scheduler_execution_context=fields if any(fields.values()) else None, - benchmark_mode=os.environ.get("LOOPX_EXECUTION_MODE") - if os.environ.get("LOOPX_CONTEXT_DELIVERY") == "on" else None) + try: + if len(profiles) > 1: + return context_delivery_activation(getattr(args, "context_delivery", "off")) + return context_delivery_activation(getattr(args, "context_delivery", "off"), + runtime_profile=profiles[0] if profiles else None, + scheduler_execution_context=fields if any(fields.values()) else None, + benchmark_mode=os.environ.get("LOOPX_EXECUTION_MODE") + if os.environ.get("LOOPX_CONTEXT_DELIVERY") == "on" else None) + except ValueError as exc: + raise QuotaCommandValidationError(str(exc)) from exc def _read_capture(value: str, digest: str) -> tuple[Path, bytes, dict[str, Any]]: @@ -116,8 +119,10 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: Publication is delayed until this native tool runs, so the same actual-input observer can qualify a resumed baseline. Any optional transport failure returns the original full read reference, never a replacement guard. + Invalid run-mode opt-ins fail before that optional transport path. """ try: + activation = native_context_activation(args) path, raw, payload = _read_capture(args.capture_file, args.capture_sha256) identity = payload.get("agent_identity") receipt = payload.get("heartbeat_receipt") @@ -128,6 +133,8 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: raise ValueError("context-project requires the captured quota decision's exact Goal, Agent and Turn") if args.turn_envelope or args.decision_output_dir or args.decision_handoff_socket: raise ValueError("context-project presents the saved quota view; it does not change view or handoff") + except QuotaCommandValidationError as exc: + return {"ok": False, "reason": str(exc)} except (OSError, ValueError, TypeError, RecursionError) as exc: return {"ok": False, "reason": str(exc) if isinstance(exc, ValueError) else "original capture could not be read", "recovery": "Restore the original digest-bound decision; no guard, admission or spend was performed."} @@ -141,7 +148,7 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: from ..control_plane.turn_driver.codex_context import codex_context_directory, project_codex_context from ..control_plane.turn_driver.decision_capture import write_decision_capture - if not native_context_activation(args)["enabled"]: + if not activation["enabled"]: raise ValueError("context delivery is off") root = Path(args.decision_output_root).expanduser().absolute() metadata = root.lstat() @@ -172,7 +179,7 @@ def project_saved_context_capture(args: argparse.Namespace) -> dict[str, Any]: return {"ok": True, "mode": mode, **reference, **({"target": target} if target else {}), "recovery": full_reference, "capture_sha256": args.capture_sha256, - **({"context_delivery": native_context_activation(args)} if getattr(args, "context_delivery", "off") == "on" else {}), + **({"context_delivery": activation} if activation["enabled"] else {}), "instruction": "Presentation of the original decision only; authority and freshness are unchanged. " + CONTEXT_CAPTURE_READ_GUIDANCE} @@ -330,13 +337,18 @@ def handoff_decision(directory: Path | None, socket_path: str | None, def handle_native_context_command(args: argparse.Namespace, *, print_payload: Callable[..., None]) -> int | None: """Dispatch native presentation/lifecycle IO before any quota authority read.""" + if args.quota_command in ("context-config", "context-hook"): + try: + activation = native_context_activation(args) + except QuotaCommandValidationError as exc: + payload = {"ok": False, "reason": str(exc)} + print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) + return 1 if args.quota_command == "context-config": - import json from ..command_invocation import loopx_cli_argv from ..control_plane.turn_driver.codex_context import codex_context_configuration try: - activation = native_context_activation(args) if not activation["enabled"]: payload = {"ok": True, "context_delivery": activation} print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) @@ -358,14 +370,13 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca print_payload(payload, args.format, lambda value: json.dumps(value, ensure_ascii=False, indent=2)) return 0 if payload["ok"] else 1 if args.quota_command == "context-hook": - import json import sys from ..control_plane.effect_runtime import MAX_REQUEST_BYTES from ..control_plane.turn_driver.codex_context import codex_context_directory, codex_context_hook # Native transport only: no registry discovery, guard or quota effects. try: - if not native_context_activation(args)["enabled"]: + if not activation["enabled"]: print("{}") return 0 raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 6cd6a5ace9..80dd08697f 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -206,7 +206,7 @@ def register_quota_command( quota_parser.add_argument("--context-delivery", choices=CONTEXT_DELIVERY_SETTINGS, default="off", help="Experimental native packet reuse; off by default. On uses existing host declarations: " "Codex App heartbeat/resume/recovery (--codex-app), or the Codex benchmark heartbeat worker " - "(generic_cli plus its explicit heartbeat worker settings). Other or missing modes keep full delivery. " + "(generic_cli plus its explicit heartbeat worker settings). On rejects missing, contradictory or unsupported modes. " "A directory alone never enables it; declarations are not retention proof.") context_location = quota_parser.add_mutually_exclusive_group() context_location.add_argument("--codex-context-dir", diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index b0080ddc05..7e9dc6cb57 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -179,7 +179,7 @@ def register_heartbeat_control_commands( heartbeat_prompt_parser.add_argument("--context-delivery", choices=CONTEXT_DELIVERY_SETTINGS, default="off", help="Experimental native packet reuse; off by default. On uses existing host declarations: " "Codex App heartbeat/resume/recovery (--codex-app), or the Codex benchmark heartbeat worker " - "(generic_cli plus its explicit heartbeat worker settings). Other or missing modes keep full delivery. " + "(generic_cli plus its explicit heartbeat worker settings). On rejects missing, contradictory or unsupported modes. " "A directory alone never enables it; declarations are not retention proof.") context_location = heartbeat_prompt_parser.add_mutually_exclusive_group() context_location.add_argument( diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 19c0d54d14..229d5620b1 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -54,8 +54,15 @@ def context_delivery_activation(setting: str, *, runtime_profile: str | None = N and context.execution_mode is ExecutionMode.INTERACTIVE and benchmark_mode == "heartbeat"): scenario = "codex_benchmark_heartbeat" + if scenario is None: + raise ValueError( + "--context-delivery on requires a supported declared run mode: " + "Codex App heartbeat (--codex-app or --runtime-profile codex_app_heartbeat), " + "or the Codex benchmark heartbeat worker. Missing, contradictory or unsupported " + "declarations are rejected; use --context-delivery off for ordinary full delivery." + ) return {"requested": setting, "enabled": scenario is not None, "scenario": scenario, - "reason": "supported_declared_run_mode" if scenario else "disabled" if setting == "off" else "unsupported_or_missing_run_mode", + "reason": "supported_declared_run_mode" if scenario else "disabled", "supported_scenarios": list(CONTEXT_DELIVERY_SCENARIOS)} diff --git a/tests/control_plane/test_codex_context_lifecycle.py b/tests/control_plane/test_codex_context_lifecycle.py index 0c7afc856a..c0f9bfc610 100644 --- a/tests/control_plane/test_codex_context_lifecycle.py +++ b/tests/control_plane/test_codex_context_lifecycle.py @@ -314,8 +314,15 @@ def test_activation_reuses_declared_host_mode_and_off_is_inert(tmp_path, monkeyp args += ["--context-delivery", setting] results = [] parsed = build_parser().parse_args(args) - assert handle_native_context_command(parsed, print_payload=lambda value, *_: results.append(value)) == 0 + code = handle_native_context_command(parsed, print_payload=lambda value, *_: results.append(value)) result = results[0] + if setting == "on" and expected is None: + assert code == 1 and result["ok"] is False + assert "supported declared run mode" in result["reason"] + assert "--context-delivery off" in result["reason"] + assert "native_config" not in result and list(tmp_path.iterdir()) == [] + return + assert code == 0 and result["ok"] is True activation = result["context_delivery"] expected = expected if setting == "on" else None assert activation["requested"] == (setting or "off") @@ -350,6 +357,10 @@ def test_benchmark_activation_uses_existing_explicit_worker_declaration(monkeypa monkeypatch.delenv("LOOPX_EXECUTION_MODE", raising=False) args = build_parser().parse_args(["quota", "context-config", "--runtime-profile", "generic_cli", "--context-delivery", setting]) + if setting == "on" and not expected: + with pytest.raises(ValueError, match="supported declared run mode"): + native_context_activation(args) + return result = native_context_activation(args) assert result["enabled"] is expected assert result["scenario"] == ("codex_benchmark_heartbeat" if expected else None) diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index 554b2dfb98..9965ced4d8 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -18,7 +18,7 @@ ) from test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, - _run_cli, _spend_run_count, _write_fixture, + _heartbeat_receipt_count, _run_cli, _spend_run_count, _write_fixture, ) @@ -305,7 +305,9 @@ def unexpected(*args, **kwargs): @pytest.mark.parametrize("activation", [[], ["--context-delivery", "off"], ["--context-delivery", "on"]]) -def test_unsupported_or_off_delivery_keeps_full_and_does_not_touch_native_cache(tmp_path, activation): +def test_off_keeps_full_but_explicit_unsupported_on_errors_before_admission(tmp_path, monkeypatch, activation): + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) + monkeypatch.delenv("LOOPX_EXECUTION_MODE", raising=False) project, runtime, registry = _write_fixture(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" initialize_canonical_authority(runtime, GOAL_ID, @@ -315,8 +317,28 @@ def test_unsupported_or_off_delivery_keeps_full_and_does_not_touch_native_cache( code, result = _run_cli(registry, runtime, "quota", "should-run", "--runtime-profile", "generic_cli", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "unsupported", "--codex-context-dir", str(root), *activation, cwd=project) - assert code == 0 and result["ok"] and result.get("schema_version") != "context_delivery_v1", result assert not root.exists() if activation == ["--context-delivery", "on"]: - assert result["context_delivery"]["enabled"] is False - assert result["context_delivery"]["reason"] == "unsupported_or_missing_run_mode" + assert code != 0 and result["ok"] is False, result + assert "supported declared run mode" in result["reason"] + assert _heartbeat_receipt_count(runtime, "unsupported") == 0 + assert _spend_run_count(runtime) == 0 + else: + assert code == 0 and result["ok"] and result.get("schema_version") != "context_delivery_v1", result + + +@pytest.mark.parametrize("command", ["context-config", "context-hook", "context-project", "should-run"]) +@pytest.mark.parametrize("host", [[], ["--host-surface", "codex_app"], ["--runtime-profile", "claude_code"]]) +def test_unsupported_native_commands_fail_without_creating_transport_or_authority(tmp_path, command, host): + # A missing registry/capture must not hide this explicit configuration error + # or turn it into an optional-transport full fallback (including hooks). + runtime, registry = tmp_path / "runtime", tmp_path / "missing-registry.json" + native, captures = tmp_path / "native", tmp_path / "captures" + code, result = _run_cli(registry, runtime, "quota", command, *host, "--context-delivery", "on", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", "unqualified", + "--codex-context-dir", str(native), "--decision-output-root", str(captures), cwd=tmp_path) + assert code != 0 and result["ok"] is False, result + assert "supported declared run mode" in result["reason"] + assert not native.exists() and not captures.exists() and not registry.exists() + assert _heartbeat_receipt_count(runtime, "unqualified") == 0 + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index 7eacbee9df..9e742e447d 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -244,6 +244,10 @@ def test_real_worker_generated_guard_selection_reentry_and_settle_once( # No native proof in this CLI fixture: composed capture must still # release ordinary full output and preserve reentry and settlement. env.update(LOOPX_CONTEXT_DELIVERY='on', LOOPX_EXECUTION_MODE='heartbeat', LOOPX_CODEX_CONTEXT_DIR=str(tmp_path / 'context')) + # Generated CLI reentry uses this process's environment; preserve the + # same worker declarations carried by the native tool environment. + monkeypatch.setenv('LOOPX_CONTEXT_DELIVERY', 'on') + monkeypatch.setenv('LOOPX_EXECUTION_MODE', 'heartbeat') body = heartbeat_body(env, TURN_ID, native_goal=False, decision_handoff_socket=private_handoff) # Execute the renderer's actual guard, preserving its route and flags. block = body.split('```sh\n', 1)[1].split('```', 1)[0] @@ -359,6 +363,18 @@ def test_renderer_retains_owned_handoff_without_enabling_it_by_default(tmp_path) build_heartbeat_prompt(**args, decision_output_root=tmp_path, decision_handoff_socket=endpoint) +@pytest.mark.parametrize("profile", [None, "generic_cli", "claude_code"]) +def test_native_context_prompt_rejects_unqualified_on_and_keeps_off_inert(tmp_path, monkeypatch, profile): + monkeypatch.delenv("LOOPX_EXECUTION_MODE", raising=False) + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) + args = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, runtime_profile=profile, + thin=True) + assert build_heartbeat_prompt(**args) == build_heartbeat_prompt(**args, context_delivery="off") + with pytest.raises(ValueError, match="supported declared run mode"): + build_heartbeat_prompt(**args, context_delivery="on", codex_context_dir=str(tmp_path / "absent")) + assert list(tmp_path.iterdir()) == [] + + def test_native_context_prompt_keeps_capture_on_reentry(tmp_path, monkeypatch): monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "on") monkeypatch.setenv("LOOPX_EXECUTION_MODE", "heartbeat")