"""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 模型的边界,不是本层漏算) GET /tasks/{id}/files 读任务 worktree 文件(产物资产端点; cloud/跨机任务回 409,见下) 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 必须 把交付物完整写在最终回复里,不能只说“文件已写好”。 文件型产物走两层(产物资产):①经 GET /tasks/{id}/files 读 worktree 新增/改动文件(端点为准;本地任务走本地 worktree, 租户 Docker 任务走 tenants 下的任务级子目录);②所有执行器 都可用回复里的 DELIVERABLE FILE 块(agent 自报,端点结果 优先)。边界仍在:cloud/跨机任务端点回 409,此时只有第②层 可用,且内容以 agent 自报为准(可能截断,截断有标记)。 - 编辑(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 re import time import urllib.error import urllib.parse 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_deliverable_files, 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 _TAG_RX = re.compile(r"[A-Za-z0-9_.][A-Za-z0-9_.\-]*\Z") _SEED_TAG_RX = re.compile(r"s\d+(-|\Z)") def validate_run_tag(tag: str) -> None: """校验 run_tag 形状(上游 schedule.validate_tag 的对照补齐)。 run_tag 会进幂等键与路径段:空串表示“调用方稍后生成”,不 校验;非空时只允许字母数字与 _ . -、首字符不能是 -、不能 以 s + 数字开头(上游用 s 段表示 seed,撞名会把两轮的 产物算到同一命名空间)。 """ if not tag: return if not _TAG_RX.match(tag): raise ValueError( f"run_tag {tag!r} must match [A-Za-z0-9_.-] with no leading '-' " "and no '/'") if _SEED_TAG_RX.match(tag): raise ValueError( f"run_tag {tag!r} must not start with a seed segment like 's1'") def preflight_checks(*, base_harness_dir: str | Path, source_dir: str | Path | None = None, prompt: str = "", run_tag: str = "", base_url: str | None = None) -> tuple[list[str], list[str]]: """建任务前的本地 + 可达性预检(2026-10-29 对照补齐)。 返回 (errors, warnings):errors 非空时调用方不得建任务; warnings 只告警(health 探针失败、run_tag 为空待生成等不 拦任务)。本函数不读密钥、不建任务,--check 可离线跑。 """ errors: list[str] = [] warnings: list[str] = [] base_dir = Path(base_harness_dir) agents = base_dir / "AGENTS.md" if not base_dir.is_dir(): errors.append(f"base-harness-dir 不存在或不是目录:{base_harness_dir}") elif not agents.is_file() or not agents.read_text( encoding="utf-8", errors="replace").strip(): errors.append(f"base-harness-dir 里没有可用的 AGENTS.md:{base_harness_dir}") if source_dir is not None and str(source_dir) != "": if not Path(source_dir).is_dir(): errors.append(f"source-dir 不存在或不是目录:{source_dir}") if not (prompt or "").strip(): errors.append("需要 --prompt 或 --prompt-file(任务提示为空)") if run_tag: try: validate_run_tag(run_tag) except ValueError as exc: errors.append(str(exc)) if base_url: try: req = urllib.request.Request(base_url.rstrip("/") + "/health") with urllib.request.urlopen(req, timeout=10) as resp: if resp.status != 200: warnings.append(f"控制面 health 非 200:{resp.status}") except Exception as exc: warnings.append(f"控制面 health 探针失败(不拦任务):{type(exc).__name__}: {exc}") return errors, warnings 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. If you created deliverable files (a page, a report, an image manifest), also paste each file's full content in your final reply as a block: === DELIVERABLE FILE: === === END DELIVERABLE FILE === """ _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/