From 36f5dc97f564409c10e40051c6bd5e48cace3283 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:04:10 +0800 Subject: [PATCH] fix(explore): bind execution evidence to the finding once Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../explore-result-from-path-delta-v0.md | 45 ++++++++++- loopx/capabilities/explore/README.md | 4 + .../explore_execution_observation.ts | 4 +- .../capabilities/explore_result_writeback.ts | 39 ++++++++-- .../capabilities/explore_turn_context.ts | 3 +- loopx/control_plane/quota/context_delivery.ts | 6 +- .../quota/progressive_context.ts | 3 +- .../references/repair-patterns.md | 1 + .../control_plane/test_refresh_settle_turn.py | 78 +++++++++++++++++++ .../control_plane_ts/context_delivery.test.ts | 6 +- .../explore_result_writeback.test.ts | 38 +++++++++ 11 files changed, 212 insertions(+), 15 deletions(-) diff --git a/docs/reference/protocols/explore-result-from-path-delta-v0.md b/docs/reference/protocols/explore-result-from-path-delta-v0.md index f938372248..8fef46d5e6 100644 --- a/docs/reference/protocols/explore-result-from-path-delta-v0.md +++ b/docs/reference/protocols/explore-result-from-path-delta-v0.md @@ -100,11 +100,50 @@ through `--explore-result-json result.json`. It additionally supplies reusable result differs from the current route delta. Two sources must normalize to identical results; a conflict fails before primary commit. +## Execution evidence on the same input + +Both attachment forms accept the following `execution_observation` value +when the candidate ran on the finding's declared `input_revision`. + +```json +{ + "schema_version": "explore_execution_from_finding_v0", + "candidate_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "planned_method_id": "bounded-probe", + "executed_method_id": "bounded-probe", + "change_reason": null, + "elapsed_seconds": 0.125, + "limit_seconds": 10, + "search_iterations": 2, + "stop_reason": "iteration_limit", + "evidence_refs": "finding" +} +``` + +Use the actual candidate digest, method and measured resource values. Set +unknown measurements to null. The shorthand binds the required input revision +from this finding and rejects an explicit nested `input_revision`. Choose +`"finding"` only when all of the finding's validated evidence refs describe +the execution. Otherwise list its exact nonempty subset. With path-delta +capture this operates on the finding's selected refs, after excluding any local +file pointers. A released result still requires its `result_ref` and +matching `result_candidate_sha256`. + +The Explore owner expands this input to the complete +`explore_execution_observation_v0` before the existing attribution checks +and durable write. That complete form still accepts explicit values and rejects +mismatches. Standalone observations, including `explore finding` and stored +records, use the complete form. The captured writeback plan and Explore +turn-context expose `optional_execution_observation_example` when relevant. + +## Bounds and readback + A path-delta reference requires this same vision packet's top-level delta; a previous stored vision, a nested delta or a separate result file alone cannot -supply it. Observation allows 320 characters and interpretation 1200, matching -the Goal observation budget and retaining every legal route item as plain list -text without JSON escaping expansion. The combined finding, including its input +supply it. Question allows 180 characters and applicability 200. Observation's +320-character limit matches the Goal observation budget. Interpretation allows +1200 characters and retains every legal route item as plain list text without +JSON escaping expansion. The combined finding, including its input revision and applicability, fits within the 2000-character stored and next-turn summary budget. This replaces the previous 300/300 attachment and 1200 summary limits; the default three-result page and progressive reads are unchanged. No diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 3f8102d8a2..b6b9deb6d7 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -12,6 +12,10 @@ This records producer evidence, not verified execution or utility. Findings, replan context and the existing research-history detail retain it; omission preserves old events. See the [local probe producer](../../../benchmark/runtime/EXECUTION_OBSERVATIONS.md). +When the observed execution used the finding's tested input revision, result +attachments can bind it once and select the evidence to reuse. Readback and +standalone observations keep the complete observation form. [Execution evidence authoring](../../../docs/reference/protocols/explore-result-from-path-delta-v0.md#execution-evidence-on-the-same-input) + ## Quantitative findings An ordinary result attachment can include `measurements`, saved on its existing diff --git a/loopx/control_plane/capabilities/explore_execution_observation.ts b/loopx/control_plane/capabilities/explore_execution_observation.ts index bd34f7c319..bd77857053 100644 --- a/loopx/control_plane/capabilities/explore_execution_observation.ts +++ b/loopx/control_plane/capabilities/explore_execution_observation.ts @@ -7,12 +7,14 @@ import {requireJsonObject} from "../runtime_decode.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {isExploreEvidenceRef} from "./explore_evidence_ref.ts"; +export const EXPLORE_EXECUTION_OBSERVATION_SCHEMA = "explore_execution_observation_v0"; + export function normalizeExploreExecutionObservation(params: JsonObject): JsonObject { const row = requireJsonObject(params.observation, "execution observation"); const required = ["schema_version", "candidate_sha256", "input_revision", "planned_method_id", "executed_method_id", "change_reason", "elapsed_seconds", "limit_seconds", "search_iterations", "stop_reason", "evidence_refs"]; - if (row.schema_version !== "explore_execution_observation_v0" + if (row.schema_version !== EXPLORE_EXECUTION_OBSERVATION_SCHEMA || required.some(key => !Object.hasOwn(row, key)) || Object.keys(row).some(key => ![...required, "exit_code", "result_ref", "result_candidate_sha256"].includes(key))) { throw new EffectRuntimeRequestError("execution observation fields are missing or unsupported"); diff --git a/loopx/control_plane/capabilities/explore_result_writeback.ts b/loopx/control_plane/capabilities/explore_result_writeback.ts index c90ea88bc5..3b6587fb9b 100644 --- a/loopx/control_plane/capabilities/explore_result_writeback.ts +++ b/loopx/control_plane/capabilities/explore_result_writeback.ts @@ -5,13 +5,17 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString, requireStringArray, requireStringLiteral} from "../runtime_decode.ts"; import {contextSourceIdentity} from "../quota/diagnostic_projection.ts"; import {normalizeGoalPathDelta} from "../goals/vision_checkpoint.ts"; -import {normalizeExploreExecutionObservation} from "./explore_execution_observation.ts"; +import {EXPLORE_EXECUTION_OBSERVATION_SCHEMA, normalizeExploreExecutionObservation} from "./explore_execution_observation.ts"; import {isExploreEvidenceRef} from "./explore_evidence_ref.ts"; import {normalizeWorkYardstickDecision} from "../work_items/yardstick_decision.ts"; import {normalizeExploreMeasurements} from "./explore_measurements.ts"; export const EXPLORE_RESULT_ATTACHMENT_SCHEMA = "explore_result_attachment_v0"; export const EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA = "explore_result_from_path_delta_v0"; +const EXPLORE_EXECUTION_FROM_FINDING_SCHEMA = "explore_execution_from_finding_v0"; +const FINDING_EVIDENCE_SELECTION = "finding"; +const QUESTION_LIMIT = 180; +const APPLICABILITY_LIMIT = 200; // The complete scoped finding fits the result-log owner: 160 revision + 200 // applicability + 320 observation + 1200 interpretation + four labels < 2000. export const EXPLORE_WRITEBACK_SUMMARY_LIMIT = 2000; @@ -62,6 +66,12 @@ export function exploreResultWritebackAffordance(): JsonObject { unit: "ratio", higher_is_better: true, comparison_scope: "validation:fixed-budget-v1", input_revision: "dataset-v1", evaluation_ref: "evaluator:validation-v1", candidate_ref: "candidate:revision-2", evidence_ref: "result:validation-2"}]}, + optional_execution_observation_example: {execution_observation: { + schema_version: EXPLORE_EXECUTION_FROM_FINDING_SCHEMA, + candidate_sha256: "a".repeat(64), planned_method_id: "bounded-probe", executed_method_id: "bounded-probe", + change_reason: null, elapsed_seconds: 0.125, limit_seconds: 10, search_iterations: 2, + stop_reason: "iteration_limit", evidence_refs: FINDING_EVIDENCE_SELECTION, + }}, guidance: [ "After validating reusable evidence, put explore_result in the vision JSON already submitted with --agent-vision-json.", EXPLORE_BEFORE_WORK.openLease, @@ -94,7 +104,8 @@ export function exploreResultWritebackAffordance(): JsonObject { "To reuse explicitly verified conditions, optional measurement_context supplies comparison_scope, input_revision and evaluation_ref for observations that omit those fields; conflicting fields reject. CLI finding accepts the same context via --measurement-context-json. This copies declared conditions, not proof of comparability.", EXPLORE_BEFORE_WORK.feedbackBeforeWork, EXPLORE_BEFORE_WORK.executionEvidence, - "Observations allow 320 characters, interpretations 1200; the complete scoped summary is preserved within 2000 characters.", + `Use optional_execution_observation_example when the execution used this finding's tested input. Omit the nested input_revision and set its evidence_refs to "${FINDING_EVIDENCE_SELECTION}" only if all finding refs describe this execution, or list the exact opaque subset. Supply the actual candidate and execution facts. A released result still needs its result_ref and matching result_candidate_sha256.`, + `Questions allow ${QUESTION_LIMIT} characters, applicability ${APPLICABILITY_LIMIT}, observations 320, and interpretations 1200. The complete scoped summary is preserved within 2000 characters.`, ].join(" "), // Blank evidence fields deliberately fail validation until the caller // supplies observed facts. Goal/Agent/Todo/Turn bind in ordinary writeback; @@ -163,6 +174,20 @@ function explicitNodeId(value: unknown): string { } return value.trim(); } +/** Expand an explicit same-finding binding before the canonical codec. */ +function normalizeAttachedExecution(value: unknown, inputRevision: unknown, refs: string[]): JsonObject { + const authored = jsonObject(value); + if (authored?.schema_version !== EXPLORE_EXECUTION_FROM_FINDING_SCHEMA) { + return normalizeExploreExecutionObservation({observation: value}); + } + if (Object.hasOwn(authored, "input_revision")) { + throw new EffectRuntimeRequestError("execution observation cannot override its explicit finding input revision"); + } + return normalizeExploreExecutionObservation({observation: {...authored, + schema_version: EXPLORE_EXECUTION_OBSERVATION_SCHEMA, input_revision: inputRevision, + evidence_refs: authored.evidence_refs === FINDING_EVIDENCE_SELECTION ? refs : authored.evidence_refs, + }}); +} function normalizeAttachment(value: unknown, visionPacket?: unknown, linkedScope?: unknown): JsonObject { const row = requireJsonObject(value, "Explore result attachment"); if (row.schema_version === EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA) { @@ -172,8 +197,8 @@ function normalizeAttachment(value: unknown, visionPacket?: unknown, linkedScope throw new EffectRuntimeRequestError("Explore result attachment contains unknown fields"); } requireStringLiteral(row.schema_version, [EXPLORE_RESULT_ATTACHMENT_SCHEMA], "attachment schema"); - const question = text(row.question, "question", 180); - const applicability = text(row.applicability, "applicability", 200); + const question = text(row.question, "question", QUESTION_LIMIT); + const applicability = text(row.applicability, "applicability", APPLICABILITY_LIMIT); // Identity is mechanical, never inferred evidence. Exact scoped text, not // revision/status/observation, binds successive results to the same question. const node = Object.hasOwn(row, "node_id") @@ -190,7 +215,7 @@ function normalizeAttachment(value: unknown, visionPacket?: unknown, linkedScope return ref; }); const execution = Object.hasOwn(row, "execution_observation") - ? normalizeExploreExecutionObservation({observation: row.execution_observation}) : null; + ? normalizeAttachedExecution(row.execution_observation, row.input_revision, refs) : null; if (Object.hasOwn(row, "measurement_context") && !Object.hasOwn(row, "measurements")) { throw new EffectRuntimeRequestError("measurement_context requires measurements"); } @@ -260,8 +285,8 @@ function questionScope(row: JsonObject, value: unknown): JsonObject { if (!refs.includes(nodeId) || matches.length !== 1) { throw new EffectRuntimeRequestError("Explore capture requires an existing question linked to this Todo"); } - return {question: text(matches[0].title, "question", 180), - applicability: text(matches[0].summary, "applicability", 200)}; + return {question: text(matches[0].title, "question", QUESTION_LIMIT), + applicability: text(matches[0].summary, "applicability", APPLICABILITY_LIMIT)}; } export function normalizeExploreResultAttachment(params: JsonObject): JsonObject { const result = normalizeAttachment(params.attachment, params.vision_packet, params.linked_scope); diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index b6a4fa911e..420b640218 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -20,7 +20,7 @@ export function exploreReusableContextPaths(content: JsonObject = {}): (string | return [ ...["recent_nodes", "recent_findings", "writeback_results", "measurement_feedback", "work_yardsticks", "work_yardstick_capture", "path_delta_attachment_template", "linked_question_attachment_template", - "optional_question_evolution_example", "result_writeback_guidance", + "optional_question_evolution_example", "optional_execution_observation_example", "result_writeback_guidance", "result_attachment_template", "optional_measurement_example", "measurement_guidance", "record_node_template", "record_finding_template", "guidance", "result_writeback_option", "result_writeback_inline_option", "result_writeback_inline_field", @@ -49,6 +49,7 @@ function resultWritebackFields(writeback: JsonObject): JsonObject { path_delta_attachment_template: writeback.path_delta_attachment_template, linked_question_attachment_template: writeback.linked_question_attachment_template, optional_question_evolution_example: writeback.optional_question_evolution_example, + optional_execution_observation_example: writeback.optional_execution_observation_example, result_writeback_guidance: writeback.guidance, result_attachment_template: writeback.attachment_template, optional_measurement_example: writeback.optional_measurement_example, diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index dfec0e2f61..5ebb0af1a1 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -237,7 +237,11 @@ function freshPaths(context: JsonObject, surface: Surface, legacyQuotaFields: re 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]); + for (const property of Object.keys(item)) { + if (property === "guidance" || (property === "optional_execution_observation_example" + && item.capability_id === "explore" && item.required === false)) continue; + paths.push([...prefix, property]); + } }); } else paths.push([...path, key, i, field]); } diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 410efa97cb..178b75f437 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -537,7 +537,8 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: // Keep its identity and actual read/authoring routes discoverable. for (const key of Object.keys(object(attachment)!)) { if (["guidance", "attachment_template", "linked_question_attachment_template", "path_delta_attachment_template", - "optional_measurement_example", "optional_question_evolution_example"].includes(key)) continue; + "optional_measurement_example", "optional_question_evolution_example"].includes(key) || + (key === "optional_execution_observation_example" && object(attachment)?.capability_id === "explore")) continue; include(...path, key); // Unknown attachment controls are never hidden. } }); diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 5de1a84a5c..41ede8276a 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -219,6 +219,7 @@ teaches a reusable control-plane lesson. | `todo_text_update_continuation_residue` | `todo update --text` reports success, but status and later monitor writeback still append an older indented description after the new text. | active-state todo block, metadata-line position, `todo list` parsed text, update payload before and after monitor writeback. | Text replacement removed only continuation lines before the first metadata marker, while structured metadata had been inserted ahead of legacy continuation lines. | Replace the todo marker line, remove every indented non-metadata continuation within the same todo block, preserve structured metadata, and cover a fixture where stale prose follows the metadata marker. | | `external_sink_authority_gap` | A local state refresh must proceed while the current runtime boundary forbids external writes, but an enabled projection automatically invokes its configured remote sink; agents toggle the whole feature off or risk an unauthorized write. | goal feature policy, refresh command, sink activation packet, current external-write authority/capabilities, sink digest before/after. | Feature opt-in and per-run delivery authority were represented by one boolean, so local canonical projection could not be separated from remote delivery. | Add a run-scoped external-sink suppression boundary that preserves canonical local projection, performs zero remote writes, does not advance sink digests, and remains retryable after authorization returns. Keep goal-level feature opt-in unchanged and cover the real refresh call path. | | `sink_binding_activation_gap` | A generated heartbeat asks for Kanban or graph sync while the corresponding goal feature is disabled, or an old local board binding causes sink repair work to displace a runnable project P0. | generated heartbeat prompt, goal Explore Graph and generic Kanban switches, quota/status goal boundary, local binding presence, selected todo before and after sink failure. | The heartbeat template treated sink discovery or configuration presence as activation and emitted one unconditional delivery rule for unrelated sink types. | Remove sink-specific activation from the host prompt. Let Explore Graph activation remain inside the goal-aware material refresh path; keep generic Kanban user-command-only unless a separate goal opt-in projects one concise executable post-writeback action through quota/status. Treat local bindings as inert, keep generic Kanban failures nonblocking, and never let sink follow-up preempt runnable P0. Cover action absence by default, explicit opt-in projection, and the real quota interaction contract. | +| `explore_execution_observation_binding_mismatch` | An Explore settlement rejects the execution observation because its input revision or evidence references differ from the enclosing finding. | The original rejection, the two declarations' field identities and the captured settlement attachment guidance. Check candidate and result attribution using the producer evidence. | The caller independently recopied the same scope or used a candidate digest as the evaluated input revision. | Keep explicit mismatches rejected. When the evidence establishes the same tested input, use the existing Explore attachment's `explore_execution_from_finding_v0` form and explicitly select all finding evidence or list its exact subset. Keep the candidate and result attestations explicit. Verify canonical graph readback and replay on the same Turn. | | `explore_graph_delivery_postcondition_gap` | Graph-on is enabled and local projection changes, but the agent delivers before the configured board is synced, or a sink failure is only a warning and the remote board silently stays stale. | goal `explore_graph` policy, material refresh packet, sink semantic/readback digests, row/result ids, current external-write authority, final delivery timing. | Feature activation scheduled best-effort projection but did not expose or enforce a material-delivery postcondition; the agent also treated graph sync as a deferrable P1. | Require configured sink sync plus row/result-id readback after material refresh. Authorized failure blocks delivery and remains retryable; suppressed external delivery preserves local state but requires a concrete successor. Keep Explore Graph and Explore Harness opt-ins independent. | | `explore_sink_source_runtime_bypass` | A goal-scoped Lark/Explore sink proposes an older or smaller graph after another runtime already delivered a fresher projection; retrying would overwrite or visually regress registered rows. | invoked registry role, goal `source_registry`, compact source-runtime route, candidate result-id coverage, registered sink result ids, semantic/readback digests, and connector call count. | The sink projected directly from the invoked registry runtime instead of the registry-declared canonical source, and digest comparison could not distinguish a legitimate update from source shrinkage. | Resolve the canonical goal source before projection, expose only hashed route diagnostics, and enforce the append-only registered result-id coverage guard before previews or remote writes. Fail closed with zero connector calls and a source-reconciliation successor when registered ids disappear. | | `explore_owner_view_semantic_collapse` | Explore Base rows, digests, and whiteboard readback all succeed, but the owner-facing visual becomes a chronological card wall, fake epoch sequence, or decorative summary that hides the real work lanes, frontier, and material relations. | canonical Nodes/Edges/Findings, rendered SVG/Mermaid source, renderer metadata, exported whiteboard image, owner reference surface. | Delivery correctness and geometric stability were accepted as semantic/visual correctness; the renderer owned placement while discarding the graph's actual edge and lane meaning. | Use a configurable semantic-board renderer that derives lanes from canonical lineage/tags, highlights frontier from current status, and draws actual visible relations. Require semantic counters plus exported-image inspection before delivery; sink/readback success alone is insufficient. | diff --git a/tests/control_plane/test_refresh_settle_turn.py b/tests/control_plane/test_refresh_settle_turn.py index 787b91aa1b..b881fa3d4d 100644 --- a/tests/control_plane/test_refresh_settle_turn.py +++ b/tests/control_plane/test_refresh_settle_turn.py @@ -299,6 +299,84 @@ def test_fresh_turn_honors_the_written_route_and_keeps_unfinished_requirements( assert "Validate and settle the selected delivery" in original["todo"]["text"] +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_execution_evidence_uses_explicit_finding_binding_in_settlement(tmp_path, monkeypatch, provider): + from loopx.capabilities.explore.result_log import explore_result_log_path + from loopx.configure_goal import configure_goal + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + configure_goal(registry_path=registry, goal_id=GOAL_ID, execute=True, explore_mode="evidence") + _initialize_fixture_authority(project, runtime, provider=provider) + binding = ["--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID] + rc, entry = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + "--context-delivery", "on", "--decision-output-dir", str(tmp_path / "capture"), cwd=project) + assert rc == 0 and entry["schema_version"] == "context_capture_navigation_v1", entry + rc, detail = _run_cli(registry, runtime, "quota", "context-read", "--reference-file", + entry["recovery"]["reference_file"], "--select", "/interaction_contract/cli_channel/settlement_plan", + "--page-bytes", "24000", "--max-page-output-bytes", "64000", cwd=project) + assert rc == 0 and detail["eof"], detail + plan = json.loads(detail["content"])["selections"][0]["value"] + writeback = next(step for step in plan["ordered_steps"] if step["kind"] == "durable_writeback") + + refs = ["result:probe", "source:" + "e" * 64] + revision = "dataset:" + "d" * 64 + observation = {"schema_version": "explore_execution_observation_v0", + "candidate_sha256": "c" * 64, "input_revision": revision, + "planned_method_id": "initial", "executed_method_id": "revised", + "change_reason": "A boundary check selected the revised method.", + "elapsed_seconds": 0.125, "limit_seconds": 10, "search_iterations": 2, + "stop_reason": "iteration_limit", "evidence_refs": sorted(refs), + "result_ref": refs[0], "result_candidate_sha256": "c" * 64} + vision = tmp_path / "vision.json" + packet = {"schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "The bounded execution has a reproducible result."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "continue", + "prior_assumption": "The first method could cover the boundary.", + "observed_reality": "The revised method handled the boundary case.", + "retained": ["Validate the remaining cases."], + "evidence_refs": [*refs, ".loopx/evidence/probe.json"]}, + "explore_result": {"schema_version": "explore_result_from_path_delta_v0", + "question": "Which method handles the boundary?", "applicability": "Synthetic boundary case only", + "input_revision": revision, "status": "confirmed", "evidence_refs": refs}} + command = writeback["semantic_closeout_command_template"] + for key, value in {"": "verified_slice", "": "implementation", + "": "outcome_progress", "": shlex.quote(str(vision))}.items(): + command = command.replace(key, value) + if "--settle-turn" not in shlex.split(command): + command += " --settle-turn --settlement-receipt" + command += " --no-global-sync --suppress-external-sinks" + + packet["explore_result"]["execution_observation"] = {**observation, "input_revision": "foreign-input"} + vision.write_text(json.dumps(packet)) + rc, rejected = _run_generated_cli(command, registry_path=registry, cwd=project) + assert rc == 1 and rejected["appended"] is False, rejected + assert "execution observation must retain this finding's input revision" in rejected["error"], rejected + log = explore_result_log_path(runtime, GOAL_ID) + assert _spend_run_count(runtime) == 0 and not log.exists() + + compact = {key: value for key, value in observation.items() if key != "input_revision"} + compact.update(schema_version="explore_execution_from_finding_v0", evidence_refs="finding") + packet["explore_result"]["execution_observation"] = compact + vision.write_text(json.dumps(packet)) + rc, settled = _run_generated_cli(command, registry_path=registry, cwd=project) + assert rc == 0 and settled["settlement_progress"]["state"] == "settled", settled.get("error", settled) + assert settled["settlement_identity"] == plan["identity"] + assert settled["quota_spend"]["ok"] and "settlement_owed" not in settled + + example = writeback["optional_attachments"][0]["optional_execution_observation_example"] + assert example["execution_observation"]["schema_version"] == compact["schema_version"] + assert compact["schema_version"] not in json.dumps(entry) + assert settled["explore_result"]["execution_observation"] == observation + rc, context = _run_cli(registry, runtime, "explore", "turn-context", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, cwd=project) + assert rc == 0 and context["graph"]["writeback_results"][0]["execution_observation"] == observation + before = log.read_bytes() + rc, replay = _run_generated_cli(command, registry_path=registry, cwd=project) + assert rc == 0 and replay["idempotent_replay"] and replay["settlement_identity"] == plan["identity"], replay + assert _spend_run_count(runtime) == 1 and log.read_bytes() == before + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_owed_command_identity_and_lost_response_recover_without_another_debit(tmp_path, monkeypatch, provider): from loopx.cli_commands import refresh_settlement diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index 073ab91ff1..06911d40a8 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -694,7 +694,9 @@ function packet(): JsonObject { 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}]}, + {kind: "writeback", owner: "agent", precondition: long, command_template: "writeback-a", + optional_attachments: [{capability_id: "explore", guidance: long, required: false, + optional_execution_observation_example: {execution_observation: {evidence_refs: "finding"}}}]}, ]}}}}; } const packetFull = (value: JsonObject, capture_id = "capture-a", surface = "quota_packet") => @@ -829,6 +831,8 @@ test("whole packet reuses complete projections; live controls and unknown fields ] 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.equal(inline(delta, ["interaction_contract", "cli_channel", "settlement_plan", "ordered_steps", + 1, "optional_attachments", 0, "optional_execution_observation_example"]), undefined); 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. diff --git a/tests/control_plane_ts/explore_result_writeback.test.ts b/tests/control_plane_ts/explore_result_writeback.test.ts index 3228ec6560..a926694a00 100644 --- a/tests/control_plane_ts/explore_result_writeback.test.ts +++ b/tests/control_plane_ts/explore_result_writeback.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import {normalizeExploreExecutionObservation} from "../../loopx/control_plane/capabilities/explore_execution_observation.ts"; import {exploreResultWritebackAffordance, normalizeExploreResultAttachment, planExploreQuestionEvolution} from "../../loopx/control_plane/capabilities/explore_result_writeback.ts"; const attachment = { @@ -46,6 +47,43 @@ test("execution observations reuse the finding's existing evidence references", {...ordinary, execution_observation: {...observed, evidence_refs: [...refs].sort()}}); }); +test("explicit finding bindings expand to the existing complete execution record", () => { + const {input_revision: _revision, ...facts} = execution; + const authoring = {...facts, schema_version: "explore_execution_from_finding_v0", evidence_refs: "finding"}; + const normalize = (observation: Record) => + normalizeExploreResultAttachment({attachment: {...attachment, execution_observation: observation}}); + const complete = {...attachment, execution_observation: execution}; + assert.deepEqual(normalize(authoring), complete); + assert.deepEqual(normalizeExploreResultAttachment({ + attachment: {...attachment, execution_observation: authoring}, other_attachment: complete, + }), complete); + const subset = [attachment.evidence_refs[1]]; + assert.deepEqual(normalize({...authoring, evidence_refs: subset}).execution_observation, + {...execution, evidence_refs: subset}); + + const {evidence_refs: _selection, ...unbound} = authoring; + assert.throws(() => normalize(unbound), /one to eight evidence references/); + for (const refs of [null, [], ["foreign:execution"], ["/absolute/file"]]) { + assert.throws(() => normalize({...authoring, evidence_refs: refs})); + } + for (const revision of [null, execution.input_revision, "different-revision"]) { + assert.throws(() => normalize({...authoring, input_revision: revision}), /cannot override/); + } + assert.throws(() => normalize({...authoring, result_ref: attachment.evidence_refs[0], + result_candidate_sha256: "b".repeat(64)}), /this executed candidate/); + assert.throws(() => normalize({...authoring, result_ref: attachment.evidence_refs[0], + result_candidate_sha256: execution.candidate_sha256, evidence_refs: subset}), /included evidence reference/); + assert.throws(() => normalize(facts), /fields are missing or unsupported/); + assert.throws(() => normalizeExploreExecutionObservation({observation: authoring}), /fields are missing or unsupported/); + + const affordance = exploreResultWritebackAffordance(); + const example = affordance.optional_execution_observation_example as Record; + const expanded = normalize(example.execution_observation as Record).execution_observation; + assert.deepEqual(expanded, {...example.execution_observation as Record, + schema_version: execution.schema_version, input_revision: attachment.input_revision, + evidence_refs: attachment.evidence_refs}); +}); + test("execution reference compatibility preserves attribution, text bounds and method identifiers", () => { const refs = ["validation:run/1", "result:released/1"]; const ordinary = {...attachment, evidence_refs: refs};