"""Harness² 接线层:把 cloud/harness2.py 的三个协议落到真实 codex 任务上。 只用控制面已有的 API,不新增任何服务端端点: POST /uploads(multipart + paths JSON)-> upload_id POST /tasks(upload_id 绑定 + idempotency_key 幂等)-> 任务排队 GET /tasks/{id} 轮询终态(completed / failed / interrupted / merged / merge-failed) GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源) GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD, 这是现有 cost 模型的边界,不是本层漏算) POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱 认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、 不打印密钥。 三类任务是怎么跑的: - 探针(CodexProbeAdapter.execute):bundle = 源文件 + harness 树。 Worker 派发时把 bundle 经 host.py --seed-dir 拷进 worktree,harness 文件会覆盖仓库同名文件——这正是探针要的效果:同一任务在不同 harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见 _PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须 把交付物完整写在最终回复里,不能只说“文件已写好”。这就是本层 最大的适用边界:交付物是最终回复文本的任务;文件型产物(代码 diff、报告文件)目前只能靠最终回复复述/摘要,worktree 快照 API 只给 diff 截断与未跟踪文件列表、不给完整文件内容,跨主机还读 不到——文件产物评审需要新增文件读取端点,另行评审,不在本层 假装已解决。 - 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md / rollouts / deliverables / harness / library / pool / decisions)。 agent 在 worktree 的 context/harness/ 里改文件,最终回复里用 文件块只输出改动的文件(没改的省略即继承),外加 块。 接线层解析文件块、落到该阶段的 fallback 树上,再走核心校验。 最终回复里既没有文件块也没有 ,或文件块路径越界、未 闭合,都算该阶段失败:核心循环给一次修正机会,再失败回落到 fallback 树——绝不把“没看懂输出格式”静默当成“无需改动”。 - 选择(CodexSelector.select)与反思(CodexReflector.reflect):同款 bundle + 最终回复解析(```choice + ; + ), 不读 worktree 文件。选择失败时核心循环回落到已交付终版;反思 三项不全时返回 {},不入库。 幂等与断点续跑:每个任务都带 idempotency_key = f"h2-{run_tag}-{role}-{label}"。同一 run_tag 重跑时服务端直接返回 已有任务(200 而非 201),已终态的任务直接复用结果——这是断点 续跑语义,不是 bug;想从头重跑必须换 run_tag。注意:换了 harness 内容但沿用旧 run_tag 时,旧任务结果会被复用,改进轮次就白跑了, 所以 run_tag 必须随“任务 + harness 基线 + 参数”一起变,调用方 (见 examples/harness2_run.py)默认用时间戳生成,显式 --run-tag 只用于“接着上次断点跑”。 成本与审批:本层不拼任何 codex 参数、不改任务默认值。model / effort / approval / environment_id / executor 全部由调用方透传, 缺省时走 Worker 现有默认值;探针与编辑任务走与普通任务完全相同 的执行器、沙箱、租户隔离、配额门与审计。长任务走 #133 的自动续 跑;接线层单任务超时默认 3600 秒,超时先取消任务再记失败(探针) 或抛错走回落(编辑/选择/反思),不留任务在后台继续烧钱。 本层是同步阻塞的:improve_task 每一步都要等任务终态。k=1 parallel 一轮 = 1 base + 2 候选 + 1 终版探针 + 3 编辑 + 1 选择 + 1 反思 ≈ 9 个任务串行等待,每个都可能几分钟——这是方法本身的 成本(上游同款:4k 次执行 + 3k 次改进会话),不是本层低效。 """ from __future__ import annotations import json import time import urllib.error import urllib.request import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Protocol from .harness2 import ( EditResult, Rollout, Tree, improve_task, parse_choice, parse_experience, parse_harness_files, parse_no_change, parse_notes, parse_why, read_tree, ) TERMINAL_STATUSES = frozenset( {"completed", "failed", "interrupted", "merged", "merge-failed"} ) DEFAULT_TASK_TIMEOUT_SECONDS = 3600 DEFAULT_POLL_SECONDS = 10 TRACE_MAX_CHARS = 50000 def unwrap_result_text(raw: str) -> str: """把服务端 result_text 的 JSON 信封拆开,只取 agent 的最终回复。 2026-10-29 诊断的真根因:控制面返回的 result_text 不是 agent 原文,而是一层 JSON 信封(task / thread_id / cwd / status / text / usage …),text 字段里才是最终回复。接线层此前原样把 信封当回复用:notes / choice / experience 的正则不锚定行首, 在信封里照样命中,所以三轮里只有行首锚定的文件块解析一直 看不见块——编辑明明发了块,却被判成 notes-only、无改动、 格式错误。拆信封放在 run_task 这一个 choke point:探针、 编辑、选择、反思四路共用它,一处拆全受益;拆不开(非 JSON、无 text 字段、text 非字符串)时原样返回,不丢内容。 """ if not raw or not raw.lstrip().startswith("{"): return raw try: obj = json.loads(raw) except ValueError: return raw if isinstance(obj, dict) and isinstance(obj.get("text"), str): return obj["text"] return raw _PROBE_DELIVERABLE_FOOTER = """ [DELIVERABLE — READ THIS BEFORE YOU FINISH] Your final reply IS the deliverable that will be judged. When you finish, write the complete deliverable in your final reply itself — do not only say "done" or "the file is written". If the task asks for a file, also write the file in the worktree, but the final reply must still contain the full content a reviewer needs to judge the work. """ _EDITOR_OUTPUT_FORMAT = """ # OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY Edit the files under context/harness/ in the worktree. Then, in your final reply, output ONLY the files you changed, one block per file, in this format (files you did not change are inherited automatically — do NOT re-emit them): === HARNESS FILE: === === END HARNESS FILE === Example (a one-line file changed in full — your blocks must contain the COMPLETE new content, not a diff and not a description of the change): === HARNESS FILE: scripts/summarize.py === print("Total: 85.15") === END HARNESS FILE === Then a ... block: what you changed, and the exact trajectory moment that justifies each change. HARD RULES — READ CAREFULLY, THEY ARE MACHINE-CHECKED: 1. A change described only in is NOT a change. If you changed a file, its COMPLETE new content MUST appear in a file block above, or the change is discarded and this attempt counts as FAILED. 2. If nothing in the evidence justifies a change, you MUST emit an explicit ... block with the reason, and no file blocks. Saying "no change needed" only in is NOT enough — without the marker the attempt counts as FAILED. 3. File blocks and a marker in the same reply contradict each other and the attempt counts as FAILED. """ _SELECTOR_OUTPUT_FORMAT = """ # OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY Reply with a ```choice block containing only the chosen candidate id, and a ... block explaining the decision in one paragraph. """ _REFLECTOR_OUTPUT_FORMAT = """ # EVIDENCE NOTE context/harness/ is the adopted harness; context/base_harness/ is the unmodified base harness; context/pool/