From 4c264d122e5d2ca6e078efc08c99b5f820b0acba Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 14:13:13 +0800 Subject: [PATCH 1/8] fix(replan): require material delivery for result followthrough Signed-off-by: song --- .../goal-vision-replan-contract-v0.md | 15 +++++++++++ .../work_items/replan_context.ts | 9 ++++++- .../test_effective_turn_replan.py | 25 +++++++++++++++++++ tests/control_plane_ts/replan_context.test.ts | 24 ++++++++++++++++-- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index a8abdfa8a6..1c86f45b5f 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -327,6 +327,21 @@ plan invalidates its unexecuted proposal, not the unresolved source. This is a read model, not a pending-duty ledger, new write gate or forced continuation. The Agent still checks the provider's exact candidate/input/result revision. +The relation uses the existing delivery outcome owner: only `outcome_gap`, +`outcome_progress` and `primary_goal_outcome` records create pending work results +or satisfy subsequent followthrough. `surface_only`, missing and unsupported +historical outcomes remain readable in ordinary history but neither consume a +cited result nor create a new unresolved result. An optional progress attachment +does not override that classification. Negative results and concrete blockers +remain eligible material evidence; this does not certify execution or gain, +rewrite source history, or alter settlement, waiting or continuation authority. + +结果接续复用既有 delivery outcome 分类:只有 `outcome_gap`、`outcome_progress` +和 `primary_goal_outcome` 才形成待复盘工作结果或后续接续关系。纯整理 +`surface_only`、缺失或未知历史分类仍可从普通历史读取,但不会把旧结果标为已接续, +也不会产生新的待复盘结果;可选 progress 附件不能覆盖这一分类。有效负结果和明确 +阻塞仍保留。这只是来源关系,不认证实际执行或收益,不改写历史、结算或继续运行权限。 + 启用 `evidence_conditioned_v1` 且历史中已有被接受、绑定证据的 Vision 路线时,replan context 返回上次决定与之后的 进度引用,按合格来源的顺序关联,而非根据时间戳或文案猜测因果。完整本地索引提供 追加位置;已知最新在前的紧凑载体与 `newest_first_runs` 入口转换位置,保留普通 diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index b48fc4b622..ab6c1d6b4f 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -9,6 +9,7 @@ import {FRESH_PATH_DISPOSITIONS, requiredSemanticOutcomes} from "./replan_semant import {readReplanSnapshot} from "./replan_history_snapshot.ts"; import {normalizeGoalPathDelta} from "../goals/vision_checkpoint.ts"; import {normalizeRecordedStrideDecision, normalizeStrideStrategy, projectStrideExploreBasis} from "./stride_policy.ts"; +import {MATERIAL_DELIVERY_OUTCOMES} from "./delivery_outcome.ts"; const CONTEXT_SCHEMA = "replan_context_v0"; // Replan is an infrequent decision, not the ordinary guard's display window. @@ -229,6 +230,12 @@ function workResultUse(scoped: JsonObject[]): JsonObject | null { } continue; } + // Reuse the delivery owner's recorded classification. A status/preparation + // writeback is still readable evidence, but cannot consume a cited result + // or create a new pending work result. Unknown legacy outcomes stay unknown. + // Material gaps remain eligible: this relation certifies neither successful + // execution nor scientific gain, and must preserve useful negative evidence. + if (!MATERIAL_DELIVERY_OUTCOMES.some(outcome => outcome === row.delivery_outcome)) continue; const todo = String(observationTodo(row)); if (!origin.has(todo)) continue; const ref = "replan-evidence-" + digest(readableEvidenceRow(row)); @@ -250,7 +257,7 @@ function workResultUse(scoped: JsonObject[]): JsonObject | null { unresolved: visible, recorded_followthrough_count: followed.length, recorded_followthrough: followed.slice(-6).reverse(), omitted_followthrough_count: Math.max(0, followed.length - 6), - guidance: "Resolve unresolved evidence even when a newer review has occurred. A cited result stays unresolved until later recorded work is linked through that decision's declared Todo. " + + guidance: "Resolve unresolved evidence even when a newer review has occurred. A cited result stays unresolved until a later material delivery is linked through that decision's declared Todo; surface-only and unknown outcomes do not consume it. " + "Recorded followthrough is an attributable history relation, not verified execution, applicability to a current candidate, or Goal gain. " + "Check the provider's exact candidate/input/result revision before adopting an outcome; retain valid negative and inconclusive evidence. Existing authority, stopping and settlement rules remain unchanged."}; } diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index 57cd117791..e64a28ca7a 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -559,6 +559,31 @@ def call(*args: str, expected_code: int = 0) -> dict: call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--replan-obligation-id", next_obligation["obligation_id"], "--turn-instance-id", "stride-next-review", "--slots", "1", "--source", "heartbeat", "--execute") + # An unbound observation may name the declared work without claiming a Turn + # settlement. The public CLI rejects surface-only Turn settlement, but this + # ordinary status route must not consume the cited negative result either. + status_update = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--progress-work-item-id", next_successor["todo_id"], "--progress-result-class", "unchanged", + "--classification", "status_readback", + "--delivery-batch-scale", "single_surface", "--delivery-outcome", "surface_only", + "--recommended-action", "The next probe remains planned; no new experiment has run.", + "--vision-summary", "The accepted source comparison is still pending; this is a status clarification only.", + "--no-global-sync", "--suppress-external-sinks") + status_run = json.loads(Path(status_update["json_path"]).read_text()) + assert status_run["delivery_outcome"] == "surface_only" + assert status_run["agent_vision"], "the ordinary source must enter the causal history replay" + from loopx.control_plane.work_items.replan_context_codec import project_replan_context + from loopx.history import load_index_snapshot + + context = project_replan_context(goal_id=GOAL_ID, agent_id=AGENT_ID, + runs=load_index_snapshot(runtime / "goals" / GOAL_ID / "runs/index.jsonl", + include_artifact_status=False).records, + history_newest_first=False, stride_strategy="evidence_conditioned_v1") + assert any(row["coverage_ref"] == status_run["progress_observation"]["fingerprint"] + for row in context["decision_followthrough"]["observations"]) + pending = context["decision_followthrough"]["work_result_use"] + assert any(row["evidence_ref"] == ordinary_ref for row in pending["unresolved"]) + assert not any(row["result_evidence_ref"] == ordinary_ref for row in pending["recorded_followthrough"]) for index in range(2): turn = f"adopted-probe-{index}" guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts index 47d56781ca..d85a28e524 100644 --- a/tests/control_plane_ts/replan_context.test.ts +++ b/tests/control_plane_ts/replan_context.test.ts @@ -134,7 +134,7 @@ const strideRequest = {...request, stride_strategy: "evidence_conditioned_v1"}; test("unconsumed result survives later reviews and citations alone; recorded work closes only its source relation", () => { const first = reviewed(1); (first._source_facts as JsonObject).planned_todo_ids = ["todo-a"]; - const result = {...subsequent(2), progress_observation: null}; + const result = {...subsequent(2), progress_observation: null, delivery_outcome: "outcome_progress"}; const ref = (projectReplanContext({...request, rows: [result]}).evidence as JsonObject[])[0].evidence_ref; const next = reviewed(3); (next._source_facts as JsonObject).planned_todo_ids = ["todo-b"]; @@ -146,7 +146,18 @@ test("unconsumed result survives later reviews and citations alone; recorded wor assert.equal(proposed.unresolved_count, 1); assert.equal((proposed.unresolved as JsonObject[])[0].evidence_ref, ref); assert.deepEqual(proposed.recorded_followthrough, []); - const actual = {...subsequent(4), todo_id: "todo-b", progress_observation: null}; + // A later record is not necessarily work. Keep preparation and unknown + // legacy outcomes readable without consuming the result or creating a new + // pending research result from each status update. + for (const delivery_outcome of ["surface_only", null, "future_outcome"]) { + for (const progress_observation of [null, {...progress, work_item_id: "todo-b"}]) { + const status = {...subsequent(4), todo_id: "todo-b", delivery_outcome, progress_observation}; + assert.deepEqual(readUse([first, result, next, status]), proposed); + assert.equal((projectReplanContext({...request, rows: [status]}).evidence as JsonObject[]).length, 1); + } + } + const actual = {...subsequent(4), todo_id: "todo-b", progress_observation: null, + delivery_outcome: "outcome_progress"}; const adopted = readUse([first, result, next, actual]); assert.equal(adopted.unresolved_count, 1, "the next result remains available for the next decision"); assert.notEqual((adopted.unresolved as JsonObject[])[0].evidence_ref, ref); @@ -156,6 +167,15 @@ test("unconsumed result survives later reviews and citations alone; recorded wor assert.deepEqual(readUse([first, result, next, actual, actual]), adopted, "replay cannot consume or duplicate twice"); const replayedResult = {...result, _source_facts: {...result._source_facts as JsonObject, history_position: 5}}; assert.deepEqual(readUse([first, result, next, actual, replayedResult]), adopted, "a lost-response replay cannot resurrect consumed evidence"); + // A negative experiment or a new concrete blocker can still inform a later + // decision. This relation must not require a positive scientific outcome. + for (const delivery_outcome of ["outcome_gap", "primary_goal_outcome"]) { + const use = readUse([first, result, next, {...actual, delivery_outcome}]); + assert.equal(use.recorded_followthrough_count, 1); + assert.equal((use.recorded_followthrough as JsonObject[])[0].result_evidence_ref, ref); + } + const metadata = {...result, delivery_outcome: "surface_only"}; + assert.equal(readUse([first, metadata]), undefined, "metadata is not a new unresolved work result"); const omitted = reviewed(3); (omitted._source_facts as JsonObject).planned_todo_ids = ["todo-b"]; assert.equal(readUse([first, result, omitted]).unresolved_count, 1, "another review cannot silently drop an uncited result"); From 13a5714ea1b73ee5199ec7e782e9c26429798d13 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 15:33:56 +0800 Subject: [PATCH 2/8] feat(research): connect scoped continuation, request admission and execution evidence Signed-off-by: song --- .../goal-research-history.tsx | 19 +- benchmark/edgebench/README.md | 5 + benchmark/edgebench/export_report.py | 5 + benchmark/edgebench/run.py | 13 ++ benchmark/runtime/RESEARCH_LOOP.md | 87 +++++++ benchmark/runtime/checkpoint_wake.py | 6 +- benchmark/runtime/harbor.py | 28 ++- benchmark/runtime/planning.py | 7 +- benchmark/runtime/research_loop.py | 78 +++++++ benchmark/runtime/research_probe.py | 126 ++++++++++ benchmark/runtime/research_request_guard.py | 108 +++++++++ benchmark/runtime/sforge.py | 12 + benchmark/runtime/worker.py | 29 ++- benchmark/tests/test_sforge_runtime.py | 17 ++ benchmark/tests/test_task_entry.py | 31 +++ docs/assets/research-execution/desktop.png | Bin 0 -> 175521 bytes docs/assets/research-execution/narrow.png | Bin 0 -> 81286 bytes .../research-history.mjs | 13 +- .../benchmark_toolkit/provider_gateway.py | 85 ++++++- loopx/capabilities/explore/README.md | 10 + loopx/capabilities/explore/README.zh-CN.md | 7 + .../explore/execution_observation.py | 21 ++ loopx/capabilities/explore/result_log.py | 12 +- .../capabilities/explore/result_writeback.py | 1 + .../explore_execution_observation.ts | 78 +++++++ .../capabilities/explore_result_writeback.ts | 15 +- .../capabilities/explore_turn_context.ts | 1 + .../control_plane/effect_runtime_handlers.ts | 1 + .../presentation/explore_history.ts | 6 + .../control_plane/work_items/stride_policy.ts | 1 + tests/capabilities/test_provider_gateway.py | 20 ++ .../test_research_execution_loop.py | 221 ++++++++++++++++++ .../test_research_request_guard.py | 89 +++++++ .../explore_result_writeback.test.ts | 25 ++ 34 files changed, 1157 insertions(+), 20 deletions(-) create mode 100644 benchmark/runtime/RESEARCH_LOOP.md create mode 100644 benchmark/runtime/research_loop.py create mode 100644 benchmark/runtime/research_probe.py create mode 100644 benchmark/runtime/research_request_guard.py create mode 100644 docs/assets/research-execution/desktop.png create mode 100644 docs/assets/research-execution/narrow.png create mode 100644 loopx/capabilities/explore/execution_observation.py create mode 100644 loopx/control_plane/capabilities/explore_execution_observation.ts create mode 100644 tests/control_plane/test_research_execution_loop.py create mode 100644 tests/control_plane/test_research_request_guard.py diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index e06ec8d728..6d616d40b2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -75,9 +75,10 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const outcomeCopy: Record = zh ? {replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} : {replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human"}; const findingBody = (finding: ExploreHistoryEvent) => { const sections = parseExploreFindingSections(finding.summary); - if (!sections) return

{finding.summary}

; - const decision = sections.decision; - return
+ const decision = sections?.decision; + const execution = finding.execution_observation; + const measured = (value: unknown) => value === null ? (zh ? "未测量" : "Not measured") : String(value); + return <>{sections ?
{sectionCopy.observation}
{sections.observation}
{decision ? <>
{zh ? "决策" : "Decision"}
{outcomeCopy[decision.outcome] ?? decision.outcome}
@@ -85,7 +86,17 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re : <>
{sectionCopy.interpretation}
{sections.interpretation}
}
{sectionCopy.applicability}
{sections.applicability}
{sectionCopy.inputRevision}
{sections.inputRevision}
-
; +
:

{finding.summary}

}{execution ?
{zh ? "实际执行与资源观测" : "Execution and resource observations"} +
+
{zh ? "计划方法 → 实际方法" : "Planned → executed method"}
{String(execution.planned_method_id)} → {String(execution.executed_method_id)}
+ {execution.change_reason ? <>
{zh ? "改向原因" : "Reason for change"}
{String(execution.change_reason)}
: null} +
{zh ? "运行秒数 / 上限秒数" : "Elapsed / limit seconds"}
{measured(execution.elapsed_seconds)} / {measured(execution.limit_seconds)}
+
{zh ? "搜索次数" : "Search iterations"}
{measured(execution.search_iterations)}
+
{zh ? "结束原因" : "Termination reason"}
{measured(execution.stop_reason)}
+
{zh ? "执行候选 SHA-256" : "Executed candidate SHA-256"}
{String(execution.candidate_sha256)}
+ {execution.result_ref ? <>
{zh ? "该候选的结果引用" : "Result for this candidate"}
{String(execution.result_ref)}
: null} +

{zh ? "来源记录的执行观测;不等于方法有效或当前候选已通过验收。" : "Source-recorded execution observations; not proof of method benefit or current-candidate acceptance."}

+
: null}; }; const findingElement = (finding: ExploreHistoryEvent) =>
{finding.title}{timeLabel(finding.recorded_at)} · {zh ? "来源标记" : "Source label"} · {stateLabel(finding.status)}{finding.confidence !== null ? ` · ${zh ? "来源自报置信度" : "Source-reported confidence"} ${finding.confidence}` : ""}{findingBody(finding)}{finding.evidence_refs.map(ref => {ref})}
; const updateRecords = (events: readonly ExploreHistoryEvent[]) =>
    {events.map(event =>
  1. {stateLabel(event.status)} · {event.title}{event.summary ? ` — ${event.summary}` : ""}{event.parent_id ? {copy.group}: {event.parent_id} : null}{event.evidence_refs.map(ref => {ref})}{event.run_id ? {event.run_id} : null}
  2. )}
; diff --git a/benchmark/edgebench/README.md b/benchmark/edgebench/README.md index e1fcb4d356..eb6b535ae0 100644 --- a/benchmark/edgebench/README.md +++ b/benchmark/edgebench/README.md @@ -1,5 +1,10 @@ # Native EdgeBench worker trials +The explicit fresh-run [`--research-loop` treatment](../runtime/RESEARCH_LOOP.md) +connects candidate-stage separation, per-request admission, actual local probe +diagnostics and source-bound Explore feedback. It is not enabled for existing +attempts or baselines and does not change scoring or extend budget. + This research adapter calls SForge's native `run_agent`. Task images, prompts, submission cooldowns, judge isolation and scoring remain owned by the pinned EdgeBench checkout. Best-only adds host capture/admission scheduling; native and diff --git a/benchmark/edgebench/export_report.py b/benchmark/edgebench/export_report.py index ad54a6e11c..2e1be10a08 100644 --- a/benchmark/edgebench/export_report.py +++ b/benchmark/edgebench/export_report.py @@ -129,6 +129,8 @@ def _validate_run_data(row, config, summary, points): raise ValueError(f"Settings identity mismatch: {key}") runner, profile = config["runner"], config["worker_profile"] _validate_feedback_payload(runner, profile) + if runner.get("research_loop") != profile.get("research_loop"): + raise ValueError("Settings profile disagrees with research-loop treatment") if ( runner["runner_commit"] != row["runner_revision"] or runner["model"] != row["model_id"] @@ -337,6 +339,9 @@ def export(runs_root: Path, selection: list, output: Path, *, observed_at: str): if "planning_horizon_goal" in receipt: config["runner"]["planning_horizon_goal"] = receipt["planning_horizon_goal"] config["worker_profile"]["planning_horizon_goal"] = profile["planning_horizon_goal"] + if "research_loop" in receipt: + config["runner"]["research_loop"] = receipt["research_loop"] + config["worker_profile"]["research_loop"] = profile["research_loop"] if "stride_strategy" in receipt: config["runner"]["stride_strategy"] = receipt["stride_strategy"] config["worker_profile"]["stride_strategy"] = profile["stride_strategy"] diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index f8ef59bf85..9820e2d64c 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -108,6 +108,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("--research-loop", action="store_true", + help="Fresh heartbeat-explore treatment: separate candidate/research work, request admission and execution evidence") 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)") @@ -205,6 +207,15 @@ def main(argv=None): parser.error("Warm continuation requires full native checkpoint capture, no periodic evaluation and effective-Turn cadence") # One directory is one attempt: never reuse native registration or overwrite # source/profile evidence after an ambiguous launch. + if args.research_loop and (args.worker != "heartbeat-explore" or args.feedback != "native" + or args.restore_checkpoint is not None or not api_base_url): + parser.error("--research-loop requires a fresh heartbeat-explore native-feedback attempt and a custom model endpoint") + from benchmark.runtime.research_loop import research_stride_strategy + + try: + args.stride_strategy = research_stride_strategy(args.research_loop, args.stride_strategy) + except ValueError as error: + parser.error(str(error)) trial = args.log_dir / "runs" / args.run_id / args.task if args.restore_checkpoint is None: trial.mkdir(parents=True, exist_ok=False) @@ -290,6 +301,7 @@ def main(argv=None): timeout_seconds=args.timeout, feedback_prompt=feedback_prompt, feedback=args.feedback, workspace_instructions=workspace_instructions, task_objective=task_objective, + research_loop=args.research_loop, task_entry=args.task_entry, turn_envelope=args.turn_envelope, native_goal_continue=args.native_goal_continue, @@ -372,6 +384,7 @@ def main(argv=None): if agent.replan_after_turns is not None else {}), **({"replan_after_completed_todos": agent.replan_after_todos} if agent.replan_after_todos is not None else {}), + **({"research_loop": True} if args.research_loop else {}), **({"planning_horizon_goal": args.planning_horizon_goal} if args.planning_horizon_goal is not None else {}), **({"stride_strategy": args.stride_strategy} diff --git a/benchmark/runtime/RESEARCH_LOOP.md b/benchmark/runtime/RESEARCH_LOOP.md new file mode 100644 index 0000000000..f81d060187 --- /dev/null +++ b/benchmark/runtime/RESEARCH_LOOP.md @@ -0,0 +1,87 @@ +# Coordinated research-loop treatment + +For a **fresh** EdgeBench `heartbeat-explore` attempt with native public feedback +and a custom Responses-compatible endpoint, `--research-loop` enables one +coordinated treatment. Omission preserves the previous treatment. The runner +rejects adding it to an older checkpoint. Runtime, worker and export receipts +retain the flag; do not compare mixed treatments as identical configurations. +Roll back by omitting the flag on a new attempt. Retained findings containing +the new optional observation require a supporting reader; do not rewrite old +history or claim that an older strict event reader understands the new field. +The treatment selects the existing `evidence_conditioned_v1` replan strategy; +an explicitly conflicting strategy is rejected. This is a combined treatment, +not an isolated estimate of the contribution of any one component. + +The entry creates an initial-candidate Todo and a separate deferred research +Todo using existing `todo_done`/successor semantics. Finishing the candidate +releases real optimization work; it cannot by itself close that work. The +original task remains the Goal objective. Research retains the best candidate, +meaningful negative results and reasoned closeout against the original objective. +User Stop, the original budget and genuine blockers remain authoritative. There +is no requirement to fill time or change the native scoring/submission rules. +Preparation records this scope through the ordinary Agent Vision writer as +`surface_only`, without claiming research progress or spending a work Turn. +Existing prepared research work is not reopened or its later Vision overwritten. + +The worker reuses the live Host launch owner before **each** provider request, +including requests inside one long native wake. Current wait/Stop/refusal means +no upstream send and interruption of the owned native process; the original +scheduler handles wait/recovery. Checkpoint continuations retain the held Turn. +Unreadable authority fails closed. Already-forwarded requests cannot be +retroactively cancelled, and absent canonical waits are not inferred from prose. + +Private `provider-requests.jsonl` correlates wake, LoopX Turn, native +session/thread/Turn when supplied, client request, gateway request and returned +upstream request identities. A dispatch without a finished record is unknown; +`forwarded=null` preserves ambiguity during a failed send. Missing identifiers +remain null. The journal excludes bodies, credentials and upstream endpoints. +Qualify the pinned client at its request boundary. Admission adds control cost; +include it in subsequent matched comparisons instead of promising token savings. +The adapter retains the declared HTTP(S) forward-proxy route and `NO_PROXY` +exclusions; it does not bypass an isolated task's model egress boundary. + +The initial work context requests a local probe before first submission. The +installed `loopx-research-probe` uses the staged runner/Python: + +```sh +loopx-research-probe --candidate-file candidate.py --output probe-001.json \ + --input-revision public-case-v1 --planned-method-id scheduler \ + --executed-method-id mask-selection --change-reason 'The control isolated mask selection first.' \ + --evidence-ref probe:local-001 --limit-seconds 30 --diagnostics probe-diagnostics.json \ + -- python3 candidate.py +``` + +The solver may write `{"search_iterations":2,"stop_reason":"search_cap"}` to +`LOOPX_RESEARCH_DIAGNOSTICS`. The host measures elapsed time, exit/timeout and +the selected candidate-file digest before/after. A changed candidate rejects +attribution. Solver counters/reasons remain reports, missing counters remain +null, and low resource use is a diagnostic rather than a mandatory time floor. +Pins cover only declared files at those boundaries, not transitive imports or +independent writers. Evidence paths must be fresh; raw output stays local. +The probe neither submits nor accesses hidden evaluation. + +The adjacent `.candidate.json` preserves the selected relative paths and file +hashes. `candidate_sha256` hashes that sorted JSON file manifest (sorted keys, +compact separators), not a native archive's container bytes. Compare the same +manifest against the submitted/released snapshot before attaching feedback; +an archive SHA-256 is not interchangeable. If that comparison is unavailable, +leave result attribution unclaimed rather than copying the current hash. + +Attach the emitted object as `execution_observation` in the existing Explore +result writeback, preserving its input revision and evidence refs. For released +feedback, add `result_ref` to those refs and `result_candidate_sha256` naming the +evaluated candidate. A different hash is rejected; late feedback belongs on its +actual older candidate, not newer files. Different planned/executed method ids +require a change reason. Replan and the research-history page read the same +structured source; neither certifies method benefit or Goal completion. + +This treatment does not automatically instrument arbitrary solvers or guarantee +a stronger first score. The model may omit a probe or misreport a method; actual +adoption and benefit still require qualification. Stage separation does not +replace the default whole-Goal acceptance owner. Do not extend budget or suppress +legal closeout to manufacture a successful long run. + +中文:这一显式处理将初始候选、持续研究、等待准入、执行证据与复盘接在既有 +Todo/Host/Explore 路径上。候选完成只释放后续研究;已知等待阻止新上游请求。 +探针记录候选指纹、耗时和可选搜索诊断;反馈按实际候选绑定,改向保留原因。 +未知不填零,不改变评分,不强制耗满时间,不保证模型采纳或最终收益。 diff --git a/benchmark/runtime/checkpoint_wake.py b/benchmark/runtime/checkpoint_wake.py index 31ceb51398..3e4683036e 100644 --- a/benchmark/runtime/checkpoint_wake.py +++ b/benchmark/runtime/checkpoint_wake.py @@ -183,7 +183,7 @@ def _capture(self, held, deadline): "deadline_epoch_ms": window * 1000, }, timeout=max(.1, window - time.time()), retry_safe=False, large_local_snapshot=True) - def run(self, execution, body, wake, session_wake, stdout, stderr, timeout): + def run(self, execution, body, wake, session_wake, stdout, stderr, timeout, *, request_guard=None): from .worker import child_process, heartbeat_launch_check, native_command from .sessions import BenchmarkSessionWake @@ -194,12 +194,16 @@ def run(self, execution, body, wake, session_wake, stdout, stderr, timeout): if remaining <= 0: raise subprocess.TimeoutExpired("checkpoint source wake", timeout) command = native_command(launch_env, execution, "execute", wake, session_wake) + if request_guard is not None: + command = request_guard.command(command) continuation = None with child_process(command, env=launch_env, stdout=stdout, stderr=stderr, goal_admission=session_wake.admission if session_wake is not None else None, check_current_launch=heartbeat_launch_check( launch_env, session_wake, original_quota=original_quota)) as process: self.receipt["host_invoked"] = True + if request_guard is not None: + request_guard.bind(process, launch_env, session_wake, original_quota=original_quota) process.stdin.write(body) process.stdin.close() while process.poll() is None: diff --git a/benchmark/runtime/harbor.py b/benchmark/runtime/harbor.py index 6216d3dc46..c17c7afd62 100644 --- a/benchmark/runtime/harbor.py +++ b/benchmark/runtime/harbor.py @@ -73,6 +73,7 @@ def __init__( turn_envelope=False, native_goal_continue=False, task_objective=None, + research_loop=False, goal_id=_GOAL_ID, agent_id=_AGENT_ID, defer_codex_home=False, @@ -112,6 +113,9 @@ def __init__( self.replan_after_todos = int(3 if replan_after_todos is None else replan_after_todos) if not 1 <= self.replan_after_todos <= 5: raise ValueError("replan_after_todos must be between 1 and 5") + from .research_loop import research_stride_strategy + + stride_strategy = research_stride_strategy(research_loop, stride_strategy) if stride_strategy is not None and not self.execution.uses_loopx: raise ValueError("stride_strategy requires a LoopX execution mode") self.stride_strategy = (normalize_stride_strategy(stride_strategy) @@ -122,6 +126,9 @@ def __init__( if not self.execution.uses_loopx: raise ValueError("planning_horizon_goal requires a LoopX execution mode") self.planning_horizon_goal = planning_horizon_goal + if not isinstance(research_loop, bool) or (research_loop and self.execution.mode != "heartbeat"): + raise ValueError("research_loop requires heartbeat execution") + self.research_loop = research_loop self.checkpoint_request_path = None self._checkpoint_declaration = None if checkpoint_request is not None: @@ -327,6 +334,12 @@ async def install(self, environment: BaseEnvironment) -> None: raise RuntimeError("LoopX doctor returned invalid JSON") from exc if doctor_payload.get("ok") is not True: raise RuntimeError(f"LoopX doctor failed: {doctor_payload}") + if self.research_loop: + wrapper = (f"#!/bin/sh\nexec env PYTHONPATH={shlex.quote(_SRC)} " + f"{_PYTHON}/bin/python3 -m benchmark.runtime.research_probe \"$@\"\n") + await self.exec_as_agent(environment, + command=f"printf %s {shlex.quote(wrapper)} > {_PROFILE}/bin/loopx-research-probe; " + f"chmod 0755 {_PROFILE}/bin/loopx-research-probe", env=self._profile_env()) receipt = { "loopx_commit": actual_commit, @@ -335,6 +348,7 @@ async def install(self, environment: BaseEnvironment) -> None: "execution_mode": self.execution.mode, "iteration_context": self.execution.context, "task_entry": self.execution.task_entry, + **({"research_loop": True} if self.research_loop else {}), **self._turn_envelope_receipt(), **self._planning_receipt(), "home_scope": "trial", @@ -494,8 +508,12 @@ async def _prepare_phase( cwd=cwd, ) - if self.execution.task_entry == "seeded-todo": + if self.execution.task_entry == "seeded-todo" or self.research_loop: await self._seed_phase(environment, cwd=cwd) + if self.research_loop: + from .research_loop import prepare_research_continuation + + await prepare_research_continuation(self, environment, cwd=cwd) cadence = await self._loopx( environment, ["configure-goal", "--goal-id", self.goal_id], cwd=cwd, @@ -521,6 +539,13 @@ async def _seed_phase(self, environment: BaseEnvironment, *, cwd: str) -> None: "implement and validate against the task's full requirements and acceptance " "criteria. Keep unmet requirements explicit when judging task completion." ) + if self.research_loop: + text = ( + f"[P0] Build and evaluate the strongest feasible initial candidate for {self._task_document}. " + "Check the actual interface, correctness and resource use before the first submission. " + "Preserve the tested candidate and released feedback. Completing this candidate stage " + "does not complete the separate original research work." + ) if self._seeded_todo_id: listed = await self._loopx(environment, [ "todo", "list", "--goal-id", self.goal_id, "--role", "agent", @@ -582,6 +607,7 @@ def _worker_env(self, *, cwd: str) -> dict[str, str]: "LOOPX_SHARED_SKILLS": _SHARED_SKILLS, "LOOPX_EXECUTION_MODE": self.execution.mode, "LOOPX_TASK_ENTRY": self.execution.task_entry, + **({"LOOPX_RESEARCH_LOOP": "1"} if self.research_loop else {}), **({"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 {}), diff --git a/benchmark/runtime/planning.py b/benchmark/runtime/planning.py index 93fa37163a..545a31ca68 100644 --- a/benchmark/runtime/planning.py +++ b/benchmark/runtime/planning.py @@ -36,7 +36,12 @@ def render_planning_prompt(packet: dict, env: dict[str, str]) -> str: value = float(env[key]) if not math.isfinite(value) or value <= 0: raise ValueError(f"{key} must be finite and positive") - return ("$loopx\n\nBenchmark research planning guidance:\n" + _RESEARCH_PLANNING_GUIDANCE + research = "" + if env.get("LOOPX_RESEARCH_LOOP") == "1": + from .research_loop import research_execution_guidance + + research = research_execution_guidance() + return ("$loopx\n\nBenchmark research planning guidance:\n" + _RESEARCH_PLANNING_GUIDANCE + research + "\n\nHost-supplied planning checkpoint:\n" + json.dumps(packet, ensure_ascii=False)) diff --git a/benchmark/runtime/research_loop.py b/benchmark/runtime/research_loop.py new file mode 100644 index 0000000000..4a16ae3951 --- /dev/null +++ b/benchmark/runtime/research_loop.py @@ -0,0 +1,78 @@ +"""Compose research scope with ordinary Todo/wait owners, not a second planner.""" +from __future__ import annotations + + +def research_stride_strategy(enabled, strategy): + """One treatment definition shared by native CLI and programmatic entries.""" + if not enabled: + return strategy + if strategy not in (None, "evidence_conditioned_v1"): + raise ValueError("research_loop requires evidence_conditioned_v1, not a disabled or different review strategy") + return "evidence_conditioned_v1" + + +async def prepare_research_continuation(runtime, environment, *, cwd): + """A candidate stage cannot close a separately retained research outcome. + + The deferred successor contains actual optimization work, not a recurring + instruction to replan. Its existing resume owner releases it when the + initial stage is done; budget, Stop and evidence-backed closeout stay native. + """ + initial = runtime._seeded_todo_id + if not initial: + raise ValueError("research continuation requires its initial candidate stage") + listed = await runtime._loopx(environment, ["todo", "list", "--goal-id", runtime.goal_id, + "--role", "agent"], cwd=cwd) + matches = [todo for todo in listed["todos"] if todo.get("target_key") == "research-continuation"] + if len(matches) > 1: + raise ValueError("research continuation identity is ambiguous") + if matches: + current = matches[0] + if current.get("claimed_by") != runtime.agent_id: + raise ValueError("research continuation belongs to another Agent") + return current # Never reopen completed work or clear a real wait on preparation. + research = await runtime._loopx(environment, [ + "todo", "add", "--goal-id", runtime.goal_id, "--role", "agent", + "--claimed-by", runtime.agent_id, "--task-class", "advancement_task", + "--action-kind", "benchmark_task", "--target-key", "research-continuation", + "--status", "deferred", "--resume-when", "todo_done:" + initial, + "--text", f"[P1] Improve the original research objective in {runtime._task_document} through " + "complete, evidence-driven experiments. Preserve the best validated candidate. Use released " + "results and actual resource diagnostics to choose between a local repair and a different " + "mechanism; retain negative results. Candidate correctness and a successful submission settle " + "only that candidate stage. Close this research work only with evidence against the original " + "objective and remaining useful authorized work. Explicit user Stop, the original budget limit " + "and genuine blockers remain valid stops; do not manufacture work or run merely to fill time.", + "--execute"], cwd=cwd) + await runtime._loopx(environment, ["todo", "update", "--goal-id", runtime.goal_id, + "--agent-id", runtime.agent_id, "--todo-id", initial, + "--successor-todo-id", research["todo_id"], "--execute"], cwd=cwd) + await runtime._loopx(environment, ["refresh-state", "--goal-id", runtime.goal_id, + "--agent-id", runtime.agent_id, "--classification", "research_scope_initialized", + "--delivery-batch-scale", "single_surface", "--delivery-outcome", "surface_only", + "--vision-state", "active", "--vision-advancement-policy", "as_needed", + "--vision-summary", "Build a strong initial candidate, then execute evidence-driven improvements while retaining the best validated artifact.", + "--vision-acceptance", "Candidate validity settles its stage, not the original research objective. Respect user Stop, original budget and genuine blockers; justify research closeout from remaining useful authorized work.", + "--no-global-sync", "--suppress-external-sinks"], cwd=cwd) + return research + + +def research_execution_guidance() -> str: + return ( + "\nResearch-loop execution evidence:\n" + "The initial candidate and ongoing research are separate canonical Todos. Do not close the " + "research successor merely because the candidate passes tests. Preserve the original objective " + "and existing Stop/budget/blocked conditions. Before the first native submission, run a relevant " + "local candidate check and retain actual resource observations, including unknown measurements. " + "Use `loopx-research-probe --help` for the local probe wrapper: it pins " + "explicit candidate files, measures runtime and enforces the selected local limit. A solver can " + "write search_iterations and stop_reason as JSON to LOOPX_RESEARCH_DIAGNOSTICS; absent values " + "remain unknown, and solver reports are not independently verified. This is not another " + "evaluation or a requirement to use every available second. Attach the emitted " + "execution_observation in the normal Explore result writeback. Preserve its input revision " + "and evidence refs; when released feedback arrives, add result_ref and result_candidate_sha256 " + "for the evaluated snapshot, not newer files. Record changed method ids and the reason when " + "actual implementation differs from the proposal. Resolve this evidence at the next replan. " + "When all useful work is waiting, record a real resume condition through the existing Todo " + "owner and return; do not repeatedly call a model to inspect unchanged feedback.\n" + ) diff --git a/benchmark/runtime/research_probe.py b/benchmark/runtime/research_probe.py new file mode 100644 index 0000000000..58ce7a914f --- /dev/null +++ b/benchmark/runtime/research_probe.py @@ -0,0 +1,126 @@ +"""Run a caller-selected local probe and emit an Explore execution attachment. + +No evaluator access or submission is performed. Candidate file pins cover only +the explicitly selected files at the probe boundaries, not transitive imports. +The optional solver diagnostic is reported separately from host measurements. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import signal +import subprocess +import time + + +def candidate_snapshot(paths: list[Path], workspace: Path) -> tuple[str, list[dict[str, str]]]: + files = [] + for path in paths: + resolved = path.resolve(strict=True) + if not resolved.is_relative_to(workspace) or not resolved.is_file(): + raise ValueError("candidate files must be existing files in the workspace") + with resolved.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + files.append({"path": resolved.relative_to(workspace).as_posix(), "sha256": digest}) + if not 1 <= len(files) <= 32 or len({row["path"] for row in files}) != len(files): + raise ValueError("select one to 32 distinct candidate files") + files.sort(key=lambda row: row["path"]) + encoded = json.dumps(files, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode()).hexdigest(), files + + +def run_probe(*, command: list[str], candidate_files: list[Path], output: Path, + input_revision: str, planned_method_id: str, executed_method_id: str, + evidence_ref: str, limit_seconds: float, change_reason: str | None = None, + diagnostics: Path | None = None) -> dict: + from loopx.capabilities.explore.execution_observation import normalize_execution_observation + + if not command or not math.isfinite(limit_seconds) or limit_seconds <= 0: + raise ValueError("a command and finite positive local probe limit are required") + workspace = Path.cwd().resolve() + before, files = candidate_snapshot(candidate_files, workspace) + observation = dict(schema_version="explore_execution_observation_v0", + candidate_sha256=before, input_revision=input_revision, planned_method_id=planned_method_id, + executed_method_id=executed_method_id, change_reason=change_reason, + elapsed_seconds=None, limit_seconds=limit_seconds, search_iterations=None, + stop_reason=None, exit_code=None, evidence_refs=[evidence_ref, "source:" + before]) + normalize_execution_observation(observation) # Reject bad attribution before execution. + if diagnostics is not None and diagnostics.exists(): + raise FileExistsError("use a fresh diagnostic path; previous observations cannot describe this probe") + # Reserve before any process effect. Existing evidence is never overwritten. + with output.open("x", encoding="utf-8") as record: + with output.with_suffix(output.suffix + ".candidate.json").open("x", encoding="utf-8") as source: + source.write(json.dumps({"candidate_sha256": before, "files": files}, sort_keys=True) + "\n") + env = dict(os.environ) + if diagnostics is not None: + env["LOOPX_RESEARCH_DIAGNOSTICS"] = str(diagnostics.resolve()) + started = time.monotonic() + timed_out = False + with output.with_suffix(output.suffix + ".stdout").open("xb") as stdout, \ + output.with_suffix(output.suffix + ".stderr").open("xb") as stderr: + process = subprocess.Popen(command, cwd=workspace, env=env, stdout=stdout, stderr=stderr, + start_new_session=True) + try: + process.wait(timeout=limit_seconds) + except subprocess.TimeoutExpired: + timed_out = True + finally: + # Stop the owned group, including descendants that outlive a + # successful leader; they must not continue changing a candidate. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + observation["elapsed_seconds"] = time.monotonic() - started + observation["exit_code"] = process.returncode + observation["stop_reason"] = "host_timeout" if timed_out else f"process_exit:{process.returncode}" + after, _ = candidate_snapshot(candidate_files, workspace) + if after != before: + record.write(json.dumps({"ok": False, "reason": "candidate_changed_during_probe", + "before_sha256": before, "after_sha256": after})) + raise ValueError("candidate changed during the probe; preserve this attempt and rerun a fixed candidate") + if diagnostics is not None and diagnostics.exists(): + if diagnostics.stat().st_size > 4096: + raise ValueError("solver diagnostics exceed the bounded observation size") + reported = json.loads(diagnostics.read_text(encoding="utf-8")) + if not isinstance(reported, dict) or set(reported) - {"search_iterations", "stop_reason"}: + raise ValueError("solver diagnostics accept only search_iterations and stop_reason") + observation["search_iterations"] = reported.get("search_iterations") + if reported.get("stop_reason") is not None and not timed_out: + observation["stop_reason"] += "; solver_report:" + str(reported["stop_reason"]) + result = normalize_execution_observation(observation) + record.write(json.dumps(result, ensure_ascii=False, allow_nan=False) + "\n") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--candidate-file", type=Path, action="append", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--input-revision", required=True) + parser.add_argument("--planned-method-id", required=True) + parser.add_argument("--executed-method-id", required=True) + parser.add_argument("--change-reason") + parser.add_argument("--evidence-ref", required=True) + parser.add_argument("--limit-seconds", type=float, required=True) + parser.add_argument("--diagnostics", type=Path) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = vars(parser.parse_args()) + args["candidate_files"] = args.pop("candidate_file") + if args["command"][:1] == ["--"]: + args["command"] = args["command"][1:] + result = run_probe(**args) + print(json.dumps(result, allow_nan=False)) + if result["stop_reason"] == "host_timeout": + return 124 + code = result["exit_code"] + return 128 - code if code < 0 else code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmark/runtime/research_request_guard.py b/benchmark/runtime/research_request_guard.py new file mode 100644 index 0000000000..5126c0900b --- /dev/null +++ b/benchmark/runtime/research_request_guard.py @@ -0,0 +1,108 @@ +"""Bind each request in a research wake to the existing live launch owner. + +This is transport instrumentation, not an alternative quota/wait decision. No +prompt, response body, credential, or upstream URL is written to the journal. +""" +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +import json +import os +from pathlib import Path +import signal +import threading +import time +from urllib.parse import urlsplit +from urllib.request import proxy_bypass_environment + +from loopx.capabilities.benchmark_toolkit.provider_gateway import ( + ProviderGatewayRequestRejected, serve_runner_owned_provider_gateway, +) +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostRuntimeRejected + + +class ResearchRequestGuard: + def __init__(self, env, receipt, wake): + if not env.get("OPENAI_BASE_URL") or not env.get("OPENAI_API_KEY"): + raise ValueError("request admission requires the declared custom model endpoint") + if env.get("CODEX_WIRE_API", "responses") != "responses": + raise ValueError("request admission currently requires the Responses transport") + self.env, self.receipt, self.wake = env, receipt, Path(wake) + self.process = None + self.admission = None + self.refused = False + self.admission_error = False + self.lock = threading.Lock() + self.binding = {} + self.base_url = None + + def bind(self, process, env, session_wake, original_quota=None): + from .worker import heartbeat_launch_check + + if session_wake is None: + raise ValueError("research request admission requires the original session owner") + with self.lock: + self.process = process + self.admission = session_wake.admission.with_launch_decision( + heartbeat_launch_check(env, session_wake, original_quota=original_quota)) + self.binding = {"wake_id": self.receipt["turn_id"], "loopx_turn_id": env["LOOPX_TURN"], + "goal_id": env["LOOPX_GOAL_ID"], "agent_id": env["LOOPX_AGENT_ID"], + "bound_native_session_id": session_wake.session_id} + + @contextmanager + def lifetime(self, request): + with self.lock: + owner, process = self.admission, self.process + request.update(self.binding) + with ExitStack() as scope: + try: + if self.refused: + raise FirstPartyHostRuntimeRejected("research_original_request_recovery_required") + if owner is None or process is None or process.poll() is not None: + raise FirstPartyHostRuntimeRejected("research_request_without_live_owner") + if time.time() >= float(self.env["LOOPX_PHASE_DEADLINE_EPOCH"]): + raise FirstPartyHostRuntimeRejected("research_phase_budget_exhausted") + scope.enter_context(owner.launch_lifetime(operation="benchmark_provider_request")) + except Exception as error: + # Failure to read authority is not permission. This catch is + # confined to admission, never an upstream/network exception. + self.refused = True + self.admission_error = self.admission_error or not isinstance(error, FirstPartyHostRuntimeRejected) + code = error.code if isinstance(error, FirstPartyHostRuntimeRejected) else "research_request_admission_unavailable" + request["refusal_code"] = code + self.receipt.setdefault("request_admission_refused", {"request_id": request["request_id"], "code": code}) + if process is not None and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + pass + raise ProviderGatewayRequestRejected() from None + yield + + def observe(self, event): + with self.lock: + with (self.wake / "provider-requests.jsonl").open("a", encoding="utf-8") as stream: + stream.write(json.dumps(event, allow_nan=False) + "\n") + stream.flush() + os.fsync(stream.fileno()) + + @contextmanager + def serve(self): + target = urlsplit(self.env["OPENAI_BASE_URL"]) + proxy = None + if not proxy_bypass_environment(target.hostname or "", {"no": self.env.get("NO_PROXY", self.env.get("no_proxy", ""))}): + key = "HTTPS_PROXY" if target.scheme == "https" else "HTTP_PROXY" + proxy = self.env.get(key, self.env.get(key.lower())) + with serve_runner_owned_provider_gateway(upstream_base_url=self.env["OPENAI_BASE_URL"], + upstream_bearer_token=self.env["OPENAI_API_KEY"], + upstream_proxy_url=proxy, + request_lifetime=self.lifetime, observe_request=self.observe) as gateway: + self.base_url = gateway.base_url + yield self + + def command(self, command): + if self.base_url is None: + raise ValueError("request gateway is not running") + # Invocation-only routing; the pinned home and session profile retain + # the actual upstream. Restart never persists an obsolete loopback port. + return [command[0], "-c", "model_providers.harbor.base_url=" + json.dumps(self.base_url), *command[1:]] diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index 9a16b1b4c5..e5635b2b59 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -89,6 +89,7 @@ def __init__(self, config, *, profile: str, cwd: str, feedback: str = "native", workspace_instructions: str | None = None, task_objective: str | None = None, + research_loop: bool = False, task_entry: str | None = None, turn_envelope: bool = False, native_goal_continue: bool = False, @@ -107,6 +108,12 @@ def __init__(self, config, *, profile: str, cwd: str, ): raise ValueError("turn_envelope requires a heartbeat worker") self.turn_envelope = turn_envelope + if not isinstance(research_loop, bool) or (research_loop and ( + profile != "heartbeat-explore" or feedback != "native" or not config.agent_api_base_url)): + raise ValueError("research_loop requires heartbeat-explore, native feedback and a custom model endpoint") + if research_loop and warm_start is not None: + raise ValueError("research_loop must be admitted as a fresh treatment, not added to an old checkpoint") + self.research_loop = research_loop 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: @@ -139,6 +146,9 @@ def __init__(self, config, *, profile: str, cwd: str, replan_after_turns = DEFAULT_REPLAN_AFTER_TURNS self.replan_after_turns = replan_after_turns self.replan_after_todos = replan_after_todos + from .research_loop import research_stride_strategy + + stride_strategy = research_stride_strategy(research_loop, stride_strategy) if stride_strategy is not None and not profile.startswith("heartbeat-"): raise ValueError("stride_strategy requires a heartbeat profile") if stride_strategy is not None: @@ -280,6 +290,7 @@ def _install_worker(self, backend, handle, log_dir, logger): checkpoint_request_path=self.checkpoint_request_path, checkpoint_request=self.checkpoint_request, task_objective=self.task_objective, + research_loop=self.research_loop, **({"goal_id": self.warm_start.goal_id, "agent_id": self.warm_start.agent_id, "defer_codex_home": True} if self.warm_start is not None else {}), ) @@ -334,6 +345,7 @@ def _install_worker(self, backend, handle, log_dir, logger): raise RuntimeError("Best-only Codex delivery hooks are unreadable by worker") (log_dir / "worker-profile.json").write_text(json.dumps({ "profile": self.profile, "model": self._config.agent_model, + **({"research_loop": True} if self.research_loop else {}), "task_entry": self.task_entry, "reasoning_effort": effort, "timeout_seconds": self.timeout_seconds, "loopx_usage_ping_enabled": False, diff --git a/benchmark/runtime/worker.py b/benchmark/runtime/worker.py index 91dfbd553e..5b899828c4 100644 --- a/benchmark/runtime/worker.py +++ b/benchmark/runtime/worker.py @@ -85,9 +85,14 @@ def heartbeat_body(env: dict[str, str], turn_id: str, *, native_goal: bool, raise RuntimeError("formal LoopX heartbeat renderer is not ready") if not native_goal and payload.get("turn_instance_id") != turn_id: raise RuntimeError("heartbeat Turn identity mismatch") - return payload["task_body"].replace( + body = payload["task_body"].replace( "$HOME/.codex/loopx/registry.global.json", env["LOOPX_REGISTRY"] ) + if env.get("LOOPX_RESEARCH_LOOP") == "1": + from .research_loop import research_execution_guidance + + body += research_execution_guidance() + return body @contextmanager @@ -362,6 +367,8 @@ def _error_tail(exc: BaseException) -> dict[str, str]: def run_once(env: dict[str, str]) -> dict: + if env.get("LOOPX_RESEARCH_LOOP", "0") not in {"0", "1"}: + raise ValueError("LOOPX_RESEARCH_LOOP 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( @@ -389,6 +396,7 @@ def run_once(env: dict[str, str]) -> dict: "mode": execution.mode, "context": execution.context, "task_entry": execution.task_entry, + **({"research_loop": True} if env.get("LOOPX_RESEARCH_LOOP") == "1" else {}), **({"turn_envelope": True, "turn_envelope_source": "explicit"} if execution.turn_envelope else {}), "stage": stage, @@ -397,6 +405,7 @@ def run_once(env: dict[str, str]) -> dict: "timed_out": False, } session_wake = None + request_guard = None checkpoint_context = ExitStack() pending_path = ( Path(env.get("LOOPX_RUNTIME_ROOT", str(home))) / "benchmark-pending-turn.json" @@ -466,6 +475,12 @@ def run_once(env: dict[str, str]) -> dict: from benchmark.runtime.checkpoint import capture_before_replan body = capture_before_replan(env, receipt, body) + if env.get("LOOPX_RESEARCH_LOOP") == "1": + from .research_request_guard import ResearchRequestGuard + + if execution.mode != "heartbeat": + raise ValueError("research request guard requires heartbeat execution") + request_guard = checkpoint_context.enter_context(ResearchRequestGuard(env, receipt, wake).serve()) with (wake / "stderr.log").open("w") as stderr: if execution.native_goal and stage == "execute": run_native_goal(env, execution, body, receipt, stderr) @@ -498,17 +513,27 @@ def run_once(env: dict[str, str]) -> dict: else execution.timeout_seconds + allowance) if checkpoint_wake.listener is not None: receipt["return_code"] = checkpoint_wake.run( - execution, body, wake, session_wake, stdout, stderr, timeout) + execution, body, wake, session_wake, stdout, stderr, timeout, + **({"request_guard": request_guard} if request_guard is not None else {})) receipt["ok"] = receipt["return_code"] == 0 else: + if request_guard is not None: + command = request_guard.command(command) with child_process(command, env=env, stdout=stdout, stderr=stderr, goal_admission=session_wake.admission if session_wake is not None else None, check_current_launch=heartbeat_launch_check(env, session_wake)) as process: if session_wake is not None: receipt["host_invoked"] = True + if request_guard is not None: + request_guard.bind(process, env, session_wake) process.communicate(input=body, timeout=timeout) receipt["return_code"] = process.returncode receipt["ok"] = process.returncode == 0 + if request_guard is not None and request_guard.refused and stage == "execute": + # The wake returns to its original scheduler for a current + # wait/stop/recovery read; native interruption is not work + # success and never manufactures a settlement. + receipt["ok"] = not request_guard.admission_error if stage == "plan" and receipt["ok"]: from benchmark.runtime.planning import task_plan_packet, validate_plan_readback diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index e0cce406b9..dcf7a4447a 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -94,6 +94,23 @@ def test_only_official_delegates_continuation_to_sforge(profile, resumes, monkey assert 'web_search="disabled"' in command +def test_research_loop_uses_one_explicit_profile_and_rejects_conflicting_controls(): + pytest.importorskip("sforge") + pytest.importorskip("harbor") + from sforge.harness.config import SForgeConfig + from benchmark.runtime.sforge import SForgeWorker + + config = SForgeConfig(agent_model="fixture", agent_effort="high", + agent_api_base_url="https://model.example.invalid/v1", agent_api_key="fixture-credential") + worker = SForgeWorker(config, profile="heartbeat-explore", cwd="/task", research_loop=True) + assert worker.research_loop and worker.stride_strategy == "evidence_conditioned_v1" + for options in ({"stride_strategy": "off"}, {"profile": "official"}, {"warm_start": object()}): + with pytest.raises(ValueError): + SForgeWorker(config, **{"profile": "heartbeat-explore", "cwd": "/task", "research_loop": True, **options}) + ordinary = SForgeWorker(config, profile="heartbeat-explore", cwd="/task") + assert ordinary.research_loop is False and ordinary.stride_strategy is None + + def test_invalid_worker_inputs_fail_before_install(monkeypatch): pytest.importorskip("sforge") pytest.importorskip("harbor") diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index bf21ff3332..5462236a48 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -177,6 +177,37 @@ def test_planning_writes_real_todo_then_reuses_it_without_executing_task( ) +def test_research_worker_routes_each_resume_without_changing_the_pinned_home(planning_env): + from tests.capabilities.test_provider_gateway import _recording_upstream + + binary = Path(planning_env["CODEX_BIN"]) + request = '''import urllib.request +endpoint = json.loads(next(value.split("=", 1)[1] for value in sys.argv if value.startswith("model_providers.harbor.base_url="))) +with urllib.request.urlopen(urllib.request.Request(endpoint + "/responses", data=b"{}", headers={ + "session-id": "planning-fixture-session", "x-client-request-id": "fixture-client-request", + "x-codex-turn-metadata": json.dumps({"session_id": "planning-fixture-session", "turn_id": "fixture-native-turn"})}), timeout=30) as response: + assert json.load(response)["ok"] +''' + binary.write_text(binary.read_text().replace("packet = json.loads(sys.stdin.read()", request + "packet = json.loads(sys.stdin.read()")) + planning_env.update(LOOPX_RESEARCH_LOOP="1", LOOPX_ITERATION_CONTEXT="resume", NO_PROXY="127.0.0.1,localhost") + with _recording_upstream() as (url, _observed): + planning_env["OPENAI_BASE_URL"] = url + first = run_once(planning_env) + config = (Path(planning_env["LOOPX_CODEX_HOME"]) / "config.toml").read_bytes() + second = run_once(planning_env) + assert first["ok"] and second["ok"] and second["session"]["action"] == "resume" + assert (Path(planning_env["LOOPX_CODEX_HOME"]) / "config.toml").read_bytes() == config + assert url.encode() in config + for receipt in (first, second): + assert receipt["research_loop"] + events = [json.loads(line) for line in (Path(planning_env["LOOPX_WAKE_LOG_DIR"]) / + receipt["turn_id"] / "provider-requests.jsonl").read_text().splitlines()] + finished = next(event for event in events if event["event"] == "finished") + assert finished["forwarded"] is True + assert finished["wake_id"] == receipt["turn_id"] == finished["loopx_turn_id"] + assert finished["native_turn_id"] == "fixture-native-turn" + + def test_prose_or_fabricated_todo_does_not_qualify_planning(planning_env): binary = Path(planning_env["CODEX_BIN"]) binary.write_text( diff --git a/docs/assets/research-execution/desktop.png b/docs/assets/research-execution/desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..a5e8c95e778ea76985969ff77ffef37d5ab76ff5 GIT binary patch literal 175521 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

Q%y>#F?FC|g7b(KU0sM|QbSRcE(#(npPwjgiL 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�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 k|F^wA3EC&e25MW)78&qol`;+0C=Khng9R* literal 0 HcmV?d00001 diff --git a/docs/assets/research-execution/narrow.png b/docs/assets/research-execution/narrow.png new file mode 100644 index 0000000000000000000000000000000000000000..29ad0c3a5523648df64e4f40fe064638becc72b4 GIT binary patch literal 81286 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%0D%ZXfn9X>*FmM0$Ri9D$vg&&+#mi_Kl1~a)YY}N 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 literal 0 HcmV?d00001 diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 19657cb5a8..98160bf2bd 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -230,7 +230,13 @@ events=[build_explore_node_event(goal_id=goal,node_id=f'hit-{index}',title=f'Syn summary='Pointer regression only',parent_id='hit-0' if index else None, recorded_at='2026-01-01T00:00:00Z' if index<9 else '2026-01-01T18:00:00Z') for index in range(10)] events.append(build_explore_finding_event(goal_id=goal,finding_id='negative',node_id='hit-6', - title='Synthetic negative',summary='Negative pointer regression',status='refuted',recorded_at='2026-01-01T00:00:00Z')) + title='Synthetic negative',summary='Negative pointer regression',status='refuted',recorded_at='2026-01-01T00:00:00Z', + evidence_refs=['probe:synthetic'], execution_observation={ + 'schema_version':'explore_execution_observation_v0','candidate_sha256':'a'*64, + 'input_revision':'synthetic-v1','planned_method_id':'scheduler','executed_method_id':'mask-selection', + 'change_reason':'The control isolated a different first intervention.', + 'elapsed_seconds':0.2,'limit_seconds':30,'search_iterations':None,'exit_code':0, + 'stop_reason':'process_exit:0','evidence_refs':['probe:synthetic']})) Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Synthetic pointer regression','events':events})) `,fixture],{cwd:repoRoot}); collisionOwner=await startHistoryOwner(fixture); @@ -289,6 +295,11 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); const detail = history.locator('.research-history-detail'); await detail.waitFor(); + await detail.getByText('实际执行与资源观测', {exact:true}).click(); + assert(await detail.getByText('scheduler → mask-selection', {exact:true}).isVisible()); + assert(await detail.getByText('未测量', {exact:true}).isVisible(), 'Unknown iterations must not become zero'); + assert(await detail.getByText('0.2 / 30', {exact:true}).isVisible()); + await page.screenshot({path:resolve(outputDir, `research-execution-${viewport.width}.png`), animations:'disabled'}); assert.deepEqual(selectedNodes(),ids); assert(ids.includes('hit-6')); await detail.getByRole('heading',{name:'Synthetic hit target 6',exact:true}).waitFor(); diff --git a/loopx/capabilities/benchmark_toolkit/provider_gateway.py b/loopx/capabilities/benchmark_toolkit/provider_gateway.py index 11996d37dc..7e7b882b74 100644 --- a/loopx/capabilities/benchmark_toolkit/provider_gateway.py +++ b/loopx/capabilities/benchmark_toolkit/provider_gateway.py @@ -10,9 +10,12 @@ import http.client import json +import re import threading -from collections.abc import Iterator, Sequence -from contextlib import contextmanager +import time +import uuid +from collections.abc import Callable, Iterator, Sequence +from contextlib import AbstractContextManager, contextmanager, nullcontext from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import SplitResult, urlsplit @@ -46,6 +49,35 @@ class RunnerOwnedProviderGateway: } +class ProviderGatewayRequestRejected(RuntimeError): + """The caller's existing admission owner refused this new provider effect.""" + + +def _observed_id(value): + return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", value) else None + + +def _native_request_identity(headers): + # Codex supplies Turn metadata separately from the Responses body. Decode + # only the identity fields observed at the native request boundary; never + # retain its installation, user, model, prompt or environment metadata. + raw = headers.get("x-codex-turn-metadata", "") + try: + metadata = json.loads(raw) if len(raw) <= 8192 else {} + except (ValueError, TypeError): + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + def identity(*values): + observed = {_observed_id(value) for value in values} - {None} + return next(iter(observed)) if len(observed) == 1 else None + return { + "native_session_id": identity(headers.get("session-id"), headers.get("session_id"), metadata.get("session_id")), + "native_thread_id": identity(headers.get("thread-id"), metadata.get("thread_id")), + "native_turn_id": identity(headers.get("x-codex-turn-id"), metadata.get("turn_id")), + } + + def _validated_upstream(value: str) -> SplitResult: parsed = urlsplit(value.rstrip("/")) if parsed.scheme not in {"http", "https"} or not parsed.hostname: @@ -77,12 +109,18 @@ def _validated_allowed_paths(values: Sequence[str]) -> tuple[str, ...]: def _upstream_connection( - parsed: SplitResult, *, timeout_sec: float + parsed: SplitResult, *, timeout_sec: float, proxy: SplitResult | None = None ) -> http.client.HTTPConnection: host = parsed.hostname if host is None: # Defensive narrowing; _validated_upstream rejects this. raise ValueError("upstream_base_url must include a host") port = parsed.port + if proxy is not None: + if parsed.scheme == "https": + connection = http.client.HTTPSConnection(proxy.hostname, proxy.port or 80, timeout=timeout_sec) + connection.set_tunnel(host, port or 443) + return connection + return http.client.HTTPConnection(proxy.hostname, proxy.port or 80, timeout=timeout_sec) if parsed.scheme == "https": return http.client.HTTPSConnection(host, port, timeout=timeout_sec) return http.client.HTTPConnection(host, port, timeout=timeout_sec) @@ -96,6 +134,9 @@ def serve_runner_owned_provider_gateway( allowed_paths: Sequence[str] = _DEFAULT_ALLOWED_PATHS, request_timeout_sec: float = 600, max_request_bytes: int = 64 * 1024 * 1024, + upstream_proxy_url: str | None = None, + request_lifetime: Callable[[dict], AbstractContextManager] | None = None, + observe_request: Callable[[dict], None] | None = None, ) -> Iterator[RunnerOwnedProviderGateway]: """Serve a loopback-only Responses API gateway for one benchmark run. @@ -105,6 +146,10 @@ def serve_runner_owned_provider_gateway( """ upstream = _validated_upstream(upstream_base_url) + proxy = urlsplit(upstream_proxy_url) if upstream_proxy_url else None + if proxy is not None and (proxy.scheme != "http" or not proxy.hostname or proxy.username or proxy.password + or proxy.query or proxy.fragment or proxy.path not in ("", "/")): + raise ValueError("upstream proxy must be an explicit credential-free HTTP proxy") token = str(upstream_bearer_token) if not token.strip(): raise ValueError("upstream_bearer_token must be non-empty") @@ -143,9 +188,20 @@ def do_POST(self) -> None: self._json_error(413, "provider_gateway_request_size_invalid") return body = self.rfile.read(content_length) + request_id = "request-" + uuid.uuid4().hex + observed = {"request_id": request_id, "path": requested.path, + # Only explicitly received transport identities; no inference + # from timestamps, messages, tokens or request body contents. + "client_request_id": _observed_id(self.headers.get("x-client-request-id") or self.headers.get("x-request-id")), + **_native_request_identity(self.headers), + "upstream_request_id": None, "forwarded": False, "status": None, + "response_bytes": 0} + started = time.monotonic() upstream_path = f"{upstream.path.rstrip('/')}{requested.path}" if requested.query: upstream_path = f"{upstream_path}?{requested.query}" + if proxy is not None and upstream.scheme == "http": + upstream_path = f"{upstream.scheme}://{upstream.netloc}{upstream_path}" headers = { key: value for key, value in self.headers.items() @@ -159,11 +215,22 @@ def do_POST(self) -> None: } headers["Authorization"] = f"Bearer {token}" headers["Content-Length"] = str(len(body)) - connection = _upstream_connection(upstream, timeout_sec=request_timeout_sec) + if observe_request is not None: + headers["X-LoopX-Request-Id"] = request_id + connection = _upstream_connection(upstream, timeout_sec=request_timeout_sec, proxy=proxy) response_started = False try: - connection.request("POST", upstream_path, body=body, headers=headers) + with request_lifetime(observed) if request_lifetime is not None else nullcontext(): + # Keep the caller's original lifetime/authority through the + # actual send, not through the potentially long response. + observed["forwarded"] = None # A send failure may still have reached the upstream. + if observe_request is not None: + observe_request({**observed, "event": "dispatch"}) + connection.request("POST", upstream_path, body=body, headers=headers) + observed["forwarded"] = True response = connection.getresponse() + observed["status"] = response.status + observed["upstream_request_id"] = _observed_id(response.getheader("x-request-id")) self.send_response(response.status, response.reason) for key, value in response.getheaders(): if key.lower() not in { @@ -173,9 +240,13 @@ def do_POST(self) -> None: self.send_header(key, value) self.end_headers() response_started = True - while chunk := response.read(64 * 1024): + while chunk := response.read1(64 * 1024): + observed["response_bytes"] += len(chunk) self.wfile.write(chunk) self.wfile.flush() + except ProviderGatewayRequestRejected: + observed["status"] = 409 + self._json_error(409, "provider_gateway_current_admission_refused") except (OSError, http.client.HTTPException): if not response_started and not self.wfile.closed: self._json_error(502, "provider_gateway_upstream_unavailable") @@ -183,6 +254,8 @@ def do_POST(self) -> None: self.close_connection = True finally: connection.close() + if observe_request is not None: + observe_request({**observed, "event": "finished", "elapsed_seconds": time.monotonic() - started}) def do_GET(self) -> None: self._json_error(405, "provider_gateway_method_not_admitted") diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index d30ea8c99b..106b7c9da5 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -2,6 +2,16 @@ Status: supported optional capability; default-off harness execution contract. +Ordinary result writeback can include an optional `execution_observation` +(`explore_execution_observation_v0`): exact candidate SHA-256 and input revision, +planned/executed method ids and change reason, measured elapsed/limit seconds, +search iterations, stop reason and opaque evidence refs. Unknown measurements +are explicit nulls. Its refs and input revision must belong to the enclosing +finding. Released `result_ref` requires a matching `result_candidate_sha256`. +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/RESEARCH_LOOP.md). + ## At a Glance Explore Harness is a supported, default-off optional capability for diff --git a/loopx/capabilities/explore/README.zh-CN.md b/loopx/capabilities/explore/README.zh-CN.md index fc72ece36a..2d643c1110 100644 --- a/loopx/capabilities/explore/README.zh-CN.md +++ b/loopx/capabilities/explore/README.zh-CN.md @@ -2,6 +2,13 @@ 状态:受支持的 optional capability;harness 执行契约默认关闭。 +普通结果写回可附带可选 `execution_observation`(`explore_execution_observation_v0`): +候选 SHA-256、输入版本、计划/实际方法标识、改向原因、实测耗时/上限、搜索次数、 +结束原因与证据引用。未测量项显式为 null;版本和引用必须与所属 finding 一致。 +已释放结果的 `result_ref` 必须有匹配的 `result_candidate_sha256`。该记录是来源证据, +不认证执行真实性或收益;finding、replan 和既有研究历程详情保留相同信息,缺省仍兼容 +旧事件。参见[本地探针生产路径](../../../benchmark/runtime/RESEARCH_LOOP.md)。 + ## 概览 LoopX Explore 是面向长程探索目标(软件研究、安全攻击面测绘、领域调研)的受支持、默认关闭的可选能力。它把“到处看看”变成有界、可观测、有门控的流程,包含三根支柱: diff --git a/loopx/capabilities/explore/execution_observation.py b/loopx/capabilities/explore/execution_observation.py new file mode 100644 index 0000000000..bf5364baae --- /dev/null +++ b/loopx/capabilities/explore/execution_observation.py @@ -0,0 +1,21 @@ +"""Transport/cache for the Explore-owned typed execution observation codec.""" +from __future__ import annotations + +from functools import lru_cache +import json + +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.public_safety import validate_public_safe_value + + +@lru_cache(maxsize=512) +def _normalize(encoded: str) -> str: + result = effect_runtime_result("explore.execution.normalize", {"observation": json.loads(encoded)}) + return json.dumps(result, sort_keys=True, allow_nan=False) + + +def normalize_execution_observation(value): + encoded = json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False) + validate_public_safe_value(value, path="execution_observation") + # Do not let readers mutate the cached canonical source. + return json.loads(_normalize(encoded)) diff --git a/loopx/capabilities/explore/result_log.py b/loopx/capabilities/explore/result_log.py index 903026f458..d82c41e440 100644 --- a/loopx/capabilities/explore/result_log.py +++ b/loopx/capabilities/explore/result_log.py @@ -349,6 +349,7 @@ def build_explore_finding_event( tags: Sequence[str] | None = None, supersedes: str | None = None, recorded_at: str | None = None, + execution_observation: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _safe_goal_id(goal_id) safe_title = _compact_text(title, limit=TITLE_LIMIT, field="title") @@ -379,6 +380,13 @@ def build_explore_finding_event( safe_confidence = _safe_confidence(confidence) if safe_confidence is not None: event["confidence"] = safe_confidence + if execution_observation is not None: + from .execution_observation import normalize_execution_observation + + observed = normalize_execution_observation(execution_observation) + if any(ref not in event.get("evidence_refs", []) for ref in observed["evidence_refs"]): + raise ValueError("execution evidence must be included in the finding's evidence_refs") + event["execution_observation"] = observed event["event_id"] = _event_id(event) return event @@ -497,6 +505,7 @@ def validate_explore_result_event( evidence_refs=payload.get("evidence_refs"), tags=payload.get("tags"), supersedes=payload.get("supersedes"), + execution_observation=payload.get("execution_observation"), ) if summary: rebuilt["summary"] = summary @@ -719,7 +728,7 @@ def explore_history_rows(events, *, goal_id=None): "schema_version", "event_id", "goal_id", "event_kind", "result_id", "recorded_at", "title", "summary", "status", "node_kind", "blocked_reason", "parent_id", "node_id", "from_node", "to_node", - "edge_type", "agent_id", "run_id", "evidence_refs", "supersedes", "confidence", + "edge_type", "agent_id", "run_id", "evidence_refs", "supersedes", "confidence", "execution_observation", ) return [{key: event[key] for key in fields if key in event} for event in events] @@ -820,6 +829,7 @@ def _materialized_parent_edges( def _finding_view(event: Mapping[str, Any]) -> dict[str, Any]: return { + **({"execution_observation": event["execution_observation"]} if "execution_observation" in event else {}), "finding_id": str(event.get("result_id") or ""), "finding": str(event.get("title") or ""), "summary": str(event.get("summary") or ""), diff --git a/loopx/capabilities/explore/result_writeback.py b/loopx/capabilities/explore/result_writeback.py index b8719e3420..8497e13fdb 100644 --- a/loopx/capabilities/explore/result_writeback.py +++ b/loopx/capabilities/explore/result_writeback.py @@ -133,6 +133,7 @@ def _events(attachment, *, goal_id, agent_id, source_id, recorded_at, existing_n ), evidence_refs=attachment["evidence_refs"], tags=["writeback-result"], + execution_observation=attachment.get("execution_observation"), recorded_at=recorded_at, ) relations = [build_explore_edge_event( diff --git a/loopx/control_plane/capabilities/explore_execution_observation.ts b/loopx/control_plane/capabilities/explore_execution_observation.ts new file mode 100644 index 0000000000..c66d4e0956 --- /dev/null +++ b/loopx/control_plane/capabilities/explore_execution_observation.ts @@ -0,0 +1,78 @@ +/** Candidate execution facts carried by an existing Explore finding. + * The producer owns measurement; this codec validates attribution, not utility. + */ +import type {JsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; + +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" + || 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"); + } + const text = (value: unknown, field: string, max: number): string => { + if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f]/.test(value)) { + throw new EffectRuntimeRequestError(`${field} requires bounded single-line text`); + } + return value.trim(); + }; + const id = (value: unknown, field: string): string => { + const result = text(value, field, 128); + if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(result)) { + throw new EffectRuntimeRequestError(`${field} requires an opaque identifier`); + } + return result; + }; + const sha = (value: unknown): string => { + if (typeof value !== "string" || !BARE_SHA256_PATTERN.test(value)) { + throw new EffectRuntimeRequestError("execution candidate requires its exact SHA-256"); + } + return value; + }; + const measured = (value: unknown, field: string, integer = false, positive = false): number | null => { + if (value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (positive && value === 0) + || (integer && !Number.isSafeInteger(value))) { + throw new EffectRuntimeRequestError(`${field} requires a nonnegative measurement or explicit null`); + } + return value; + }; + const planned = id(row.planned_method_id, "planned_method_id"); + const executed = id(row.executed_method_id, "executed_method_id"); + const change = row.change_reason === null ? null : text(row.change_reason, "change_reason", 240); + if (planned !== executed && change === null) { + throw new EffectRuntimeRequestError("a different executed method requires its change_reason"); + } + if (!Array.isArray(row.evidence_refs) || !row.evidence_refs.length || row.evidence_refs.length > 8) { + throw new EffectRuntimeRequestError("execution observation requires one to eight evidence references"); + } + const result: JsonObject = {schema_version: row.schema_version, + candidate_sha256: sha(row.candidate_sha256), input_revision: text(row.input_revision, "input_revision", 160), + planned_method_id: planned, executed_method_id: executed, change_reason: change, + elapsed_seconds: measured(row.elapsed_seconds, "elapsed_seconds"), + limit_seconds: measured(row.limit_seconds, "limit_seconds", false, true), + search_iterations: measured(row.search_iterations, "search_iterations", true), + stop_reason: row.stop_reason === null ? null : text(row.stop_reason, "stop_reason", 160), + evidence_refs: [...new Set(row.evidence_refs.map(ref => id(ref, "execution evidence_ref")))].sort()}; + if (Object.hasOwn(row, "exit_code")) { + if (row.exit_code !== null && (typeof row.exit_code !== "number" || !Number.isSafeInteger(row.exit_code))) { + throw new EffectRuntimeRequestError("exit_code requires an integer process status or null"); + } + result.exit_code = row.exit_code; + } + if (Object.hasOwn(row, "result_ref") || Object.hasOwn(row, "result_candidate_sha256")) { + result.result_ref = id(row.result_ref, "result_ref"); + result.result_candidate_sha256 = sha(row.result_candidate_sha256); + if (result.result_candidate_sha256 !== result.candidate_sha256 + || !(result.evidence_refs as string[]).includes(String(result.result_ref))) { + throw new EffectRuntimeRequestError("result evidence must name this executed candidate and an included evidence reference"); + } + } + return result; +} diff --git a/loopx/control_plane/capabilities/explore_result_writeback.ts b/loopx/control_plane/capabilities/explore_result_writeback.ts index 6b2b8c8b1e..b189f886f1 100644 --- a/loopx/control_plane/capabilities/explore_result_writeback.ts +++ b/loopx/control_plane/capabilities/explore_result_writeback.ts @@ -4,6 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireJsonObject, requireNonEmptyString, requireStringArray, requireStringLiteral} from "../runtime_decode.ts"; import {normalizeGoalPathDelta} from "../goals/vision_checkpoint.ts"; +import {normalizeExploreExecutionObservation} from "./explore_execution_observation.ts"; export const EXPLORE_RESULT_ATTACHMENT_SCHEMA = "explore_result_attachment_v0"; export const EXPLORE_PATH_DELTA_ATTACHMENT_SCHEMA = "explore_result_from_path_delta_v0"; @@ -58,6 +59,7 @@ export function exploreResultWritebackAffordance(): JsonObject { "Capture is optional, not a settlement obligation.", "A stopped route does not imply refuted status, and a score alone does not prove refutation.", "Keep raw logs local.", + "When an experiment has execution evidence, attach execution_observation: preserve its exact candidate and input version, planned versus executed method, change reason and measured resources. Missing measurements are null, not zero. A finding source digest is not an executable digest. Result evidence must name the candidate it actually evaluated, including delayed or regressing results; it does not certify current workspace quality or Goal completion.", "Observations allow 320 characters, interpretations 1200; the complete scoped summary is preserved within 2000 characters.", ].join(" "), // Blank evidence fields deliberately fail validation until the caller @@ -72,7 +74,7 @@ export function exploreResultWritebackAffordance(): JsonObject { } const FIELDS = ["schema_version", "node_id", "question", "applicability", "input_revision", - "observation", "interpretation", "status", "evidence_refs", "question_update", "relations"]; + "observation", "interpretation", "status", "evidence_refs", "question_update", "relations", "execution_observation"]; // Reuse the persisted Explore node/edge vocabulary, distinct from finding status. const QUESTION_STATUSES = ["open", "exploring", "blocked", "resolved", "dead_end"] as const; const RELATION_TYPES = ["subtopic_of", "depends_on", "answers", "supports", "refutes", "leads_to"] as const; @@ -153,6 +155,12 @@ function normalizeAttachment(value: unknown, visionPacket?: unknown, linkedScope } return ref; }); + const execution = Object.hasOwn(row, "execution_observation") + ? normalizeExploreExecutionObservation({observation: row.execution_observation}) : null; + if (execution && (execution.input_revision !== text(row.input_revision, "input_revision", 160) + || (execution.evidence_refs as string[]).some(ref => !refs.includes(ref)))) { + throw new EffectRuntimeRequestError("execution observation must retain this finding's input revision and evidence references"); + } // Status reuses the existing finding vocabulary. The caller supplies the // interpretation; a failed prerequisite belongs in a tentative observation. return {schema_version: EXPLORE_RESULT_ATTACHMENT_SCHEMA, node_id: node, @@ -161,11 +169,12 @@ function normalizeAttachment(value: unknown, visionPacket?: unknown, linkedScope observation: text(row.observation, "observation", 320), interpretation: text(row.interpretation, "interpretation", 1200), status: requireStringLiteral(row.status, ["tentative", "confirmed", "refuted"], "finding status"), - evidence_refs: [...new Set(refs)], ...evolutionFields(row, node)}; + evidence_refs: [...new Set(refs)], ...evolutionFields(row, node), + ...(execution ? {execution_observation: execution} : {})}; } /** Explicit reference, not inferred evidence. Preserve every route item verbatim. */ function attachmentFromPathDelta(row: JsonObject, visionPacket: unknown, linkedScope: unknown): JsonObject { - const fields = ["schema_version", "node_id", "question", "applicability", "input_revision", "status", "evidence_refs", "question_update", "relations"]; + const fields = ["schema_version", "node_id", "question", "applicability", "input_revision", "status", "evidence_refs", "question_update", "relations", "execution_observation"]; if (Object.keys(row).some(key => !fields.includes(key))) { throw new EffectRuntimeRequestError("Explore path_delta attachment contains unknown fields"); } diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index cd21d0296e..6acf3c2fd3 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -205,6 +205,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { writeback_results: visibleResults .map(row => ({...compact(row, ["finding_id", "node_id", "finding", "status", "evidence_refs"]), summary: String(row.summary ?? "").slice(0, EXPLORE_WRITEBACK_SUMMARY_LIMIT), + ...(row.execution_observation ? {execution_observation: row.execution_observation} : {}), ...currentQuestion(row), ...findingDigest(row, findingSources)})), result_page: { diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index ab48e0118a..fabfb15e9a 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -799,6 +799,7 @@ export function createEffectRuntimeHandlers( ["explore.configuration.resolve", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({resolveExploreConfiguration}) => resolveExploreConfiguration)], ["explore.configuration.plan", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({planExploreConfiguration}) => planExploreConfiguration)], ["explore.result.normalize", lazyHandler(() => import("./capabilities/explore_result_writeback.ts"), ({normalizeExploreResultAttachment}) => normalizeExploreResultAttachment)], + ["explore.execution.normalize", lazyHandler(() => import("./capabilities/explore_execution_observation.ts"), ({normalizeExploreExecutionObservation}) => normalizeExploreExecutionObservation)], ["explore.result.evolution", lazyHandler(() => import("./capabilities/explore_result_writeback.ts"), ({planExploreQuestionEvolution}) => planExploreQuestionEvolution)], ["explore.result.intent", lazyHandler(() => import("./capabilities/explore_result_writeback.ts"), ({produceExploreResultIntent}) => produceExploreResultIntent)], ["explore.turn_context", lazyHandler(() => import("./capabilities/explore_turn_context.ts"), ({projectExploreTurnContext}) => projectExploreTurnContext)], diff --git a/loopx/control_plane/presentation/explore_history.ts b/loopx/control_plane/presentation/explore_history.ts index d161650c9b..bde2c9f296 100644 --- a/loopx/control_plane/presentation/explore_history.ts +++ b/loopx/control_plane/presentation/explore_history.ts @@ -2,12 +2,15 @@ * Event kinds/statuses/relationships remain owned by Explore result_log.py. * Local display grouping must not turn parentage or time into supporting evidence. */ +import {normalizeExploreExecutionObservation} from "../capabilities/explore_execution_observation.ts"; +import type {JsonObject} from "../runtime_decode.ts"; export type ExploreHistoryEvent = { schema_version: string; event_id: string; goal_id: string; event_kind: string; result_id: string; recorded_at: string; title: string; summary: string; status: string; node_kind: string; blocked_reason: string; parent_id: string; node_id: string; from_node: string; to_node: string; edge_type: string; agent_id: string; run_id: string; evidence_refs: string[]; supersedes: string; confidence: number | null; + execution_observation?: JsonObject; }; export type ExploreHistoryPage = {goal_id: string; items: ExploreHistoryEvent[]; total: number; next_cursor: string | null}; export type ExploreHistoryNode = {id: string; latest: ExploreHistoryEvent; updates: ExploreHistoryEvent[]; findings: ExploreHistoryEvent[]; revisions: ExploreHistoryEvent[]}; @@ -58,6 +61,9 @@ export function parseExploreHistoryPage(input: unknown, goalId: string): Explore ...Object.fromEntries(fields.map(key => [key, raw[key] ?? ""])), evidence_refs: [...(raw.evidence_refs as string[] | undefined ?? [])], confidence: typeof raw.confidence === "number" ? raw.confidence : null, + ...(raw.execution_observation === undefined ? {} : { + execution_observation: normalizeExploreExecutionObservation({observation: raw.execution_observation}), + }), } as ExploreHistoryEvent; }); return {goal_id: goalId, items, total: page.total as number, next_cursor: page.next_cursor as string | null}; diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index bbd352c79e..622e8364b2 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -281,6 +281,7 @@ export function projectStrideExploreBasis(value: unknown, goal: string, agent: s for (const row of [...findings, ...audits, ...recent]) { const id = requireNonEmptyString(row.finding_id, "Explore finding id"); if (!byId.has(id)) byId.set(id, {finding_id: id, node_id: row.node_id, status: row.status, + ...(row.execution_observation ? {execution_observation: row.execution_observation} : {}), source_sha256: typeof row.source_sha256 === "string" ? row.source_sha256 : createHash("sha256").update(JSON.stringify(row)).digest("hex")}); } diff --git a/tests/capabilities/test_provider_gateway.py b/tests/capabilities/test_provider_gateway.py index f83050809b..15502c4d15 100644 --- a/tests/capabilities/test_provider_gateway.py +++ b/tests/capabilities/test_provider_gateway.py @@ -99,6 +99,26 @@ def test_runner_owned_gateway_rejects_non_model_paths_without_upstream_call() -> assert observed == {} +def test_explicit_http_proxy_preserves_endpoint_and_https_tunnel_target(): + from urllib.parse import urlsplit + from loopx.capabilities.benchmark_toolkit.provider_gateway import _upstream_connection + + with _recording_upstream() as (proxy, observed): + origin = urlsplit(proxy) + proxy_url = f"http://{origin.hostname}:{origin.port}" + with serve_runner_owned_provider_gateway(upstream_base_url="http://127.0.0.1:1/v1", + upstream_bearer_token="fixture-proxy-credential", upstream_proxy_url=proxy_url) as gateway: + with urllib.request.urlopen(urllib.request.Request(gateway.base_url + "/responses", data=b"{}"), timeout=5) as response: + assert json.load(response) == {"ok": True} + assert observed["path"] == "http://127.0.0.1:1/v1/responses" + assert observed["authorization"] == "Bearer " + "fixture-proxy-credential" + connection = _upstream_connection(urlsplit("https://model.example.invalid/v1"), + timeout_sec=5, proxy=urlsplit(proxy_url)) + assert connection.host == origin.hostname and connection.port == origin.port + assert connection._tunnel_host == "model.example.invalid" and connection._tunnel_port == 443 + connection.close() + + @pytest.mark.parametrize( "upstream", ( diff --git a/tests/control_plane/test_research_execution_loop.py b/tests/control_plane/test_research_execution_loop.py new file mode 100644 index 0000000000..8a707be683 --- /dev/null +++ b/tests/control_plane/test_research_execution_loop.py @@ -0,0 +1,221 @@ +"""Synthetic real-process/CLI evidence for stage, candidate and result continuity.""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from benchmark.runtime.research_loop import prepare_research_continuation +from benchmark.runtime.research_probe import run_probe +from canonical_authority_fixture import isolate_sqlite_runtime +import test_quota_settlement_cli as fixture + + +def probe(tmp_path, monkeypatch, *, source=None, **options): + monkeypatch.chdir(tmp_path) + candidate = tmp_path / "candidate.py" + candidate.write_text(source or "import json,os; from pathlib import Path; " + "Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text(json.dumps(" + "{'search_iterations': 2, 'stop_reason': 'search_cap'})); print(sum(x*x for x in (1,2)))") + return run_probe(command=[sys.executable, str(candidate)], candidate_files=[candidate], + output=tmp_path / "observation.json", input_revision="synthetic-input-v1", + planned_method_id="scheduler", executed_method_id="mask-selection", + change_reason="The measured control motivated a different first intervention.", + evidence_ref="probe:local-1", limit_seconds=options.pop("limit_seconds", 30), + diagnostics=tmp_path / "diagnostics.json", **options) + + +def test_real_probe_reports_early_exit_not_budget_exhaustion(tmp_path, monkeypatch): + observation = probe(tmp_path, monkeypatch) + assert observation["search_iterations"] == 2 + assert 0 < observation["elapsed_seconds"] < observation["limit_seconds"] + assert observation["stop_reason"] == "process_exit:0; solver_report:search_cap" + assert len(observation["candidate_sha256"]) == 64 + assert (tmp_path / "observation.json.stdout").read_text().strip() == "5" + + +def test_missing_solver_telemetry_stays_unknown_and_timeout_is_host_observed(tmp_path, monkeypatch): + observation = probe(tmp_path, monkeypatch, source="import time; time.sleep(10)", limit_seconds=0.05) + assert observation["search_iterations"] is None + assert observation["stop_reason"] == "host_timeout" + assert observation["elapsed_seconds"] >= 0.05 + + +def test_probe_refuses_changed_candidate_and_never_reuses_existing_evidence(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="candidate changed"): + probe(tmp_path, monkeypatch, source="from pathlib import Path; Path(__file__).write_text('print(0)')") + before = (tmp_path / "observation.json").read_bytes() + with pytest.raises(FileExistsError): + probe(tmp_path, monkeypatch) + assert (tmp_path / "observation.json").read_bytes() == before + + +def test_probe_cli_preserves_a_failed_candidate_exit_and_its_observation(tmp_path): + candidate = tmp_path / "candidate.py" + candidate.write_text("raise SystemExit(3)") + output = tmp_path / "observed.json" + result = subprocess.run([sys.executable, "-m", "benchmark.runtime.research_probe", + "--candidate-file", str(candidate), "--output", str(output), "--input-revision", "fixture-v1", + "--planned-method-id", "baseline", "--executed-method-id", "baseline", + "--evidence-ref", "probe:failed", "--limit-seconds", "5", "--", sys.executable, str(candidate)], + cwd=tmp_path, env={**os.environ, "PYTHONPATH": str(fixture.REPO_ROOT)}, capture_output=True, text=True) + assert result.returncode == 3, result.stderr + observed = json.loads(output.read_text()) + assert observed["exit_code"] == 3 and observed["search_iterations"] is None + assert json.loads(output.with_suffix(".json.candidate.json").read_text())["candidate_sha256"] == observed["candidate_sha256"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_candidate_stage_leaves_research_work_and_execution_reaches_next_context(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = fixture._write_fixture(tmp_path) + configuration = json.loads(registry.read_text()) + configuration["goals"][0]["quota"]["allowed_slots"] = 12 + registry.write_text(json.dumps(configuration)) + fixture._initialize_fixture_authority(project, runtime, provider=provider) + monkeypatch.chdir(project) + + def call(*args, expected=0): + code, result = fixture._run_cli(registry, runtime, *args, cwd=project) + assert code == expected, json.dumps({key: result.get(key) for key in ( + "ok", "error", "error_code", "effective_action", "reason", "blocker", "autonomous_replan_obligation")}) + return result + + async def cli(_environment, args, **_kwargs): + return call(*args) + + call("configure-goal", "--goal-id", fixture.GOAL_ID, "--execution-replan-after-turns", "2", + "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + owner = SimpleNamespace(_seeded_todo_id=fixture.TODO_ID, _loopx=cli, + goal_id=fixture.GOAL_ID, agent_id=fixture.AGENT_ID, _task_document="TASK.md") + root = asyncio.run(prepare_research_continuation(owner, None, cwd=str(project))) + original = registry.read_bytes() + again = asyncio.run(prepare_research_continuation(owner, None, cwd=str(project))) + assert again["todo_id"] == root["todo_id"] + assert registry.read_bytes() == original + call("todo", "complete", "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, + "--todo-id", fixture.TODO_ID) + binding = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID) + current = call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--turn-instance-id", "research-after-candidate", "--scan-path", str(project)) + assert current["should_run"] + assert current["selected_todo"]["todo_id"] == root["todo_id"] + assert current.get("goal_terminal_state") is None + assert current["effective_action"] == "successor_replan_required" + # Follow the existing resume action. Readiness is not itself a durable + # open-state transition and must not be treated as completed research. + call("todo", "update", *binding, "--todo-id", root["todo_id"], "--status", "open", + "--clear-resume-when", "--reason", "The initial candidate stage completed; perform the retained research work.") + scope = project / "research-scope.json" + scope.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", "state": "active", + "vision_patch": {"advancement_policy": "as_needed", "vision_summary": "The retained research work is now runnable.", + "acceptance_summary": "Compare actual mechanisms and resources against the original objective."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "continue", + "prior_assumption": "Research work awaits the initial candidate stage.", + "observed_reality": "The canonical candidate stage is done and its retained research successor is open.", + "retained": ["Original objective, resource limits and best candidate."], + "changed": ["Run the retained research work."], "evidence_refs": ["todo:" + fixture.TODO_ID]}})) + preparation = call("refresh-state", *binding, "--todo-id", root["todo_id"], "--turn-instance-id", "research-after-candidate", + "--classification", "successor_replan_recorded", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--agent-vision-json", str(scope), + "--no-global-sync", "--suppress-external-sinks") + assert json.loads(Path(preparation["json_path"]).read_text())["vision_checkpoint"]["satisfied"] + call("quota", "spend-slot", *binding, "--todo-id", root["todo_id"], "--turn-instance-id", "research-after-candidate", + "--slots", "1", "--source", "heartbeat", "--execute") + work_turn = "research-local-probe" + work_guard = call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--todo-id", root["todo_id"], "--turn-instance-id", work_turn, "--scan-path", str(project)) + assert work_guard["should_run"] and work_guard["effective_action"] == "normal_run" + call("configure-goal", "--goal-id", fixture.GOAL_ID, "--explore-graph-enabled", "--execute") + observed = probe(project, monkeypatch) + observed.update(result_ref="result:released-1", result_candidate_sha256=observed["candidate_sha256"]) + observed["evidence_refs"].append("result:released-1") + observed["evidence_refs"].sort() + attachment = {"schema_version": "explore_result_attachment_v0", "question": "Which mechanism improves the bounded case?", + "applicability": "Synthetic public local inputs only", "input_revision": observed["input_revision"], + "observation": "The process stopped after two search steps with allowance remaining.", + "interpretation": "Retain the incumbent; change the search cap before claiming scheduler benefit.", + "status": "tentative", "evidence_refs": observed["evidence_refs"], "execution_observation": observed} + path = project / "result.json" + path.write_text(json.dumps(attachment)) + result = call("refresh-state", *binding, "--todo-id", root["todo_id"], + "--turn-instance-id", work_turn, "--classification", "validated_probe", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--vision-unchanged-reason", "Original research remains open; the changed intervention needs validation.", + "--explore-result-json", str(path), "--no-global-sync", "--suppress-external-sinks") + assert result["explore_result_delivery"]["ok"] + context = call("explore", "turn-context", *binding) + [finding] = context["graph"]["writeback_results"] + assert finding["execution_observation"] == observed + from loopx.capabilities.explore.result_log import explore_result_log_path, load_explore_result_events_strict, explore_history_rows + rows = explore_history_rows(load_explore_result_events_strict(explore_result_log_path(runtime, fixture.GOAL_ID), + goal_id=fixture.GOAL_ID), goal_id=fixture.GOAL_ID) + assert next(row for row in rows if row["event_kind"] == "finding")["execution_observation"] == observed + # Late feedback for another snapshot must not be attached to this execution. + attachment["execution_observation"]["result_candidate_sha256"] = "b" * 64 + path.write_text(json.dumps(attachment)) + bad = call("refresh-state", *binding, "--todo-id", root["todo_id"], + "--turn-instance-id", work_turn, "--classification", "validated_probe", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--explore-result-json", str(path), "--no-global-sync", "--suppress-external-sinks", expected=1) + assert not bad.get("appended") + call("quota", "spend-slot", *binding, "--todo-id", root["todo_id"], + "--turn-instance-id", work_turn, "--slots", "1", "--source", "heartbeat", "--execute") + review = call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--turn-instance-id", "review-resource-evidence", "--scan-path", str(project)) + assert "replan_action_packet" in review, {key: review.get(key) for key in ( + "should_run", "effective_action", "reason", "autonomous_replan_obligation", "effective_turn_replan")} + control = review["replan_action_packet"]["stride_control"] + delivered = next(item for item in control["explore_basis"]["finding_refs"] if item["finding_id"] == finding["finding_id"]) + assert delivered["execution_observation"]["candidate_sha256"] == observed["candidate_sha256"] + assert delivered["execution_observation"]["search_iterations"] == 2 + # The actor uses delivered measurements, then runs a changed implementation; + # a new finding/identifier alone is not the observable outcome. + assert delivered["execution_observation"]["elapsed_seconds"] < delivered["execution_observation"]["limit_seconds"] + next_work = call("todo", "add", "--goal-id", fixture.GOAL_ID, "--role", "agent", + "--claimed-by", fixture.AGENT_ID, "--task-class", "advancement_task", "--action-kind", "validate", + "--target-key", "search-cap-control", "--replan-obligation-id", review["autonomous_replan_obligation"]["obligation_id"], + "--text", "Vary the measured search cap while retaining the incumbent and the original method claim.") + assert next_work["todo_id"] != root["todo_id"] + review = call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--todo-id", next_work["todo_id"], "--turn-instance-id", "review-resource-evidence", "--scan-path", str(project)) + control = review["replan_action_packet"]["stride_control"] + decision = control["minimal_example"] + decision["explore_review"] = {"revision": control["explore_basis"]["revision"], + "disposition": "used", "considered_refs": [finding["finding_id"]], + "reason": "The executed mask candidate stopped at its search cap, not at the resource limit."} + path.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "Validate a changed search cap against the retained reference."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "replan", + "prior_assumption": "A new scheduler was expected to explain the gain.", + "observed_reality": "The executed artifact uses mask selection and stops after two search steps.", + "changed": ["Vary the search cap before attributing gain to a scheduler."], + "evidence_refs": [finding["finding_id"]]}, + "stride_decision": decision, "todo_delta": ["create:" + next_work["todo_id"]]})) + replan_binding = (*binding, "--replan-obligation-id", review["autonomous_replan_obligation"]["obligation_id"], + "--turn-instance-id", "review-resource-evidence") + accepted = call("refresh-state", *replan_binding, "--classification", "resource_probe_replan", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--agent-vision-json", str(path), "--no-global-sync", "--suppress-external-sinks") + assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] + call("quota", "spend-slot", *replan_binding, "--slots", "1", "--source", "heartbeat", "--execute") + admitted = call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--todo-id", next_work["todo_id"], "--turn-instance-id", "execute-search-control", "--scan-path", str(project)) + assert admitted["should_run"] and admitted["selected_todo"]["todo_id"] == next_work["todo_id"] + candidate = project / "candidate.py" + candidate.write_text("import json,os; from pathlib import Path; " + "Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text(json.dumps(" + "{'search_iterations': 5, 'stop_reason': 'local_cases_complete'})); print(1)") + revised = run_probe(command=[sys.executable, str(candidate)], candidate_files=[candidate], + output=project / "next-observation.json", input_revision="synthetic-input-v1", + planned_method_id="mask-selection", executed_method_id="mask-selection", evidence_ref="probe:local-2", + limit_seconds=30, diagnostics=project / "next-diagnostics.json") + assert revised["candidate_sha256"] != observed["candidate_sha256"] + assert revised["search_iterations"] == 5 + assert (project / "next-observation.json.stdout").read_text().strip() == "1" diff --git a/tests/control_plane/test_research_request_guard.py b/tests/control_plane/test_research_request_guard.py new file mode 100644 index 0000000000..fa96389b01 --- /dev/null +++ b/tests/control_plane/test_research_request_guard.py @@ -0,0 +1,89 @@ +"""Actual request forwarding follows canonical waits under the original Turn.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from types import SimpleNamespace +import urllib.error +import urllib.request + +import pytest + +from benchmark.runtime.research_request_guard import ResearchRequestGuard +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from canonical_authority_fixture import isolate_sqlite_runtime +import test_quota_settlement_cli as fixture +from tests.capabilities.test_provider_gateway import _recording_upstream + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_wait_at_request_boundary_has_no_upstream_effect_and_retains_lineage(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = fixture._write_fixture(tmp_path) + fixture._initialize_fixture_authority(project, runtime, provider=provider) + + def cli(*args): + code, result = fixture._run_cli(registry, runtime, *args, cwd=project) + assert code == 0, result + return result + + turn = "request-boundary-turn" + binding = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID) + initial = cli("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--todo-id", fixture.TODO_ID, "--turn-instance-id", turn, "--scan-path", str(project)) + assert initial["should_run"] + session = SimpleNamespace(session_id="native-session-fixture", goal_ref=None, + admission=FirstPartyHostGoalAdmission.for_plan(registry_path=registry, goal_id=fixture.GOAL_ID, + planned_goal_ref=None)) + wake = tmp_path / "wake" + wake.mkdir() + receipt = {"turn_id": "wake-fixture"} + with _recording_upstream() as (url, upstream): + env = dict(os.environ, LOOPX_TURN=turn, LOOPX_REGISTRY=str(registry), + LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_PROJECT=str(project), LOOPX_GOAL_ID=fixture.GOAL_ID, + LOOPX_AGENT_ID=fixture.AGENT_ID, LOOPX_PHASE_DEADLINE_EPOCH=str(time.time() + 180), + OPENAI_BASE_URL=url, OPENAI_API_KEY="fixture-credential") + guard = ResearchRequestGuard(env, receipt, wake) + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(180)"], + start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + with guard.serve(): + guard.bind(process, env, session, original_quota=initial) + + def request(): + return urllib.request.urlopen(urllib.request.Request(guard.base_url + "/responses", + data=b'{"input":"private fixture body"}', headers={"Content-Type": "application/json", + "session-id": "native-session-fixture", "thread-id": "native-thread-fixture", + "x-client-request-id": "client-request-fixture", + "x-codex-turn-metadata": json.dumps({"session_id": "native-session-fixture", + "thread_id": "native-thread-fixture", "turn_id": "native-turn-fixture", + "installation_id": "must-not-be-recorded"})}), timeout=40) + + with request() as response: + assert json.load(response) == {"ok": True} + assert upstream["body"] + upstream.clear() + cli("todo", "update", *binding, "--todo-id", fixture.TODO_ID, + "--status", "deferred", "--resume-when", "resume_at:2099-01-01T00:00:00Z") + with pytest.raises(urllib.error.HTTPError) as refused: + request() + assert refused.value.code == 409 + assert upstream == {}, "no paid request may escape after the current wait" + process.wait(timeout=10) + assert guard.refused and receipt["request_admission_refused"] + finally: + if process.poll() is None: + process.kill() + process.wait() + encoded = (wake / "provider-requests.jsonl").read_text() + assert "fixture-credential" not in encoded and "private fixture body" not in encoded + assert "must-not-be-recorded" not in encoded + events = [json.loads(line) for line in encoded.splitlines()] + finished = [event for event in events if event["event"] == "finished"] + assert [event["forwarded"] for event in finished] == [True, False] + assert len({event["request_id"] for event in finished}) == 2 + assert all(event["wake_id"] == "wake-fixture" and event["loopx_turn_id"] == turn + and event["native_turn_id"] == "native-turn-fixture" for event in finished) diff --git a/tests/control_plane_ts/explore_result_writeback.test.ts b/tests/control_plane_ts/explore_result_writeback.test.ts index 3a53311e55..031d80d167 100644 --- a/tests/control_plane_ts/explore_result_writeback.test.ts +++ b/tests/control_plane_ts/explore_result_writeback.test.ts @@ -11,6 +11,31 @@ const attachment = { evidence_refs: ["validation:prefix", "validation:tail"], }; +const execution = {schema_version: "explore_execution_observation_v0", + candidate_sha256: "a".repeat(64), input_revision: attachment.input_revision, + planned_method_id: "scheduler", executed_method_id: "mask-selection", + change_reason: "The control identified mask selection as the first discriminating change.", + elapsed_seconds: 0.2, limit_seconds: 30, search_iterations: 2, + stop_reason: "process_exit:0; solver_report:search_cap", evidence_refs: attachment.evidence_refs}; + +test("execution observations bind resources and changed methods without implying adoption", () => { + const normalized = normalizeExploreResultAttachment({attachment: {...attachment, execution_observation: execution}}); + assert.deepEqual(normalized.execution_observation, execution); + assert.deepEqual(normalizeExploreResultAttachment({attachment}), attachment); + const unknown = {...execution, elapsed_seconds: null, search_iterations: null, limit_seconds: null, stop_reason: null}; + assert.deepEqual(normalizeExploreResultAttachment({attachment: {...attachment, execution_observation: unknown}}).execution_observation, unknown); + for (const mutation of [{change_reason: null}, {input_revision: "foreign-input"}, + {search_iterations: -1}, {search_iterations: 1.1}, {elapsed_seconds: NaN}, {limit_seconds: 0}, + {evidence_refs: ["foreign:probe"]}, {candidate_sha256: "finding-digest-is-not-code"}, + {result_ref: attachment.evidence_refs[0]}, + {result_ref: attachment.evidence_refs[0], result_candidate_sha256: "b".repeat(64)}]) { + assert.throws(() => normalizeExploreResultAttachment({attachment: {...attachment, + execution_observation: {...execution, ...mutation}}})); + } + const result = {...execution, result_ref: attachment.evidence_refs[0], result_candidate_sha256: execution.candidate_sha256}; + assert.deepEqual(normalizeExploreResultAttachment({attachment: {...attachment, execution_observation: result}}).execution_observation, result); +}); + test("question lifecycle is explicit, independent of finding status, and available on path capture", () => { const update = {status: "resolved"}; const relations = [{to_node: "uniform-estimate", edge_type: "depends_on", summary: "Required before transfer."}]; From f7618580346f82008b1ad8ade6649123f233efaf Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 15:35:49 +0800 Subject: [PATCH 3/8] fix(research): retain port-specific provider proxy exclusions Signed-off-by: song --- benchmark/runtime/research_request_guard.py | 2 +- tests/control_plane/test_research_request_guard.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/benchmark/runtime/research_request_guard.py b/benchmark/runtime/research_request_guard.py index 5126c0900b..aa90b70ed1 100644 --- a/benchmark/runtime/research_request_guard.py +++ b/benchmark/runtime/research_request_guard.py @@ -90,7 +90,7 @@ def observe(self, event): def serve(self): target = urlsplit(self.env["OPENAI_BASE_URL"]) proxy = None - if not proxy_bypass_environment(target.hostname or "", {"no": self.env.get("NO_PROXY", self.env.get("no_proxy", ""))}): + if not proxy_bypass_environment(target.netloc, {"no": self.env.get("NO_PROXY", self.env.get("no_proxy", ""))}): key = "HTTPS_PROXY" if target.scheme == "https" else "HTTP_PROXY" proxy = self.env.get(key, self.env.get(key.lower())) with serve_runner_owned_provider_gateway(upstream_base_url=self.env["OPENAI_BASE_URL"], diff --git a/tests/control_plane/test_research_request_guard.py b/tests/control_plane/test_research_request_guard.py index fa96389b01..31085b96b9 100644 --- a/tests/control_plane/test_research_request_guard.py +++ b/tests/control_plane/test_research_request_guard.py @@ -9,6 +9,7 @@ from types import SimpleNamespace import urllib.error import urllib.request +from urllib.parse import urlsplit import pytest @@ -45,7 +46,8 @@ def cli(*args): env = dict(os.environ, LOOPX_TURN=turn, LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_PROJECT=str(project), LOOPX_GOAL_ID=fixture.GOAL_ID, LOOPX_AGENT_ID=fixture.AGENT_ID, LOOPX_PHASE_DEADLINE_EPOCH=str(time.time() + 180), - OPENAI_BASE_URL=url, OPENAI_API_KEY="fixture-credential") + OPENAI_BASE_URL=url, OPENAI_API_KEY="fixture-credential", + NO_PROXY=urlsplit(url).netloc, HTTP_PROXY="http://127.0.0.1:1") guard = ResearchRequestGuard(env, receipt, wake) process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(180)"], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) From 14faa72859518d5eb428c0d15e2746b48bc1a426 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 17:11:44 +0800 Subject: [PATCH 4/8] fix(benchmark): preserve request admission across slow connections Pin the original native owner before connecting, then recheck its liveness, binding and current authority immediately before sending. Keep connection setup outside the authority lock so a slow transport cannot block Stop or borrow a resumed Turn. Signed-off-by: Codex --- benchmark/runtime/research_request_guard.py | 62 +++-- .../benchmark_toolkit/provider_gateway.py | 13 +- tests/capabilities/test_provider_gateway.py | 119 ++++++++++ .../test_research_request_guard_races.py | 220 ++++++++++++++++++ 4 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 tests/control_plane/test_research_request_guard_races.py diff --git a/benchmark/runtime/research_request_guard.py b/benchmark/runtime/research_request_guard.py index aa90b70ed1..9f25211e0f 100644 --- a/benchmark/runtime/research_request_guard.py +++ b/benchmark/runtime/research_request_guard.py @@ -49,34 +49,56 @@ def bind(self, process, env, session_wake, original_quota=None): "goal_id": env["LOOPX_GOAL_ID"], "agent_id": env["LOOPX_AGENT_ID"], "bound_native_session_id": session_wake.session_id} - @contextmanager def lifetime(self, request): + # Capture before the gateway connects. A delayed request must not borrow + # a checkpoint continuation's replacement process or held Turn binding. with self.lock: owner, process = self.admission, self.process request.update(self.binding) + try: + self._require_live_binding(owner, process) + except Exception as error: + self._reject(request, process, error) + return self._bound_lifetime(request, owner, process) + + def _require_live_binding(self, owner, process): + with self.lock: + if self.refused: + raise FirstPartyHostRuntimeRejected("research_original_request_recovery_required") + if (owner is None or process is None or self.admission is not owner + or self.process is not process or process.poll() is not None): + raise FirstPartyHostRuntimeRejected("research_request_without_live_owner") + if time.time() >= float(self.env["LOOPX_PHASE_DEADLINE_EPOCH"]): + raise FirstPartyHostRuntimeRejected("research_phase_budget_exhausted") + + def _reject(self, request, process, error): + # Failure to read authority is not permission. Only admission calls + # reach here; a connect/send error remains a transport observation. + code = error.code if isinstance(error, FirstPartyHostRuntimeRejected) else "research_request_admission_unavailable" + with self.lock: + self.refused = True + self.admission_error = self.admission_error or not isinstance(error, FirstPartyHostRuntimeRejected) + request["refusal_code"] = code + self.receipt.setdefault("request_admission_refused", {"request_id": request["request_id"], "code": code}) + if process is not None and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + pass + raise ProviderGatewayRequestRejected() from None + + @contextmanager + def _bound_lifetime(self, request, owner, process): with ExitStack() as scope: try: - if self.refused: - raise FirstPartyHostRuntimeRejected("research_original_request_recovery_required") - if owner is None or process is None or process.poll() is not None: - raise FirstPartyHostRuntimeRejected("research_request_without_live_owner") - if time.time() >= float(self.env["LOOPX_PHASE_DEADLINE_EPOCH"]): - raise FirstPartyHostRuntimeRejected("research_phase_budget_exhausted") + self._require_live_binding(owner, process) scope.enter_context(owner.launch_lifetime(operation="benchmark_provider_request")) + # Reading canonical admission can wait behind a writer. The + # original process, phase clock and refusal must still be live + # after that wait and immediately before the provider send. + self._require_live_binding(owner, process) except Exception as error: - # Failure to read authority is not permission. This catch is - # confined to admission, never an upstream/network exception. - self.refused = True - self.admission_error = self.admission_error or not isinstance(error, FirstPartyHostRuntimeRejected) - code = error.code if isinstance(error, FirstPartyHostRuntimeRejected) else "research_request_admission_unavailable" - request["refusal_code"] = code - self.receipt.setdefault("request_admission_refused", {"request_id": request["request_id"], "code": code}) - if process is not None and process.poll() is None: - try: - os.killpg(process.pid, signal.SIGINT) - except ProcessLookupError: - pass - raise ProviderGatewayRequestRejected() from None + self._reject(request, process, error) yield def observe(self, event): diff --git a/loopx/capabilities/benchmark_toolkit/provider_gateway.py b/loopx/capabilities/benchmark_toolkit/provider_gateway.py index 7e7b882b74..6fd7f660b6 100644 --- a/loopx/capabilities/benchmark_toolkit/provider_gateway.py +++ b/loopx/capabilities/benchmark_toolkit/provider_gateway.py @@ -143,6 +143,10 @@ def serve_runner_owned_provider_gateway( The returned URL and request surface contain no upstream credential. The caller owns the stronger process/filesystem boundary between this gateway and the agent runtime. + + A request_lifetime factory captures the caller binding before connection + setup; its context is entered only for the admitted POST send. The factory + must not retain authority locks while DNS/TCP/CONNECT/TLS can block. """ upstream = _validated_upstream(upstream_base_url) @@ -220,7 +224,14 @@ def do_POST(self) -> None: connection = _upstream_connection(upstream, timeout_sec=request_timeout_sec, proxy=proxy) response_started = False try: - with request_lifetime(observed) if request_lifetime is not None else nullcontext(): + lifetime = request_lifetime(observed) if request_lifetime is not None else nullcontext() + if request_lifetime is not None: + # Pin the request's owner before any slow connection work, + # then leave DNS/TCP/CONNECT/TLS outside its authority lock. + # Reconnection during POST would reintroduce that lock hold. + connection.connect() + connection.auto_open = 0 + with lifetime: # Keep the caller's original lifetime/authority through the # actual send, not through the potentially long response. observed["forwarded"] = None # A send failure may still have reached the upstream. diff --git a/tests/capabilities/test_provider_gateway.py b/tests/capabilities/test_provider_gateway.py index 15502c4d15..26583689c0 100644 --- a/tests/capabilities/test_provider_gateway.py +++ b/tests/capabilities/test_provider_gateway.py @@ -9,6 +9,7 @@ import pytest +import loopx.capabilities.benchmark_toolkit.provider_gateway as gateway_module from loopx.capabilities.benchmark_toolkit.provider_gateway import ( serve_runner_owned_provider_gateway, ) @@ -119,6 +120,124 @@ def test_explicit_http_proxy_preserves_endpoint_and_https_tunnel_target(): connection.close() +@pytest.mark.parametrize("guarded", [False, True]) +def test_connection_setup_is_outside_guarded_dispatch_and_default_path_stays_lazy(monkeypatch, guarded): + original_connection = gateway_module._upstream_connection + observed = [] + state = {"bound": False, "in_lifetime": False, "sending": False} + + def connection(*args, **kwargs): + current = original_connection(*args, **kwargs) + connect, request = current.connect, current.request + + def observed_connect(): + observed.append(dict(state)) + return connect() + + def observed_request(*args, **kwargs): + state["sending"] = True + assert state["in_lifetime"] is guarded + return request(*args, **kwargs) + + current.connect, current.request = observed_connect, observed_request + return current + + @contextmanager + def lifetime(): + state["in_lifetime"] = True + try: + yield + finally: + state["in_lifetime"] = False + + def bind(_request): + state["bound"] = True + return lifetime() + + monkeypatch.setattr(gateway_module, "_upstream_connection", connection) + with _recording_upstream() as (upstream, _observed): + with serve_runner_owned_provider_gateway(upstream_base_url=upstream, + upstream_bearer_token="fixture-credential", request_lifetime=bind if guarded else None) as gateway: + with urllib.request.urlopen(urllib.request.Request(gateway.base_url + "/responses", data=b"{}"), timeout=5) as response: + assert response.status == 200 + assert observed == [{"bound": guarded, "in_lifetime": False, "sending": not guarded}] + + +@pytest.mark.parametrize("failure", ["connect", "send"]) +def test_connection_failure_is_unforwarded_but_failed_send_remains_unknown(monkeypatch, failure): + original_connection = gateway_module._upstream_connection + events = [] + + def connection(*args, **kwargs): + current = original_connection(*args, **kwargs) + request = current.request + + def failed_connect(): + raise OSError("synthetic connection failure") + + def uncertain_send(*args, **kwargs): + request(*args, **kwargs) + raise OSError("synthetic loss after sending") + + if failure == "connect": + current.connect = failed_connect + else: + current.request = uncertain_send + return current + + @contextmanager + def lifetime(_request): + yield + + monkeypatch.setattr(gateway_module, "_upstream_connection", connection) + with _recording_upstream() as (upstream, _observed): + with serve_runner_owned_provider_gateway(upstream_base_url=upstream, + upstream_bearer_token="fixture-credential", request_lifetime=lifetime, + observe_request=events.append) as gateway: + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(urllib.request.Request(gateway.base_url + "/responses", data=b"{}"), timeout=5) + assert error.value.code == 502 + if failure == "connect": + assert len(events) == 1 and events[0]["forwarded"] is False + else: + assert [event["event"] for event in events] == ["dispatch", "finished"] + assert all(event["forwarded"] is None for event in events) + + +def test_guarded_send_cannot_silently_reconnect_under_the_authority_lock(monkeypatch): + original_connection = gateway_module._upstream_connection + connection_ref = [] + connects = [] + + def connection(*args, **kwargs): + current = original_connection(*args, **kwargs) + connect = current.connect + + def observed_connect(): + connects.append(True) + return connect() + + current.connect = observed_connect + connection_ref.append(current) + return current + + @contextmanager + def lifetime(_request): + # Simulate the established connection disappearing before the send. + connection_ref[0].close() + yield + + monkeypatch.setattr(gateway_module, "_upstream_connection", connection) + with _recording_upstream() as (upstream, observed): + with serve_runner_owned_provider_gateway(upstream_base_url=upstream, + upstream_bearer_token="fixture-credential", request_lifetime=lifetime) as gateway: + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(urllib.request.Request(gateway.base_url + "/responses", data=b"{}"), timeout=5) + assert error.value.code == 502 + assert observed == {} + assert connects == [True] + + @pytest.mark.parametrize( "upstream", ( diff --git a/tests/control_plane/test_research_request_guard_races.py b/tests/control_plane/test_research_request_guard_races.py new file mode 100644 index 0000000000..e2b99069ea --- /dev/null +++ b/tests/control_plane/test_research_request_guard_races.py @@ -0,0 +1,220 @@ +"""Request dispatch rechecks the original owner after network and lock waits.""" +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +import socketserver +import subprocess +import sys +import threading +import time +from types import SimpleNamespace +import urllib.error +import urllib.request + +import pytest + +import benchmark.runtime.research_request_guard as guard_module +import loopx.capabilities.benchmark_toolkit.provider_gateway as gateway_module +from benchmark.runtime.research_request_guard import ResearchRequestGuard +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from loopx.control_plane.projects.registry_codec import project_registry_transaction +from canonical_authority_fixture import isolate_sqlite_runtime +import test_quota_settlement_cli as fixture +from tests.capabilities.test_provider_gateway import _recording_upstream + + +def _admitted_owner(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = fixture._write_fixture(tmp_path) + fixture._initialize_fixture_authority(project, runtime, provider=provider) + turn = "request-race-turn" + code, initial = fixture._run_cli(registry, runtime, "quota", "should-run", + "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, + "--runtime-profile", "generic_cli", "--todo-id", fixture.TODO_ID, + "--turn-instance-id", turn, "--scan-path", str(project), cwd=project) + assert code == 0 and initial["should_run"], initial + session = SimpleNamespace(session_id="native-request-race", goal_ref=None, + admission=FirstPartyHostGoalAdmission.for_plan(registry_path=registry, + goal_id=fixture.GOAL_ID, planned_goal_ref=None)) + env = dict(os.environ, LOOPX_TURN=turn, LOOPX_REGISTRY=str(registry), + LOOPX_RUNTIME_ROOT=str(runtime), LOOPX_PROJECT=str(project), LOOPX_GOAL_ID=fixture.GOAL_ID, + LOOPX_AGENT_ID=fixture.AGENT_ID, LOOPX_PHASE_DEADLINE_EPOCH=str(time.time() + 180), + OPENAI_API_KEY="fixture-credential", NO_PROXY="127.0.0.1,localhost") + return env, session, initial, registry + + +@contextmanager +def _native_process(): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(180)"], + start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + yield process + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def _send(guard, result): + try: + with urllib.request.urlopen(urllib.request.Request(guard.base_url + "/responses", + data=b'{"input":"synthetic request"}'), timeout=40) as response: + result["status"] = response.status + except urllib.error.HTTPError as error: + result["status"] = error.code + except Exception as error: + result["error"] = type(error).__name__ + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_process_and_phase_are_rechecked_after_registry_contention(tmp_path, monkeypatch, provider): + env, session, initial, registry = _admitted_owner(tmp_path, monkeypatch, provider) + # The clock is confined to the transport guard; canonical quota retains its + # real clock. Events ensure invalidation happens after the initial check. + clock = [time.time()] + monkeypatch.setattr(guard_module, "time", SimpleNamespace(time=lambda: clock[0])) + for invalidation in ("process_exit", "phase_deadline"): + clock[0] = time.time() + current_env = dict(env, LOOPX_PHASE_DEADLINE_EPOCH=str(clock[0] + 180)) + wake = tmp_path / invalidation + wake.mkdir() + receipt = {"turn_id": "wake-" + invalidation} + reached_lock = threading.Event() + with _recording_upstream() as (url, upstream), _native_process() as process: + current_env["OPENAI_BASE_URL"] = url + guard = ResearchRequestGuard(current_env, receipt, wake) + with guard.serve(): + guard.bind(process, current_env, session, original_quota=initial) + original = guard.admission + + @contextmanager + def observed_lifetime(**kwargs): + reached_lock.set() + with original.launch_lifetime(**kwargs): + yield + + guard.admission = SimpleNamespace(launch_lifetime=observed_lifetime) + response = {} + with project_registry_transaction(registry, operation="fixture_request_contention"): + reader = threading.Thread(target=_send, args=(guard, response)) + reader.start() + assert reached_lock.wait(30), "request did not reach canonical admission" + if invalidation == "process_exit": + process.kill() + process.wait(timeout=5) + else: + clock[0] = float(current_env["LOOPX_PHASE_DEADLINE_EPOCH"]) + assert upstream == {}, "the owner lock must precede the provider effect" + reader.join(timeout=40) + assert not reader.is_alive() + assert response == {"status": 409} + assert upstream == {} and guard.refused + expected = ("research_request_without_live_owner" if invalidation == "process_exit" + else "research_phase_budget_exhausted") + assert receipt["request_admission_refused"]["code"] == expected + events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + assert len(events) == 1 and events[0]["event"] == "finished" + assert events[0]["forwarded"] is False and events[0]["loopx_turn_id"] == env["LOOPX_TURN"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_slow_proxy_connect_cannot_hold_the_goal_stop_writer(tmp_path, monkeypatch, provider): + env, session, initial, registry = _admitted_owner(tmp_path, monkeypatch, provider) + connected, release = threading.Event(), threading.Event() + seen = {} + + class SlowProxy(socketserver.BaseRequestHandler): + def handle(self): + seen["method"] = self.request.recv(65536).split(b" ", 1)[0] + connected.set() + release.wait(30) + # Closing this synthetic tunnel is a connection failure, never an + # accepted model request. No external destination is contacted. + + with socketserver.ThreadingTCPServer(("127.0.0.1", 0), SlowProxy) as proxy, _native_process() as process: + proxy.daemon_threads = True + proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) + proxy_thread.start() + current_env = dict(env, OPENAI_BASE_URL="https://model.example.invalid/v1", + HTTPS_PROXY=f"http://127.0.0.1:{proxy.server_address[1]}") + wake = tmp_path / "proxy-wake" + wake.mkdir() + guard = ResearchRequestGuard(current_env, {"turn_id": "wake-proxy"}, wake) + response = {} + reader = None + try: + with guard.serve(): + guard.bind(process, current_env, session, original_quota=initial) + reader = threading.Thread(target=_send, args=(guard, response)) + reader.start() + assert connected.wait(30), "request did not reach the declared proxy" + assert seen["method"] == b"CONNECT" + with project_registry_transaction(registry, operation="fixture_stop_during_connect") as transaction: + payload = transaction.payload_copy() + goal = next(row for row in payload["goals"] if row["id"] == fixture.GOAL_ID) + goal["activation"] = {"schema_version": "loopx_goal_activation_v1", "state": "stopped", + "updated_at": "2026-01-01T00:00:00Z", "reason": "Synthetic Stop during connection setup"} + transaction.commit(payload) + assert not release.is_set(), "Stop must commit while connection setup is still pending" + assert json.loads(registry.read_text())["goals"][0]["activation"]["state"] == "stopped" + release.set() + reader.join(timeout=40) + assert not reader.is_alive() and response == {"status": 502} + finally: + release.set() + if reader is not None: + reader.join(timeout=40) + proxy.shutdown() + proxy_thread.join(timeout=5) + events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + assert len(events) == 1 and events[0]["event"] == "finished" + assert events[0]["forwarded"] is False + assert events[0]["loopx_turn_id"] == env["LOOPX_TURN"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_pending_connection_cannot_borrow_a_rebound_process_or_turn(tmp_path, monkeypatch, provider): + env, session, initial, _registry = _admitted_owner(tmp_path, monkeypatch, provider) + connected, release = threading.Event(), threading.Event() + original_connection = gateway_module._upstream_connection + + def connection(*args, **kwargs): + current = original_connection(*args, **kwargs) + connect = current.connect + + def slow_connect(): + connected.set() + assert release.wait(30) + return connect() + + current.connect = slow_connect + return current + + monkeypatch.setattr(gateway_module, "_upstream_connection", connection) + wake = tmp_path / "rebound-wake" + wake.mkdir() + receipt = {"turn_id": "wake-rebound"} + with _recording_upstream() as (url, upstream), _native_process() as original, _native_process() as replacement: + env["OPENAI_BASE_URL"] = url + guard = ResearchRequestGuard(env, receipt, wake) + with guard.serve(): + guard.bind(original, env, session, original_quota=initial) + response = {} + reader = threading.Thread(target=_send, args=(guard, response)) + reader.start() + try: + assert connected.wait(30), "request did not reach connection setup" + rebound_env = dict(env, LOOPX_TURN="replacement-held-turn") + guard.bind(replacement, rebound_env, session, original_quota=initial) + finally: + release.set() + reader.join(timeout=40) + assert not reader.is_alive() and response == {"status": 409} + assert upstream == {} and replacement.poll() is None + original.wait(timeout=5) + assert receipt["request_admission_refused"]["code"] == "research_request_without_live_owner" + events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + assert len(events) == 1 and events[0]["forwarded"] is False + assert events[0]["loopx_turn_id"] == env["LOOPX_TURN"], "failed request retains its original Turn" From bc1b9d535fbba9612b9754aceea10a83b4a5050e Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 17:12:44 +0800 Subject: [PATCH 5/8] feat(research): bind native feedback and resume canonical waiting work Journal the actual native submission snapshot and operation, recover lost acceptance responses without another POST, and bind released results to accepted archive and probe identities. Feed durable observations through the existing Explore and Todo owners, release only explicit feedback dependencies, and wake the existing scheduler without new admission or budget authority. Retain probe measurements when optional diagnostics fail, record passive cost coverage, and enforce research-treatment receipt parity. Cover local HTTP acceptance, File/SQLite waiting and interrupted writeback, original-operation recovery, replan and an executed successor experiment. Signed-off-by: Codex --- benchmark/edgebench/export_report.py | 17 +- benchmark/edgebench/research_cost.py | 188 +++++++++ benchmark/edgebench/research_feedback.py | 345 ++++++++++++++++ benchmark/edgebench/run.py | 18 + benchmark/runtime/harbor.py | 27 +- benchmark/runtime/research_feedback_sync.py | 164 ++++++++ benchmark/runtime/research_feedback_wait.py | 228 +++++++++++ benchmark/runtime/research_loop.py | 15 +- benchmark/runtime/research_probe.py | 61 ++- benchmark/runtime/research_scheduler.py | 67 +++ benchmark/runtime/research_submission.py | 381 ++++++++++++++++++ benchmark/runtime/scheduler.py | 5 +- benchmark/runtime/sforge.py | 61 +++ benchmark/runtime/sforge_backend.py | 10 +- benchmark/tests/test_edgebench_report.py | 57 +++ .../tests/test_native_research_feedback.py | 372 +++++++++++++++++ benchmark/tests/test_research_cost.py | 188 +++++++++ benchmark/tests/test_sforge_runtime.py | 96 +++++ .../capabilities/explore/result_writeback.py | 66 +++ scripts/external_scheduler_worker.py | 4 +- .../test_native_research_continuity.py | 196 +++++++++ .../test_research_execution_loop.py | 97 +++++ .../test_research_feedback_wait.py | 215 ++++++++++ .../control_plane/test_research_scheduler.py | 105 +++++ 24 files changed, 2961 insertions(+), 22 deletions(-) create mode 100644 benchmark/edgebench/research_cost.py create mode 100644 benchmark/edgebench/research_feedback.py create mode 100644 benchmark/runtime/research_feedback_sync.py create mode 100644 benchmark/runtime/research_feedback_wait.py create mode 100644 benchmark/runtime/research_scheduler.py create mode 100644 benchmark/runtime/research_submission.py create mode 100644 benchmark/tests/test_native_research_feedback.py create mode 100644 benchmark/tests/test_research_cost.py create mode 100644 tests/control_plane/test_native_research_continuity.py create mode 100644 tests/control_plane/test_research_feedback_wait.py create mode 100644 tests/control_plane/test_research_scheduler.py diff --git a/benchmark/edgebench/export_report.py b/benchmark/edgebench/export_report.py index 2e1be10a08..66ae815b1d 100644 --- a/benchmark/edgebench/export_report.py +++ b/benchmark/edgebench/export_report.py @@ -29,6 +29,19 @@ def _validate_feedback_payload(runner, profile): raise ValueError("Unsupported feedback payload for this mode") +def _validate_research_loop(runner, profile): + # Compare the original sources before the allowlisted settings projection + # can discard a one-sided flag. Historical absence remains distinct from + # an explicitly disabled treatment, and bool cannot be supplied as 0/1. + if ("research_loop" in runner) != ("research_loop" in profile): + raise ValueError("Settings profile disagrees with runtime: research_loop") + for source in (runner, profile): + if "research_loop" in source and type(source["research_loop"]) is not bool: + raise ValueError("research_loop must be a boolean") + if runner.get("research_loop") != profile.get("research_loop"): + raise ValueError("Settings profile disagrees with runtime: research_loop") + + def digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -129,8 +142,7 @@ def _validate_run_data(row, config, summary, points): raise ValueError(f"Settings identity mismatch: {key}") runner, profile = config["runner"], config["worker_profile"] _validate_feedback_payload(runner, profile) - if runner.get("research_loop") != profile.get("research_loop"): - raise ValueError("Settings profile disagrees with research-loop treatment") + _validate_research_loop(runner, profile) if ( runner["runner_commit"] != row["runner_revision"] or runner["model"] != row["model_id"] @@ -256,6 +268,7 @@ def export(runs_root: Path, selection: list, output: Path, *, observed_at: str): if row.get("runner_revision") != receipt["runner_commit"]: raise ValueError("Board and runner revision disagree") _validate_feedback_payload(receipt, profile) + _validate_research_loop(receipt, profile) if receipt.get("planning_horizon_goal") != profile.get("planning_horizon_goal"): raise ValueError("Profile disagrees with runtime: planning_horizon_goal") for source in (receipt, profile): diff --git a/benchmark/edgebench/research_cost.py b/benchmark/edgebench/research_cost.py new file mode 100644 index 0000000000..34c7c6bba3 --- /dev/null +++ b/benchmark/edgebench/research_cost.py @@ -0,0 +1,188 @@ +"""Passive whole-run cost readback over existing private research artifacts. + +The TypeScript trajectory owner interprets Codex counters. This adapter only +selects non-overlapping complete captures and reports observed counts; it does +not price tokens, divide costs between stages, or infer missing consumption. +""" +from __future__ import annotations + +import hashlib +import json +import math +from pathlib import Path +import re + + +def _json(path): + try: + return json.loads(path.read_bytes()) + except (OSError, ValueError): + return None + + +def _coverage(known, missing): + return "partial" if known and missing else "complete" if known else "unknown" + + +def _collection(trial): + rows = _json(trial / "collected" / "artifact-collection.json") + return {row["artifact"] for row in rows if isinstance(row, dict) + and isinstance(row.get("artifact"), str) and row.get("collected") is True} \ + if isinstance(rows, list) else set() + + +def _requests(trial, collected): + paths = sorted((trial / "collected" / "runtime").rglob("provider-requests.jsonl")) + grouped, malformed, readable = {}, 0, 0 + for path in paths: + try: + if path.resolve() != path.absolute(): + raise ValueError("nonphysical capture") + lines = path.read_text(encoding="utf-8").splitlines() + readable += 1 + for line in lines: + try: + row = json.loads(line) + if (not isinstance(row, dict) or not isinstance(row.get("request_id"), str) + or not row["request_id"] or row.get("event") not in {"dispatch", "finished"}): + raise ValueError("invalid request record") + grouped.setdefault(row["request_id"], []).append(row) + except (ValueError, TypeError): + malformed += 1 + except (OSError, UnicodeError, ValueError): + malformed += 1 + counts = {"forwarded": 0, "not_forwarded": 0, "send_unknown": 0} + for rows in grouped.values(): + finished = {json.dumps(row, sort_keys=True, separators=(",", ":")): + row for row in rows if row["event"] == "finished"} + value = next(iter(finished.values())) if len(finished) == 1 else {} + forwarded = value.get("forwarded") + if forwarded is True: + counts["forwarded"] += 1 + elif forwarded is False and not any(row["event"] == "dispatch" for row in rows): + counts["not_forwarded"] += 1 + else: + counts["send_unknown"] += 1 + return {"capture_coverage": _coverage(readable, malformed or "runtime" not in collected), + "captured_distinct_requests": len(grouped) if readable else None, + **(counts if readable else {key: None for key in counts}), "invalid_records": malformed, + "scope": "observed_gateway_requests_only"} + + +def _prefix(shorter, longer): + with shorter.open("rb") as left, longer.open("rb") as right: + while chunk := left.read(1024 * 1024): + if chunk != right.read(len(chunk)): + return False + return True + + +def _tokens(trial, collected): + from loopx.capabilities.benchmark_toolkit.trajectory_assessment_store import _usage_capture + + root = trial / "collected" + paths = {path for name in ("native-sessions", "worker-sessions") for path in (root / name).rglob("*.jsonl")} + paths.update(path for path in (root / "runtime").rglob("*.jsonl") + if "sessions" in path.relative_to(root / "runtime").parts) + grouped, invalid = {}, 0 + for path in sorted(paths): + try: + if path.resolve() != path.absolute(): + raise ValueError("nonphysical session capture") + with path.open("rb") as stream: + header = json.loads(stream.readline()) + payload = header.get("payload") or {} + session = payload.get("id") + if header.get("type") != "session_meta" or not isinstance(session, str) or not session: + raise ValueError("missing native session identity") + stream.seek(0) + digest = hashlib.file_digest(stream, "sha256").hexdigest() + grouped.setdefault(session, []).append((path, path.stat().st_size, digest)) + except (OSError, ValueError, AttributeError): + invalid += 1 + fields = ("input_tokens", "cached_input_tokens", "uncached_input_tokens", "output_tokens") + totals, qualified, unknown, duplicates = dict.fromkeys(fields, 0), 0, invalid, 0 + for session, captures in grouped.items(): + longest, _, digest = max(captures, key=lambda row: row[1]) + try: + # Same identity alone does not make divergent/forked snapshots one + # charge. Only byte-identical or strict-prefix captures are deduped. + if not all(row[2] == digest or _prefix(row[0], longest) for row in captures): + raise ValueError("divergent session captures") + result = _usage_capture(root, longest.relative_to(root).as_posix(), session) + if result["artifact_sha256"] != digest: + raise ValueError("session moved during readback") + projection = result["projection"] + for field in fields: + totals[field] += projection[field] + qualified += 1 + duplicates += len(captures) - 1 + except (OSError, ValueError, RuntimeError): + # Missing prefixes, forks, gaps and unfinished Turns are rejected + # by the existing typed owner. Never substitute the maximum count. + unknown += 1 + complete_collection = {"runtime", "native-sessions", "worker-sessions"}.issubset(collected) + return {"capture_coverage": _coverage(qualified, unknown or not complete_collection), + "whole_run_coverage": "unknown", "qualified_sessions": qualified, + "unknown_sessions_or_files": unknown, "duplicate_or_prefix_captures": duplicates, + "known_captured_totals": ({**totals, "input_plus_output_tokens": + totals["input_tokens"] + totals["output_tokens"]} if qualified else None), + "method": "codex_complete_session_counters_v1", + "limitations": "Known complete captured sessions only. Input includes cached input; output includes reasoning. Missing/provider-side consumption and billing remain unknown."} + + +def _submissions(trial): + from .research_feedback import validate_packet + + packet = _json(trial / "native-research-feedback" / "native.json") + final = _json(trial / "final_result.json") + native_total = final.get("total_rounds") if isinstance(final, dict) else None + if type(native_total) is not int or native_total < 0: + native_total = None + try: + validate_packet(packet) + except (ValueError, TypeError): + return {"capture_coverage": "unknown", "manual_accepted_count": None, + "first_accepted": None, "native_total_rounds": native_total} + grouped, unknown = {}, 0 + for row in packet["records"]: + if (not isinstance(row, dict) or row.get("accepted_record") is not True + or not isinstance(row.get("submission_id"), str) or not row["submission_id"] + or not isinstance(row.get("round_id"), str) + or not re.fullmatch(r"agent-[1-9][0-9]*", row["round_id"])): + unknown += 1 + continue + grouped.setdefault(row["submission_id"], []).append(row) + accepted = [] + for rows in grouped.values(): + if len({json.dumps(row, sort_keys=True) for row in rows}) != 1: + unknown += 1 + else: + accepted.append(rows[0]) + rounds = [row["round_id"] for row in accepted] + unique = [row for row in accepted if rounds.count(row["round_id"]) == 1] + unknown += len(accepted) - len(unique) + ordered = sorted(unique, key=lambda row: int(row["round_id"].split("-")[1])) + first = ordered[0] if ordered and ordered[0]["round_id"] == "agent-1" else None + released = first and first.get("attribution_status") == "verified" and first.get("result_ref") + summary = first.get("result_summary") if released else None + safe_summary = {key: summary[key] for key in ("score", "pass_rate", "valid", "passed", "failed", "total_tests") + if isinstance(summary, dict) and key in summary and (summary[key] is None + or type(summary[key]) is bool or type(summary[key]) in (int, float) and math.isfinite(summary[key]))} + return {"capture_coverage": "partial" if unknown else "complete", "manual_accepted_count": len(unique), + "first_accepted": ({"acceptance_ordinal": 1, "released": bool(released), + "result_summary": safe_summary if released else None} + if first else None), "native_total_rounds": native_total} + + +def summarize_research_cost(trial: Path, runner_elapsed_seconds): + """No new I/O to models/judges; report only already captured local evidence.""" + trial = Path(trial) + elapsed = runner_elapsed_seconds if type(runner_elapsed_seconds) in (int, float) \ + and math.isfinite(runner_elapsed_seconds) and runner_elapsed_seconds >= 0 else None + collected = _collection(trial) + return {"schema_version": "edgebench_research_cost_v0", "runner_wall_seconds": elapsed, + "wall_scope": "measured_runner_entry_through_native_cleanup_and_final_result", + "stage_seconds": None, "stage_allocation_coverage": "unknown", "billing_usd": None, + "provider_requests": _requests(trial, collected), "tokens": _tokens(trial, collected), + "evaluations": _submissions(trial)} diff --git a/benchmark/edgebench/research_feedback.py b/benchmark/edgebench/research_feedback.py new file mode 100644 index 0000000000..7172e38c7a --- /dev/null +++ b/benchmark/edgebench/research_feedback.py @@ -0,0 +1,345 @@ +"""Read native accepted snapshots and released feedback for an explicit research run. + +SForge retains admission, evaluation, scoring and opportunity ownership. This +adapter uses only the token-visible history/results and the native submitted +source archive; it never reads judge artifacts or initiates an evaluation. +""" +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import tarfile +import threading +import uuid + +SCHEMA = "edgebench_native_feedback_v1" +MAX_ARCHIVE_BYTES = 256 * 1024 * 1024 +MAX_UNPACKED_BYTES = 512 * 1024 * 1024 +_ID = re.compile(r"[A-Za-z0-9_-]{1,128}\Z") +_ROUND = re.compile(r"agent-[1-9][0-9]*\Z") + + +def canonical_json(value) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode() + + +def write_bytes_atomic(path: Path, raw: bytes, *, create_only=False): + """Commit one private provider receipt before reporting the associated fact.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp") + try: + with temporary.open("xb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + if create_only: + os.link(temporary, path) + else: + temporary.replace(path) + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + +def read_archive(path: Path, *, root: Path) -> bytes: + """Read one physical native source file; detect movement during capture.""" + root = root.resolve(strict=True) + path = path.absolute() + if not path.is_relative_to(root) or path.resolve(strict=True) != path: + raise ValueError("native_archive_not_physical") + with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), "rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_ARCHIVE_BYTES: + raise ValueError("native_archive_size_or_type") + raw = stream.read(MAX_ARCHIVE_BYTES + 1) + after = os.fstat(stream.fileno()) + current = path.stat(follow_symlinks=False) + def signature(row): + return row.st_dev, row.st_ino, row.st_size, row.st_mtime_ns, row.st_ctime_ns + if (signature(before) != signature(after) or signature(after) != signature(current) + or len(raw) != before.st_size or path.resolve(strict=True) != path): + raise ValueError("native_archive_moved") + return raw + + +def archive_manifest(raw: bytes, selected_paths: list[str] | None = None): + """Use exactly research_probe's sorted selected-file manifest, not tar bytes. + + Nothing is extracted. Ambiguous member names, links and oversized sources + remain unqualified, including when the current workspace looks plausible. + """ + if len(raw) > MAX_ARCHIVE_BYTES: + raise ValueError("native_archive_too_large") + requested = None if selected_paths is None else set(selected_paths) + if requested is not None and (not 1 <= len(requested) <= 32 or len(requested) != len(selected_paths)): + raise ValueError("invalid_selected_files") + members, files, total = set(), [], 0 + with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as archive: + for member in archive: + name = member.name + while name.startswith("./"): + name = name[2:] + if member.isdir() and name in ("", "."): + continue + path = PurePosixPath(name) + if (path.is_absolute() or ".." in path.parts or not name or "\\" in name + or path.as_posix() != name.rstrip("/") or name in members): + raise ValueError("ambiguous_archive_member") + members.add(name) + if member.isdir(): + continue + if not member.isfile(): + raise ValueError("non_regular_archive_member") + total += member.size + if len(members) > 100000 or total > MAX_UNPACKED_BYTES: + raise ValueError("native_archive_expansion_limit") + if requested is not None and name not in requested: + continue + stream = archive.extractfile(member) + if stream is None: + raise ValueError("unreadable_archive_member") + with stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + files.append({"path": name, "sha256": digest}) + if requested is not None and {row["path"] for row in files} != requested: + raise ValueError("selected_files_not_in_accepted_archive") + if not 1 <= len(files) <= 32: + raise ValueError("select_one_to_32_files") + files.sort(key=lambda row: row["path"]) + # research_probe uses json.dumps' default ensure_ascii=True. + encoded = json.dumps(files, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest(), files + + +def validate_packet(packet): + if (not isinstance(packet, dict) or packet.get("schema_version") != SCHEMA + or not _ID.fullmatch(packet.get("run_id") or "") + or not _ID.fullmatch(packet.get("task_id") or "") + or not isinstance(packet.get("records"), list)): + raise ValueError("invalid_native_feedback_packet") + return packet + + +class NativeResearchFeedback: + """One low-cost publisher scoped to the registration, surviving native wakes. + + ``after_publish(backend, handle, packet)`` invokes the installed normal + Explore/Todo consumer. Publication is retried until that consumer succeeds; + neither a copied file nor an unchanged poll is a control-plane settlement. + """ + def __init__(self, *, trial: Path, run_id: str, task_id: str, judge_url: str, + remote_root: str, logger=None, session=None, poll_seconds: float = 5): + if not _ID.fullmatch(run_id) or not _ID.fullmatch(task_id) or poll_seconds <= 0: + raise ValueError("invalid_native_feedback_scope") + self.trial, self.run_id, self.task_id = Path(trial), run_id, task_id + self.directory = self.trial / "native-research-feedback" + self.judge_url, self.remote_root = judge_url.rstrip("/"), PurePosixPath(remote_root) + self.logger, self.poll_seconds = logger, poll_seconds + self.token, self.thread, self.after_publish = None, None, None + self._stop, self._active = threading.Event(), threading.Event() + self._lock = threading.RLock() + self._published, self._packet = None, None + self._observed_at = {} + previous = self.directory / "native.json" + if previous.exists(): + packet = validate_packet(json.loads(previous.read_bytes())) + if (packet["run_id"], packet["task_id"]) != (run_id, task_id): + raise ValueError("native_feedback_saved_scope_mismatch") + self._observed_at = {(row["submission_id"], row.get("result_sha256")): row["result_observed_at"] + for row in packet["records"] if row.get("result_observed_at")} + self._artifacts = {} + if session is None: + import requests + session = requests.Session() + session.trust_env = False # Host path to its declared native judge. + self.session = session + + def bind(self, environment): + token = (environment or {}).get("SFORGE_TOKEN") + if not token or (self.token is not None and self.token != token): + raise ValueError("native_feedback_registration_mismatch") + self.token = token + + def _get(self, path, **kwargs): + response = self.session.get(self.judge_url + path, timeout=(3, 5), **kwargs) + response.raise_for_status() + return response.json() + + def poll(self): + if self.token is None: + raise ValueError("native_feedback_registration_missing") + history = self._get("/api/v1/history", params={"token": self.token}) + if history.get("run_id") != self.run_id or not isinstance(history.get("entries"), list): + raise ValueError("native_feedback_history_scope_mismatch") + entries = [row for row in history["entries"] if row.get("type") == "submission" + and isinstance(row.get("round"), str) and row["round"].startswith("agent-")] + ids = [row.get("submission_id") for row in entries] + rounds = [row.get("round") for row in entries] + if any(row.get("task_id") != self.task_id for row in entries): + raise ValueError("native_feedback_task_scope_mismatch") + records, artifacts = [], {} + for entry in entries: + sid, round_id, status = entry.get("submission_id"), entry.get("round"), entry.get("status") + if not _ID.fullmatch(sid or "") or not _ROUND.fullmatch(round_id or ""): + raise ValueError("native_feedback_identity_invalid") + row = {"accepted_record": True, "submission_id": sid, "round_id": round_id, + "status": status, "attribution_status": "unknown"} + records.append(row) + if ids.count(sid) != 1 or rounds.count(round_id) != 1: + row["accepted_record"] = False + row["reason"] = "ambiguous_native_identity" + continue + if status not in {"queued", "running", "completed", "error"}: + row["accepted_record"] = False + row["reason"] = "unknown_native_status" + continue + try: + source = self.trial / "submissions" / round_id / "submission.tar.gz" + raw = read_archive(source, root=self.trial) + digest = hashlib.sha256(raw).hexdigest() + self._bind_archive(sid, round_id, digest, len(raw)) + archive_name = f"{sid}-{digest}.tar.gz" + row.update(archive_sha256=digest, archive_bytes=len(raw), + archive_path=str(self.remote_root / "archives" / archive_name)) + artifacts["archives/" + archive_name] = raw + # Large/multi-file submissions remain usable for selected probe + # manifests. Do not confuse an absent full-manifest digest with + # an absent exact native archive binding. + try: + row["candidate_sha256"], _ = archive_manifest(raw) + except (ValueError, tarfile.TarError): + pass + row["attribution_status"] = "verified" + if status not in {"completed", "error"}: + continue + result = self._get("/api/v1/result/" + sid) + self._validate_result(result, entry) + result_bytes = canonical_json(result) + result_sha = hashlib.sha256(result_bytes).hexdigest() + identity = dict(run_id=self.run_id, task_id=self.task_id, submission_id=sid, + round_id=round_id, archive_sha256=digest, result_sha256=result_sha) + ref = "native-result:" + hashlib.sha256(canonical_json(identity)).hexdigest() + result_name = f"{sid}-{result_sha}.json" + artifacts["results/" + result_name] = result_bytes + report = result.get("report") or {} + observed_at = self._observed_at.setdefault((sid, result_sha), + datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")) + row.update(result_ref=ref, result_sha256=result_sha, result_observed_at=observed_at, + result_path=str(self.remote_root / "results" / result_name), + result_summary={key: report.get(key) for key in ( + "score", "pass_rate", "valid", "passed", "failed", "total_tests")}) + except (OSError, ValueError, tarfile.TarError) as error: + row["attribution_status"] = "unknown" + row["reason"] = str(error) if isinstance(error, ValueError) else type(error).__name__ + row.pop("result_ref", None) + row.pop("result_sha256", None) + packet = {"schema_version": SCHEMA, "run_id": self.run_id, "task_id": self.task_id, + "records": records} + with self._lock: + self._artifacts, self._packet = artifacts, packet + return packet + + def _bind_archive(self, sid, round_id, digest, size): + """A native submission can never silently move to a different archive.""" + binding = dict(run_id=self.run_id, task_id=self.task_id, submission_id=sid, + round_id=round_id, archive_sha256=digest, archive_bytes=size) + path = self.directory / "bindings" / (sid + ".json") + try: + write_bytes_atomic(path, canonical_json(binding), create_only=True) + except FileExistsError: + if json.loads(path.read_bytes()) != binding: + raise ValueError("native_accepted_archive_changed") + + def _validate_result(self, result, entry): + if (not isinstance(result, dict) or result.get("submission_id") != entry["submission_id"] + or result.get("status") != entry["status"]): + raise ValueError("released_result_identity_mismatch") + report = result.get("report") + if entry["status"] == "error": + if report is not None: + raise ValueError("native_error_report_ambiguous") + return + if (not isinstance(report, dict) or result.get("error") is not None + or report.get("submission_id") != entry["submission_id"] + or report.get("task_id") != self.task_id): + raise ValueError("released_result_scope_mismatch") + if any(report.get(key) != entry.get(key) for key in ("score", "pass_rate", "valid")): + raise ValueError("released_result_history_mismatch") + + def update(self, backend, handle): + packet = self.poll() + encoded = canonical_json(packet) + revision = hashlib.sha256(encoded).hexdigest() + if revision == self._published: + return packet + self.directory.mkdir(parents=True, exist_ok=True) + for name, raw in {**self._artifacts, "native.json": encoded}.items(): + local = self.directory / name + local.parent.mkdir(parents=True, exist_ok=True) + write_bytes_atomic(local, raw) + # Copy to a fresh sibling, then atomically publish the complete file. + remote = self.remote_root / name + temporary = str(remote) + "." + uuid.uuid4().hex + ".tmp" + result = backend.exec_run(handle, ["mkdir", "-p", str(remote.parent)], user="root") + if result.exit_code: + raise RuntimeError("native_feedback_directory_failed") + backend.copy_to_container(handle, local, PurePosixPath(temporary)) + result = backend.exec_run(handle, ["chmod", "0644", temporary], user="root") + if result.exit_code: + raise RuntimeError("native_feedback_permissions_failed") + result = backend.exec_run(handle, ["mv", "-f", temporary, str(remote)], user="root") + if result.exit_code: + raise RuntimeError("native_feedback_publish_failed") + if self.after_publish is not None: + self.after_publish(backend, handle, packet) + self._published = revision + return packet + + def snapshot(self): + with self._lock: + return json.loads(canonical_json(self._packet)) if self._packet is not None else None + + def start(self, backend, handle): + if self._stop.is_set(): + raise RuntimeError("native_feedback_already_closed") + # Establish the scope packet before any worker submission can happen. + self.update(backend, handle) + self._active.set() + if self.thread is None: + self.thread = threading.Thread(target=self._loop, args=(backend, handle), daemon=True) + self.thread.start() + + def _loop(self, backend, handle): + while not self._stop.wait(self.poll_seconds): + if not self._active.is_set(): + continue + try: + self.update(backend, handle) + except Exception as error: + # Never log a native URL containing a token or raw feedback. + if self.logger: + self.logger.warning("Native research observation failed: %s", type(error).__name__) + + def pause(self): + self._active.clear() + + def close(self): + self._stop.set() + if self.thread is not None: + # Let a bounded consumer finish or kill its process group before + # the native runner removes the container and collects artifacts. + self.thread.join(timeout=90) + if self.thread.is_alive(): + raise RuntimeError("native_feedback_publisher_did_not_stop") + self.session.close() diff --git a/benchmark/edgebench/run.py b/benchmark/edgebench/run.py index 9820e2d64c..28f015013c 100644 --- a/benchmark/edgebench/run.py +++ b/benchmark/edgebench/run.py @@ -98,6 +98,7 @@ def _task_default(task_id: str, key: str, explicit: int | None, fallback: int) - def main(argv=None): + runner_started = time.monotonic() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--task", required=True) parser.add_argument("--tasks-dir", required=True, type=Path) @@ -329,6 +330,14 @@ def main(argv=None): judge_url=args.judge_url.replace("host.docker.internal", "127.0.0.1"), admin_secret=get_admin_secret(args.log_dir), logger=logger, sampler=sampler, ) if args.feedback == "best-only" else None) + research_feedback = None + if args.research_loop: + from benchmark.edgebench.research_feedback import NativeResearchFeedback + from benchmark.runtime.research_feedback_sync import FEEDBACK_ROOT + + research_feedback = NativeResearchFeedback(trial=trial, run_id=args.run_id, task_id=args.task, + judge_url=args.judge_url.replace("host.docker.internal", "127.0.0.1"), + remote_root=FEEDBACK_ROOT, logger=logger) checkpoint = None if args.checkpoint_controller_file is not None: from benchmark.edgebench.checkpoint_supervisor import CheckpointSupervisor @@ -338,6 +347,7 @@ def main(argv=None): backend = RecordingDockerBackend(log_dir=trial / "collected", logger=logger, oauth_proxy=bool(args.api_proxy_url or api_base_url), blind_api_endpoint=blind_endpoint, feedback=feedback, + **({"research_feedback": research_feedback} if research_feedback is not None else {}), **({"checkpoint": checkpoint} if checkpoint is not None else {})) for image in (task.work_image_key, task.judge_image_key): if not backend.image_exists(image): @@ -412,6 +422,8 @@ def main(argv=None): finally: if feedback is not None: feedback.close() + if research_feedback is not None: + research_feedback.close() # Native cancellation and swallowed Docker failures return runtime=0. # Observe the signal independently; never infer cancellation from output prose. execution = backend.execution_receipt or {} @@ -428,6 +440,12 @@ def main(argv=None): _write_native_final_result(trial, result, status=status, agent=agent.name, task=task.task_id, run_id=args.run_id, model=args.model, effort=args.effort, feedback=args.feedback) + if args.research_loop: + from benchmark.edgebench.research_cost import summarize_research_cost + from benchmark.edgebench.research_feedback import canonical_json, write_bytes_atomic + + cost = summarize_research_cost(trial, time.monotonic() - runner_started) + write_bytes_atomic(trial / "research-cost.json", canonical_json(cost)) receipt_path.write_text(json.dumps(receipt, indent=2)) print(json.dumps(receipt)) return 0 if status == "terminal" else 130 if status == "cancelled" else 1 diff --git a/benchmark/runtime/harbor.py b/benchmark/runtime/harbor.py index c17c7afd62..8859bf8ddf 100644 --- a/benchmark/runtime/harbor.py +++ b/benchmark/runtime/harbor.py @@ -74,6 +74,7 @@ def __init__( native_goal_continue=False, task_objective=None, research_loop=False, + research_feedback_root=None, goal_id=_GOAL_ID, agent_id=_AGENT_ID, defer_codex_home=False, @@ -129,6 +130,9 @@ def __init__( if not isinstance(research_loop, bool) or (research_loop and self.execution.mode != "heartbeat"): raise ValueError("research_loop requires heartbeat execution") self.research_loop = research_loop + if research_feedback_root is not None and (not research_loop or not str(research_feedback_root).startswith("/")): + raise ValueError("native research feedback requires an enabled treatment and absolute staged root") + self.research_feedback_root = research_feedback_root self.checkpoint_request_path = None self._checkpoint_declaration = None if checkpoint_request is not None: @@ -205,7 +209,12 @@ async def _stage_source(self, environment: BaseEnvironment, source: Path) -> str if runner != source: # Only research runtime code is overlaid; LoopX product code # and its installer remain exactly at the product revision. - archive_source(runner, runner_head, archive, ("benchmark/runtime",)) + overlay = ("benchmark/runtime",) + if self.research_feedback_root is not None: + # The native submission producer is a pinned EdgeBench + # adapter, not product scoring or control-plane policy. + overlay += ("benchmark/edgebench/research_feedback.py",) + archive_source(runner, runner_head, archive, overlay) await environment.upload_file(archive, f"{_ROOT}/runner.tar") await self.exec_as_root(environment, command=( f"tar -xf {_ROOT}/runner.tar -C {_SRC} && rm {_ROOT}/runner.tar" @@ -335,11 +344,19 @@ async def install(self, environment: BaseEnvironment) -> None: if doctor_payload.get("ok") is not True: raise RuntimeError(f"LoopX doctor failed: {doctor_payload}") if self.research_loop: - wrapper = (f"#!/bin/sh\nexec env PYTHONPATH={shlex.quote(_SRC)} " + feedback_env = (f"LOOPX_RESEARCH_FEEDBACK_ROOT={shlex.quote(self.research_feedback_root)} " + if self.research_feedback_root is not None else "") + wrapper = (f"#!/bin/sh\nexec env PYTHONPATH={shlex.quote(_SRC)} {feedback_env}" f"{_PYTHON}/bin/python3 -m benchmark.runtime.research_probe \"$@\"\n") await self.exec_as_agent(environment, command=f"printf %s {shlex.quote(wrapper)} > {_PROFILE}/bin/loopx-research-probe; " f"chmod 0755 {_PROFILE}/bin/loopx-research-probe", env=self._profile_env()) + if self.research_feedback_root is not None: + wrapper = (f"#!/bin/sh\nexec env PYTHONPATH={shlex.quote(_SRC)} {feedback_env}" + f"{_PYTHON}/bin/python3 -m benchmark.runtime.research_feedback_wait \"$@\"\n") + await self.exec_as_agent(environment, + command=f"printf %s {shlex.quote(wrapper)} > {_PROFILE}/bin/loopx-research-feedback; " + f"chmod 0755 {_PROFILE}/bin/loopx-research-feedback", env=self._profile_env()) receipt = { "loopx_commit": actual_commit, @@ -477,6 +494,9 @@ async def _prepare_phase( self.goal_id, "--registered-agent", self.agent_id, + *(["--registered-agent", "edgebench-feedback-observer", + "--agent-work-mode", "edgebench-feedback-observer=active"] + if self.research_feedback_root is not None else []), "--boundary-authority-scope", "**", "--boundary-authority-source", @@ -608,6 +628,9 @@ def _worker_env(self, *, cwd: str) -> dict[str, str]: "LOOPX_EXECUTION_MODE": self.execution.mode, "LOOPX_TASK_ENTRY": self.execution.task_entry, **({"LOOPX_RESEARCH_LOOP": "1"} if self.research_loop else {}), + **({"LOOPX_RESEARCH_FEEDBACK_ROOT": self.research_feedback_root, + "LOOPX_RESEARCH_FEEDBACK_AGENT": "edgebench-feedback-observer"} + if self.research_feedback_root is not None else {}), **({"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 {}), diff --git a/benchmark/runtime/research_feedback_sync.py b/benchmark/runtime/research_feedback_sync.py new file mode 100644 index 0000000000..4fd456a85a --- /dev/null +++ b/benchmark/runtime/research_feedback_sync.py @@ -0,0 +1,164 @@ +"""Deliver native feedback through ordinary Explore writeback before Todo resume. + +This is the EdgeBench producer adapter. Explore owns its attachment codec and +history, Todo owns waiting, and the current Host owns subsequent admission. +No score comparison, candidate adoption or research completion is inferred here. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path + +from .layout import _CONTROL + + +FEEDBACK_ROOT = f"{_CONTROL}/native-feedback" +_OWNER_ENV_KEYS = frozenset({"LOOPX_CLI", "LOOPX_REGISTRY", "LOOPX_RUNTIME_ROOT", "LOOPX_GOAL_ID", + "LOOPX_AGENT_ID", "LOOPX_PROJECT", "LOOPX_RESEARCH_FEEDBACK_ROOT", + "LOOPX_RESEARCH_FEEDBACK_AGENT", "LOOPX_PYTHON", "LOOPX_USAGE_PING", "PATH", "PYTHONPATH"}) + + +def feedback_owner_environment(env): + """Only control coordinates and staged tooling enter the installed config.""" + return {key: value for key, value in env.items() if key in _OWNER_ENV_KEYS} + + +def feedback_environment(root: Path, base=None): + """Use the installed, credential-free owner when a tool filters its env.""" + env = dict(os.environ if base is None else base) + owner = json.loads((root / "owner.json").read_text(encoding="utf-8")) + if (not isinstance(owner, dict) or set(owner) - _OWNER_ENV_KEYS + or any(not isinstance(value, str) for value in owner.values()) + or owner.get("LOOPX_RESEARCH_FEEDBACK_ROOT") != str(root)): + raise ValueError("native feedback owner configuration mismatch") + env.update(owner) + return env + + +def _attachment(packet, record, *, observation=None, probe_ref=None): + # Stable native identity survives delayed feedback and a lost writeback + # response. Identity includes the exact released result digest, never the + # current workspace. A closed native observation is not silently revised. + source = [packet["run_id"], packet["task_id"], record["submission_id"], + record["round_id"], record["archive_sha256"], record["result_sha256"], probe_ref] + identity = hashlib.sha256(json.dumps(source, separators=(",", ":")).encode()).hexdigest() + evidence = [record["result_ref"], "native-archive:" + record["archive_sha256"]] + summary = {"status": record["status"], **record.get("result_summary", {})} + attachment = { + "schema_version": "explore_result_attachment_v0", + "node_id": "native-feedback-" + identity[:32], + "question": "What did the released native evaluation report for this accepted snapshot?", + "applicability": "One native accepted snapshot; local probe measurements cover only their declared files and input.", + "input_revision": "native-submission:" + identity[:32], + "observation": json.dumps(summary, sort_keys=True, allow_nan=False), + "interpretation": "The result belongs to the archived submission. It does not describe newer workspace files. " + "Candidate adoption, rollback and the next experiment require the research owner's separate decision; " + "negative or error results remain evidence, not automatic research failure.", + "status": "tentative", + "evidence_refs": evidence, + } + if observation is not None: + attachment["execution_observation"] = observation + attachment["input_revision"] = observation["input_revision"] + attachment["evidence_refs"] = sorted(set([*evidence, *observation["evidence_refs"]])) + return identity, attachment + + +def write_feedback_finding(env, packet, record, todo_id): + """Write an actual observer result; never settle the model's original Turn. + +The caller completes the finite observation Todo only after this readback. Each +attachment has a deterministic ordinary writeback identity, so a partial copy, +lost response or repeated native history read can finish without a second result. +""" + from .research_feedback_wait import FEEDBACK_OBSERVER_AGENT + from .research_submission import bind_execution_observations + from benchmark.edgebench.research_feedback import write_bytes_atomic + from loopx.capabilities.explore.result_writeback import record_observer_result_attachment + + root = Path(env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) + attached = [row for row in bind_execution_observations(root, packet) + if row["submission_id"] == record["submission_id"]] + variants = [(None, None), *[(row["execution_observation"], row["probe_ref"]) for row in attached]] + receipts = [] + for observation, probe_ref in variants: + identity, attachment = _attachment(packet, record, observation=observation, probe_ref=probe_ref) + directory = root / "ingestion" + directory.mkdir(parents=True, exist_ok=True) + path = directory / (identity + ".json") + encoded = json.dumps(attachment, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n" + try: + write_bytes_atomic(path, encoded.encode(), create_only=True) + except FileExistsError: + if path.read_text(encoding="utf-8") != encoded: + raise ValueError("native feedback ingestion identity changed") + # Native observation is external evidence, not a model Turn. The + # capability's ordinary log/link owner records it without a synthetic + # quota receipt, stride progress or acceptance declaration. + result = record_observer_result_attachment(attachment, + registry_path=Path(env["LOOPX_REGISTRY"]), runtime_root=Path(env["LOOPX_RUNTIME_ROOT"]), + goal_id=env["LOOPX_GOAL_ID"], + agent_id=env.get("LOOPX_RESEARCH_FEEDBACK_AGENT", FEEDBACK_OBSERVER_AGENT), + todo_id=todo_id, source_id="native-feedback:" + identity, + recorded_at=record["result_observed_at"]) + if result.get("ok") is not True: + raise RuntimeError("native feedback Explore writeback is incomplete") + receipts.append({"observation_id": identity, "written": True}) + return {"submission_id": record["submission_id"], "findings": receipts} + + +def synchronize_native_feedback(env, *, packet=None): + """Called by the low-cost runner and the native submit response hook.""" + from .research_feedback_wait import synchronize_feedback_waits, wait_for_feedback + from .research_submission import declared_wait_packet, read_packet, synchronize_intents + from benchmark.edgebench.research_feedback import canonical_json, write_bytes_atomic + + root = Path(env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) + packet = read_packet(root) if packet is None else packet + intents = synchronize_intents(root, packet) + revision = hashlib.sha256(canonical_json(packet)).hexdigest() + + def released(observed): + # An independent, already durable result must still wake its work if a + # later result in the same packet fails and needs a separate retry. + partial = hashlib.sha256(canonical_json([revision, observed])).hexdigest() + write_bytes_atomic(root / "ingestion" / "wake.json", canonical_json({"revision": partial})) + + result = synchronize_feedback_waits(env, packet, after_release=released) + waiting = [] + for intent in intents: + if (intent.get("wait_todo_id") and intent.get("acceptance") == "accepted" + and intent.get("submission_id") and not intent.get("result_observed")): + declaration = declared_wait_packet(root, intent) + current = [row for row in declaration["records"] + if row.get("submission_id") == intent["submission_id"]] + if (len(current) == 1 and current[0].get("status") in {"completed", "error"} + and current[0].get("attribution_status") == "verified" + and current[0].get("result_ref")): + # The native process may have died before its result receipt. + # Released evidence has already satisfied the observation; + # replay must not re-defer a now completed or repurposed Todo. + continue + waiting.append(wait_for_feedback(env, todo_id=intent["wait_todo_id"], + submission_id=intent["submission_id"], packet=declaration)) + # A durable consumer readback, rather than a pending raw feedback copy, + # wakes the existing scheduler. It still owns launch/Stop/budget decisions. + write_bytes_atomic(root / "ingestion" / "wake.json", canonical_json({"revision": revision})) + return {**result, "waits": waiting, "revision": revision} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, + default=os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT", FEEDBACK_ROOT)) + args = parser.parse_args(argv) + env = feedback_environment(args.root) + print(json.dumps(synchronize_native_feedback(env), allow_nan=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmark/runtime/research_feedback_wait.py b/benchmark/runtime/research_feedback_wait.py new file mode 100644 index 0000000000..b21614aac2 --- /dev/null +++ b/benchmark/runtime/research_feedback_wait.py @@ -0,0 +1,228 @@ +"""Adapt an explicit native-feedback dependency to the existing Todo owner. + +The native observer owns accepted submission/feedback facts. This transport +only creates its finite observation Todo and defers the caller-selected work. +It never infers a wait from prose, an unchanged score, or another pending job. +The regular quota and Host owners retain all admission and resume decisions. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +from .layout import _CONTROL + + +FEEDBACK_OBSERVER_AGENT = "edgebench-feedback-observer" + + +def _loopx(env, *args): + from .worker import loopx_command + + result = subprocess.run(loopx_command(env) + list(args), cwd=env["LOOPX_PROJECT"], + env=env, capture_output=True, text=True, timeout=120, check=False) + try: + payload = json.loads(result.stdout) + except (ValueError, TypeError): + raise RuntimeError("native feedback Todo command returned no structured result") from None + if result.returncode or not isinstance(payload, dict) or payload.get("ok") is False: + # Native results, credentials and host-local paths do not enter errors. + code = payload.get("error_code", "todo_command_failed") if isinstance(payload, dict) else "todo_command_failed" + raise RuntimeError("native feedback Todo command rejected: " + str(code)) + return payload + + +def _identity(value, label): + if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(char) < 32 for char in value): + raise ValueError("native feedback requires a bounded " + label) + return value + + +def _records(packet): + if not isinstance(packet, dict) or packet.get("schema_version") != "edgebench_native_feedback_v1": + raise ValueError("native feedback packet schema mismatch") + run, task = (_identity(packet.get(key), key) for key in ("run_id", "task_id")) + if not isinstance(packet.get("records"), list): + raise ValueError("native feedback records are unavailable") + accepted = [record for record in packet["records"] + if isinstance(record, dict) and record.get("accepted_record") is True] + identities = [record.get("submission_id") for record in accepted] + records = {} + for record in accepted: + submission = _identity(record.get("submission_id"), "submission_id") + round_id = _identity(record.get("round_id"), "round_id") + if identities.count(submission) != 1 or record.get("status") not in {"queued", "running", "completed", "error"}: + continue # One unresolved native identity cannot suppress independent feedback. + key = hashlib.sha256(json.dumps([run, task, submission, round_id], separators=(",", ":")).encode()).hexdigest() + records[submission] = ("native-feedback:" + key[:32], record) + return records + + +def _released(record): + if record.get("status") not in {"completed", "error"} or record.get("attribution_status") != "verified": + return False + if not record.get("result_ref"): + return False + _identity(record["result_ref"], "result_ref") + # Native archive/result identity proves released feedback even when no + # optional selected-file probe manifest covers that submitted archive. + for field in ("archive_sha256", "result_sha256"): + if not isinstance(record.get(field), str) or not re.fullmatch(r"[0-9a-f]{64}", record[field]): + raise ValueError("released native feedback is missing its verified " + field) + return True + + +def _scope(env): + goal = _identity(env.get("LOOPX_GOAL_ID"), "goal_id") + agent = _identity(env.get("LOOPX_AGENT_ID"), "agent_id") + observer = _identity(env.get("LOOPX_RESEARCH_FEEDBACK_AGENT", FEEDBACK_OBSERVER_AGENT), "observer_agent_id") + if agent == observer: + raise ValueError("native feedback observation requires its separate runner owner") + return goal, agent, observer + + +def _dependency(todos, key, observer, agent): + matches = [todo for todo in todos if todo.get("target_key") == key] + if len(matches) > 1: + raise ValueError("native feedback Todo identity is ambiguous") + if not matches: + return None + todo = matches[0] + if (todo.get("role") != "agent" or todo.get("claimed_by") != observer + or todo.get("task_class") != "advancement_task" or todo.get("action_kind") != "observe" + or agent not in (todo.get("excluded_agents") or [])): + raise ValueError("native feedback Todo belongs to another operation") + return todo + + +def _create_feedback_todo(env, *, goal, agent, observer, key, record): + created = _loopx(env, "todo", "add", "--goal-id", goal, "--role", "agent", + "--claimed-by", observer, "--excluded-agent", agent, "--task-class", "advancement_task", + "--action-kind", "observe", "--target-key", key, "--operation-id", key + ":observe", + "--text", "Receive released feedback for native submission " + record["submission_id"] + + " (" + key + ").", + "--execute") + return _loopx(env, "todo", "list", "--goal-id", goal, "--todo-id", created["todo_id"])["todo"] + + +def wait_for_feedback(env, *, todo_id, submission_id, packet): + """Declare one exact dependency, preserving unrelated work and its owner. + + Submission/recovery must have returned an accepted native identity before + this call. A missing or ambiguous response is not authority to guess one. + Reentry reads canonical state; it neither resubmits nor changes Turn budget. + """ + goal, agent, observer = _scope(env) + selected = _records(packet).get(submission_id) + if selected is None: + raise ValueError("native feedback wait requires one accepted submission; its identity is absent or ambiguous") + key, record = selected + binding = ("--goal-id", goal) + listed = _loopx(env, "todo", "list", *binding, "--role", "agent") + source = _dependency(listed["todos"], key, observer, agent) + waiting = next((todo for todo in listed["todos"] if todo.get("todo_id") == todo_id), None) + if (waiting is None or waiting.get("role") != "agent" or waiting.get("claimed_by") != agent + or waiting.get("task_class") != "advancement_task"): + raise ValueError("native feedback wait requires the caller's advancement Todo") + if _released(record): + # A delayed/replayed availability notice never reopens a completed Todo + # or replaces a newer wait; the current control owner still decides it. + return {"waiting": False, "todo_id": todo_id, "submission_id": submission_id, + "result_ref": record["result_ref"]} + if waiting.get("status") not in {"open", "deferred"}: + raise ValueError("native feedback wait cannot reopen completed or blocked work") + resume = "todo_done:" + source["todo_id"] if source else None + if waiting.get("resume_when") and waiting["resume_when"] != resume: + raise ValueError("native feedback wait cannot replace another resume condition") + if waiting.get("status") == "deferred" and (source is None or waiting.get("resume_when") != resume): + raise ValueError("native feedback wait cannot replace an existing deferral") + if source is None: + source = _create_feedback_todo(env, goal=goal, agent=agent, observer=observer, key=key, record=record) + resume = "todo_done:" + source["todo_id"] + # Observe again after source creation: feedback or an owner edit may have + # committed meanwhile. The public CAS binds deferral to this fresh source. + current = _loopx(env, "todo", "list", *binding, "--role", "agent") + source = _dependency(current["todos"], key, observer, agent) + waiting = next(todo for todo in current["todos"] if todo.get("todo_id") == todo_id) + if source["status"] == "done": + return {"waiting": False, "todo_id": todo_id, "submission_id": submission_id, + "dependency_todo_id": source["todo_id"]} + if source["status"] != "open": + raise ValueError("native feedback observation was paused or cancelled") + if waiting.get("status") == "deferred" and waiting.get("resume_when") == resume: + return {"waiting": True, "todo_id": todo_id, "submission_id": submission_id, + "dependency_todo_id": source["todo_id"], "resume_when": resume} + if waiting.get("status") != "open" or waiting.get("resume_when") or waiting.get("claimed_by") != agent: + raise ValueError("native feedback work changed before wait registration") + revision = current["authority_read"]["provider_revision"] + operation = key + ":wait:" + hashlib.sha256((todo_id + "\0" + revision).encode()).hexdigest()[:24] + _loopx(env, "todo", "update", *binding, "--agent-id", agent, "--todo-id", todo_id, + "--update-operation-id", operation, "--update-expected-provider-revision", revision, + "--status", "deferred", "--resume-when", resume, + "--reason", "This work requires released feedback from its accepted native submission.", "--execute") + return {"waiting": True, "todo_id": todo_id, "submission_id": submission_id, + "dependency_todo_id": source["todo_id"], "resume_when": resume} + + +def synchronize_feedback_waits(env, packet, *, after_release=None): + """A runner observation releases only its exact existing dependencies. + + A native error or negative scored result can still be usable evidence. + Unknown attribution stays unknown; receiving a record does not authorize + adopting a candidate, rolling back files, completing research or resubmitting. + """ + goal, agent, observer = _scope(env) + records = _records(packet) + if not records: + return {"released": []} + listed = _loopx(env, "todo", "list", "--goal-id", goal, "--role", "agent") + completed = [] + for key, record in records.values(): + source = _dependency(listed["todos"], key, observer, agent) + if source is None: + source = _create_feedback_todo(env, goal=goal, agent=agent, observer=observer, key=key, record=record) + if source.get("status") == "done" or not _released(record): + continue # An unchanged replay creates no work; unknown is not failure. + if source.get("status") != "open": + continue # A runner does not override an owner pause or cancellation. + from .research_feedback_sync import write_feedback_finding + + # Publish the existing Explore finding before changing runnable state; + # otherwise the request guard could admit a replan without its evidence. + write_feedback_finding(env, packet, record, source["todo_id"]) + result = _loopx(env, "todo", "complete", "--goal-id", goal, "--agent-id", observer, + "--todo-id", source["todo_id"], "--no-follow-up", "--evidence", record["result_ref"], + "--note", "Released native feedback is available for this exact submitted snapshot.", "--execute") + completed.append({"submission_id": record["submission_id"], "dependency_todo_id": source["todo_id"], + "result_ref": record["result_ref"], "completed": result.get("ok") is not False}) + if after_release is not None: + after_release(completed[-1]) + return {"released": completed} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + wait = commands.add_parser("wait", help="Defer one Todo until its accepted native submission has released feedback.") + wait.add_argument("--todo-id", required=True) + wait.add_argument("--submission-id", required=True) + commands.add_parser("sync", help="Apply released native feedback through the runner-owned Todo observer.") + args = parser.parse_args() + from .research_feedback_sync import feedback_environment, synchronize_native_feedback + + root = Path(os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT", _CONTROL + "/native-feedback")) + env = feedback_environment(root) + packet = json.loads((root / "native.json").read_text(encoding="utf-8")) + result = (wait_for_feedback(env, todo_id=args.todo_id, submission_id=args.submission_id, packet=packet) + if args.command == "wait" else synchronize_native_feedback(env, packet=packet)) + print(json.dumps(result, allow_nan=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmark/runtime/research_loop.py b/benchmark/runtime/research_loop.py index 4a16ae3951..5182b514a7 100644 --- a/benchmark/runtime/research_loop.py +++ b/benchmark/runtime/research_loop.py @@ -70,9 +70,16 @@ def research_execution_guidance() -> str: "remain unknown, and solver reports are not independently verified. This is not another " "evaluation or a requirement to use every available second. Attach the emitted " "execution_observation in the normal Explore result writeback. Preserve its input revision " - "and evidence refs; when released feedback arrives, add result_ref and result_candidate_sha256 " - "for the evaluated snapshot, not newer files. Record changed method ids and the reason when " + "and evidence refs. On the native EdgeBench adapter, probes register automatically and released " + "results are matched to the actual accepted archive; do not fill result attribution from the " + "current workspace. An unknown match cannot justify adoption or rollback. " + "Record changed method ids and the reason when " "actual implementation differs from the proposal. Resolve this evidence at the next replan. " - "When all useful work is waiting, record a real resume condition through the existing Todo " - "owner and return; do not repeatedly call a model to inspect unchanged feedback.\n" + "If the current Todo requires that external result, use `sforge-submit --wait-todo TODO_ID` " + "or `loopx-research-feedback wait --todo-id TODO_ID --submission-id SUBMISSION_ID`; " + "the native accepted receipt creates the specific dependency and the runner observes it. " + "Independent useful work remains runnable. When all useful work is waiting, return to the " + "existing scheduler; do not repeatedly call a model to inspect unchanged feedback. " + "If a submission response is lost, repeat the same submit entrypoint to recover its original " + "operation and native ID; unresolved acceptance stays unknown without another evaluation.\n" ) diff --git a/benchmark/runtime/research_probe.py b/benchmark/runtime/research_probe.py index 58ce7a914f..55e6382c95 100644 --- a/benchmark/runtime/research_probe.py +++ b/benchmark/runtime/research_probe.py @@ -84,17 +84,58 @@ def run_probe(*, command: list[str], candidate_files: list[Path], output: Path, record.write(json.dumps({"ok": False, "reason": "candidate_changed_during_probe", "before_sha256": before, "after_sha256": after})) raise ValueError("candidate changed during the probe; preserve this attempt and rerun a fixed candidate") - if diagnostics is not None and diagnostics.exists(): - if diagnostics.stat().st_size > 4096: - raise ValueError("solver diagnostics exceed the bounded observation size") - reported = json.loads(diagnostics.read_text(encoding="utf-8")) - if not isinstance(reported, dict) or set(reported) - {"search_iterations", "stop_reason"}: - raise ValueError("solver diagnostics accept only search_iterations and stop_reason") - observation["search_iterations"] = reported.get("search_iterations") - if reported.get("stop_reason") is not None and not timed_out: - observation["stop_reason"] += "; solver_report:" + str(reported["stop_reason"]) + # Host measurements stand independently of the optional solver report. + # Validate them first, so a malformed/unavailable report cannot erase a + # completed execution or change its real exit/timeout status. result = normalize_execution_observation(observation) - record.write(json.dumps(result, ensure_ascii=False, allow_nan=False) + "\n") + try: + if diagnostics is not None: + try: + if diagnostics.exists(): + if not diagnostics.is_file(): + raise ValueError("solver diagnostics must be a regular file") + with diagnostics.open("rb") as stream: + raw = stream.read(4097) + if len(raw) > 4096: + raise ValueError("solver diagnostics exceed the bounded observation size") + reported = json.loads(raw) + if not isinstance(reported, dict) or set(reported) - {"search_iterations", "stop_reason"}: + raise ValueError("solver diagnostics accept only search_iterations and stop_reason") + proposed = dict(result, search_iterations=reported.get("search_iterations")) + reason = reported.get("stop_reason") + if reason is not None: + if not isinstance(reason, str): + raise ValueError("solver stop_reason must be text or null") + proposed["stop_reason"] += "; solver_report:" + reason + checked = normalize_execution_observation(proposed) + if timed_out: + checked["stop_reason"] = "host_timeout" + result = checked + except Exception as error: + # Confined to optional diagnostics, never candidate checks + # or process execution. Retain a bounded local explanation + # without copying raw solver text or exception paths. + detail = dict(source="solver_diagnostics", error_kind=type(error).__name__, + candidate_sha256=before, probe_evidence_ref=evidence_ref, + message="Optional solver diagnostics are invalid or unavailable; solver measurements remain unknown.") + encoded = json.dumps(detail, sort_keys=True, separators=(",", ":")).encode() + b"\n" + with output.with_suffix(output.suffix + ".diagnostics-error.json").open("xb") as error_record: + error_record.write(encoded) + result["evidence_refs"].append("diagnostics-error:" + hashlib.sha256(encoded).hexdigest()) + result = normalize_execution_observation(result) + finally: + # Even failure to retain the diagnostic explanation leaves the + # independently valid host observation available to its caller. + record.write(json.dumps(result, ensure_ascii=False, allow_nan=False) + "\n") + record.flush() + os.fsync(record.fileno()) + feedback_root = os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT") + if feedback_root: + from .research_submission import register_probe + + # The observation must be fully persisted before it becomes a source + # for matching a later native accepted archive/released result. + register_probe(Path(feedback_root), result, {"candidate_sha256": before, "files": files}) return result diff --git a/benchmark/runtime/research_scheduler.py b/benchmark/runtime/research_scheduler.py new file mode 100644 index 0000000000..49b43a9e39 --- /dev/null +++ b/benchmark/runtime/research_scheduler.py @@ -0,0 +1,67 @@ +"""Let durable research feedback interrupt only the outer scheduler's sleep. + +The event is a hint to read current quota again. It grants no model admission, +changes no Turn or native operation identity, and cannot reset a phase budget. +The ordinary scheduler still owns its state route, dispatch and stop handling. +""" +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +import re +import signal +import time + +from .layout import _CONTROL + + +class FeedbackEventSleep: + """Bounded local file observation while the ordinary worker is asleep.""" + + def __init__(self, path: Path, *, clock=time.monotonic, pause=time.sleep): + self.path, self.clock, self.pause = path, clock, pause + # Capture before the first quota read, not on entry into sleep: a + # consumer may publish between the decision and its eventual sleep. + self.revision = self._revision() + + def _revision(self): + try: + with self.path.open("r", encoding="utf-8") as source: + value = json.loads(source.read(4097)) + revision = value.get("revision") if isinstance(value, dict) else None + return revision if isinstance(revision, str) and re.fullmatch(r"[0-9a-f]{64}", revision) else None + except (OSError, ValueError): + return None # Unavailable/partial signals never substitute for state. + + def __call__(self, seconds): + if not math.isfinite(seconds) or seconds < 0: + raise ValueError("scheduler sleep requires a finite nonnegative duration") + deadline = self.clock() + seconds + while True: + current = self._revision() + if current is not None and current != self.revision: + self.revision = current + return + remaining = deadline - self.clock() + if remaining <= 0: + return + self.pause(min(1.0, remaining)) + + +def main(argv=None): + if os.environ.get("LOOPX_RESEARCH_LOOP") != "1": + raise ValueError("research feedback scheduler requires the explicit research-loop treatment") + from scripts.external_scheduler_worker import main as run_scheduler + + root = Path(os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT", _CONTROL + "/native-feedback")) + return run_scheduler(argv, sleep=FeedbackEventSleep(root / "ingestion" / "wake.json")) + + +if __name__ == "__main__": + def cancelled(signum, frame): + raise KeyboardInterrupt("scheduler cancelled") + + signal.signal(signal.SIGTERM, cancelled) + raise SystemExit(main()) diff --git a/benchmark/runtime/research_submission.py b/benchmark/runtime/research_submission.py new file mode 100644 index 0000000000..2a1e7a9963 --- /dev/null +++ b/benchmark/runtime/research_submission.py @@ -0,0 +1,381 @@ +"""Exact native archive journaling and feedback binding for research-loop workers. + +The generated SForge script still packages, submits once and prints its native +result. These hooks retain its actual archive before that send. Recovery reads +native history; it never posts an archive or invents an operation identity. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import sys +import uuid +import urllib.parse +import urllib.request + +from benchmark.edgebench.research_feedback import ( + archive_manifest, canonical_json, read_archive, validate_packet, write_bytes_atomic, +) + +_OPERATION = re.compile(r"[0-9a-f]{32}\Z") + + +def _atomic(path, value): + write_bytes_atomic(path, canonical_json(value)) + +@contextmanager +def _locked(root): + root.mkdir(parents=True, exist_ok=True) + with (root / "submission.lock").open("a") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + yield + + +def read_packet(root: Path): + return validate_packet(json.loads((root / "native.json").read_bytes())) + + +def register_probe(root: Path, observation: dict, manifest: dict): + """Register a completed local probe; no result attribution is added here.""" + files = manifest.get("files") + if not isinstance(files, list) or not 1 <= len(files) <= 32: + raise ValueError("invalid_probe_manifest") + encoded = json.dumps(sorted(files, key=lambda row: row["path"]), sort_keys=True, + separators=(",", ":")).encode() + digest = hashlib.sha256(encoded).hexdigest() + if observation.get("candidate_sha256") != digest or manifest.get("candidate_sha256") != digest: + raise ValueError("probe_manifest_digest_mismatch") + if observation.get("result_ref") is not None: + raise ValueError("probe_registration_must_precede_result_binding") + value = dict(observation=observation, manifest=manifest) + identifier = hashlib.sha256(canonical_json(value)).hexdigest() + with _locked(root): + _atomic(root / "probes" / (identifier + ".json"), value) + return "native-probe:" + identifier + + +def bind_execution_observations(root: Path, packet: dict): + """Automatically bind matching probe manifests to the *accepted* snapshot. + + Multiple probes of the same source remain independent observations. A probe + with different selected source, no exact native archive, or an unresolved + result stays unbound; a changed workspace is never read by this function. + """ + validate_packet(packet) + probes = [(path.stem, json.loads(path.read_bytes())) + for path in sorted((root / "probes").glob("*.json"))] + bound = [] + for row in packet["records"]: + if (row.get("attribution_status") != "verified" or not row.get("result_ref") + or row.get("status") not in {"completed", "error"}): + continue + try: + source = Path(row["archive_path"]) + raw = read_archive(source, root=root) + if hashlib.sha256(raw).hexdigest() != row["archive_sha256"]: + continue + for identifier, probe in probes: + observation = probe["observation"] + manifest = probe["manifest"] + try: + digest, files = archive_manifest(raw, [entry["path"] for entry in manifest["files"]]) + except ValueError: + continue + if (digest != observation["candidate_sha256"] or digest != manifest["candidate_sha256"] + or files != manifest["files"]): + continue + observation = dict(observation, result_ref=row["result_ref"], result_candidate_sha256=digest) + observation["evidence_refs"] = sorted(set([ + *observation["evidence_refs"], row["result_ref"], + "native-archive:" + row["archive_sha256"], + ])) + bound.append({**row, "probe_ref": "native-probe:" + identifier, + "execution_observation": observation}) + except (KeyError, OSError, ValueError): + continue + return bound + + +def _intents(root): + return [json.loads(path.read_bytes()) for path in sorted((root / "intents").glob("*.json"))] + + +def _path(root, operation_id): + if not _OPERATION.fullmatch(operation_id): + raise ValueError("invalid_submission_operation") + return root / "intents" / (operation_id + ".json") + + +def _reconcile(root, packet): + validate_packet(packet) + records = packet["records"] + intents = _intents(root) + claimed = {row["submission_id"] for row in intents if row.get("submission_id")} + for intent in intents: + if any(intent.get(key) != packet[key] for key in ("run_id", "task_id")): + raise ValueError("submission_operation_scope_mismatch") + if intent.get("acceptance") == "rejected" or not intent.get("dispatch_started"): + continue + sid = intent.get("submission_id") + candidates = [row for row in records if row.get("accepted_record") is True + and row.get("submission_id") not in intent["before_submission_ids"] + and row.get("archive_sha256") == intent["archive_sha256"] + and (row.get("submission_id") == sid if sid else row.get("submission_id") not in claimed)] + if len(candidates) == 1: + matched = candidates[0] + if sid and intent.get("round_id") != matched["round_id"]: + intent["recovery_reason"] = "native_round_identity_changed" + else: + intent.update(acceptance="accepted", submission_id=matched["submission_id"], + round_id=matched["round_id"], recovered=not bool(sid) or intent.get("recovered", False)) + if matched.get("attribution_status") == "verified" and matched.get("result_ref"): + intent["result_ref"] = matched["result_ref"] + claimed.add(matched["submission_id"]) + intent.pop("recovery_reason", None) + else: + intent["recovery_reason"] = "ambiguous_native_archive_match" if candidates else "native_archive_match_unavailable" + _atomic(_path(root, intent["operation_id"]), intent) + return intents + + +def synchronize_intents(root: Path, packet: dict): + with _locked(root): + return _reconcile(root, packet) + + +def _fresh_history(packet, *, history_reader=None): + if history_reader is not None: + history = history_reader() + else: + endpoint = os.environ.get("SFORGE_JUDGE_URL", "").rstrip("/") + token = os.environ.get("SFORGE_TOKEN") + if not endpoint or not token: + raise ValueError("native_registration_environment_missing") + query = urllib.parse.urlencode({"token": token}) + # Read only the native public history. urllib retains the environment's + # declared proxy/no_proxy route and never retries a submission. + with urllib.request.urlopen(endpoint + "/api/v1/history?" + query, timeout=15) as response: + history = json.load(response) + if history.get("run_id") != packet["run_id"] or not isinstance(history.get("entries"), list): + raise ValueError("native_history_scope_mismatch") + entries = [row for row in history["entries"] if row.get("type") == "submission"] + if any(row.get("task_id") != packet["task_id"] for row in entries): + raise ValueError("native_history_task_mismatch") + ids = [row.get("submission_id") for row in entries] + if any(not isinstance(sid, str) or not sid for sid in ids) or len(ids) != len(set(ids)): + raise ValueError("native_history_identity_ambiguous") + return ids + + +def prepare_submission(root: Path, archive: Path, *, history_reader=None, wait_todo_id=None): + """Retain native packaged bytes before an effect; recover an unresolved call.""" + with _locked(root): + packet = read_packet(root) + intents = _reconcile(root, packet) + unresolved = [row for row in intents if row.get("acceptance") != "rejected" + and not row.get("result_observed")] + if len(unresolved) > 1: + raise ValueError("multiple_unresolved_native_operations") + if unresolved: + intent = unresolved[0] + if intent.get("submission_id") and intent.get("acceptance") == "accepted": + return dict(action="recover", operation_id=intent["operation_id"], + submission_id=intent["submission_id"]) + if intent.get("dispatch_started"): + raise ValueError("native_acceptance_unknown_no_resubmit") + return dict(action="submit", operation_id=intent["operation_id"], archive_path=intent["archive_path"]) + before = _fresh_history(packet, history_reader=history_reader) + raw = read_archive(archive.absolute(), root=archive.parent.resolve()) + operation_id = uuid.uuid4().hex + snapshot = root / "snapshots" / (operation_id + ".tar.gz") + write_bytes_atomic(snapshot, raw, create_only=True) + snapshot.chmod(0o400) + intent = dict(operation_id=operation_id, run_id=packet["run_id"], task_id=packet["task_id"], + archive_path=str(snapshot), archive_sha256=hashlib.sha256(raw).hexdigest(), + archive_bytes=len(raw), before_submission_ids=before, dispatch_started=False, + acceptance="unknown", result_observed=False) + if wait_todo_id: + intent["wait_todo_id"] = wait_todo_id + _atomic(_path(root, operation_id), intent) + return dict(action="submit", operation_id=operation_id, archive_path=str(snapshot)) + + +def mark_dispatched(root: Path, operation_id: str): + with _locked(root): + path = _path(root, operation_id) + intent = json.loads(path.read_bytes()) + if intent["dispatch_started"]: + raise ValueError("native_operation_already_dispatched") + raw = read_archive(Path(intent["archive_path"]), root=root) + if hashlib.sha256(raw).hexdigest() != intent["archive_sha256"]: + raise ValueError("prepared_native_archive_changed") + intent["dispatch_started"] = True + _atomic(path, intent) + return intent + + +def record_response(root: Path, operation_id: str, http_status: int, response: dict): + with _locked(root): + path = _path(root, operation_id) + intent = json.loads(path.read_bytes()) + if not intent.get("dispatch_started"): + raise ValueError("native_operation_not_dispatched") + sid, round_id = response.get("submission_id"), response.get("round_id") + if (http_status == 200 and isinstance(sid, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid) + and isinstance(round_id, str) and re.fullmatch(r"agent-[1-9][0-9]*", round_id) + and sid not in intent["before_submission_ids"]): + if intent.get("submission_id") not in (None, sid) or intent.get("round_id") not in (None, round_id): + raise ValueError("native_acceptance_receipt_changed") + intent.update(acceptance="accepted", submission_id=sid, round_id=round_id) + elif http_status in {400, 401, 403, 404, 413, 429} and isinstance(response.get("detail"), str): + # Native rejection responses precede admission. A 5xx, parse error + # or lost response cannot prove rejection and remains unresolved. + intent["acceptance"] = "rejected" + _atomic(path, intent) + return intent + + +def declared_wait_packet(root: Path, intent: dict): + """An actual accepted response can enter wait before the next host poll. + + This says only that the named operation was accepted. It makes no archive + or released-result attribution claim; that requires the native observer. + """ + if intent.get("acceptance") != "accepted" or not intent.get("submission_id"): + raise ValueError("native_acceptance_unconfirmed") + packet = read_packet(root) + if any(intent.get(key) != packet[key] for key in ("run_id", "task_id")): + raise ValueError("native_acceptance_scope_mismatch") + records = [row for row in packet["records"] if row.get("submission_id") == intent["submission_id"]] + if len(records) > 1 or (records and records[0].get("round_id") != intent["round_id"]): + raise ValueError("native_acceptance_identity_ambiguous") + if not records: + packet["records"].append(dict(accepted_record=True, submission_id=intent["submission_id"], + round_id=intent["round_id"], status="queued", attribution_status="unknown")) + return packet + + +def _synchronize_declared_wait(root, intent): + if intent.get("wait_todo_id") and intent.get("acceptance") == "accepted": + from .research_feedback_wait import wait_for_feedback + from .research_feedback_sync import feedback_environment + wait_for_feedback(feedback_environment(root), todo_id=intent["wait_todo_id"], + submission_id=intent["submission_id"], packet=declared_wait_packet(root, intent)) + + +def record_result(root: Path, submission_id: str, response: dict): + if response.get("submission_id") != submission_id: + raise ValueError("native_result_id_mismatch") + if response.get("status") not in {"completed", "error"}: + return + with _locked(root): + for intent in _intents(root): + if intent.get("submission_id") == submission_id and intent.get("acceptance") == "accepted": + report = response.get("report") + if response["status"] == "completed" and (not isinstance(report, dict) + or report.get("submission_id") != submission_id + or report.get("task_id") != intent["task_id"]): + raise ValueError("native_completed_result_scope_mismatch") + if response["status"] == "error" and report is not None: + raise ValueError("native_error_result_ambiguous") + intent["result_observed"] = True + _atomic(_path(root, intent["operation_id"]), intent) + + +def instrument_submit_script(script: str, helper_command: list[str], root: str) -> str: + """Fail closed on an unqualified native client, without changing its HTTP API.""" + command = shlex.join([*helper_command, "--root", root]) + replacements = { + ' echo " --list, -l List all previous submissions and scores for this run"': + ' echo " --wait-todo ID Declare that this owned work depends on the accepted feedback"\n' + ' echo " --list, -l List all previous submissions and scores for this run"', + ' --details|-d) DETAILS_MODE=1 ;;': + ' --wait-todo)\n [ "$#" -gt 0 ] && [ -n "$1" ] || exit 1\n' + ' RESEARCH_WAIT_TODO="$1"; shift ;;\n --details|-d) DETAILS_MODE=1 ;;', + 'ARCHIVE_SIZE=$(wc -c < "$ARCHIVE_FILE")': + f'RESEARCH_PREPARED=$({command} prepare --archive "$ARCHIVE_FILE" --wait-todo "${{RESEARCH_WAIT_TODO:-}}")\n' + 'if [ "$(echo "$RESEARCH_PREPARED" | jq -r .action)" = "recover" ]; then\n' + ' RECOVERED_ID=$(echo "$RESEARCH_PREPARED" | jq -r .submission_id)\n' + ' if [ "$DETAILS_MODE" = "1" ]; then exec "$0" --result "$RECOVERED_ID" --details; fi\n' + ' exec "$0" --result "$RECOVERED_ID"\nfi\n' + 'RESEARCH_OPERATION=$(echo "$RESEARCH_PREPARED" | jq -r .operation_id)\n' + 'ARCHIVE_FILE=$(echo "$RESEARCH_PREPARED" | jq -r .archive_path)\n' + 'ARCHIVE_SIZE=$(wc -c < "$ARCHIVE_FILE")', + 'if ! HTTP_CODE=$(curl -sS -o "$REQUEST_TMP/submit.json"': + f'{command} dispatched --operation-id "$RESEARCH_OPERATION" >/dev/null\n' + 'if ! HTTP_CODE=$(curl -sS -o "$REQUEST_TMP/submit.json"', + 'SUBMIT_RESP=$(cat "$REQUEST_TMP/submit.json")': + 'SUBMIT_RESP=$(cat "$REQUEST_TMP/submit.json")\n' + f'{command} response --operation-id "$RESEARCH_OPERATION" --http-status "$HTTP_CODE" ' + '--response-file "$REQUEST_TMP/submit.json" >/dev/null', + ' RESULT=$(get_json "$JUDGE_URL/api/v1/result/$SUBMISSION_ID")': + ' RESULT=$(get_json "$JUDGE_URL/api/v1/result/$SUBMISSION_ID")\n' + f' {command} result --submission-id "$SUBMISSION_ID" --response-file "$REQUEST_TMP/response.json"', + } + for anchor, replacement in replacements.items(): + if script.count(anchor) != 1: + raise ValueError("unsupported_native_submit_script_boundary") + script = script.replace(anchor, replacement, 1) + return script + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT")) + sub = parser.add_subparsers(dest="action", required=True) + prepare = sub.add_parser("prepare") + prepare.add_argument("--archive", type=Path, required=True) + prepare.add_argument("--wait-todo") + dispatched = sub.add_parser("dispatched") + dispatched.add_argument("--operation-id", required=True) + response = sub.add_parser("response") + response.add_argument("--operation-id", required=True) + response.add_argument("--http-status", type=int, required=True) + response.add_argument("--response-file", type=Path, required=True) + result = sub.add_parser("result") + result.add_argument("--submission-id", required=True) + result.add_argument("--response-file", type=Path, required=True) + sub.add_parser("sync") + args = parser.parse_args(argv) + if args.root is None: + parser.error("native research feedback root is required") + try: + if args.action == "prepare": + value = prepare_submission(args.root, args.archive, wait_todo_id=args.wait_todo) + if value["action"] == "recover": + _synchronize_declared_wait(args.root, json.loads(_path(args.root, value["operation_id"]).read_bytes())) + elif args.action == "dispatched": + value = mark_dispatched(args.root, args.operation_id) + elif args.action == "response": + try: + payload = json.loads(args.response_file.read_bytes()) + except (ValueError, OSError): + payload = {} + value = record_response(args.root, args.operation_id, args.http_status, payload) + _synchronize_declared_wait(args.root, value) + elif args.action == "result": + record_result(args.root, args.submission_id, json.loads(args.response_file.read_bytes())) + return 0 + else: + packet = read_packet(args.root) + value = dict(packet=packet, intents=synchronize_intents(args.root, packet), + observations=bind_execution_observations(args.root, packet)) + print(json.dumps(value, allow_nan=False)) + return 0 + except Exception as error: + # Transport exceptions may contain credential-bearing URLs. Expose only + # controlled validation codes or their type, never the raw exception. + reason = str(error) if type(error) is ValueError else type(error).__name__ + print("Research submission halted: " + reason + "; no automatic resubmission.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmark/runtime/scheduler.py b/benchmark/runtime/scheduler.py index 40eea1f383..99031adda8 100644 --- a/benchmark/runtime/scheduler.py +++ b/benchmark/runtime/scheduler.py @@ -12,8 +12,11 @@ def worker_command(env: dict[str, str], *, python: str, source: str, wake = [python, "-m", "benchmark.runtime.worker"] if env["LOOPX_EXECUTION_MODE"] not in {"heartbeat", "turn"}: return wake + scheduler = ([python, "-m", "benchmark.runtime.research_scheduler"] + if env.get("LOOPX_RESEARCH_LOOP") == "1" and env.get("LOOPX_RESEARCH_FEEDBACK_ROOT") + else [python, f"{source}/scripts/external_scheduler_worker.py"]) return [ - python, f"{source}/scripts/external_scheduler_worker.py", + *scheduler, "--cli-bin", env["LOOPX_CLI"], "--registry", env["LOOPX_REGISTRY"], "--runtime-root", env["LOOPX_RUNTIME_ROOT"], "--runtime-profile", "generic_cli", "--goal-id", env["LOOPX_GOAL_ID"], diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index e5635b2b59..e1797e3d54 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -291,6 +291,7 @@ def _install_worker(self, backend, handle, log_dir, logger): checkpoint_request=self.checkpoint_request, task_objective=self.task_objective, research_loop=self.research_loop, + **({"research_feedback_root": f"{_CONTROL}/native-feedback"} if self.research_loop else {}), **({"goal_id": self.warm_start.goal_id, "agent_id": self.warm_start.agent_id, "defer_codex_home": True} if self.warm_start is not None else {}), ) @@ -343,6 +344,8 @@ def _install_worker(self, backend, handle, log_dir, logger): f"Path({str(hook)!r}).read_bytes(); Path('/etc/codex/hooks.json').read_bytes()"], user="agent") if result.exit_code: raise RuntimeError("Best-only Codex delivery hooks are unreadable by worker") + if self.research_loop: + self._install_native_research_feedback(backend, handle) (log_dir / "worker-profile.json").write_text(json.dumps({ "profile": self.profile, "model": self._config.agent_model, **({"research_loop": True} if self.research_loop else {}), @@ -366,6 +369,60 @@ def _install_worker(self, backend, handle, log_dir, logger): self.profile.startswith("heartbeat-") else {}), }, indent=2)) + def _install_native_research_feedback(self, backend, handle): + from .research_submission import instrument_submit_script + from .research_feedback_sync import FEEDBACK_ROOT + + if getattr(backend, "research_feedback", None) is None: + raise ValueError("research_loop requires the native accepted-snapshot observer on RecordingDockerBackend") + capability = backend.exec_run(handle, [f"{_PYTHON}/bin/python3", "-c", + "from loopx.capabilities.explore.result_writeback import record_observer_result_attachment; " + "from benchmark.edgebench.research_feedback import archive_manifest"], + environment={"PYTHONPATH": _SRC, "LOOPX_USAGE_PING": "0"}, user="agent") + if capability.exit_code: + raise RuntimeError("Native research feedback requires a matching Explore observer capability in the pinned product source") + native = backend.exec_run(handle, ["cat", "/usr/local/bin/sforge-submit"], user="root") + if native.exit_code: + raise RuntimeError("The native submission entrypoint is unavailable") + script = instrument_submit_script(native.output, ["env", f"PYTHONPATH={_SRC}", + f"{_PYTHON}/bin/python3", "-m", "benchmark.runtime.research_submission"], FEEDBACK_ROOT) + directories = [f"{FEEDBACK_ROOT}/{name}" for name in ("intents", "snapshots", "probes", "ingestion")] + for command in (["mkdir", "-p", *directories], + ["chown", "agent:agent", *directories], + ["chmod", "0700", *directories], + ["touch", f"{FEEDBACK_ROOT}/submission.lock"], + ["chown", "agent:agent", f"{FEEDBACK_ROOT}/submission.lock"]): + if backend.exec_run(handle, command, user="root").exit_code: + raise RuntimeError("Could not prepare the native research evidence owner") + with tempfile.TemporaryDirectory(prefix="native-research-client-") as directory: + path = Path(directory) / "sforge-submit" + path.write_text(script, encoding="utf-8") + path.chmod(0o755) + backend.copy_to_container(handle, path, PurePosixPath("/usr/local/bin/sforge-submit")) + if backend.exec_run(handle, ["chmod", "0755", "/usr/local/bin/sforge-submit"], user="root").exit_code: + raise RuntimeError("The research submission entrypoint is not executable") + + def _prepare_native_research_consumer(self, env): + from .research_feedback_sync import FEEDBACK_ROOT, feedback_owner_environment + + owner = feedback_owner_environment(env) + with tempfile.TemporaryDirectory(prefix="native-research-owner-") as directory: + path = Path(directory) / "owner.json" + path.write_text(json.dumps(owner, sort_keys=True), encoding="utf-8") + path.chmod(0o644) + self.backend.copy_to_container(self.handle, path, PurePosixPath(f"{FEEDBACK_ROOT}/owner.json")) + + def consume(backend, handle, _packet): + # The runner keeps observing while the scheduler is sleeping. A + # failed writeback is retried before any dependency is released. + result = backend.exec_run_with_timeout(handle, + ["timeout", "--signal=TERM", "--kill-after=5", "60s", + f"{_PYTHON}/bin/python3", "-m", "benchmark.runtime.research_feedback_sync", + "--root", FEEDBACK_ROOT], timeout=70, environment=owner, user="agent") + if result.exit_code or result.timed_out: + raise RuntimeError("Native feedback consumption is incomplete") + self.backend.research_feedback.after_publish = consume + def format_run_cmd(self, prompt_path, *, model=None, cwd="", internet=True, resume=False): if self.workspace_instructions is not None and not self.instructions_installed and self.warm_start is None: self._install_workspace_instructions() @@ -421,6 +478,8 @@ def format_run_cmd(self, prompt_path, *, model=None, cwd="", internet=True, resu raise RuntimeError("Could not protect fresh receiver credentials") self.prepared = True env = self.runtime._worker_env(cwd=self.cwd) + if self.research_loop: + self._prepare_native_research_consumer(env) if self.warm_start is not None: env["LOOPX_WARM_START"] = f"{_CONTROL}/warm-start.json" command = worker_command(env, python=f"{_PYTHON}/bin/python3", source=_SRC, @@ -504,4 +563,6 @@ def _execution_command(self, command, *, execution_start_marker=None): self.backend.execution_start_marker = execution_start_marker if self.feedback == "best-only": self.backend.start_feedback(self.handle) + if self.research_loop: + self.backend.research_feedback.start(self.backend, self.handle) return command diff --git a/benchmark/runtime/sforge_backend.py b/benchmark/runtime/sforge_backend.py index aa301db308..191d57171a 100644 --- a/benchmark/runtime/sforge_backend.py +++ b/benchmark/runtime/sforge_backend.py @@ -15,11 +15,13 @@ class RecordingDockerBackend(DockerBackend): def __init__(self, *, log_dir: Path, logger, oauth_proxy: bool = False, - blind_api_endpoint: tuple[str, int] | None = None, feedback=None, checkpoint=None, client=None): + blind_api_endpoint: tuple[str, int] | None = None, feedback=None, checkpoint=None, + research_feedback=None, client=None): super().__init__(client) self.log_dir, self.logger = log_dir, logger self.blind_api_endpoint = blind_api_endpoint self.feedback = feedback + self.research_feedback = research_feedback self.checkpoint = checkpoint self.execution_command = None self.execution_start_marker = None @@ -35,6 +37,8 @@ def create_container(self, image, name, **kwargs): kwargs["extra_hosts"] = hosts if self.feedback is not None: self.feedback.bind(kwargs.get("environment")) + if self.research_feedback is not None: + self.research_feedback.bind(kwargs.get("environment")) if self.checkpoint is not None: self.checkpoint.bind(kwargs.get("environment")) kwargs["environment"] = self._agent_environment(kwargs.get("environment")) @@ -73,6 +77,8 @@ def exec_run_with_timeout(self, handle, cmd, timeout=60, **kwargs): self.checkpoint.close() if is_solver and self.feedback is not None: self.feedback.pause() + if is_solver and self.research_feedback is not None: + self.research_feedback.pause() def create_network_isolation(self, handle, allowed_endpoints, logger): # ChatGPT login may refresh during an 18h run. Preserve the native @@ -103,6 +109,8 @@ def cleanup_container(self, handle, logger=None): self.checkpoint.close() if self.feedback is not None: self.feedback.close() + if self.research_feedback is not None: + self.research_feedback.close() if handle is not None: self.log_dir.mkdir(parents=True, exist_ok=True) observations = [] diff --git a/benchmark/tests/test_edgebench_report.py b/benchmark/tests/test_edgebench_report.py index 31e39e5a7f..19dbeae9ac 100644 --- a/benchmark/tests/test_edgebench_report.py +++ b/benchmark/tests/test_edgebench_report.py @@ -654,3 +654,60 @@ def test_report_rejects_unbound_horizon_setting(trial, receipt, profile): write(path, value) with pytest.raises(ValueError, match="planning_horizon_goal"): export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") + + +@pytest.mark.parametrize("enabled", [None, False, True]) +def test_report_preserves_research_treatment_and_historical_absence(trial, enabled): + for name in ("runtime-receipt", "worker-profile"): + path = trial[3] / (name + ".json") + value = json.loads(path.read_text()) + if enabled is not None: + value["research_loop"] = enabled + if enabled: + value["worker" if name == "runtime-receipt" else "profile"] = "heartbeat-explore" + value["feedback"] = "native" + write(path, value) + out = build(trial) + config = json.loads((out / "settings/run.json").read_text()) + for owner in ("runner", "worker_profile"): + assert config[owner].get("research_loop") is enabled + assert ("research_loop" in config[owner]) is (enabled is not None) + assert verify(out)["verified"] is True + + +@pytest.mark.parametrize("receipt,profile", [ + ({}, {"research_loop": True}), + ({"research_loop": True}, {}), + ({}, {"research_loop": False}), + ({"research_loop": True}, {"research_loop": False}), + ({"research_loop": True}, {"research_loop": 1}), + ({"research_loop": "true"}, {"research_loop": "true"}), + ({"research_loop": None}, {"research_loop": None}), +]) +def test_report_rejects_one_sided_or_invalid_raw_research_treatment_before_projection(trial, receipt, profile): + for name, fields in (("runtime-receipt", receipt), ("worker-profile", profile)): + path = trial[3] / (name + ".json") + value = json.loads(path.read_text()) + value.update(fields) + write(path, value) + with pytest.raises(ValueError, match="research_loop"): + build(trial) + assert not trial[2].exists(), "a discarded source flag must not become a verified portable report" + + +@pytest.mark.parametrize("runner,profile", [ + ({}, {"research_loop": True}), + ({"research_loop": True}, {}), + ({"research_loop": True}, {"research_loop": 1}), + ({"research_loop": None}, {"research_loop": None}), +]) +def test_rehashed_portable_research_treatment_still_requires_matching_booleans(trial, runner, profile): + out = build(trial) + path = out / "settings/run.json" + config = json.loads(path.read_text()) + config["runner"].update(runner) + config["worker_profile"].update(profile) + write(path, config) + reseal(out, "settings/run.json") + with pytest.raises(ValueError, match="research_loop"): + verify(out) diff --git a/benchmark/tests/test_native_research_feedback.py b/benchmark/tests/test_native_research_feedback.py new file mode 100644 index 0000000000..484f810edb --- /dev/null +++ b/benchmark/tests/test_native_research_feedback.py @@ -0,0 +1,372 @@ +"""Native public HTTP + exact tar snapshots, with bounded synthetic evaluations.""" +from __future__ import annotations + +from contextlib import contextmanager +from email.parser import BytesParser +from email.policy import default +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import io +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tarfile +import threading +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest + +from benchmark.edgebench.research_feedback import NativeResearchFeedback, archive_manifest, canonical_json +from benchmark.runtime.research_submission import ( + bind_execution_observations, declared_wait_packet, instrument_submit_script, + mark_dispatched, prepare_submission, record_response, record_result, + register_probe, synchronize_intents, +) + + +def archive_bytes(files): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:gz") as archive: + for name, data in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + return stream.getvalue() + + +def probe_record(source=b"original"): + files = [{"path": "candidate.py", "sha256": hashlib.sha256(source).hexdigest()}] + digest = hashlib.sha256(json.dumps(files, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + observation = dict(schema_version="explore_execution_observation_v0", candidate_sha256=digest, + input_revision="synthetic-public-v1", planned_method_id="local-search", executed_method_id="local-search", + change_reason=None, elapsed_seconds=0.4, limit_seconds=30.0, search_iterations=2, + stop_reason="process_exit:0; solver_report:search_cap", exit_code=0, + evidence_refs=["probe:synthetic", "source:" + digest]) + return observation, {"candidate_sha256": digest, "files": files} + + +class LocalCopyBackend: + """Exercise the publisher's actual copy/rename protocol in a disposable tree.""" + def exec_run(self, _handle, command, **_kwargs): + result = subprocess.run(command, capture_output=True) + return SimpleNamespace(exit_code=result.returncode) + + def copy_to_container(self, _handle, source, destination): + shutil.copyfile(source, destination) + + +@contextmanager +def native_http(tmp_path, *, immediately_complete=False, lose_reply=False): + state = dict(entries=[], results={}, posts=0, result_reads=0, immediate=immediately_complete, + lose_reply=lose_reply, run_id="synthetic-run", task_id="synthetic-task") + trial = tmp_path / "trial" + trial.mkdir() + + def release(sid, *, score=0, status="completed"): + entry = next(row for row in state["entries"] if row["submission_id"] == sid) + report = (dict(submission_id=sid, task_id=state["task_id"], score=score, pass_rate=0.0, + valid=True, passed=0, failed=2, total_tests=2, summary="Synthetic negative result") + if status == "completed" else None) + entry.update(status=status, score=score if report else None, + pass_rate=0.0 if report else None, valid=True if report else None) + state["results"][sid] = dict(submission_id=sid, status=status, report=report, + error=None if report else "Synthetic evaluator failure") + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def send(self, payload, status=200): + raw = canonical_json(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + url = urlsplit(self.path) + if url.path == "/api/v1/history": + assert parse_qs(url.query) == {"token": ["synthetic-token"]} + return self.send(dict(run_id=state["run_id"], entries=state["entries"], + best_score=0, best_pass_rate=0.0, best_round=None)) + if url.path.startswith("/api/v1/result/"): + sid = url.path.rsplit("/", 1)[-1] + state["result_reads"] += 1 + return self.send(state["results"][sid]) + self.send({}, 404) + + def do_POST(self): + assert self.path == "/api/v1/submit" + state["posts"] += 1 + raw = self.rfile.read(int(self.headers["Content-Length"])) + message = BytesParser(policy=default).parsebytes( + b"Content-Type: " + self.headers["Content-Type"].encode() + b"\r\n\r\n" + raw) + parts = {part.get_param("name", header="content-disposition"): part.get_payload(decode=True) + for part in message.iter_parts()} + assert parts["token"] == b"synthetic-token" + number = state["posts"] + sid, round_id = f"submission-{number}", f"agent-{number}" + folder = trial / "submissions" / round_id + folder.mkdir(parents=True) + (folder / "submission.tar.gz").write_bytes(parts["archive"]) + state["entries"].append(dict(type="submission", submission_id=sid, round=round_id, + task_id=state["task_id"], status="queued", valid=None, score=None, pass_rate=None)) + state["results"][sid] = dict(submission_id=sid, status="queued", report=None, error=None) + if state["immediate"]: + release(sid) + if state["lose_reply"]: + self.connection.shutdown(socket.SHUT_RDWR) + self.connection.close() + return + self.send(dict(submission_id=sid, round_id=round_id, status="queued", remaining_submissions=8)) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + root = tmp_path / "feedback" + observer = NativeResearchFeedback(trial=trial, run_id=state["run_id"], task_id=state["task_id"], + judge_url=f"http://127.0.0.1:{server.server_port}", remote_root=str(root)) + observer.bind({"SFORGE_TOKEN": "synthetic-token"}) + observer.update(LocalCopyBackend(), None) + try: + yield state, release, observer, root + finally: + observer.close() + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def submit_fixture(observer, raw): + response = observer.session.post(observer.judge_url + "/api/v1/submit", + data={"token": "synthetic-token"}, files={"archive": ("submission.tar.gz", raw)}, timeout=5) + response.raise_for_status() + return response.json() + + +def test_late_negative_result_binds_original_selected_manifest_not_current_files(tmp_path): + with native_http(tmp_path) as (state, release, observer, root): + raw = archive_bytes({"candidate.py": b"original", **{f"data/{i}.txt": b"x" for i in range(35)}}) + accepted = submit_fixture(observer, raw) + old, manifest = probe_record() + register_probe(root, old, manifest) + new, new_manifest = probe_record(b"new workspace") + register_probe(root, new, new_manifest) + pending = observer.update(LocalCopyBackend(), None) + assert state["result_reads"] == 0, "pending public history must not read a result" + assert "result_ref" not in pending["records"][0] + assert "candidate_sha256" not in pending["records"][0], "a large full archive is not a 32-file probe" + (tmp_path / "candidate.py").write_text("new workspace") + release(accepted["submission_id"], score=0) + completed = observer.update(LocalCopyBackend(), None) + [bound] = bind_execution_observations(root, completed) + assert bound["execution_observation"]["result_candidate_sha256"] == old["candidate_sha256"] + assert bound["archive_sha256"] == hashlib.sha256(raw).hexdigest() + assert bound["archive_sha256"] != old["candidate_sha256"] + assert bound["result_summary"]["score"] == 0 + assert completed == observer.poll(), "unchanged results retain their first observation identity" + assert (tmp_path / "candidate.py").read_text() == "new workspace" + + +def test_lost_reply_recovers_same_operation_and_never_resubmits(tmp_path): + with native_http(tmp_path) as (state, release, observer, root): + archive = tmp_path / "packaged.tar.gz" + archive.write_bytes(archive_bytes({"candidate.py": b"original"})) + initial = prepare_submission(root, archive, + history_reader=lambda: {"run_id": state["run_id"], "entries": []}) + operation = initial["operation_id"] + mark_dispatched(root, operation) + accepted = submit_fixture(observer, Path(initial["archive_path"]).read_bytes()) + # Simulate response loss: there is no record_response call. + with pytest.raises(ValueError, match="unknown_no_resubmit"): + prepare_submission(root, archive) + packet = observer.update(LocalCopyBackend(), None) + [intent] = synchronize_intents(root, packet) + assert intent["operation_id"] == operation + assert intent["submission_id"] == accepted["submission_id"] + archive.write_bytes(archive_bytes({"candidate.py": b"newer"})) + recovered = prepare_submission(root, archive) + assert recovered == dict(action="recover", operation_id=operation, submission_id=accepted["submission_id"]) + assert state["posts"] == 1 + with pytest.raises(ValueError, match="already_dispatched"): + mark_dispatched(root, operation) + + +def test_ambiguous_archives_remain_unknown_and_duplicate_native_ids_cannot_release(tmp_path): + with native_http(tmp_path) as (state, release, observer, root): + source = tmp_path / "packaged.tar.gz" + source.write_bytes(archive_bytes({"candidate.py": b"original"})) + prepared = prepare_submission(root, source, + history_reader=lambda: {"run_id": state["run_id"], "entries": []}) + mark_dispatched(root, prepared["operation_id"]) + for _ in range(2): + submit_fixture(observer, source.read_bytes()) + packet = observer.update(LocalCopyBackend(), None) + [intent] = synchronize_intents(root, packet) + assert intent["acceptance"] == "unknown" + assert intent["recovery_reason"] == "ambiguous_native_archive_match" + with pytest.raises(ValueError, match="unknown_no_resubmit"): + prepare_submission(root, source) + state["entries"].append(dict(state["entries"][0])) + duplicate = observer.poll() + rows = [row for row in duplicate["records"] if row["submission_id"] == "submission-1"] + assert all(row["attribution_status"] == "unknown" and "result_ref" not in row for row in rows) + + +def test_pending_acceptance_can_register_wait_before_host_poll_without_result_attribution(tmp_path): + with native_http(tmp_path) as (state, release, observer, root): + archive = tmp_path / "packaged.tar.gz" + archive.write_bytes(archive_bytes({"candidate.py": b"original"})) + prepared = prepare_submission(root, archive, wait_todo_id="requested-work", + history_reader=lambda: {"run_id": state["run_id"], "entries": []}) + mark_dispatched(root, prepared["operation_id"]) + receipt = submit_fixture(observer, archive.read_bytes()) + intent = record_response(root, prepared["operation_id"], 200, receipt) + [row] = declared_wait_packet(root, intent)["records"] + assert row["accepted_record"] and row["status"] == "queued" + assert row["submission_id"] == receipt["submission_id"] + assert row["attribution_status"] == "unknown" and "result_ref" not in row + assert observer.snapshot()["records"] == [], "the receipt fills only this explicitly requested wait" + + +def test_failure_to_publish_control_effect_retries_and_restart_preserves_release_time(tmp_path): + with native_http(tmp_path, immediately_complete=True) as (state, release, observer, root): + submit_fixture(observer, archive_bytes({"candidate.py": b"original"})) + attempts = [] + def after_publish(*_args): + attempts.append(True) + if len(attempts) == 1: + raise RuntimeError("synthetic control writeback interruption") + observer.after_publish = after_publish + with pytest.raises(RuntimeError, match="control writeback"): + observer.update(LocalCopyBackend(), None) + completed = observer.update(LocalCopyBackend(), None) + assert len(attempts) == 2 + duplicate = observer.update(LocalCopyBackend(), None) + assert duplicate == completed and len(attempts) == 2 + restarted = NativeResearchFeedback(trial=observer.trial, run_id=state["run_id"], + task_id=state["task_id"], judge_url=observer.judge_url, remote_root=str(root)) + try: + restarted.bind({"SFORGE_TOKEN": "synthetic-token"}) + assert restarted.poll() == completed + finally: + restarted.close() + + +def test_mismatched_released_details_and_native_symlink_stay_unattributed(tmp_path): + with native_http(tmp_path, immediately_complete=True) as (state, release, observer, root): + receipt = submit_fixture(observer, archive_bytes({"candidate.py": b"original"})) + state["results"][receipt["submission_id"]]["report"]["task_id"] = "unrelated-task" + [row] = observer.poll()["records"] + assert row["attribution_status"] == "unknown" and "result_ref" not in row + archive = observer.trial / "submissions" / "agent-1" / "submission.tar.gz" + outside = tmp_path / "untrusted.tar.gz" + archive.rename(outside) + archive.symlink_to(outside) + [row] = observer.poll()["records"] + assert row["reason"] == "native_archive_not_physical" and "archive_sha256" not in row + + +def test_archive_manifest_rejects_ambiguous_paths_links_and_matches_probe_hash(): + observation, _ = probe_record() + digest, _ = archive_manifest(archive_bytes({"./candidate.py": b"original"}), ["candidate.py"]) + assert digest == observation["candidate_sha256"] + for files in ({"candidate.py": b"x", "./candidate.py": b"y"}, {"../candidate.py": b"x"}): + with pytest.raises(ValueError, match="ambiguous_archive_member"): + archive_manifest(archive_bytes(files)) + + +@pytest.mark.parametrize("lose_reply", [False, True]) +def test_actual_native_submit_script_archives_once_and_recovers_lost_reply(tmp_path, lose_reply): + native = pytest.importorskip("sforge.harness.evolve_scripts") + with native_http(tmp_path, immediately_complete=True, lose_reply=lose_reply) as (state, release, observer, root): + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "candidate.py").write_bytes(b"original") + generated = native.generate_submit_script() + # Give this real client a private display cache rather than the fixed + # global /tmp display cache; admission/packaging/HTTP paths stay native. + generated = generated.replace('STATE_FILE="/tmp/sforge_state.json"', + 'STATE_FILE="' + str(tmp_path / "display.json") + '"') + script = tmp_path / "sforge-submit" + script.write_text(instrument_submit_script(generated, + [sys.executable, "-m", "benchmark.runtime.research_submission"], str(root))) + script.chmod(0o755) + env = dict(os.environ, SFORGE_JUDGE_URL=observer.judge_url, SFORGE_TOKEN="synthetic-token", + SFORGE_PATCH_DIR=str(workspace), SFORGE_SUBMIT_PATHS="candidate.py", + PYTHONPATH=str(Path(__file__).resolve().parents[2]), NO_PROXY="127.0.0.1") + first = subprocess.run([str(script)], cwd=workspace, env=env, capture_output=True, text=True, timeout=30) + assert (first.returncode != 0) == lose_reply, first.stderr + assert state["posts"] == 1 + [intent_file] = list((root / "intents").glob("*.json")) + intent = json.loads(intent_file.read_bytes()) + archive = observer.trial / "submissions" / "agent-1" / "submission.tar.gz" + assert archive.read_bytes() == Path(intent["archive_path"]).read_bytes() + if lose_reply: + observer.update(LocalCopyBackend(), None) + (workspace / "candidate.py").write_bytes(b"newer workspace") + recovered = subprocess.run([str(script)], cwd=workspace, env=env, + capture_output=True, text=True, timeout=30) + assert recovered.returncode == 0, recovered.stderr + assert "Reading existing submission: submission-1" in recovered.stdout + assert state["posts"] == 1, "recovery never creates a second native opportunity" + assert json.loads(intent_file.read_bytes())["operation_id"] == intent["operation_id"] + + +def test_native_client_hook_requires_the_observed_packaging_boundaries(): + with pytest.raises(ValueError, match="unsupported_native_submit_script"): + instrument_submit_script("#!/bin/sh\nexit 0\n", [sys.executable, "-m", "fixture"], "/synthetic") + + +def test_ambiguous_record_does_not_prevent_an_independent_verified_result(tmp_path): + from benchmark.runtime.research_feedback_wait import _records + with native_http(tmp_path, immediately_complete=True) as (state, release, observer, root): + submit_fixture(observer, archive_bytes({"candidate.py": b"first"})) + independent = submit_fixture(observer, archive_bytes({"candidate.py": b"second"})) + state["entries"].append(dict(state["entries"][0])) + packet = observer.poll() + accepted = _records(packet) + assert set(accepted) == {independent["submission_id"]} + assert accepted[independent["submission_id"]][1]["result_ref"] + + +def test_malformed_terminal_feedback_cannot_make_a_retry_submit_again(tmp_path): + with native_http(tmp_path) as (state, release, observer, root): + source = tmp_path / "packaged.tar.gz" + source.write_bytes(archive_bytes({"candidate.py": b"original"})) + prepared = prepare_submission(root, source, + history_reader=lambda: {"run_id": state["run_id"], "entries": []}) + mark_dispatched(root, prepared["operation_id"]) + receipt = submit_fixture(observer, source.read_bytes()) + record_response(root, prepared["operation_id"], 200, receipt) + with pytest.raises(ValueError, match="completed_result_scope"): + record_result(root, receipt["submission_id"], + dict(submission_id=receipt["submission_id"], status="completed", report=None)) + assert prepare_submission(root, source)["action"] == "recover" + assert state["posts"] == 1 + + +def test_accepted_archive_identity_cannot_be_replaced_after_first_observation(tmp_path): + with native_http(tmp_path, immediately_complete=True) as (state, release, observer, root): + submit_fixture(observer, archive_bytes({"candidate.py": b"original"})) + original = observer.update(LocalCopyBackend(), None) + native_archive = observer.trial / "submissions" / "agent-1" / "submission.tar.gz" + native_archive.write_bytes(archive_bytes({"candidate.py": b"changed after acceptance"})) + [changed] = observer.update(LocalCopyBackend(), None)["records"] + assert changed["attribution_status"] == "unknown" + assert changed["reason"] == "native_accepted_archive_changed" + assert "result_ref" not in changed + restarted = NativeResearchFeedback(trial=observer.trial, run_id=state["run_id"], + task_id=state["task_id"], judge_url=observer.judge_url, remote_root=str(root)) + try: + restarted.bind({"SFORGE_TOKEN": "synthetic-token"}) + assert restarted.poll()["records"][0]["reason"] == "native_accepted_archive_changed" + finally: + restarted.close() + assert Path(original["records"][0]["archive_path"]).read_bytes() != native_archive.read_bytes() diff --git a/benchmark/tests/test_research_cost.py b/benchmark/tests/test_research_cost.py new file mode 100644 index 0000000000..54d8575312 --- /dev/null +++ b/benchmark/tests/test_research_cost.py @@ -0,0 +1,188 @@ +"""Passive cost readback uses captured transport facts and the real TS owner.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from benchmark.edgebench.research_cost import summarize_research_cost +from benchmark.edgebench.research_feedback import SCHEMA + + +def _write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def _lines(path, events): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(event) + "\n" for event in events), encoding="utf-8") + + +def _collected(trial, names=("runtime", "native-sessions", "worker-sessions")): + _write(trial / "collected/artifact-collection.json", + [{"artifact": name, "collected": True} for name in names]) + + +def _session(session_id): + def usage(i, c, o): + return {"input_tokens": i, "cached_input_tokens": c, "output_tokens": o} + + rows = [ + {"type": "session_meta", "payload": {"id": session_id}}, + {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "a"}}, + {"type": "event_msg", "payload": {"type": "token_count", "info": { + "total_token_usage": usage(100, 60, 10), "last_token_usage": usage(100, 60, 10)}}}, + {"type": "event_msg", "payload": {"type": "task_complete", "turn_id": "a"}}, + {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "b"}}, + {"type": "event_msg", "payload": {"type": "token_count", "info": { + "total_token_usage": usage(140, 80, 15), "last_token_usage": usage(40, 20, 5)}}}, + {"type": "event_msg", "payload": {"type": "task_complete", "turn_id": "b"}}, + ] + for index, row in enumerate(rows): + row["timestamp"] = f"2026-01-01T00:00:0{index}Z" + return rows + + +def test_missing_captures_are_unknown_and_wall_is_not_a_sum_of_stages(tmp_path): + report = summarize_research_cost(tmp_path, 123.5) + assert report["runner_wall_seconds"] == 123.5 + assert report["stage_seconds"] is None and report["billing_usd"] is None + assert report["provider_requests"]["capture_coverage"] == "unknown" + assert report["provider_requests"]["captured_distinct_requests"] is None + assert report["provider_requests"]["forwarded"] is None + assert report["tokens"]["known_captured_totals"] is None + assert report["tokens"]["capture_coverage"] == "unknown" + assert report["evaluations"]["manual_accepted_count"] is None + assert report["evaluations"]["native_total_rounds"] is None + assert summarize_research_cost(tmp_path, float("nan"))["runner_wall_seconds"] is None + + +def test_gateway_replicas_dedup_and_interrupted_dispatch_remains_unknown(tmp_path): + _collected(tmp_path) + events = [ + {"request_id": "private-forwarded", "event": "dispatch", "forwarded": None}, + {"request_id": "private-forwarded", "event": "finished", "forwarded": True}, + {"request_id": "private-refused", "event": "finished", "forwarded": False}, + {"request_id": "private-lost", "event": "dispatch", "forwarded": None}, + {"request_id": "private-maybe", "event": "dispatch", "forwarded": None}, + {"request_id": "private-maybe", "event": "finished", "forwarded": None}, + {"request_id": "private-conflict", "event": "finished", "forwarded": True}, + {"request_id": "private-conflict", "event": "finished", "forwarded": False}, + ] + _lines(tmp_path / "collected/runtime/agent/first/provider-requests.jsonl", events) + _lines(tmp_path / "collected/runtime/agent/replica/provider-requests.jsonl", events) + result = summarize_research_cost(tmp_path, 1) + counts = result["provider_requests"] + assert counts["captured_distinct_requests"] == 5 + assert (counts["forwarded"], counts["not_forwarded"], counts["send_unknown"]) == (1, 1, 3) + assert "private-" not in json.dumps(result) + + +def test_unreadable_capture_is_not_zero_but_an_observed_empty_log_can_be(tmp_path): + _collected(tmp_path) + source = tmp_path / "outside.jsonl" + _lines(source, [{"request_id": "outside", "event": "finished", "forwarded": True}]) + path = tmp_path / "collected/runtime/agent/provider-requests.jsonl" + path.parent.mkdir(parents=True) + path.symlink_to(source) + counts = summarize_research_cost(tmp_path, 1)["provider_requests"] + assert counts["capture_coverage"] == "unknown" + assert counts["forwarded"] is None + path.unlink() + _lines(path, []) + counts = summarize_research_cost(tmp_path, 1)["provider_requests"] + assert counts["capture_coverage"] == "complete" and counts["forwarded"] == 0 + + +def test_real_usage_owner_dedups_same_and_prefix_captures_across_runtime_homes(tmp_path): + _collected(tmp_path) + session = _session("private-native-session") + # A retained prefix, worker home and copied runtime are the same session. + _lines(tmp_path / "collected/native-sessions/sessions/old.jsonl", session[:4]) + _lines(tmp_path / "collected/worker-sessions/sessions/full.jsonl", session) + _lines(tmp_path / "collected/runtime/agent/loopx/sessions/replica.jsonl", session) + _lines(tmp_path / "collected/runtime/agent/other/sessions/independent.jsonl", + _session("private-independent-session")[:4]) + # Non-session runtime JSONL is not interpreted as a token capture. + _lines(tmp_path / "collected/runtime/agent/decisions.jsonl", [{"not": "a rollout"}]) + report = summarize_research_cost(tmp_path, 420) + tokens = report["tokens"] + assert tokens["capture_coverage"] == "complete" + assert tokens["whole_run_coverage"] == "unknown" + assert tokens["qualified_sessions"] == 2 and tokens["duplicate_or_prefix_captures"] == 2 + assert tokens["known_captured_totals"] == { + "input_tokens": 240, "cached_input_tokens": 140, "uncached_input_tokens": 100, + "output_tokens": 25, "input_plus_output_tokens": 265, + } + assert "private-" not in json.dumps(report) and str(tmp_path) not in json.dumps(report) + _collected(tmp_path, ("runtime",)) + assert summarize_research_cost(tmp_path, 420)["tokens"]["capture_coverage"] == "partial" + + +@pytest.mark.parametrize("case", ["divergent", "forked", "unfinished", "counter_gap"]) +def test_ambiguous_or_incomplete_session_never_adds_an_unproven_subtotal(tmp_path, case): + _collected(tmp_path) + rows = _session("ambiguous-session") + if case == "divergent": + other = copy.deepcopy(rows) + other[-1]["timestamp"] = "2026-01-01T00:00:07Z" + _lines(tmp_path / "collected/worker-sessions/sessions/divergence.jsonl", other) + elif case == "forked": + rows[0]["payload"]["forked_from_id"] = "parent-session" + elif case == "unfinished": + # The longest capture is unfinished; do not fall back to an older + # completed prefix and call the missing second Turn free. + _lines(tmp_path / "collected/worker-sessions/sessions/older.jsonl", rows[:4]) + rows = rows[:-1] + else: + rows[5]["payload"]["info"]["total_token_usage"]["input_tokens"] = 999 + _lines(tmp_path / "collected/native-sessions/sessions/unknown.jsonl", rows) + _lines(tmp_path / "collected/runtime/agent/sessions/known.jsonl", _session("known")[:4]) + tokens = summarize_research_cost(tmp_path, 1)["tokens"] + assert tokens["capture_coverage"] == "partial" and tokens["qualified_sessions"] == 1 + assert tokens["unknown_sessions_or_files"] == 1 + assert tokens["known_captured_totals"]["input_tokens"] == 100 + assert tokens["known_captured_totals"]["input_plus_output_tokens"] == 110 + + +def test_native_acceptance_order_precedes_release_order_and_total_uses_native_report(tmp_path): + first = {"submission_id": "private-first", "round_id": "agent-1", "accepted_record": True, + "status": "running", "attribution_status": "verified"} + second = {"submission_id": "private-second", "round_id": "agent-2", "accepted_record": True, + "status": "completed", "attribution_status": "verified", "result_ref": "native-result:abc", + "result_summary": {"score": 4}} + packet = {"schema_version": SCHEMA, "run_id": "private-run", "task_id": "private-task", + "records": [second, first, second]} + target = tmp_path / "native-research-feedback/native.json" + _write(target, packet) + _write(tmp_path / "final_result.json", {"total_rounds": 5, "private": "not part of cost"}) + evaluations = summarize_research_cost(tmp_path, 1)["evaluations"] + assert evaluations["manual_accepted_count"] == 2 and evaluations["native_total_rounds"] == 5 + assert evaluations["first_accepted"] == {"acceptance_ordinal": 1, "released": False, "result_summary": None} + first.update(status="completed", result_ref="native-result:def", result_summary={ + "score": 0, "valid": False, "feedback": "private model feedback", "passed": 0}) + _write(target, packet) + report = summarize_research_cost(tmp_path, 1) + assert report["evaluations"]["first_accepted"]["result_summary"] == {"score": 0, "valid": False, "passed": 0} + assert "private" not in json.dumps(report) + # A disagreement about an accepted identity must not double count it or + # select the earlier-released second candidate as the first submission. + packet["records"].append({**first, "round_id": "agent-3"}) + _write(target, packet) + evaluations = summarize_research_cost(tmp_path, 1)["evaluations"] + assert evaluations["capture_coverage"] == "partial" and evaluations["manual_accepted_count"] == 1 + assert evaluations["first_accepted"] is None + + +def test_bad_native_scopes_and_round_collisions_do_not_invent_counts(tmp_path): + target = tmp_path / "native-research-feedback/native.json" + _write(target, {"schema_version": SCHEMA, "run_id": 123, "task_id": "t", "records": []}) + assert summarize_research_cost(tmp_path, 1)["evaluations"]["manual_accepted_count"] is None + _write(target, {"schema_version": SCHEMA, "run_id": "r", "task_id": "t", "records": [ + {"accepted_record": True, "submission_id": "a", "round_id": "agent-1"}, + {"accepted_record": True, "submission_id": "b", "round_id": "agent-1"}, + ]}) + result = summarize_research_cost(tmp_path, 1)["evaluations"] + assert result["capture_coverage"] == "partial" and result["first_accepted"] is None diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index dcf7a4447a..aa57c3bf8e 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -418,6 +418,7 @@ def test_artifact_collection_preserves_both_session_homes_without_auth(tmp_path, pytest.importorskip("harbor") from benchmark.runtime.sforge_backend import RecordingDockerBackend, DockerBackend backend = object.__new__(RecordingDockerBackend) + backend.research_feedback = None backend.checkpoint = None backend.log_dir = tmp_path / "artifacts" backend.feedback = None @@ -472,6 +473,7 @@ def test_blind_policy_removes_judge_route_and_credentials_native_is_unchanged(mo pytest.importorskip("harbor") from benchmark.runtime.sforge_backend import RecordingDockerBackend, DockerBackend, AllowedEndpoint backend = object.__new__(RecordingDockerBackend) + backend.research_feedback = None backend.checkpoint = None backend.auth_ips = [] backend.blind_api_endpoint = None @@ -990,6 +992,7 @@ def test_recorded_solver_exit_does_not_take_status_from_output(tmp_path, monkeyp from types import SimpleNamespace from benchmark.runtime.sforge_backend import RecordingDockerBackend, DockerBackend backend = object.__new__(RecordingDockerBackend) + backend.research_feedback = None backend.checkpoint = None backend.log_dir = tmp_path / "collected" backend.blind_api_endpoint = None @@ -1212,3 +1215,96 @@ def before_solver(**kwargs): args += ["--checkpoint-request", str(declaration)] with pytest.raises(RuntimeError, match="validated before model execution"): run.main(args) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_native_research_install_is_explicit_and_consumer_owner_excludes_credentials(tmp_path, monkeypatch, enabled): + pytest.importorskip("sforge") + pytest.importorskip("harbor") + from types import SimpleNamespace + from sforge.harness.config import SForgeConfig + from sforge.harness.evolve_scripts import generate_submit_script + from benchmark.runtime.sforge import BenchmarkCodex, SForgeWorker + from benchmark.runtime.research_feedback_sync import FEEDBACK_ROOT, feedback_environment + + async def installed(*_args): + pass # Bound packaging/owner install; no network setup or model call. + monkeypatch.setattr(BenchmarkCodex, "install", installed) + native = generate_submit_script() + copied, calls, starts = {}, [], [] + observer = SimpleNamespace(after_publish=None, start=lambda *args: starts.append(args)) + def execute(_handle, command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(exit_code=0, output=native if command == ["cat", "/usr/local/bin/sforge-submit"] else "") + def copy(_handle, path, target): + copied[str(target)] = path.read_bytes() + backend = SimpleNamespace(research_feedback=observer, exec_run=execute, copy_to_container=copy) + worker = SForgeWorker(SForgeConfig(agent_model="synthetic-model", agent_effort="high", + agent_api_base_url="https://model.example.invalid/v1", agent_api_key="synthetic-provider-secret"), + profile="heartbeat-explore", cwd="/task", research_loop=enabled) + worker.install_stop_hook(backend, "synthetic-container", tmp_path, None) + env = worker.runtime._worker_env(cwd="/task") + script = copied.get("/usr/local/bin/sforge-submit") + if not enabled: + assert script is None and not calls + assert "LOOPX_RESEARCH_FEEDBACK_ROOT" not in env + worker._execution_command("synthetic-execution") + assert not starts and observer.after_publish is None + return + assert script and script.decode() != native + parsed = subprocess.run(["bash", "-n"], input=script, capture_output=True) + assert parsed.returncode == 0, parsed.stderr + assert env["LOOPX_RESEARCH_FEEDBACK_ROOT"] == FEEDBACK_ROOT + assert b"--wait-todo" in script and b"benchmark.runtime.research_submission" in script + assert b'sforge-submit --result' in script, "native read-only recovery remains available" + env.update(SFORGE_TOKEN="synthetic-native-secret", OPENAI_API_KEY="synthetic-provider-secret", + CODEX_AUTH_JSON_PATH="/synthetic-unused-auth.json") + consumer_calls = [] + def consume(_handle, command, **kwargs): + consumer_calls.append((command, kwargs)) + return SimpleNamespace(exit_code=0, timed_out=False) + backend.exec_run_with_timeout = consume + worker._prepare_native_research_consumer(env) + owner = json.loads(copied[FEEDBACK_ROOT + "/owner.json"]) + assert not {"SFORGE_TOKEN", "OPENAI_API_KEY", "CODEX_AUTH_JSON_PATH"} & owner.keys() + assert "synthetic-provider-secret" not in json.dumps(owner) + root = tmp_path / "feedback-owner" + root.mkdir() + (root / "owner.json").write_text(json.dumps({**owner, "LOOPX_RESEARCH_FEEDBACK_ROOT": str(root)})) + restored = feedback_environment(root, base={"UNRELATED": "kept"}) + assert restored["LOOPX_GOAL_ID"] == env["LOOPX_GOAL_ID"] + assert restored["LOOPX_AGENT_ID"] == env["LOOPX_AGENT_ID"] + assert restored["UNRELATED"] == "kept" and "OPENAI_API_KEY" not in restored + worker._execution_command("synthetic-execution") + assert starts == [(backend, "synthetic-container")] + observer.after_publish(backend, "synthetic-container", {}) + [command, options] = consumer_calls[0] + assert "benchmark.runtime.research_feedback_sync" in command + assert command[:4] == ["timeout", "--signal=TERM", "--kill-after=5", "60s"] + assert options["timeout"] == 70 + assert options["user"] == "agent" and options["environment"] == owner + backend.exec_run_with_timeout = lambda *_args, **_kwargs: SimpleNamespace(exit_code=1, timed_out=False) + with pytest.raises(RuntimeError, match="consumption is incomplete"): + observer.after_publish(backend, "synthetic-container", {}) + + +def test_native_research_installer_rejects_unobserved_or_changed_provider_before_execution(tmp_path, monkeypatch): + pytest.importorskip("sforge") + pytest.importorskip("harbor") + from types import SimpleNamespace + from sforge.harness.config import SForgeConfig + from benchmark.runtime.sforge import SForgeWorker + + worker = SForgeWorker(SForgeConfig(agent_model="synthetic", agent_effort="high", + agent_api_base_url="https://model.example.invalid/v1", agent_api_key="synthetic-secret"), + profile="heartbeat-explore", cwd="/task", research_loop=True) + with pytest.raises(ValueError, match="accepted-snapshot observer"): + worker._install_native_research_feedback(SimpleNamespace(research_feedback=None), None) + missing_capability = SimpleNamespace(research_feedback=object(), + exec_run=lambda *_args, **_kwargs: SimpleNamespace(exit_code=1, output="")) + with pytest.raises(RuntimeError, match="matching Explore observer capability"): + worker._install_native_research_feedback(missing_capability, None) + unsupported = SimpleNamespace(research_feedback=object(), + exec_run=lambda *_args, **_kwargs: SimpleNamespace(exit_code=0, output="#!/bin/bash\nexit 0\n")) + with pytest.raises(ValueError, match="unsupported_native_submit_script_boundary"): + worker._install_native_research_feedback(unsupported, None) diff --git a/loopx/capabilities/explore/result_writeback.py b/loopx/capabilities/explore/result_writeback.py index 8497e13fdb..59c8f96254 100644 --- a/loopx/capabilities/explore/result_writeback.py +++ b/loopx/capabilities/explore/result_writeback.py @@ -254,6 +254,72 @@ def prepare_result_attachment( return result +def record_observer_result_attachment( + attachment, *, registry_path, runtime_root, goal_id, agent_id, todo_id, + source_id, recorded_at, +): + """Record external facts without settling a model work Turn. + + A runner or Monitor may finish real observation work while every model + Todo is waiting. It uses the same attachment codec, event log and Todo + link owner as ordinary writeback. It cannot spend a slot, acknowledge a + replan, evolve a research question or declare a Goal outcome. The caller + may complete its observation dependency only after this durable readback. + """ + attachment = normalize_result_attachment(attachment) + if not isinstance(source_id, str) or not source_id or not isinstance(recorded_at, str) or not recorded_at: + raise ValueError("External observation requires stable source and observation-time identity") + if _evolves_question(attachment): + raise ValueError("External observation cannot decide question evolution") + _, graph, gate = _policy(registry_path, goal_id) + if not (graph or gate["enabled"]): + raise ValueError("External result observation requires enabled Explore evidence") + detail = list_goal_todos(registry_path=registry_path, runtime_root_arg=str(runtime_root), + goal_id=goal_id, todo_id=todo_id) + todo = detail.get("todo") + if (not detail.get("matched") or detail.get("ambiguous") or not isinstance(todo, dict) + or todo.get("claimed_by") != agent_id or todo.get("task_class") != "advancement_task" + or todo.get("action_kind") != "observe" or todo.get("status") not in {"open", "done"}): + raise ValueError("External result must belong to the caller's finite observation Todo") + expected = _events(attachment, goal_id=goal_id, agent_id=agent_id, + source_id=source_id, recorded_at=recorded_at) + path = explore_result_log_path(runtime_root, goal_id) + existing = load_explore_result_events_strict(path, goal_id=goal_id) + projection = build_explore_result_projection(existing, goal_id=goal_id) + for node in projection.get("nodes", []): + if node.get("node_id") == attachment["node_id"] and any( + node.get(key) != expected[0].get(key) for key in ("node_kind", "title", "summary") + ): + raise ValueError("External observation question conflicts with existing scope") + if expected[1] not in existing or attachment["node_id"] not in (todo.get("explore_result_node_refs") or []): + if todo.get("status") != "open": + raise ValueError("An already completed observation cannot acquire new result evidence") + # Reject a stable source identity being reused for different facts. + collisions = [row for row in existing if row.get("event_kind") == "finding" + and row.get("result_id") == expected[1]["result_id"]] + if collisions and expected[1] not in collisions: + raise ValueError("External result source identity changed") + proof = _link_arguments(registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, agent_id=agent_id, todo_id=todo_id, node_id=attachment["node_id"]) + if not update_goal_todo(**proof, dry_run=True).get("ok"): + raise ValueError("External observation Todo link was not admitted") + append_explore_result_events(path, expected, expected_goal_id=goal_id, + create_only_node_ids=[attachment["node_id"]], + expected_event_ids=[row["event_id"] for row in existing]) + # The normal mutation rechecks claim/lease after append. A concurrent + # change leaves a replayable fact, never a falsely released dependency. + linked = update_goal_todo(**proof, dry_run=False) + if not linked.get("ok"): + raise ValueError("External observation Todo link is incomplete") + stored = load_explore_result_events_strict(path, goal_id=goal_id) + current = list_goal_todos(registry_path=registry_path, runtime_root_arg=str(runtime_root), + goal_id=goal_id, todo_id=todo_id).get("todo") + if (expected[1] not in stored or not isinstance(current, dict) + or attachment["node_id"] not in (current.get("explore_result_node_refs") or [])): + raise ValueError("External observation readback is incomplete") + return {"ok": True, "finding_id": expected[1]["result_id"], "node_id": attachment["node_id"]} + + def deliver_result_attachment( *, payload, diff --git a/scripts/external_scheduler_worker.py b/scripts/external_scheduler_worker.py index 2822460a89..affde23c41 100644 --- a/scripts/external_scheduler_worker.py +++ b/scripts/external_scheduler_worker.py @@ -802,7 +802,7 @@ def _run_worker_locked( sleep(max(5, interval_minutes * 60)) -def main(argv: Sequence[str] | None = None) -> int: +def main(argv: Sequence[str] | None = None, *, sleep: Callable[[float], None] | None = None) -> int: parser = argparse.ArgumentParser( description="Scheduler-hint-aware external shell_worker for generic CLI loops.", ) @@ -867,7 +867,7 @@ def main(argv: Sequence[str] | None = None) -> int: args.registry = str(global_registry_path()) except ValueError as exc: parser.error(str(exc)) - return run_worker(args) + return run_worker(args) if sleep is None else run_worker(args, sleep=sleep) if __name__ == "__main__": diff --git a/tests/control_plane/test_native_research_continuity.py b/tests/control_plane/test_native_research_continuity.py new file mode 100644 index 0000000000..a2362d5d35 --- /dev/null +++ b/tests/control_plane/test_native_research_continuity.py @@ -0,0 +1,196 @@ +"""A real probe, native HTTP acceptance, canonical wait and next experiment.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import pytest + +from benchmark.runtime.research_probe import run_probe +from benchmark.runtime.research_feedback_sync import synchronize_native_feedback +from benchmark.runtime.research_submission import ( + mark_dispatched, prepare_submission, record_response, synchronize_intents, +) +from benchmark.tests.test_native_research_feedback import ( + LocalCopyBackend, archive_bytes, native_http, submit_fixture, +) +from test_research_feedback_wait import runtime as runtime # real File/SQLite fixture +import test_quota_settlement_cli as fixture + + +def test_observer_replays_after_result_and_todo_link_commit_but_response_is_lost(runtime, monkeypatch): + from benchmark.runtime.research_feedback_wait import synchronize_feedback_waits + from test_research_feedback_wait import packet, waiting, todos + from loopx.capabilities.explore import result_writeback + + dependency = waiting(runtime)["dependency_todo_id"] + released = packet(status="completed", attribution="verified") + released["records"][0]["result_observed_at"] = "2026-01-01T00:01:00Z" + original = result_writeback.update_goal_todo + interrupted = [] + + def lose_committed_response(**args): + result = original(**args) + if args.get("dry_run") is False and not interrupted: + interrupted.append(True) + raise RuntimeError("synthetic lost Todo-link response") + return result + + monkeypatch.setattr(result_writeback, "update_goal_todo", lose_committed_response) + with pytest.raises(RuntimeError, match="lost Todo-link response"): + synchronize_feedback_waits(runtime.env, released) + assert next(row for row in todos(runtime)["todos"] if row["todo_id"] == dependency)["status"] == "open" + assert not runtime.quota()["should_run"] + observed = synchronize_feedback_waits(runtime.env, released) + assert len(observed["released"]) == 1 + context = runtime.call("explore", "turn-context", *runtime.binding) + assert len(context["graph"]["writeback_results"]) == 1 + assert runtime.quota()["should_run"] + assert synchronize_feedback_waits(runtime.env, released) == {"released": []} + + +def test_released_independent_work_gets_a_wake_even_when_a_later_result_needs_retry(runtime, monkeypatch): + from benchmark.runtime import research_feedback_sync + from test_research_feedback_wait import packet, waiting, todos + + dependency = waiting(runtime)["dependency_todo_id"] + released = packet(status="completed", attribution="verified") + released["records"][0]["result_observed_at"] = "2026-01-01T00:01:00Z" + released["records"].append({**released["records"][0], "submission_id": "submission-2", + "round_id": "agent-2", "result_ref": "native-result:submission-2"}) + original = research_feedback_sync.write_feedback_finding + + def unavailable(env, packet, record, todo_id): + if record["submission_id"] == "submission-2": + raise RuntimeError("synthetic independent result needs retry") + return original(env, packet, record, todo_id) + + monkeypatch.setattr(research_feedback_sync, "write_feedback_finding", unavailable) + with pytest.raises(RuntimeError, match="independent result needs retry"): + synchronize_native_feedback(runtime.env, packet=released) + signal = Path(runtime.env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) / "ingestion" / "wake.json" + assert len(json.loads(signal.read_text())["revision"]) == 64 + assert next(row for row in todos(runtime)["todos"] if row["todo_id"] == dependency)["status"] == "done" + assert runtime.quota()["should_run"], "one failing result cannot suppress already released work" + + +def test_native_result_reaches_replan_and_a_changed_real_experiment(runtime, monkeypatch): + monkeypatch.chdir(runtime.project) + native_root = runtime.temp / "native" + native_root.mkdir() + runtime.call("configure-goal", "--goal-id", fixture.GOAL_ID, + "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + original = runtime.quota("--turn-instance-id", runtime.env["LOOPX_TURN"]) + original_budget = original["quota"]["spent_slots"] + with native_http(native_root) as (state, release, observer, root): + env = dict(runtime.env, LOOPX_RESEARCH_FEEDBACK_ROOT=str(root)) + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(root)) + observer.after_publish = lambda _backend, _handle, packet: synchronize_native_feedback(env, packet=packet) + candidate = runtime.project / "candidate.py" + candidate.write_text("import json,os; from pathlib import Path; " + "Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text(json.dumps(" + "{'search_iterations':2,'stop_reason':'search_cap'})); print(2)") + measured = run_probe(command=[sys.executable, str(candidate)], candidate_files=[candidate], + output=runtime.project / "first-probe.json", input_revision="public-local-v1", + planned_method_id="scheduler", executed_method_id="mask-selection", + change_reason="A local control isolated mask selection before changing the scheduler.", + evidence_ref="probe:first", limit_seconds=10, diagnostics=runtime.project / "first-diagnostics.json") + archive = runtime.project / "packaged.tar.gz" + archive.write_bytes(archive_bytes({"candidate.py": candidate.read_bytes()})) + intent = prepare_submission(root, archive, wait_todo_id=fixture.TODO_ID, + history_reader=lambda: {"run_id": state["run_id"], "entries": []}) + mark_dispatched(root, intent["operation_id"]) + accepted = submit_fixture(observer, Path(intent["archive_path"]).read_bytes()) + record_response(root, intent["operation_id"], 200, accepted) + # The accepted response can declare the exact dependency before the + # runner's next history poll. It does not assert result attribution. + synchronized = synchronize_native_feedback(env) + assert synchronized["waits"][0]["waiting"] + pending = observer.update(LocalCopyBackend(), None) + from loopx.cli_commands.turn_decision import current_heartbeat_host_launch_decision + quiet = current_heartbeat_host_launch_decision(registry_path=runtime.registry, + runtime_root=runtime.root, project=runtime.project, goal_id=fixture.GOAL_ID, + agent_id=fixture.AGENT_ID, turn_instance_id=env["LOOPX_TURN"], original_quota=original)["current"] + assert not quiet["should_run"] + assert quiet["quota"]["spent_slots"] == original_budget + assert not runtime.quota("--turn-instance-id", "another-wake-while-pending")["should_run"] + assert state["posts"] == 1 and state["result_reads"] == 0 + + # Interrupted native operation, newer workspace, then late negative + # feedback: the journal and accepted archive remain the sole binding. + candidate.write_text("print('unsubmitted newer version')") + observer.after_publish = None + release(accepted["submission_id"], score=0) + completed = observer.update(LocalCopyBackend(), None) + assert pending["records"][0]["archive_sha256"] == completed["records"][0]["archive_sha256"] + first_write = synchronize_native_feedback(env, packet=completed) + assert first_write["released"] and not first_write["waits"] + assert json.loads((root / "ingestion" / "wake.json").read_text())["revision"] == first_write["revision"] + assert candidate.read_text() == "print('unsubmitted newer version')" + recovered = prepare_submission(root, archive) + assert recovered == {"action": "recover", "operation_id": intent["operation_id"], + "submission_id": accepted["submission_id"]} + assert state["posts"] == 1 + [current_intent] = synchronize_intents(root, completed) + assert not current_intent.get("result_observed"), "observer must not forge native operation completion" + + # Observe readiness without minting another work Turn. The original + # interrupted Turn still needs the current owner's normal recovery. + ready = runtime.quota() + assert ready["should_run"] and ready["effective_action"] == "successor_replan_required", { + key: ready.get(key) for key in ("should_run", "effective_action", "reason", "blocker")} + assert ready["quota"]["spent_slots"] == original_budget + context = runtime.call("explore", "turn-context", *runtime.binding) + finding = next(row for row in context["graph"]["writeback_results"] if row.get("execution_observation")) + bound = finding["execution_observation"] + assert bound["candidate_sha256"] == bound["result_candidate_sha256"] == measured["candidate_sha256"] + assert bound["search_iterations"] == 2 + assert '"score": 0' in finding["summary"] + assert bound["result_ref"] == completed["records"][0]["result_ref"] + + runtime.call("todo", "update", *runtime.binding, "--todo-id", fixture.TODO_ID, + "--status", "open", "--clear-resume-when", "--reason", "Use the released source-bound result in the resumed experiment.") + decision = runtime.project / "resume-decision.json" + decision.write_text(json.dumps({"schema_version": "goal_vision_replan_contract_v0", "state": "active", + "vision_patch": {"advancement_policy": "as_needed", "vision_summary": "Vary the measured search cap while retaining the incumbent.", + "acceptance_summary": "Execute the declared local control before attributing any scheduler benefit."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "continue", + "prior_assumption": "The scheduler proposal was expected to explain improvement.", + "observed_reality": "The archived mask candidate stopped at two search steps and its native result was zero.", + "retained": ["Original objective, best validated candidate and original budget."], + "changed": ["Test five search steps before changing the scheduler."], + "evidence_refs": [finding["finding_id"], bound["result_ref"]]}})) + next_turn = env["LOOPX_TURN"] + admitted = runtime.quota("--turn-instance-id", next_turn) + assert admitted["should_run"] and admitted["effective_action"] == "normal_run", { + key: admitted.get(key) for key in ("should_run", "effective_action", "unsettled_host_turn_recovery")} + assert admitted["heartbeat_receipt"]["settlement_identity"] == original["heartbeat_receipt"]["settlement_identity"] + candidate.write_text("import json,os; from pathlib import Path; " + "Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text(json.dumps(" + "{'search_iterations':5,'stop_reason':'local_cases_complete'})); print(5)") + next_probe = run_probe(command=[sys.executable, str(candidate)], candidate_files=[candidate], + output=runtime.project / "next-probe.json", input_revision="public-local-v1", + planned_method_id="mask-selection", executed_method_id="mask-selection", + evidence_ref="probe:next", limit_seconds=10, diagnostics=runtime.project / "next-diagnostics.json") + assert next_probe["search_iterations"] == 5 and next_probe["candidate_sha256"] != measured["candidate_sha256"] + assert (runtime.project / "next-probe.json.stdout").read_text().strip() == "5" + followthrough = runtime.project / "followthrough.json" + followthrough.write_text(json.dumps({"schema_version": "explore_result_attachment_v0", + "question": "Did the declared search-cap control actually execute?", + "applicability": "This synthetic local case and the declared selected candidate files.", + "input_revision": next_probe["input_revision"], "observation": "The modified process executed five search steps and exited successfully.", + "interpretation": "The feedback-led next action ran as declared; improvement on native evaluation remains unknown.", + "status": "tentative", "evidence_refs": next_probe["evidence_refs"], "execution_observation": next_probe})) + runtime.call("refresh-state", *runtime.binding, "--todo-id", fixture.TODO_ID, + "--turn-instance-id", next_turn, "--classification", "native_feedback_replan_executed", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--agent-vision-json", str(decision), "--explore-result-json", str(followthrough), + "--no-global-sync", "--suppress-external-sinks") + runtime.call("quota", "spend-slot", *runtime.binding, "--todo-id", fixture.TODO_ID, + "--turn-instance-id", next_turn, "--slots", "1", "--source", "heartbeat", "--execute") + assert synchronize_native_feedback(env, packet=completed)["released"] == [] + after = runtime.quota() + assert after["quota"]["spent_slots"] == original_budget + 1 + assert after["effective_action"] != "unsettled_host_turn_recovery" + assert state["posts"] == 1, "a new local experiment does not consume a native evaluation" diff --git a/tests/control_plane/test_research_execution_loop.py b/tests/control_plane/test_research_execution_loop.py index 8a707be683..05454549a6 100644 --- a/tests/control_plane/test_research_execution_loop.py +++ b/tests/control_plane/test_research_execution_loop.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os from pathlib import Path @@ -48,12 +49,108 @@ def test_missing_solver_telemetry_stays_unknown_and_timeout_is_host_observed(tmp def test_probe_refuses_changed_candidate_and_never_reuses_existing_evidence(tmp_path, monkeypatch): + feedback_root = tmp_path / "feedback" + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(feedback_root)) with pytest.raises(ValueError, match="candidate changed"): probe(tmp_path, monkeypatch, source="from pathlib import Path; Path(__file__).write_text('print(0)')") before = (tmp_path / "observation.json").read_bytes() with pytest.raises(FileExistsError): probe(tmp_path, monkeypatch) assert (tmp_path / "observation.json").read_bytes() == before + assert not feedback_root.exists(), "a changed/unexecuted candidate cannot register as a completed probe" + + +@pytest.mark.parametrize("reported", [ + "{", + '{"search_iterations":"unknown","stop_reason":"search_cap"}', + '{"search_iterations":2,"stop_reason":{"unexpected":"value"}}', + '{"unexpected":"do-not-retain-raw-diagnostic-body"}', + "x" * 4097, +]) +def test_invalid_optional_diagnostics_preserve_host_measurements_and_bounded_error_evidence(tmp_path, monkeypatch, reported): + observed = probe(tmp_path, monkeypatch, source=( + "import os; from pathlib import Path; " + f"Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text({reported!r}); " + "print('work completed'); raise SystemExit(3)")) + assert observed["elapsed_seconds"] > 0 + assert observed["exit_code"] == 3 and observed["stop_reason"] == "process_exit:3" + assert observed["search_iterations"] is None + assert (tmp_path / "observation.json.stdout").read_text().strip() == "work completed" + assert json.loads((tmp_path / "observation.json").read_text()) == observed + raw_error = (tmp_path / "observation.json.diagnostics-error.json").read_bytes() + assert len(raw_error) < 1024 and b"do-not-retain-raw-diagnostic-body" not in raw_error + detail = json.loads(raw_error) + assert detail["source"] == "solver_diagnostics" + assert detail["candidate_sha256"] == observed["candidate_sha256"] + assert "diagnostics-error:" + hashlib.sha256(raw_error).hexdigest() in observed["evidence_refs"] + + +def test_invalid_solver_report_cannot_hide_a_host_timeout(tmp_path, monkeypatch): + observed = probe(tmp_path, monkeypatch, source=( + "import os,time; from pathlib import Path; " + "Path(os.environ['LOOPX_RESEARCH_DIAGNOSTICS']).write_text('{'); time.sleep(10)"), limit_seconds=1) + assert observed["stop_reason"] == "host_timeout" and observed["exit_code"] < 0 + assert observed["search_iterations"] is None and observed["elapsed_seconds"] >= 1 + assert (tmp_path / "observation.json.diagnostics-error.json").is_file() + + +def test_persisted_probe_auto_registers_and_binds_late_native_feedback(tmp_path, monkeypatch): + from benchmark.runtime import research_submission + from benchmark.tests.test_native_research_feedback import ( + LocalCopyBackend, archive_bytes, native_http, submit_fixture, + ) + + register = research_submission.register_probe + registrations = [] + + def register_after_persist(root, observed, manifest): + assert json.loads((tmp_path / "observation.json").read_text()) == observed + assert json.loads((tmp_path / "observation.json.candidate.json").read_text()) == manifest + registrations.append(root) + return register(root, observed, manifest) + + monkeypatch.setattr(research_submission, "register_probe", register_after_persist) + with native_http(tmp_path) as (_state, release, observer, root): + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(root)) + observed = probe(tmp_path, monkeypatch) + assert registrations == [root] + [registered] = list((root / "probes").glob("*.json")) + assert json.loads(registered.read_text())["observation"] == observed + assert "result_ref" not in observed, "a completed local probe does not invent a native result" + source = tmp_path / "candidate.py" + accepted = submit_fixture(observer, archive_bytes({"candidate.py": source.read_bytes()})) + pending = observer.update(LocalCopyBackend(), None) + assert research_submission.bind_execution_observations(root, pending) == [] + source.write_text("print('newer candidate')") + release(accepted["submission_id"], score=0) + packet = observer.update(LocalCopyBackend(), None) + [bound] = research_submission.bind_execution_observations(root, packet) + assert bound["submission_id"] == accepted["submission_id"] + assert bound["execution_observation"]["result_candidate_sha256"] == observed["candidate_sha256"] + assert source.read_text() == "print('newer candidate')" + + +def test_probe_without_feedback_root_does_not_register(tmp_path, monkeypatch): + from benchmark.runtime import research_submission + + monkeypatch.delenv("LOOPX_RESEARCH_FEEDBACK_ROOT", raising=False) + + def unexpected(*_args): + raise AssertionError("feature-off probe must not create a native registration") + + monkeypatch.setattr(research_submission, "register_probe", unexpected) + assert probe(tmp_path, monkeypatch)["exit_code"] == 0 + + +def test_registration_failure_preserves_the_completed_observation(tmp_path, monkeypatch): + feedback_root = tmp_path / "unavailable-feedback" + feedback_root.write_text("an existing non-directory") + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(feedback_root)) + with pytest.raises(FileExistsError): + probe(tmp_path, monkeypatch) + observed = json.loads((tmp_path / "observation.json").read_text()) + assert observed["exit_code"] == 0 and observed["elapsed_seconds"] > 0 + assert observed["search_iterations"] == 2 def test_probe_cli_preserves_a_failed_candidate_exit_and_its_observation(tmp_path): diff --git a/tests/control_plane/test_research_feedback_wait.py b/tests/control_plane/test_research_feedback_wait.py new file mode 100644 index 0000000000..1447b3cc79 --- /dev/null +++ b/tests/control_plane/test_research_feedback_wait.py @@ -0,0 +1,215 @@ +"""Accepted native feedback reaches canonical waits and real request admission.""" +from __future__ import annotations + +import copy +from contextlib import redirect_stdout +import io +import json +import os +import subprocess +import sys +import time +from types import SimpleNamespace +import urllib.error +import urllib.request + +import pytest + +from benchmark.runtime import research_feedback_wait as feedback +from benchmark.runtime.research_request_guard import ResearchRequestGuard +from canonical_authority_fixture import isolate_sqlite_runtime +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from loopx.cli import main as cli_main +import test_quota_settlement_cli as fixture +from tests.capabilities.test_provider_gateway import _recording_upstream + + +@pytest.fixture(params=["file", "sqlite"]) +def runtime(request, tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, root, registry = fixture._write_fixture(tmp_path) + fixture._initialize_fixture_authority(project, root, provider=request.param) + + def call(*args): + output = io.StringIO() + with monkeypatch.context() as scope, redirect_stdout(output): + scope.chdir(project) + code = cli_main(["--registry", str(registry), "--runtime-root", str(root), "--format", "json", *args]) + payload = json.loads(output.getvalue()) + assert code == 0, {key: payload.get(key) for key in ( + "ok", "error", "errors", "error_code", "reason", "blocker", "diagnostics")} + return payload + + call("configure-goal", "--goal-id", fixture.GOAL_ID, "--registered-agent", fixture.AGENT_ID, + "--registered-agent", feedback.FEEDBACK_OBSERVER_AGENT, "--explore-graph-enabled", "--execute") + env = dict(os.environ, LOOPX_REGISTRY=str(registry), LOOPX_RUNTIME_ROOT=str(root), + LOOPX_PROJECT=str(project), LOOPX_GOAL_ID=fixture.GOAL_ID, LOOPX_AGENT_ID=fixture.AGENT_ID, + LOOPX_PHASE_DEADLINE_EPOCH=str(time.time() + 300), LOOPX_TURN="original-feedback-turn", + LOOPX_RESEARCH_FEEDBACK_ROOT=str(tmp_path / "native-feedback")) + # Keep product state, CLI validation/CAS, provider transactions and readback + # real; only share the fixture's public command construction. + monkeypatch.setattr(feedback, "_loopx", lambda _env, *args: call(*args)) + binding = ("--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID) + + def quota(*args): + return call("quota", "should-run", *binding, "--runtime-profile", "generic_cli", + "--include-detail", "scheduler", "--scan-path", str(project), *args) + + return SimpleNamespace(env=env, call=call, quota=quota, project=project, root=root, registry=registry, + binding=binding, temp=tmp_path) + + +def packet(*, status="running", attribution="unknown", submission="submission-1", run="native-attempt-1"): + record = dict(accepted_record=True, submission_id=submission, round_id="agent-1", + status=status, attribution_status=attribution) + if attribution == "verified": + record.update(archive_sha256="a" * 64, result_sha256="b" * 64, + result_ref="native-result:" + submission, + result_observed_at="2026-10-19T00:00:00Z") + return dict(schema_version="edgebench_native_feedback_v1", run_id=run, + task_id="synthetic-task", records=[record]) + + +def waiting(runtime, value=None): + return feedback.wait_for_feedback(runtime.env, todo_id=fixture.TODO_ID, + submission_id="submission-1", packet=value or packet()) + + +def todos(runtime): + return runtime.call("todo", "list", "--goal-id", fixture.GOAL_ID) + + +def test_pending_native_submission_requires_explicit_dependency_and_preserves_independent_work(runtime): + pending = packet() + feedback.synchronize_feedback_waits(runtime.env, pending) + initial = runtime.quota() + assert initial["should_run"] and initial["selected_todo"]["todo_id"] == fixture.TODO_ID + result = waiting(runtime, pending) + source = next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == result["dependency_todo_id"]) + assert source["claimed_by"] == feedback.FEEDBACK_OBSERVER_AGENT + assert fixture.AGENT_ID in source["excluded_agents"] + code, denied = fixture._run_cli(runtime.registry, runtime.root, "quota", "should-run", *runtime.binding, + "--runtime-profile", "generic_cli", "--todo-id", source["todo_id"], cwd=runtime.project) + assert code == 1 and denied.get("should_run") is not True + quiet = runtime.quota() + assert quiet["should_run"] is False + assert quiet["effective_action"] == "agent_scope_wait" + current_work = next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == fixture.TODO_ID) + assert current_work["status"] == "deferred" and current_work["resume_when"] == result["resume_when"] + before = todos(runtime)["authority_read"]["provider_revision"] + assert waiting(runtime, pending) == result + assert feedback.synchronize_feedback_waits(runtime.env, pending) == {"released": []} + assert todos(runtime)["authority_read"]["provider_revision"] == before + + independent = runtime.call("todo", "add", "--goal-id", fixture.GOAL_ID, "--role", "agent", + "--claimed-by", fixture.AGENT_ID, "--task-class", "advancement_task", "--action-kind", "validate", + "--text", "Validate an independent local mechanism without external feedback.", "--execute") + ready = runtime.quota() + assert ready["should_run"] and ready["selected_todo"]["todo_id"] == independent["todo_id"] + assert not ready.get("vision_wait_state") or ready["vision_wait_state"].get("applied") is not True + assert next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == fixture.TODO_ID)["resume_when"] == result["resume_when"] + + +def test_native_wait_stops_actual_upstream_send_under_original_turn(runtime): + original = runtime.quota("--todo-id", fixture.TODO_ID, "--turn-instance-id", runtime.env["LOOPX_TURN"]) + assert original["should_run"] + session = SimpleNamespace(session_id="native-feedback-session", goal_ref=None, + admission=FirstPartyHostGoalAdmission.for_plan(registry_path=runtime.registry, + goal_id=fixture.GOAL_ID, planned_goal_ref=None)) + wake = runtime.temp / "wake" + wake.mkdir() + receipt = {"turn_id": "native-feedback-wake"} + with _recording_upstream() as (url, upstream): + env = dict(runtime.env, OPENAI_BASE_URL=url, OPENAI_API_KEY="synthetic-credential", NO_PROXY="127.0.0.1") + guard = ResearchRequestGuard(env, receipt, wake) + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(180)"], start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + with guard.serve(): + guard.bind(process, env, session, original_quota=original) + + def send(): + return urllib.request.urlopen(urllib.request.Request(guard.base_url + "/responses", + data=b'{"input":"synthetic input"}', headers={"Content-Type": "application/json"}), timeout=45) + + with send() as response: + assert json.load(response) == {"ok": True} + upstream.clear() + waiting(runtime) + with pytest.raises(urllib.error.HTTPError) as refused: + send() + assert refused.value.code == 409 and upstream == {} + process.wait(timeout=10) + assert guard.refused + finally: + if process.poll() is None: + process.kill() + process.wait() + rows = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + finished = [row for row in rows if row["event"] == "finished"] + assert [row["forwarded"] for row in finished] == [True, False] + assert all(row["loopx_turn_id"] == runtime.env["LOOPX_TURN"] for row in finished) + assert runtime.env["LOOPX_PHASE_DEADLINE_EPOCH"] == env["LOOPX_PHASE_DEADLINE_EPOCH"] + + +def test_ambiguous_or_other_attempt_feedback_never_releases_original_wait(runtime, monkeypatch): + result = waiting(runtime) + ambiguous = packet() + ambiguous["records"].append(copy.deepcopy(ambiguous["records"][0])) + with pytest.raises(ValueError, match="ambiguous"): + waiting(runtime, ambiguous) + with pytest.raises(ValueError, match="accepted submission"): + waiting(runtime, packet(submission="submission-2")) + current = next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == result["dependency_todo_id"]) + assert current["status"] == "open" + before = todos(runtime)["authority_read"]["provider_revision"] + assert feedback.synchronize_feedback_waits(runtime.env, packet(status="completed")) == {"released": []} + assert todos(runtime)["authority_read"]["provider_revision"] == before + from benchmark.runtime import research_feedback_sync + written = [] + monkeypatch.setattr(research_feedback_sync, "write_feedback_finding", lambda *args: written.append(args[2]["submission_id"])) + other = packet(status="completed", attribution="verified", run="native-attempt-2") + feedback.synchronize_feedback_waits(runtime.env, other) + assert written == ["submission-1"] + assert next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == current["todo_id"])["status"] == "open" + assert runtime.quota()["should_run"] is False + + +@pytest.mark.parametrize("native_status", ["completed", "error"]) +def test_result_writeback_precedes_wait_release_and_negative_feedback_is_not_blocked(runtime, monkeypatch, native_status): + result = waiting(runtime) + released = packet(status=native_status, attribution="verified") + from benchmark.runtime import research_feedback_sync + real_writer = research_feedback_sync.write_feedback_finding + + def unavailable(*_args): + raise RuntimeError("synthetic finding write failure") + + monkeypatch.setattr(research_feedback_sync, "write_feedback_finding", unavailable) + with pytest.raises(RuntimeError, match="finding write failure"): + feedback.synchronize_feedback_waits(runtime.env, released) + assert next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == result["dependency_todo_id"])["status"] == "open" + assert runtime.quota()["should_run"] is False + + def persisted(*args): + assert args[3] == result["dependency_todo_id"] + assert next(todo for todo in todos(runtime)["todos"] if todo["todo_id"] == args[3])["status"] == "open" + return real_writer(*args) + + monkeypatch.setattr(research_feedback_sync, "write_feedback_finding", persisted) + observed = feedback.synchronize_feedback_waits(runtime.env, released) + assert observed["released"][0]["dependency_todo_id"] == result["dependency_todo_id"] + after = todos(runtime) + assert next(todo for todo in after["todos"] if todo["todo_id"] == result["dependency_todo_id"])["status"] == "done" + resume = runtime.quota() + assert resume["should_run"] and resume["selected_todo"]["todo_id"] == fixture.TODO_ID + assert resume["effective_action"] == "successor_replan_required" + context = runtime.call("explore", "turn-context", *runtime.binding) + [finding] = context["graph"]["writeback_results"] + assert released["records"][0]["result_ref"] in finding["evidence_refs"] + assert f'"status": "{native_status}"' in finding["summary"] + runs = runtime.root / f"goals/{fixture.GOAL_ID}/runs/index.jsonl" + run_events = map(json.loads, runs.read_text().splitlines()) if runs.exists() else [] + assert not any(row.get("classification") == "quota_slot_spent" for row in run_events) + assert feedback.synchronize_feedback_waits(runtime.env, released) == {"released": []} + assert todos(runtime)["authority_read"]["provider_revision"] == after["authority_read"]["provider_revision"] diff --git a/tests/control_plane/test_research_scheduler.py b/tests/control_plane/test_research_scheduler.py new file mode 100644 index 0000000000..a057d7ed27 --- /dev/null +++ b/tests/control_plane/test_research_scheduler.py @@ -0,0 +1,105 @@ +"""A durable feedback hint wakes the existing worker, never grants dispatch.""" +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from benchmark.runtime.research_scheduler import FeedbackEventSleep, main +from benchmark.runtime.research_feedback_wait import synchronize_feedback_waits +from scripts import external_scheduler_worker as worker +import test_research_feedback_wait as wait_fixture + +runtime = wait_fixture.runtime + + +def publish(path, revision): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps({"revision": revision})) + temporary.replace(path) + + +def test_event_between_quota_and_sleep_is_not_lost_or_replayed_as_endless_wake(tmp_path): + path = tmp_path / "ingestion/wake.json" + publish(path, "a" * 64) + now, pauses = [0.0], [] + + def pause(seconds): + pauses.append(seconds) + now[0] += seconds + + sleeper = FeedbackEventSleep(path, clock=lambda: now[0], pause=pause) + publish(path, "b" * 64) # Publication races the last quota read, before sleep. + sleeper(3600) + assert now[0] == 0 and pauses == [] + sleeper(3) + assert now[0] == 3 and sum(pauses) == 3 + path.write_text("{partial") + sleeper(2) + assert now[0] == 5 # Missing/partial signals preserve the requested cadence. + publish(path, "c" * 64) + sleeper(3600) + assert now[0] == 5 + + +def test_research_scheduler_entry_is_explicit_and_reuses_ordinary_arguments(tmp_path, monkeypatch): + monkeypatch.delenv("LOOPX_RESEARCH_LOOP", raising=False) + with pytest.raises(ValueError, match="explicit research-loop"): + main([]) + monkeypatch.setenv("LOOPX_RESEARCH_LOOP", "1") + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(tmp_path)) + called = [] + monkeypatch.setattr(worker, "main", lambda argv, **kwargs: called.append((argv, kwargs)) or 0) + argv = ["--goal-id", "synthetic-goal", "--agent-id", "synthetic-agent", "--once"] + assert main(argv) == 0 + assert called[0][0] is argv + assert called[0][1]["sleep"].path == tmp_path / "ingestion/wake.json" + + +def test_event_rechecks_real_quota_and_only_released_dependency_dispatches(runtime, monkeypatch): + original = wait_fixture.waiting(runtime) + event = runtime.temp / "native-feedback/ingestion/wake.json" + publish(event, "a" * 64) + seen, pauses, now, dispatches = [], [], [0.0], [] + original_turn = runtime.env["LOOPX_TURN"] + original_deadline = runtime.env["LOOPX_PHASE_DEADLINE_EPOCH"] + + def quota(_command, **_kwargs): + value = runtime.quota() + seen.append(value) + return value + + def observe(seconds): + pauses.append(seconds) + now[0] += seconds + if len(pauses) == 1: + publish(event, "b" * 64) # A signal alone cannot satisfy the dependency. + elif len(pauses) == 2: + value = wait_fixture.packet(status="completed", attribution="verified") + receipt = synchronize_feedback_waits(runtime.env, value) + assert receipt["released"][0]["dependency_todo_id"] == original["dependency_todo_id"] + publish(event, "c" * 64) # Consumer publication follows durable evidence. + else: + raise AssertionError("released evidence did not interrupt the long scheduler sleep") + + def wake(_command, **_kwargs): + assert len(seen) == 3 and [packet["should_run"] for packet in seen] == [False, False, True] + assert seen[-1]["selected_todo"]["todo_id"] == wait_fixture.fixture.TODO_ID + assert runtime.env["LOOPX_TURN"] == original_turn + assert runtime.env["LOOPX_PHASE_DEADLINE_EPOCH"] == original_deadline + dispatches.append(original_turn) + # Transport-only bounded synthetic Host; no model or scoring request. + return SimpleNamespace(returncode=75, failure_kind=None) + + monkeypatch.setattr(worker, "run_quota_should_run", quota) + monkeypatch.setattr(worker, "_run_wake", wake) + args = SimpleNamespace(cli_bin="unused", registry=str(runtime.registry), runtime_root=str(runtime.root), + runtime_profile="generic_cli", goal_id=runtime.env["LOOPX_GOAL_ID"], agent_id=runtime.env["LOOPX_AGENT_ID"], + state_file=str(runtime.temp / "scheduler.json"), once=False, wake_cmd="synthetic-host", + wake_stop_exit_code=75, error_backoff_seconds=5, quota_timeout_seconds=60, wake_timeout_seconds=30) + sleeper = FeedbackEventSleep(event, clock=lambda: now[0], pause=observe) + assert worker.run_worker(args, sleep=sleeper) == 0 + assert len(dispatches) == 1 and now[0] == 2 + assert json.loads((runtime.temp / "scheduler.json").read_text())["unchanged_count"] == 0 From f431bc23238759037e3c81c3c9238b446f3d87fb Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 17:12:46 +0800 Subject: [PATCH 6/8] docs(research): describe native feedback and qualification boundaries Document explicit feedback waits, immutable native attribution and retry recovery, normal scheduler wakeup, measured cost coverage and the remaining checkpoint and live-model qualification requirements. Signed-off-by: Codex --- benchmark/runtime/RESEARCH_LOOP.md | 148 ++++++++++++++++++++++++++--- 1 file changed, 136 insertions(+), 12 deletions(-) diff --git a/benchmark/runtime/RESEARCH_LOOP.md b/benchmark/runtime/RESEARCH_LOOP.md index f81d060187..9fe2933195 100644 --- a/benchmark/runtime/RESEARCH_LOOP.md +++ b/benchmark/runtime/RESEARCH_LOOP.md @@ -29,6 +29,11 @@ no upstream send and interruption of the owned native process; the original scheduler handles wait/recovery. Checkpoint continuations retain the held Turn. Unreadable authority fails closed. Already-forwarded requests cannot be retroactively cancelled, and absent canonical waits are not inferred from prose. +Connection establishment (including a forward proxy's CONNECT and TLS) happens +outside the authority lock. The original owner/process is pinned before that +connection; its binding, liveness, current control and phase deadline are checked +again immediately before sending. A delayed old request cannot borrow a newer +process or Turn, and a slow connection does not prevent a concurrent Stop write. Private `provider-requests.jsonl` correlates wake, LoopX Turn, native session/thread/Turn when supplied, client request, gateway request and returned @@ -59,21 +64,122 @@ null, and low resource use is a diagnostic rather than a mandatory time floor. Pins cover only declared files at those boundaries, not transitive imports or independent writers. Evidence paths must be fresh; raw output stays local. The probe neither submits nor accesses hidden evaluation. +Malformed optional solver diagnostics leave host elapsed/exit evidence intact; +their counters stay unknown and a bounded diagnostic-error receipt explains why. The adjacent `.candidate.json` preserves the selected relative paths and file hashes. `candidate_sha256` hashes that sorted JSON file manifest (sorted keys, compact separators), not a native archive's container bytes. Compare the same manifest against the submitted/released snapshot before attaching feedback; -an archive SHA-256 is not interchangeable. If that comparison is unavailable, -leave result attribution unclaimed rather than copying the current hash. +an archive SHA-256 is not interchangeable. The native adapter performs this +comparison automatically for registered probes, including selected files within +an archive containing more than 32 files. A missing match remains unknown. Attach the emitted object as `execution_observation` in the existing Explore -result writeback, preserving its input revision and evidence refs. For released -feedback, add `result_ref` to those refs and `result_candidate_sha256` naming the -evaluated candidate. A different hash is rejected; late feedback belongs on its -actual older candidate, not newer files. Different planned/executed method ids -require a change reason. Replan and the research-history page read the same -structured source; neither certifies method benefit or Goal completion. +result writeback for the model's own local findings. Preserve its input revision +and evidence refs. On native EdgeBench, the installed probe also registers its +manifest with the native result producer. Released native feedback is ingested +automatically with `result_ref` and, only when an accepted snapshot matches, +`result_candidate_sha256`. Late feedback belongs to that older candidate. +Automatic binding covers the registered probes available when that native +observation completes. Later probes use ordinary local Explore writeback; a +completed observation is not reopened to ingest revised results or new probes. +Different planned/executed method ids require a change reason. Replan and the +research-history page read the same Explore source; neither certifies method +benefit or Goal completion. + +## Native acceptance and recovery + +The research adapter instruments the installed native `sforge-submit` at its +actual packaging and HTTP-send boundaries. It preserves the native tar command, +submission limits, evaluation and scoring. An unsupported native script layout +or a pinned product lacking the matching Explore observer capability fails +installation before model entry. If product and runner revisions differ, pin +both compatible versions; the runner overlay does not replace product owners. +The host observes only public token-visible +history/results and the native accepted source archive. It does not initiate an +evaluation or read hidden judge artifacts. + +Each submit invocation journals an operation ID and immutable packaged bytes +before its single POST. A successful native response binds the returned +submission ID and round; the host then verifies their accepted archive digest. +The archive SHA-256, selected-file manifest and result digest remain distinct +linked facts. A completed/error native result is usable only after its public +identity, task, status and history agree. Duplicate identities, replaced archives +or ambiguous recovery retain unknown attribution without blocking unrelated +verified submissions. The first submitted candidate is ordered by native +acceptance (`agent-N`), not by when evaluations finish. + +If the submission response disappears, rerunning `sforge-submit` first recovers +the unresolved operation from its journal and native history. Once it uniquely +matches the accepted archive, the helper reads the original submission ID even +if the workspace has changed. It never posts that unresolved operation again. +An unresolved acceptance stops that retry with an explicit unknown reason; +independent research may continue. Receiving external feedback does not pretend +that the interrupted native client observed its result or completed its operation. + +## Explicit feedback dependencies and wakeup + +When the current work truly needs the external result, use its canonical Todo ID: + +```sh +sforge-submit --wait-todo TODO_ID +# Or attach a dependency after an already accepted submission: +loopx-research-feedback wait --todo-id TODO_ID --submission-id SUBMISSION_ID +``` + +Pending evaluation by itself does not defer a model Todo. The explicit option +registers exactly that work's dependency as soon as the native accepted receipt +is available. Existing different waits, completed work, claims and original +operation identities are preserved. The finite observation Todo belongs to the +runner's `edgebench-feedback-observer`; model agents cannot claim it as work. +This is real low-cost external observation through the existing Todo owner. +An independent model Todo remains eligible while one evaluation is pending. + +The runner polls public native history without invoking a model. Released facts +go through the existing Explore attachment codec, append-only event log and +canonical Todo linking API. This external observer entry does not create a model +Turn, spend a slot, acknowledge replan or claim research progress. Only durable +finding/link readback permits completion of that observation dependency. Partial +write/copy failures retry with the same source identity. Negative scores and +native error results remain evidence; neither automatically marks research blocked. +Consumption runs under a 60-second process-group timeout with bounded cleanup; +durable results can release independent work even if a later item needs retry. + +The consumer then publishes a revision signal. The opt-in scheduler wrapper +observes that small file during its ordinary sleep and promptly returns to the +same scheduler's quota check. Repeated or invalid signals do not busy-loop, and a +signal alone grants no model admission. When all model work is waiting, the +per-request guard prevents upstream sends. Resumption uses current evidence and +the existing owner of the original operation and budget. The wrapper does not +change Turn IDs, extend phase deadlines or resubmit candidates. Ordinary newly +admitted successor work can still have its own normal work Turn. + +## Measurement and checkpoint boundary + +The passive `research-cost.json` receipt records runner wall time, observed +provider requests and native evaluation counts, plus captured Codex token +coverage. Token accounting uses the existing complete-session counter owner; +identical/prefix copies of one session are deduplicated and incompatible, +forked, incomplete or missing captures remain unknown. Cached input is included +in input, and token counts do not imply an invoice. Request and session coverage +are separate: a missing journal or unfinished request cannot be interpreted as +zero cost. Known subtotals and unknown coverage are explicit. + +Runner elapsed time includes its preparation, planning, control checks, waiting, +probes and checkpoint activity inside that measured interval. These overlapping +durations are not added together. Detailed phase allocation and costs incurred +before runner entry are unknown. Compare candidate quality against complete +observed costs; do not infer benefit from more Turns, Todos or reports. + +This change does not qualify checkpoint restoration of the new native journals. +Cold runs remain the supported entry; applying `--research-loop` to an older +checkpoint is rejected. Before restoring or forking a research attempt, capture +the native submission/probe/ingestion journals with the existing checkpoint +writer barrier and verify original submission identity, available evidence, +budget and the first resumed experiment. Decisions or feedback created after +capture must not be indexed as capture-time knowledge; preserve the existing +`research_point_qualified=false` until that qualification is actually performed. This treatment does not automatically instrument arbitrary solvers or guarantee a stronger first score. The model may omit a probe or misreport a method; actual @@ -81,7 +187,25 @@ adoption and benefit still require qualification. Stage separation does not replace the default whole-Goal acceptance owner. Do not extend budget or suppress legal closeout to manufacture a successful long run. -中文:这一显式处理将初始候选、持续研究、等待准入、执行证据与复盘接在既有 -Todo/Host/Explore 路径上。候选完成只释放后续研究;已知等待阻止新上游请求。 -探针记录候选指纹、耗时和可选搜索诊断;反馈按实际候选绑定,改向保留原因。 -未知不填零,不改变评分,不强制耗满时间,不保证模型采纳或最终收益。 +## 中文交付边界 + +这一显式处理将初始候选、持续研究、等待准入、执行证据与复盘接在既有 +Goal/Todo/Host/Explore 上。候选完成只释放后续研究;本次没有包含默认全 Goal +验收工作线,也没有重新启动历史运行或长测。 + +原生真正打包后保存操作 ID 和不可变快照;接受提交后读取真实提交 ID、轮次和 +原生归档。归档哈希与所选文件清单哈希分别保存并验证关系。结果晚到时始终指向 +原候选,不改写新工作区或自动采用、回滚。提交响应丢失时恢复原操作,无法唯一 +匹配时保留未知,不重复消耗评测机会。首次候选按实际接受顺序评价。 + +只有显式 `--wait-todo` 或反馈依赖命令才延后指定 Todo。运行器负责真实外部观察, +先写入 Explore 并读回,再完成观察依赖;其自身不占模型 Turn 或预算。 +全部可执行工作等待时停止模型请求;有独立工作时保持准入。新反馈信号打断 +调度休眠后仍需通过当前准入,不通过换 Turn、重发操作或重置预算逃出等待。 +准入锁前后的原进程、绑定和截止时间都校验,慢连接也不会占住 Stop 的写锁。 + +探针保留实际耗时、退出状态及可选 solver 报告;坏诊断不能抹掉可靠测量。 +完整成本先提供已知总时长、去重计数和明确的未知覆盖,阶段分摊不伪造为零。 +检查点恢复仍需在准备恢复/分叉时补齐并验收,不能把保存成功当作恢复资格。 +有界 CLI、HTTP、真实本地进程验证只证明工程链路;模型是否采用诊断、首次质量 +是否不低于 native、能否超越平台期,仍需冻结新版本后的真实实验与合适对照。 From 6524b21a854800883fd43964e8be0167982b98c1 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 19:41:16 +0800 Subject: [PATCH 7/8] fix(research): bound guarded requests and recover native feedback Give admitted POST headers and body one shared send deadline so upstream backpressure cannot hold the Goal writer lock for a long model response. Keep stale binding refusals from poisoning a valid replacement while preserving recovery after a genuine current admission failure. Recognize only the native provider's proven pre-admission capacity refusals as rejected. Isolate malformed tar and gzip manifests per native record so unrelated verified results remain usable without false probe attribution. Serialize feedback consumption and derive wake revisions from canonical completed observation Todos. Publish each durable release immediately and reconstruct notifications after a completion-to-publication failure. Cover the repaired boundaries with real File/SQLite authority, loopback HTTP, native admission, consumer contention and replay regression cases. Signed-off-by: Codex --- benchmark/edgebench/research_feedback.py | 4 +- benchmark/runtime/research_feedback_sync.py | 106 +++++++++---- benchmark/runtime/research_feedback_wait.py | 20 ++- benchmark/runtime/research_request_guard.py | 28 ++-- benchmark/runtime/research_submission.py | 21 ++- .../tests/test_native_research_feedback.py | 112 +++++++++++++- .../benchmark_toolkit/provider_gateway.py | 48 +++++- tests/capabilities/test_provider_gateway.py | 99 ++++++++++++ .../test_native_research_continuity.py | 49 +++++- .../test_research_request_guard_races.py | 135 ++++++++++++++++- .../control_plane/test_research_scheduler.py | 143 ++++++++++++++++++ 11 files changed, 699 insertions(+), 66 deletions(-) diff --git a/benchmark/edgebench/research_feedback.py b/benchmark/edgebench/research_feedback.py index 7172e38c7a..d2c83e9bf6 100644 --- a/benchmark/edgebench/research_feedback.py +++ b/benchmark/edgebench/research_feedback.py @@ -7,6 +7,7 @@ from __future__ import annotations from datetime import datetime, timezone +import gzip import hashlib import io import json @@ -17,6 +18,7 @@ import tarfile import threading import uuid +import zlib SCHEMA = "edgebench_native_feedback_v1" MAX_ARCHIVE_BYTES = 256 * 1024 * 1024 @@ -218,7 +220,7 @@ def poll(self): # an absent exact native archive binding. try: row["candidate_sha256"], _ = archive_manifest(raw) - except (ValueError, tarfile.TarError): + except (EOFError, ValueError, gzip.BadGzipFile, tarfile.TarError, zlib.error): pass row["attribution_status"] = "verified" if status not in {"completed", "error"}: diff --git a/benchmark/runtime/research_feedback_sync.py b/benchmark/runtime/research_feedback_sync.py index 4fd456a85a..f260729837 100644 --- a/benchmark/runtime/research_feedback_sync.py +++ b/benchmark/runtime/research_feedback_sync.py @@ -7,6 +7,8 @@ from __future__ import annotations import argparse +from contextlib import contextmanager +import fcntl import hashlib import json import os @@ -110,44 +112,82 @@ def write_feedback_finding(env, packet, record, todo_id): return {"submission_id": record["submission_id"], "findings": receipts} +@contextmanager +def _consumer_lock(root): + directory = root / "ingestion" + directory.mkdir(parents=True, exist_ok=True) + with (directory / "consumer.lock").open("a") as lock: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + # The runner retries incomplete consumption. Never wait behind a + # second consumer while holding any Goal/provider authority lock. + raise RuntimeError("native_feedback_consumer_busy") from None + yield + + +def _publish_feedback_wake(env, root): + from .research_feedback_wait import _is_feedback_todo, _loopx, _scope + from benchmark.edgebench.research_feedback import canonical_json, write_bytes_atomic + + goal, agent, observer = _scope(env) + listed = _loopx(env, "todo", "list", "--goal-id", goal, "--role", "agent") + completed = sorted((todo["target_key"], todo["todo_id"]) for todo in listed["todos"] + if todo.get("status") == "done" and _is_feedback_todo(todo, observer, agent)) + # Current canonical completions, including ones absent from an older + # packet, own this hint. No publication journal or new admission authority + # is needed to recover a crash between Todo completion and notification. + revision = hashlib.sha256(canonical_json(completed)).hexdigest() + encoded = canonical_json({"revision": revision}) + path = root / "ingestion" / "wake.json" + try: + with path.open("rb") as existing: + if existing.read(len(encoded) + 1) == encoded: + return revision + except FileNotFoundError: + pass + write_bytes_atomic(path, encoded) + return revision + + def synchronize_native_feedback(env, *, packet=None): - """Called by the low-cost runner and the native submit response hook.""" + """Consume runner/manual feedback once, then hint a fresh scheduler read.""" from .research_feedback_wait import synchronize_feedback_waits, wait_for_feedback from .research_submission import declared_wait_packet, read_packet, synchronize_intents - from benchmark.edgebench.research_feedback import canonical_json, write_bytes_atomic root = Path(env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) - packet = read_packet(root) if packet is None else packet - intents = synchronize_intents(root, packet) - revision = hashlib.sha256(canonical_json(packet)).hexdigest() - - def released(observed): - # An independent, already durable result must still wake its work if a - # later result in the same packet fails and needs a separate retry. - partial = hashlib.sha256(canonical_json([revision, observed])).hexdigest() - write_bytes_atomic(root / "ingestion" / "wake.json", canonical_json({"revision": partial})) - - result = synchronize_feedback_waits(env, packet, after_release=released) - waiting = [] - for intent in intents: - if (intent.get("wait_todo_id") and intent.get("acceptance") == "accepted" - and intent.get("submission_id") and not intent.get("result_observed")): - declaration = declared_wait_packet(root, intent) - current = [row for row in declaration["records"] - if row.get("submission_id") == intent["submission_id"]] - if (len(current) == 1 and current[0].get("status") in {"completed", "error"} - and current[0].get("attribution_status") == "verified" - and current[0].get("result_ref")): - # The native process may have died before its result receipt. - # Released evidence has already satisfied the observation; - # replay must not re-defer a now completed or repurposed Todo. - continue - waiting.append(wait_for_feedback(env, todo_id=intent["wait_todo_id"], - submission_id=intent["submission_id"], packet=declaration)) - # A durable consumer readback, rather than a pending raw feedback copy, - # wakes the existing scheduler. It still owns launch/Stop/budget decisions. - write_bytes_atomic(root / "ingestion" / "wake.json", canonical_json({"revision": revision})) - return {**result, "waits": waiting, "revision": revision} + # Lock order: consumer, the short submission journal transaction, then + # individual ordinary Todo/Explore calls. No provider lock encloses this. + with _consumer_lock(root): + packet = read_packet(root) if packet is None else packet + intents = synchronize_intents(root, packet) + + def released(_observed): + # Publish each durable success before another result can fail. + _publish_feedback_wake(env, root) + + try: + result = synchronize_feedback_waits(env, packet, after_release=released) + waiting = [] + for intent in intents: + if (intent.get("wait_todo_id") and intent.get("acceptance") == "accepted" + and intent.get("submission_id") and not intent.get("result_observed")): + declaration = declared_wait_packet(root, intent) + current = [row for row in declaration["records"] + if row.get("submission_id") == intent["submission_id"]] + if (len(current) == 1 and current[0].get("status") in {"completed", "error"} + and current[0].get("attribution_status") == "verified" + and current[0].get("result_ref")): + # Released evidence must not re-defer completed or + # repurposed work after a lost native result receipt. + continue + waiting.append(wait_for_feedback(env, todo_id=intent["wait_todo_id"], + submission_id=intent["submission_id"], packet=declaration)) + finally: + # Repair a lost notification even when its Todo was already done + # before this retry, or a later independent result still fails. + revision = _publish_feedback_wake(env, root) + return {**result, "waits": waiting, "revision": revision} def main(argv=None): diff --git a/benchmark/runtime/research_feedback_wait.py b/benchmark/runtime/research_feedback_wait.py index b21614aac2..643db04d00 100644 --- a/benchmark/runtime/research_feedback_wait.py +++ b/benchmark/runtime/research_feedback_wait.py @@ -86,6 +86,14 @@ def _scope(env): return goal, agent, observer +def _is_feedback_todo(todo, observer, agent): + key = todo.get("target_key") + return (isinstance(key, str) and re.fullmatch(r"native-feedback:[0-9a-f]{32}", key) is not None + and todo.get("role") == "agent" and todo.get("claimed_by") == observer + and todo.get("task_class") == "advancement_task" and todo.get("action_kind") == "observe" + and agent in (todo.get("excluded_agents") or [])) + + def _dependency(todos, key, observer, agent): matches = [todo for todo in todos if todo.get("target_key") == key] if len(matches) > 1: @@ -93,9 +101,7 @@ def _dependency(todos, key, observer, agent): if not matches: return None todo = matches[0] - if (todo.get("role") != "agent" or todo.get("claimed_by") != observer - or todo.get("task_class") != "advancement_task" or todo.get("action_kind") != "observe" - or agent not in (todo.get("excluded_agents") or [])): + if not _is_feedback_todo(todo, observer, agent): raise ValueError("native feedback Todo belongs to another operation") return todo @@ -217,9 +223,11 @@ def main(): root = Path(os.environ.get("LOOPX_RESEARCH_FEEDBACK_ROOT", _CONTROL + "/native-feedback")) env = feedback_environment(root) - packet = json.loads((root / "native.json").read_text(encoding="utf-8")) - result = (wait_for_feedback(env, todo_id=args.todo_id, submission_id=args.submission_id, packet=packet) - if args.command == "wait" else synchronize_native_feedback(env, packet=packet)) + if args.command == "wait": + packet = json.loads((root / "native.json").read_text(encoding="utf-8")) + result = wait_for_feedback(env, todo_id=args.todo_id, submission_id=args.submission_id, packet=packet) + else: + result = synchronize_native_feedback(env) print(json.dumps(result, allow_nan=False)) return 0 diff --git a/benchmark/runtime/research_request_guard.py b/benchmark/runtime/research_request_guard.py index 9f25211e0f..1c7704705c 100644 --- a/benchmark/runtime/research_request_guard.py +++ b/benchmark/runtime/research_request_guard.py @@ -58,7 +58,7 @@ def lifetime(self, request): try: self._require_live_binding(owner, process) except Exception as error: - self._reject(request, process, error) + self._reject(request, owner, process, error) return self._bound_lifetime(request, owner, process) def _require_live_binding(self, owner, process): @@ -71,20 +71,26 @@ def _require_live_binding(self, owner, process): if time.time() >= float(self.env["LOOPX_PHASE_DEADLINE_EPOCH"]): raise FirstPartyHostRuntimeRejected("research_phase_budget_exhausted") - def _reject(self, request, process, error): + def _reject(self, request, owner, process, error): # Failure to read authority is not permission. Only admission calls # reach here; a connect/send error remains a transport observation. code = error.code if isinstance(error, FirstPartyHostRuntimeRejected) else "research_request_admission_unavailable" with self.lock: - self.refused = True - self.admission_error = self.admission_error or not isinstance(error, FirstPartyHostRuntimeRejected) request["refusal_code"] = code - self.receipt.setdefault("request_admission_refused", {"request_id": request["request_id"], "code": code}) - if process is not None and process.poll() is None: - try: - os.killpg(process.pid, signal.SIGINT) - except ProcessLookupError: - pass + current = self.admission is owner and self.process is process + if current: + # A late request retains its own rejection evidence, but must + # not poison a checkpoint replacement's valid admission. A + # genuine current refusal stays latched until the wake returns + # to its scheduler; bind() cannot clear that recovery boundary. + self.refused = True + self.admission_error = self.admission_error or not isinstance(error, FirstPartyHostRuntimeRejected) + self.receipt.setdefault("request_admission_refused", {"request_id": request["request_id"], "code": code}) + if process is not None and (current or self.process is not process) and process.poll() is None: + try: + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + pass raise ProviderGatewayRequestRejected() from None @contextmanager @@ -98,7 +104,7 @@ def _bound_lifetime(self, request, owner, process): # after that wait and immediately before the provider send. self._require_live_binding(owner, process) except Exception as error: - self._reject(request, process, error) + self._reject(request, owner, process, error) yield def observe(self, event): diff --git a/benchmark/runtime/research_submission.py b/benchmark/runtime/research_submission.py index 2a1e7a9963..eb98fee8ee 100644 --- a/benchmark/runtime/research_submission.py +++ b/benchmark/runtime/research_submission.py @@ -9,6 +9,7 @@ import argparse from contextlib import contextmanager import fcntl +import gzip import hashlib import json import os @@ -16,15 +17,23 @@ import re import shlex import sys +import tarfile import uuid import urllib.parse import urllib.request +import zlib from benchmark.edgebench.research_feedback import ( archive_manifest, canonical_json, read_archive, validate_packet, write_bytes_atomic, ) _OPERATION = re.compile(r"[0-9a-f]{32}\Z") +# Native _reserve rejects before allocating a round; this protocol exposes the +# refusal through exact detail strings, without a separate admission error code. +_NATIVE_CAPACITY_REJECTIONS = frozenset({ + "Judge capacity full; retry this snapshot later", + "Run/task evaluation capacity full; retry this snapshot later", +}) def _atomic(path, value): @@ -86,7 +95,9 @@ def bind_execution_observations(root: Path, packet: dict): manifest = probe["manifest"] try: digest, files = archive_manifest(raw, [entry["path"] for entry in manifest["files"]]) - except ValueError: + except (EOFError, ValueError, gzip.BadGzipFile, tarfile.TarError, zlib.error): + # A retained native error archive can be invalid tar. Keep + # its generic result usable without attributing a probe. continue if (digest != observation["candidate_sha256"] or digest != manifest["candidate_sha256"] or files != manifest["files"]): @@ -228,15 +239,17 @@ def record_response(root: Path, operation_id: str, http_status: int, response: d if not intent.get("dispatch_started"): raise ValueError("native_operation_not_dispatched") sid, round_id = response.get("submission_id"), response.get("round_id") + detail = response.get("detail") if (http_status == 200 and isinstance(sid, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid) and isinstance(round_id, str) and re.fullmatch(r"agent-[1-9][0-9]*", round_id) and sid not in intent["before_submission_ids"]): if intent.get("submission_id") not in (None, sid) or intent.get("round_id") not in (None, round_id): raise ValueError("native_acceptance_receipt_changed") intent.update(acceptance="accepted", submission_id=sid, round_id=round_id) - elif http_status in {400, 401, 403, 404, 413, 429} and isinstance(response.get("detail"), str): - # Native rejection responses precede admission. A 5xx, parse error - # or lost response cannot prove rejection and remains unresolved. + elif isinstance(detail, str) and (http_status in {400, 401, 403, 404, 413, 429} + or (http_status == 503 and detail in _NATIVE_CAPACITY_REJECTIONS)): + # Only proven native refusals precede admission. Other 5xx, unknown + # 503 details, parse errors and lost replies remain unresolved. intent["acceptance"] = "rejected" _atomic(path, intent) return intent diff --git a/benchmark/tests/test_native_research_feedback.py b/benchmark/tests/test_native_research_feedback.py index 484f810edb..c31b33554c 100644 --- a/benchmark/tests/test_native_research_feedback.py +++ b/benchmark/tests/test_native_research_feedback.py @@ -4,6 +4,7 @@ from contextlib import contextmanager from email.parser import BytesParser from email.policy import default +import gzip import hashlib from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import io @@ -21,7 +22,7 @@ import pytest -from benchmark.edgebench.research_feedback import NativeResearchFeedback, archive_manifest, canonical_json +from benchmark.edgebench.research_feedback import SCHEMA, NativeResearchFeedback, archive_manifest, canonical_json from benchmark.runtime.research_submission import ( bind_execution_observations, declared_wait_packet, instrument_submit_script, mark_dispatched, prepare_submission, record_response, record_result, @@ -174,6 +175,115 @@ def test_late_negative_result_binds_original_selected_manifest_not_current_files assert (tmp_path / "candidate.py").read_text() == "new workspace" +@pytest.mark.parametrize("corruption", ["invalid-gzip", "truncated-gzip", "invalid-deflate", "bad-crc"]) +def test_malformed_native_error_archive_does_not_block_independent_probe_binding(tmp_path, corruption): + with native_http(tmp_path) as (state, release, observer, root): + observation, manifest = probe_record() + register_probe(root, observation, manifest) + # Native admission accepts opaque bytes; failed archive listing releases + # an error while retaining those exact bytes, without a source manifest. + archive = archive_bytes({"candidate.py": b"original"}) + if corruption == "invalid-gzip": + invalid = b"not-a-tar-gzip" + elif corruption == "truncated-gzip": + invalid = archive[:len(archive) // 2] + else: + # Keep the tar header in one gzip member so decompression/CRC + # errors surface during member reads, after tarfile.open returns. + unpacked = gzip.decompress(archive) + first = bytearray(gzip.compress(unpacked[:512])) + if corruption == "bad-crc": + first[-8] ^= 1 + following = gzip.compress(unpacked[512:]) + else: + following = archive[:10] + b"\x07" + archive[-8:] # Reserved deflate block type. + invalid = bytes(first) + following + failed = submit_fixture(observer, invalid) + release(failed["submission_id"], status="error") + good = submit_fixture(observer, archive) + release(good["submission_id"]) + packet = observer.update(LocalCopyBackend(), None) + failed_record = next(row for row in packet["records"] if row["submission_id"] == failed["submission_id"]) + assert failed_record["status"] == "error" and failed_record["result_ref"] + assert failed_record["attribution_status"] == "verified", "the native archive/result identity remains evidence" + assert "candidate_sha256" not in failed_record, "invalid tar bytes cannot qualify a source manifest" + [bound] = bind_execution_observations(root, packet) + assert bound["submission_id"] == good["submission_id"] + assert bound["execution_observation"]["result_candidate_sha256"] == observation["candidate_sha256"] + + +@pytest.mark.parametrize("per_run_limit", [False, True], ids=["aggregate", "run-task"]) +def test_native_capacity_refusal_allows_an_explicit_later_submission(tmp_path, monkeypatch, per_run_limit): + native = pytest.importorskip("sforge.harness.judge_server") + pytest.importorskip("httpx") + from fastapi.testclient import TestClient + from sforge.harness.config import SForgeConfig + + run_id, task_id = "synthetic-run", "synthetic-task" + task = SimpleNamespace(judge=SimpleNamespace(selection="pass_rate_first", score_direction="maximize")) + monkeypatch.setattr(native, "create_backend_from_config", lambda _config: SimpleNamespace(backend_name="synthetic")) + monkeypatch.setattr(native.JudgeState, "load_tasks", lambda state: setattr(state, "tasks", {task_id: task})) + config = SForgeConfig(log_dir=tmp_path / "judge", tasks_dir=tmp_path / "tasks", + judge_max_concurrent=1, judge_max_pending=0, + judge_max_outstanding_per_run=1 if per_run_limit else None) + with TestClient(native.create_app(config)) as client: + state = client.app.state.judge + token = state.register_session(task_id, run_id) + def history(): + response = client.get("/api/v1/history", params={"token": token}) + response.raise_for_status() + return response.json() + root = tmp_path / "feedback" + root.mkdir() + (root / "native.json").write_bytes(canonical_json(dict( + schema_version=SCHEMA, run_id=run_id, task_id=task_id, records=[]))) + archive = tmp_path / "packaged.tar.gz" + archive.write_bytes(archive_bytes({"candidate.py": b"original"})) + prepared = prepare_submission(root, archive, history_reader=history) + mark_dispatched(root, prepared["operation_id"]) + # Occupy real native admission capacity without starting an evaluation. + state._reserve(run_id, task_id) + try: + response = client.post("/api/v1/submit", data={"token": token}, + files={"archive": ("submission.tar.gz", Path(prepared["archive_path"]).read_bytes())}) + assert response.status_code == 503 and response.headers["retry-after"] == "5" + intent = record_response(root, prepared["operation_id"], response.status_code, response.json()) + assert intent["acceptance"] == "rejected" + finally: + state._release(run_id, task_id) + assert history()["entries"] == [] + assert state.resolve_token(token)["next_agent"] == 1 + state._reserve(run_id, task_id) + state._release(run_id, task_id) + later = prepare_submission(root, archive, history_reader=history) + assert later["action"] == "submit" and later["operation_id"] != prepared["operation_id"] + assert not state.submissions and history()["entries"] == [], "neither response handling nor prepare resubmits" + + +@pytest.mark.parametrize("http_status,response", [ + (503, {"detail": "Service Unavailable"}), + (503, {"detail": "Judge capacity full; retry this snapshot later; acceptance unknown"}), + (500, {"detail": "Judge capacity full; retry this snapshot later"}), + (502, {"detail": "Bad Gateway"}), + (503, {"detail": {"message": "Judge capacity full; retry this snapshot later"}}), +]) +def test_unproven_server_failure_cannot_allow_resubmission(tmp_path, http_status, response): + root = tmp_path / "feedback" + root.mkdir() + (root / "native.json").write_bytes(canonical_json(dict(schema_version=SCHEMA, + run_id="synthetic-run", task_id="synthetic-task", records=[]))) + archive = tmp_path / "packaged.tar.gz" + archive.write_bytes(archive_bytes({"candidate.py": b"original"})) + prepared = prepare_submission(root, archive, + history_reader=lambda: {"run_id": "synthetic-run", "entries": []}) + mark_dispatched(root, prepared["operation_id"]) + intent = record_response(root, prepared["operation_id"], http_status, response) + assert intent["acceptance"] == "unknown" + with pytest.raises(ValueError, match="native_acceptance_unknown_no_resubmit"): + prepare_submission(root, archive) + assert len(list((root / "intents").glob("*.json"))) == 1 + + def test_lost_reply_recovers_same_operation_and_never_resubmits(tmp_path): with native_http(tmp_path) as (state, release, observer, root): archive = tmp_path / "packaged.tar.gz" diff --git a/loopx/capabilities/benchmark_toolkit/provider_gateway.py b/loopx/capabilities/benchmark_toolkit/provider_gateway.py index 6fd7f660b6..6e8ad21187 100644 --- a/loopx/capabilities/benchmark_toolkit/provider_gateway.py +++ b/loopx/capabilities/benchmark_toolkit/provider_gateway.py @@ -11,6 +11,7 @@ import http.client import json import re +import sys import threading import time import uuid @@ -30,6 +31,10 @@ class RunnerOwnedProviderGateway: _DEFAULT_ALLOWED_PATHS = ("/responses", "/responses/compact") +# An authority lifetime may hold the Goal writer lock (whose ordinary wait is +# five seconds). Bound its network send independently of long model responses; +# gateways without an authority lifetime retain their existing timeout policy. +_GUARDED_SEND_TIMEOUT_SEC = 1.0 _HOP_BY_HOP_HEADERS = { "connection", "keep-alive", @@ -126,6 +131,41 @@ def _upstream_connection( return http.client.HTTPConnection(host, port, timeout=timeout_sec) +@contextmanager +def _bounded_request_send(connection: http.client.HTTPConnection, timeout_sec: float): + """Give headers and the in-memory body one shared transport deadline.""" + sock = connection.sock + if sock is None: + raise http.client.NotConnected() + deadline = time.monotonic() + timeout_sec + original_timeout, original_send = sock.gettimeout(), connection.send + + def send(data): + # HTTPConnection supplies encoded headers and our in-memory bytes body. + # Keep its audit event, but own the partial-write loop: SSLSocket's + # sendall can otherwise give successive SSL writes a fresh timeout. + sys.audit("http.client.send", connection, data) + with memoryview(data) as view: + offset = 0 + while offset < len(view): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("provider request send deadline exhausted") + sock.settimeout(remaining) + sent = sock.send(view[offset:]) + if sent <= 0: + raise ConnectionError("provider connection closed during send") + offset += sent + + connection.send = send + try: + yield + finally: + connection.send = original_send + if connection.sock is sock: + sock.settimeout(original_timeout) + + @contextmanager def serve_runner_owned_provider_gateway( *, @@ -147,6 +187,8 @@ def serve_runner_owned_provider_gateway( A request_lifetime factory captures the caller binding before connection setup; its context is entered only for the admitted POST send. The factory must not retain authority locks while DNS/TCP/CONNECT/TLS can block. + Guarded headers and body share a one-second send budget; response reads + still use request_timeout_sec, as do unguarded requests. """ upstream = _validated_upstream(upstream_base_url) @@ -237,7 +279,11 @@ def do_POST(self) -> None: observed["forwarded"] = None # A send failure may still have reached the upstream. if observe_request is not None: observe_request({**observed, "event": "dispatch"}) - connection.request("POST", upstream_path, body=body, headers=headers) + send_lifetime = (_bounded_request_send(connection, + min(request_timeout_sec, _GUARDED_SEND_TIMEOUT_SEC)) + if request_lifetime is not None else nullcontext()) + with send_lifetime: + connection.request("POST", upstream_path, body=body, headers=headers) observed["forwarded"] = True response = connection.getresponse() observed["status"] = response.status diff --git a/tests/capabilities/test_provider_gateway.py b/tests/capabilities/test_provider_gateway.py index 26583689c0..b037170c02 100644 --- a/tests/capabilities/test_provider_gateway.py +++ b/tests/capabilities/test_provider_gateway.py @@ -1,11 +1,13 @@ from __future__ import annotations +import http.client import json import threading import urllib.error import urllib.request from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace import pytest @@ -238,6 +240,103 @@ def lifetime(_request): assert connects == [True] +@pytest.mark.parametrize("partial_write", [False, True], ids=["headers-and-body", "partial-write-progress"]) +def test_guarded_headers_and_body_share_one_send_budget(monkeypatch, partial_write): + # Model two writes which each need 0.75s. A per-write 1s timeout would let + # both succeed; the complete request is allowed only 1s, including headers. + now = [0.0] + timeouts = [] + + class SlowSocket: + timeout = 600.0 + + def gettimeout(self): + return self.timeout + + def settimeout(self, seconds): + self.timeout = seconds + + def send(self, data): + timeouts.append(self.timeout) + if self.timeout < 0.75: + now[0] += self.timeout + raise TimeoutError("synthetic send backpressure") + now[0] += 0.75 + return 1 if partial_write else len(data) + + monkeypatch.setattr(gateway_module, "time", SimpleNamespace(monotonic=lambda: now[0])) + connection = http.client.HTTPConnection("unused.invalid", timeout=600) + connection.sock = SlowSocket() + with pytest.raises(TimeoutError): + with gateway_module._bounded_request_send(connection, 1.0): + connection.request("POST", "/responses", body=b"{}") + assert timeouts == [1.0, 0.25] + assert now[0] == 1.0 + assert connection.sock.gettimeout() == 600.0 + + +def test_guarded_response_stream_can_outlive_the_send_budget(): + streaming, release, lifetime_exited, done = (threading.Event() for _ in range(4)) + result, events = {}, [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, _format, *args): + return + + def do_POST(self): + self.rfile.read(int(self.headers["Content-Length"])) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok":') + self.wfile.flush() + streaming.set() + if release.wait(10): + self.wfile.write(b'true}') + + @contextmanager + def lifetime(_request): + try: + yield + finally: + lifetime_exited.set() + + def read(gateway): + try: + with urllib.request.urlopen(urllib.request.Request(gateway.base_url + "/responses", + data=b"{}"), timeout=10) as response: + result["body"] = json.load(response) + except Exception as error: + result["error"] = type(error).__name__ + finally: + done.set() + + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + server.daemon_threads = True + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + reader = None + try: + with serve_runner_owned_provider_gateway( + upstream_base_url=f"http://127.0.0.1:{server.server_port}/v1", + upstream_bearer_token="fixture-credential", request_timeout_sec=10, + request_lifetime=lifetime, observe_request=events.append) as gateway: + reader = threading.Thread(target=read, args=(gateway,)) + reader.start() + assert streaming.wait(5) and lifetime_exited.wait(5) + assert not done.wait(gateway_module._GUARDED_SEND_TIMEOUT_SEC + 0.25) + release.set() + reader.join(timeout=10) + assert not reader.is_alive() and result == {"body": {"ok": True}} + finally: + release.set() + if reader is not None: + reader.join(timeout=10) + server.shutdown() + server_thread.join(timeout=5) + assert events[-1]["event"] == "finished" and events[-1]["forwarded"] is True + + @pytest.mark.parametrize( "upstream", ( diff --git a/tests/control_plane/test_native_research_continuity.py b/tests/control_plane/test_native_research_continuity.py index a2362d5d35..c863e0ee53 100644 --- a/tests/control_plane/test_native_research_continuity.py +++ b/tests/control_plane/test_native_research_continuity.py @@ -10,10 +10,10 @@ from benchmark.runtime.research_probe import run_probe from benchmark.runtime.research_feedback_sync import synchronize_native_feedback from benchmark.runtime.research_submission import ( - mark_dispatched, prepare_submission, record_response, synchronize_intents, + mark_dispatched, prepare_submission, record_response, register_probe, synchronize_intents, ) from benchmark.tests.test_native_research_feedback import ( - LocalCopyBackend, archive_bytes, native_http, submit_fixture, + LocalCopyBackend, archive_bytes, native_http, probe_record, submit_fixture, ) from test_research_feedback_wait import runtime as runtime # real File/SQLite fixture import test_quota_settlement_cli as fixture @@ -60,21 +60,62 @@ def test_released_independent_work_gets_a_wake_even_when_a_later_result_needs_re released["records"].append({**released["records"][0], "submission_id": "submission-2", "round_id": "agent-2", "result_ref": "native-result:submission-2"}) original = research_feedback_sync.write_feedback_finding + signal = Path(runtime.env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) / "ingestion" / "wake.json" + published = [] def unavailable(env, packet, record, todo_id): if record["submission_id"] == "submission-2": + # The first release must be visible while this consumer still + # owns the lock, before a later result has returned or failed. + published.append(json.loads(signal.read_text())["revision"]) + assert runtime.quota()["should_run"] raise RuntimeError("synthetic independent result needs retry") return original(env, packet, record, todo_id) monkeypatch.setattr(research_feedback_sync, "write_feedback_finding", unavailable) with pytest.raises(RuntimeError, match="independent result needs retry"): synchronize_native_feedback(runtime.env, packet=released) - signal = Path(runtime.env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) / "ingestion" / "wake.json" - assert len(json.loads(signal.read_text())["revision"]) == 64 + assert len(published) == 1 and len(published[0]) == 64 + assert json.loads(signal.read_text())["revision"] == published[0] assert next(row for row in todos(runtime)["todos"] if row["todo_id"] == dependency)["status"] == "done" assert runtime.quota()["should_run"], "one failing result cannot suppress already released work" +def test_error_archive_and_independent_probe_result_reach_real_writeback(runtime): + from benchmark.runtime.research_feedback_wait import wait_for_feedback + + native = runtime.temp / "native" + native.mkdir() + with native_http(native) as (_state, release, observer, root): + env = dict(runtime.env, LOOPX_RESEARCH_FEEDBACK_ROOT=str(root)) + register_probe(root, *probe_record()) + invalid = submit_fixture(observer, b"not a gzip archive") + valid = submit_fixture(observer, archive_bytes({"candidate.py": b"original"})) + pending = observer.update(LocalCopyBackend(), None) + wait_for_feedback(env, todo_id=fixture.TODO_ID, + submission_id=valid["submission_id"], packet=pending) + assert not runtime.quota()["should_run"] + observer.after_publish = lambda _backend, _handle, packet: synchronize_native_feedback(env, packet=packet) + release(invalid["submission_id"], status="error") + release(valid["submission_id"], score=0) + completed = observer.update(LocalCopyBackend(), None) + records = {row["submission_id"]: row for row in completed["records"]} + error_ref = records[invalid["submission_id"]]["result_ref"] + valid_ref = records[valid["submission_id"]]["result_ref"] + assert "candidate_sha256" not in records[invalid["submission_id"]] + context = runtime.call("explore", "turn-context", *runtime.binding) + findings = context["graph"]["writeback_results"] + [error] = [row for row in findings if error_ref in row["evidence_refs"]] + assert '"status": "error"' in error["summary"] and not error.get("execution_observation") + valid_findings = [row for row in findings if valid_ref in row["evidence_refs"]] + assert len(valid_findings) == 2 + [bound] = [row for row in valid_findings if row.get("execution_observation")] + assert bound["execution_observation"]["candidate_sha256"] == probe_record()[0]["candidate_sha256"] + assert runtime.quota()["should_run"], "an unrelated invalid archive must not strand released work" + assert synchronize_native_feedback(env, packet=completed)["released"] == [] + assert runtime.call("explore", "turn-context", *runtime.binding)["graph"]["writeback_results"] == findings + + def test_native_result_reaches_replan_and_a_changed_real_experiment(runtime, monkeypatch): monkeypatch.chdir(runtime.project) native_root = runtime.temp / "native" diff --git a/tests/control_plane/test_research_request_guard_races.py b/tests/control_plane/test_research_request_guard_races.py index e2b99069ea..9e47f07e5a 100644 --- a/tests/control_plane/test_research_request_guard_races.py +++ b/tests/control_plane/test_research_request_guard_races.py @@ -4,6 +4,7 @@ from contextlib import contextmanager import json import os +import socket import socketserver import subprocess import sys @@ -57,10 +58,10 @@ def _native_process(): process.wait(timeout=5) -def _send(guard, result): +def _send(guard, result, body=b'{"input":"synthetic request"}'): try: with urllib.request.urlopen(urllib.request.Request(guard.base_url + "/responses", - data=b'{"input":"synthetic request"}'), timeout=40) as response: + data=body), timeout=40) as response: result["status"] = response.status except urllib.error.HTTPError as error: result["status"] = error.code @@ -174,6 +175,76 @@ def handle(self): assert events[0]["loopx_turn_id"] == env["LOOPX_TURN"] +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_backpressured_post_releases_the_goal_stop_writer(tmp_path, monkeypatch, provider): + env, session, initial, registry = _admitted_owner(tmp_path, monkeypatch, provider) + headers_received, release = threading.Event(), threading.Event() + original_connection = gateway_module._upstream_connection + + def connection(*args, **kwargs): + current = original_connection(*args, **kwargs) + connect = current.connect + + def connect_with_small_buffer(): + connect() + current.sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096) + + current.connect = connect_with_small_buffer + return current + + class BackpressuredUpstream(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(10) + headers = bytearray() + while not headers.endswith(b"\r\n\r\n"): + chunk = self.request.recv(1) + if not chunk: + return + headers.extend(chunk) + headers_received.set() + # No body reads: even a modest request fills the real TCP buffers. + release.wait(20) + + monkeypatch.setattr(gateway_module, "_upstream_connection", connection) + with socketserver.ThreadingTCPServer(("127.0.0.1", 0), BackpressuredUpstream) as upstream, _native_process() as process: + upstream.daemon_threads = True + upstream.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) + upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) + upstream_thread.start() + current_env = dict(env, OPENAI_BASE_URL=f"http://127.0.0.1:{upstream.server_address[1]}/v1") + wake = tmp_path / "body-send-wake" + wake.mkdir() + guard = ResearchRequestGuard(current_env, {"turn_id": "wake-body-send"}, wake) + response, reader = {}, None + try: + with guard.serve(): + guard.bind(process, current_env, session, original_quota=initial) + body = json.dumps({"input": "x" * (128 * 1024)}).encode() + reader = threading.Thread(target=_send, args=(guard, response, body)) + reader.start() + assert headers_received.wait(30), "request did not reach the admitted POST" + with project_registry_transaction(registry, operation="fixture_stop_during_body_send") as transaction: + payload = transaction.payload_copy() + goal = next(row for row in payload["goals"] if row["id"] == fixture.GOAL_ID) + goal["activation"] = {"schema_version": "loopx_goal_activation_v1", "state": "stopped", + "updated_at": "2026-01-01T00:00:00Z", "reason": "Synthetic Stop during POST backpressure"} + transaction.commit(payload) + assert not release.is_set(), "Stop must commit without waiting for the upstream to drain" + assert json.loads(registry.read_text())["goals"][0]["activation"]["state"] == "stopped" + reader.join(timeout=10) + assert not reader.is_alive() and response == {"status": 502} + assert not guard.refused and process.poll() is None, "transport timeout is not an admission refusal" + finally: + release.set() + if reader is not None: + reader.join(timeout=40) + upstream.shutdown() + upstream_thread.join(timeout=5) + events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + assert [event["event"] for event in events] == ["dispatch", "finished"] + assert all(event["forwarded"] is None for event in events), "partial sends retain unknown acceptance" + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_pending_connection_cannot_borrow_a_rebound_process_or_turn(tmp_path, monkeypatch, provider): env, session, initial, _registry = _admitted_owner(tmp_path, monkeypatch, provider) @@ -214,7 +285,61 @@ def slow_connect(): assert not reader.is_alive() and response == {"status": 409} assert upstream == {} and replacement.poll() is None original.wait(timeout=5) - assert receipt["request_admission_refused"]["code"] == "research_request_without_live_owner" + assert not guard.refused and not guard.admission_error + assert "request_admission_refused" not in receipt + # A replacement legitimately resuming the admitted original Turn + # must still be able to send, after the stale request was refused. + guard.bind(replacement, env, session, original_quota=initial) + next_response = {} + _send(guard, next_response) + assert next_response == {"status": 200} + assert upstream["body"] and replacement.poll() is None + events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] + finished = [event for event in events if event["event"] == "finished"] + assert [event["forwarded"] for event in finished] == [False, True] + assert finished[0]["refusal_code"] == "research_request_without_live_owner" + assert finished[0]["loopx_turn_id"] == env["LOOPX_TURN"], "failed request retains its original Turn" + + +def test_current_admission_failure_stays_latched_across_rebinding(tmp_path): + @contextmanager + def unavailable(**_kwargs): + raise OSError("synthetic authority read failure") + yield + + entered = [] + + @contextmanager + def healthy(**_kwargs): + entered.append(True) + yield + + wake = tmp_path / "failed-admission-wake" + wake.mkdir() + receipt = {"turn_id": "wake-failed-admission"} + owner = SimpleNamespace(launch_lifetime=unavailable) + session = SimpleNamespace(session_id="native-failed-admission", goal_ref=None, + admission=SimpleNamespace(with_launch_decision=lambda _check: owner)) + with _recording_upstream() as (url, upstream), _native_process() as original, _native_process() as replacement: + env = dict(OPENAI_BASE_URL=url, OPENAI_API_KEY="fixture-credential", NO_PROXY="127.0.0.1", + LOOPX_TURN="failed-admission-turn", LOOPX_GOAL_ID="fixture-goal", LOOPX_AGENT_ID="fixture-agent", + LOOPX_PHASE_DEADLINE_EPOCH=str(time.time() + 180)) + guard = ResearchRequestGuard(env, receipt, wake) + with guard.serve(): + guard.bind(original, env, session) + response = {} + _send(guard, response) + assert response == {"status": 409} + assert guard.refused and guard.admission_error + assert receipt["request_admission_refused"]["code"] == "research_request_admission_unavailable" + original.wait(timeout=5) + owner = SimpleNamespace(launch_lifetime=healthy) + guard.bind(replacement, env, session) + next_response = {} + _send(guard, next_response) + assert next_response == {"status": 409} + replacement.wait(timeout=5) + assert guard.refused and guard.admission_error and not entered and not upstream events = [json.loads(line) for line in (wake / "provider-requests.jsonl").read_text().splitlines()] - assert len(events) == 1 and events[0]["forwarded"] is False - assert events[0]["loopx_turn_id"] == env["LOOPX_TURN"], "failed request retains its original Turn" + assert [event["refusal_code"] for event in events] == [ + "research_request_admission_unavailable", "research_original_request_recovery_required"] diff --git a/tests/control_plane/test_research_scheduler.py b/tests/control_plane/test_research_scheduler.py index a057d7ed27..ecb10400c2 100644 --- a/tests/control_plane/test_research_scheduler.py +++ b/tests/control_plane/test_research_scheduler.py @@ -1,7 +1,14 @@ """A durable feedback hint wakes the existing worker, never grants dispatch.""" from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor +import copy import json +from pathlib import Path +import subprocess +import sys +import threading +import time from types import SimpleNamespace import pytest @@ -58,6 +65,22 @@ def test_research_scheduler_entry_is_explicit_and_reuses_ordinary_arguments(tmp_ assert called[0][1]["sleep"].path == tmp_path / "ingestion/wake.json" +def test_feedback_sync_cli_refuses_overlap_before_reading_native_packet(tmp_path, monkeypatch): + from benchmark.runtime.research_feedback_sync import _consumer_lock + from benchmark.runtime.research_feedback_wait import main as feedback_main + + monkeypatch.setenv("LOOPX_RESEARCH_FEEDBACK_ROOT", str(tmp_path)) + monkeypatch.setattr(sys, "argv", ["loopx-research-feedback", "sync"]) + (tmp_path / "owner.json").write_text(json.dumps({"LOOPX_RESEARCH_FEEDBACK_ROOT": str(tmp_path)})) + # There is deliberately no native packet: the entry point must acquire + # consumer ownership before reading a snapshot that could become stale. + with _consumer_lock(tmp_path): + with pytest.raises(RuntimeError, match="native_feedback_consumer_busy"): + feedback_main() + with pytest.raises(FileNotFoundError, match="native.json"): + feedback_main() # The failed contender did not retain the lock. + + def test_event_rechecks_real_quota_and_only_released_dependency_dispatches(runtime, monkeypatch): original = wait_fixture.waiting(runtime) event = runtime.temp / "native-feedback/ingestion/wake.json" @@ -103,3 +126,123 @@ def wake(_command, **_kwargs): assert worker.run_worker(args, sleep=sleeper) == 0 assert len(dispatches) == 1 and now[0] == 2 assert json.loads((runtime.temp / "scheduler.json").read_text())["unchanged_count"] == 0 + + +def test_overlapping_consumers_and_old_packets_preserve_newly_runnable_work(runtime, monkeypatch, record_property): + from benchmark.edgebench.research_feedback import canonical_json, write_bytes_atomic + from benchmark.runtime import research_feedback_sync as consumer + from benchmark.runtime.research_feedback_wait import wait_for_feedback + + root = Path(runtime.env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) + old = wait_fixture.packet(status="completed", attribution="verified") + pending = wait_fixture.packet(submission="submission-2")["records"][0] + pending["round_id"] = "agent-2" + old["records"].append(pending) + write_bytes_atomic(root / "native.json", canonical_json(old)) + wait_for_feedback(runtime.env, todo_id=wait_fixture.fixture.TODO_ID, + submission_id="submission-2", packet=old) + consumer.synchronize_native_feedback(runtime.env) + assert not runtime.quota()["should_run"] + event = root / "ingestion/wake.json" + before = event.read_bytes() + now, pauses = [0.0], [] + + def pause(seconds): + pauses.append(seconds) + now[0] += seconds + + sleeper = FeedbackEventSleep(event, clock=lambda: now[0], pause=pause) + entered, finish = threading.Event(), threading.Event() + original_publish = consumer._publish_feedback_wake + + def delayed_publication(env, feedback_root): + entered.set() + assert finish.wait(timeout=45), "the test must release its paused consumer" + return original_publish(env, feedback_root) + + new = copy.deepcopy(old) + new["records"][1] = wait_fixture.packet(status="completed", attribution="verified", + submission="submission-2")["records"][0] + new["records"][1]["round_id"] = "agent-2" + with monkeypatch.context() as patch, ThreadPoolExecutor(max_workers=1) as executor: + patch.setattr(consumer, "_publish_feedback_wake", delayed_publication) + older = executor.submit(consumer.synchronize_native_feedback, runtime.env) + try: + assert entered.wait(timeout=45), "the old snapshot did not reach publication" + write_bytes_atomic(root / "native.json", canonical_json(new)) + # The real second process must be refused before reading the new + # snapshot or touching any Goal/provider state. There is no polling + # sleep or elapsed-time threshold deciding the race's outcome. + contender = subprocess.run([sys.executable, "-c", """ +import os +from benchmark.runtime.research_feedback_sync import synchronize_native_feedback +try: + synchronize_native_feedback(dict(os.environ)) +except RuntimeError as error: + assert str(error) == "native_feedback_consumer_busy", str(error) + print("busy") +else: + raise AssertionError("a second consumer entered while publication was held") +"""], env=runtime.env, cwd=Path(__file__).resolve().parents[2], + text=True, capture_output=True, timeout=15, check=True) + assert contender.stdout.strip() == "busy" + assert event.read_bytes() == before + assert not runtime.quota()["should_run"], "contention cannot grant model admission" + finally: + finish.set() + assert older.result(timeout=45)["released"] == [] + + started = time.monotonic() + released = consumer.synchronize_native_feedback(runtime.env) + record_property("feedback_consumer_seconds", time.monotonic() - started) + assert len(released["released"]) == 1 and runtime.quota()["should_run"] + after = event.read_bytes() + assert after != before + # A delayed caller may still hold the older in-memory packet. Its readback + # must include the newer canonical completion, not restore the old marker. + assert consumer.synchronize_native_feedback(runtime.env, packet=old)["released"] == [] + assert event.read_bytes() == after + sleeper(600) + assert now[0] == 0 and pauses == [] + assert consumer.synchronize_native_feedback(runtime.env, packet=new)["released"] == [] + sleeper(3) + assert now[0] == 3, "unchanged feedback must not manufacture another wake" + + +def test_retry_rebuilds_notification_after_todo_completion_was_durable(runtime, monkeypatch): + from benchmark.edgebench import research_feedback as producer + from benchmark.runtime import research_feedback_sync as consumer + + wait_fixture.waiting(runtime) + pending = wait_fixture.packet() + consumer.synchronize_native_feedback(runtime.env, packet=pending) + event = Path(runtime.env["LOOPX_RESEARCH_FEEDBACK_ROOT"]) / "ingestion/wake.json" + before = event.read_bytes() + completed = wait_fixture.packet(status="completed", attribution="verified") + writer = producer.write_bytes_atomic + + def unavailable(path, raw, **kwargs): + if path == event: + raise OSError("synthetic wake publication unavailable") + return writer(path, raw, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(producer, "write_bytes_atomic", unavailable) + with pytest.raises(OSError, match="wake publication unavailable"): + consumer.synchronize_native_feedback(runtime.env, packet=completed) + assert event.read_bytes() == before + assert runtime.quota()["should_run"], "notification failure must not undo committed evidence" + context = runtime.call("explore", "turn-context", *runtime.binding) + assert len(context["graph"]["writeback_results"]) == 1 + now = [0.0] + sleeper = FeedbackEventSleep(event, clock=lambda: now[0], + pause=lambda seconds: now.__setitem__(0, now[0] + seconds)) + # Even an older packet whose row still says running must recover the wake + # from the already completed canonical observation, without another result. + recovered = consumer.synchronize_native_feedback(runtime.env, packet=pending) + assert recovered["released"] == [] and event.read_bytes() != before + sleeper(600) + assert now[0] == 0 + consumer.synchronize_native_feedback(runtime.env, packet=completed) + sleeper(3) + assert now[0] == 3 From 6768ff30e065ccbf5c672dfb006e93d754f30a0c Mon Sep 17 00:00:00 2001 From: song Date: Mon, 19 Oct 2026 19:41:18 +0800 Subject: [PATCH 8/8] docs(research): explain bounded sends and feedback recovery Document the guarded send deadline, stale-binding isolation, explicit retry after proven native capacity refusal, malformed archive isolation and canonical feedback notification recovery in English and Chinese. Signed-off-by: Codex --- benchmark/runtime/RESEARCH_LOOP.md | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/benchmark/runtime/RESEARCH_LOOP.md b/benchmark/runtime/RESEARCH_LOOP.md index 9fe2933195..f1d0e21141 100644 --- a/benchmark/runtime/RESEARCH_LOOP.md +++ b/benchmark/runtime/RESEARCH_LOOP.md @@ -34,6 +34,12 @@ outside the authority lock. The original owner/process is pinned before that connection; its binding, liveness, current control and phase deadline are checked again immediately before sending. A delayed old request cannot borrow a newer process or Turn, and a slow connection does not prevent a concurrent Stop write. +For guarded requests, headers and body share a one-second send deadline (or +the caller's shorter timeout); response reads retain their original timeout. +An incomplete send releases the authority lock and remains ambiguous. Refusing +an obsolete binding interrupts only its captured process; it cannot mark a +valid replacement binding refused. A current binding's refusal still requires +the existing recovery path. Private `provider-requests.jsonl` correlates wake, LoopX Turn, native session/thread/Turn when supplied, client request, gateway request and returned @@ -117,6 +123,15 @@ if the workspace has changed. It never posts that unresolved operation again. An unresolved acceptance stops that retry with an explicit unknown reason; independent research may continue. Receiving external feedback does not pretend that the interrupted native client observed its result or completed its operation. +The supported native provider's two exact capacity-full 503 responses are +pre-admission refusals: they consume neither a round nor a submission. Those +operations retain their snapshots but are marked rejected, allowing a later +explicit submit invocation. Other 5xx, unrecognized 503 responses and lost +responses remain unknown; the response hook never automatically resubmits. +If the retained archive and result identities are verified but the archive +cannot be parsed, its native error remains a generic finding without a bound +probe manifest. That record does not prevent unrelated verified results from +being ingested. ## Explicit feedback dependencies and wakeup @@ -146,7 +161,15 @@ native error results remain evidence; neither automatically marks research block Consumption runs under a 60-second process-group timeout with bounded cleanup; durable results can release independent work even if a later item needs retry. -The consumer then publishes a revision signal. The opt-in scheduler wrapper +Consumers serialize ingestion with a nonblocking local lock; a busy consumer +leaves retry to the existing runner. The revision signal is derived from the +current canonical completed observation Todos, rather than the caller's possibly +older feedback packet. Each successful release publishes it immediately, and +replay reconstructs it if completion persisted before notification failed. +An old packet cannot replace a newer completion signal, and unchanged completed +observations do not create fresh wakeups. + +The opt-in scheduler wrapper observes that small file during its ordinary sleep and promptly returns to the same scheduler's quota check. Repeated or invalid signals do not busy-loop, and a signal alone grants no model admission. When all model work is waiting, the @@ -197,12 +220,22 @@ Goal/Todo/Host/Explore 上。候选完成只释放后续研究;本次没有包 原生归档。归档哈希与所选文件清单哈希分别保存并验证关系。结果晚到时始终指向 原候选,不改写新工作区或自动采用、回滚。提交响应丢失时恢复原操作,无法唯一 匹配时保留未知,不重复消耗评测机会。首次候选按实际接受顺序评价。 +支持的原生 provider 在受理前返回的两种明确容量 503 会记为拒绝,保留快照并允许 +稍后显式提交;其他 5xx、无法识别的 503 与丢失响应仍为未知,不自动重发。 +原始归档与结果身份可靠但归档无法解析时,保留未绑定探针的通用错误 finding, +不阻断独立正常结果,也不声称坏归档具有已验证的候选文件清单。 只有显式 `--wait-todo` 或反馈依赖命令才延后指定 Todo。运行器负责真实外部观察, 先写入 Explore 并读回,再完成观察依赖;其自身不占模型 Turn 或预算。 全部可执行工作等待时停止模型请求;有独立工作时保持准入。新反馈信号打断 调度休眠后仍需通过当前准入,不通过换 Turn、重发操作或重置预算逃出等待。 准入锁前后的原进程、绑定和截止时间都校验,慢连接也不会占住 Stop 的写锁。 +带准入保护的请求头与请求体共用一秒发送期限(调用者设置更短时沿用更短值), +响应读取仍用原超时;发送不完整会释放锁并保留未知转发状态。旧绑定的拒绝只影响 +旧进程,不能污染合法替换进程;当前绑定的真实拒绝仍须走原恢复路径。 +反馈消费者非阻塞互斥,繁忙时由原运行器稍后重试。唤醒版本来自当前 canonical +已完成观察 Todo,独立结果完成后立即发布;完成已落盘但通知失败时可重建补发, +旧包不能覆盖新完成信号,重复消费也不会持续制造唤醒。 探针保留实际耗时、退出状态及可选 solver 报告;坏诊断不能抹掉可靠测量。 完整成本先提供已知总时长、去重计数和明确的未知覆盖,阶段分摊不伪造为零。