Files
codex-app-server/cloud/harness2_tasks.py
T
host c20192bce8 harness2: per-role models, sweep batch, task file assets endpoint
- Role models: run_improvement_via_tasks gains role_models
  (probe/editor/selector/reflector) plus --model-<role> CLI flags;
  missing/empty falls back to shared --model, both empty means
  server default, so default behavior is unchanged.
- Sweep: run_sweep + examples/harness2_sweep.py manifest CLI with
  shared library_root (library restore), per-run failure isolation
  and sweep-report.json summary; preflight gates task creation.
- Deliverable assets: GET /tasks/{id}/files for local-executor
  tasks (safe path resolution, UTF-8 text <=256KB, listing <=200;
  docker/cloud/cross-host 409 with explicit reason, since tenant
  Docker shares one /app/work per project and per-task attribution
  would misattribute); probe rollout deliverable_files from the
  endpoint plus DELIVERABLE FILE reply blocks (endpoint wins on
  collision, bad blocks dropped), surfaced in
  deliverables/<label>/files/ context and full_result summary.
- Docs: harness2.md comparison table updated for all three items.
2026-10-29 16:32:58 +08:00

951 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 文件(产物资产端点;
Docker/跨机任务回 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 新增/改动文件(端点为准);②所有执行器都可用
回复里的 DELIVERABLE FILE 块(agent 自报,端点结果优先)。
边界仍在:租户 Docker 下 /app/work 是整项目共享目录,按任务
归属读文件会错指,端点对 Docker 任务一律 409——这时只有第
②层可用,且内容以 agent 自报为准(可能截断,截断有标记)。
- 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md /
rollouts / deliverables / harness / library / pool / decisions)。
agent 在 worktree 的 context/harness/ 里改文件,最终回复里用
文件块只输出改动的文件(没改的省略即继承),外加 <notes> 块。
接线层解析文件块、落到该阶段的 fallback 树上,再走核心校验。
最终回复里既没有文件块也没有 <notes>,或文件块路径越界、未
闭合,都算该阶段失败:核心循环给一次修正机会,再失败回落到
fallback 树——绝不把“没看懂输出格式”静默当成“无需改动”。
- 选择(CodexSelector.select)与反思(CodexReflector.reflect):同款
bundle + 最终回复解析(```choice + <why>;<experience> + <edits>),
不读 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<n> 段表示 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: <relative path> ===
<complete file content>
=== 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: <relative path on the editable surface> ===
<the file's COMPLETE new content>
=== 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 <notes>...</notes> 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 <notes> 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
<no-change>...</no-change> block with the reason, and no file blocks.
Saying "no change needed" only in <notes> is NOT enough — without the
marker the attempt counts as FAILED.
3. File blocks and a <no-change> 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
<why>...</why> 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/<label>/ holds each candidate harness
plus its rollout. Compare them before writing.
# OUTPUT FORMAT — YOU MUST FOLLOW THIS EXACTLY
Reply with an <edits>...</edits> block (one row per changed component, with
its observed effect and a verdict), then an <experience>...</experience>
block with exactly these lines: `cue: ...`, `lesson: ...`, `evidence: ...`,
`confidence: ...`. If no lesson is supported by an observed contrast, reply
with the single line NO_ENTRY instead of inventing one.
"""
# ---------------------------------------------------------------------------
# 客户端协议与 HTTP 实现
# ---------------------------------------------------------------------------
@dataclass
class TaskState:
task_id: str
status: str
result_text: str = ""
error_text: str = ""
class TaskClient(Protocol):
"""控制面客户端协议:HTTP 实现见 HttpTaskClient,测试用假实现。"""
def upload(self, files: dict[str, bytes]) -> str:
"""上传一包文件(相对路径 -> 内容),返回 upload_id。"""
...
def create_task(self, *, project_id: str, prompt: str, upload_id: str | None = None,
idempotency_key: str | None = None, model: str | None = None,
effort: str | None = None, approval: str | None = None,
environment_id: str | None = None, executor: str | None = None,
no_time_limit: bool = False) -> str:
"""建任务,返回任务 id;同幂等键返回已有任务 id,不建重复。"""
...
def get_task(self, task_id: str) -> TaskState:
...
def get_trace_text(self, task_id: str) -> str:
"""轨迹渲染文本;读不到时返回空串(调用方用最终回复兜底)。"""
...
def get_task_files(self, task_id: str, path: str = "") -> dict:
"""GET /tasks/{id}/files:不传 path 列 worktree 文件,传 path
读单个文件。服务端对 Docker/跨机任务回 409,调用方须把
409 当成“无文件通道”、不得当成探针失败。"""
...
def get_cost_usd(self, task_id: str) -> float:
"""有价成本 USD;读不到时返回 0.0,不炸轮次。"""
...
def cancel_task(self, task_id: str) -> None:
...
def build_multipart(files: dict[str, bytes]) -> tuple[bytes, str]:
"""按服务端 cloud/uploads.py 的解析器能读懂的格式编码 multipart。
paths 字段是 JSON 字符串数组,与 file part 一一对应;part 名用
file(服务端只按顺序取 files,不认 part 名)。编码结果可用
cloud.uploads.parse_multipart + parse_paths_field 原样解回,
见 test_harness2_tasks.py 的往返测试。
"""
boundary = "----harness2-" + uuid.uuid4().hex
paths = sorted(files)
parts: list[bytes] = []
parts.append(
b"--" + boundary.encode() + b"\r\n"
b'Content-Disposition: form-data; name="paths"\r\n\r\n'
+ json.dumps(paths).encode() + b"\r\n"
)
for path in paths:
parts.append(
b"--" + boundary.encode() + b"\r\n"
+ f'Content-Disposition: form-data; name="file"; filename="{path}"\r\n'.encode()
+ b"Content-Type: application/octet-stream\r\n\r\n"
+ files[path] + b"\r\n"
)
parts.append(b"--" + boundary.encode() + b"--\r\n")
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
class HttpTaskClient:
"""基于标准库 urllib 的控制面客户端(零第三方依赖,与项目风格一致)。
瞬断重试(2026-10-29 真实首跑教训):VM 到公网控制面的连接会
偶发中途断开(RemoteDisconnected / IncompleteRead),一次断开
就让整轮提前收尾,尽管服务端任务还在跑。_request 对传输错误与
502 / 503 / 429 最多重试 3 次(退避 1s、3s),其它 HTTP 错误
(401 / 403 / 400 等服务端裁决)不重试。重试安全的前提是调用
方都带 idempotency_key(本层所有建任务调用都带)与“上传包
未绑定即孤儿包”的语义:重试建任务时服务端返回已有任务,不
重复建;重试上传时最多多出一个 24 小时后过期的孤儿包。
"""
def __init__(self, base_url: str, api_key: str, *, timeout: int = 60,
max_attempts: int = 3):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.max_attempts = max(1, max_attempts)
def _request(self, method: str, path: str, body: bytes | None = None,
content_type: str | None = None) -> dict:
req = urllib.request.Request(
self.base_url + path, data=body, method=method)
req.add_header("X-API-Key", self.api_key)
if content_type:
req.add_header("Content-Type", content_type)
elif body is not None:
req.add_header("Content-Type", "application/json")
last_exc: Exception | None = None
for attempt in range(1, self.max_attempts + 1):
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
except urllib.error.HTTPError as exc:
if exc.code in (502, 503, 429) and attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(
f"{method} {path} -> HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, ConnectionError, TimeoutError,
OSError) as exc:
# RemoteDisconnected / IncompleteRead 都落在这里(前者是
# ConnectionResetError / http.client 子类,后者是
# http.client.HTTPException,而 HTTPException 不是
# OSError——见下方 except。
if attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
raise RuntimeError(
f"{method} {path} -> transport error: {exc}") from exc
except Exception as exc: # http.client.HTTPException 等非 OSError
if exc.__class__.__name__ in (
"RemoteDisconnected", "IncompleteRead", "BadStatusLine",
"HTTPException") and attempt < self.max_attempts:
last_exc = exc
time.sleep(1 if attempt == 1 else 3)
continue
raise
break
else:
raise RuntimeError(
f"{method} {path} -> transport error: {last_exc}")
try:
obj = json.loads(raw.decode("utf-8"))
except ValueError as exc:
raise RuntimeError(f"{method} {path} -> non-JSON response") from exc
if not isinstance(obj, dict):
raise RuntimeError(f"{method} {path} -> unexpected response shape")
return obj
def upload(self, files: dict[str, bytes]) -> str:
if not files:
raise ValueError("upload requires at least one file")
body, content_type = build_multipart(files)
obj = self._request("POST", "/uploads", body=body, content_type=content_type)
upload_id = obj.get("upload_id")
if not upload_id:
raise RuntimeError(f"/uploads did not return an upload_id: {obj!r}"[:300])
return str(upload_id)
def create_task(self, *, project_id: str, prompt: str, upload_id: str | None = None,
idempotency_key: str | None = None, model: str | None = None,
effort: str | None = None, approval: str | None = None,
environment_id: str | None = None, executor: str | None = None,
no_time_limit: bool = False) -> str:
payload: dict = {"project_id": project_id, "prompt": prompt}
if upload_id is not None:
payload["upload_id"] = upload_id
if idempotency_key is not None:
payload["idempotency_key"] = idempotency_key
if model is not None:
payload["model"] = model
if effort is not None:
payload["effort"] = effort
if approval is not None:
payload["approval"] = approval
if environment_id is not None:
payload["environment_id"] = environment_id
if executor is not None:
payload["executor"] = executor
if no_time_limit:
payload["no_time_limit"] = True
obj = self._request("POST", "/tasks", body=json.dumps(payload).encode())
task_id = obj.get("id")
if not task_id:
raise RuntimeError(f"/tasks did not return an id: {obj!r}"[:300])
return str(task_id)
def get_task(self, task_id: str) -> TaskState:
obj = self._request("GET", f"/tasks/{task_id}")
return TaskState(
task_id=task_id,
status=str(obj.get("status") or ""),
result_text=str(obj.get("result_text") or ""),
error_text=str(obj.get("error_text") or ""),
)
def get_trace_text(self, task_id: str) -> str:
try:
obj = self._request("GET", f"/tasks/{task_id}/trace")
except Exception:
return ""
entries = obj.get("entries") or []
lines: list[str] = []
for entry in entries:
if not isinstance(entry, dict):
continue
kind = entry.get("kind") or entry.get("type") or "entry"
text = entry.get("text") or entry.get("message") or ""
if not text:
text = json.dumps(entry, ensure_ascii=False)[:1000]
lines.append(f"[{kind}] {text}")
rendered = "\n".join(lines).strip()
return rendered[:TRACE_MAX_CHARS] if rendered else ""
def get_task_files(self, task_id: str, path: str = "") -> dict:
q = f"?path={urllib.parse.quote(path)}" if path else ""
return self._request("GET", f"/tasks/{task_id}/files{q}")
def get_cost_usd(self, task_id: str) -> float:
try:
obj = self._request("GET", f"/tasks/{task_id}/cost")
except Exception:
return 0.0
try:
return float(obj.get("usd_micros") or 0) / 1_000_000
except (TypeError, ValueError):
return 0.0
def cancel_task(self, task_id: str) -> None:
try:
self._request("POST", f"/tasks/{task_id}/cancel", body=b"{}")
except Exception:
pass # 终态任务取消会 400,本来就不用再取消;其它错误也不炸轮次
@dataclass
class CompletedTask:
task_id: str
state: TaskState
cost_usd: float = 0.0
trace_text: str = ""
def run_task(
client: TaskClient,
*,
project_id: str,
prompt: str,
files: dict[str, bytes] | None = None,
idempotency_key: str | None = None,
model: str | None = None,
effort: str | None = None,
approval: str | None = None,
environment_id: str | None = None,
executor: str | None = None,
no_time_limit: bool = False,
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS,
poll_seconds: int = DEFAULT_POLL_SECONDS,
) -> CompletedTask:
"""建任务、轮询到终态、读成本与轨迹。超时先取消再抛 TimeoutError。"""
upload_id = client.upload(files) if files else None
task_id = client.create_task(
project_id=project_id, prompt=prompt, upload_id=upload_id,
idempotency_key=idempotency_key, model=model, effort=effort,
approval=approval, environment_id=environment_id, executor=executor,
no_time_limit=no_time_limit)
deadline = time.time() + timeout_seconds
state = client.get_task(task_id)
while state.status not in TERMINAL_STATUSES:
if time.time() >= deadline:
client.cancel_task(task_id)
raise TimeoutError(
f"task {task_id} did not reach a terminal status within "
f"{timeout_seconds}s (last status: {state.status})")
time.sleep(poll_seconds)
state = client.get_task(task_id)
# 拆信封的唯一位置:run_task 是探针、编辑、选择、反思四路的
# 共同 choke point,在这里拆一次,全路受益;各 TaskClient 的
# get_task 保持原样返回服务端原文。
state = TaskState(
task_id=state.task_id, status=state.status,
result_text=unwrap_result_text(state.result_text),
error_text=state.error_text,
)
return CompletedTask(
task_id=task_id, state=state,
cost_usd=client.get_cost_usd(task_id),
trace_text=client.get_trace_text(task_id),
)
def _flatten_dir(root: Path, prefix: str = "") -> dict[str, bytes]:
"""把一个目录展平成 {相对路径: 内容};符号链接与 __pycache__ 跳过。"""
out: dict[str, bytes] = {}
root = Path(root)
if not root.is_dir():
return out
for p in sorted(root.rglob("*")):
if not p.is_file() or p.is_symlink() or "__pycache__" in p.parts:
continue
rel = str(p.relative_to(root))
out[(prefix + rel) if prefix else rel] = p.read_bytes()
return out
def _tree_to_files(tree: Tree) -> dict[str, bytes]:
return {rel: content.encode("utf-8") for rel, content in tree.items()}
# ---------------------------------------------------------------------------
# 三个协议的真实任务实现
# ---------------------------------------------------------------------------
@dataclass
class CodexProbeAdapter:
"""探针 adapter:每个 label 跑一个真实 codex 任务。
source_files:原任务需要的源文件(如 ledger.csv),会随 harness
一起 seed 进 worktree;与 harness 路径冲突时 harness 优先(探针
的意义就是让 harness 覆盖仓库默认值)。调用方应在 source_files
里避免与 harness 路径重名——重名时源文件会被静默覆盖,本层
不为此报错(与 seed“用户文件优先”的语义一致)。
"""
client: TaskClient
project_id: str
task_id: str
prompt: str
base: Tree
run_tag: str
source_files: dict[str, bytes] = field(default_factory=dict)
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
domain: str = "general"
def base_harness(self) -> Tree:
return dict(self.base)
def execute(self, label: str, harness: Tree) -> Rollout:
files = dict(self.source_files)
files.update(_tree_to_files(harness))
try:
done = run_task(
self.client, project_id=self.project_id,
prompt=self.prompt + _PROBE_DELIVERABLE_FOOTER,
files=files,
idempotency_key=f"h2-{self.run_tag}-probe-{label}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
except Exception as exc:
return Rollout(label=label, ok=False, delivered=False,
error=f"{type(exc).__name__}: {exc}"[:400])
state = done.state
delivered = state.status == "completed" and bool(state.result_text.strip())
deliverable_files = self._collect_deliverable_files(done.task_id, files)
# 回复里的产物文件块是补充通道(所有执行器都可用);
# 端点读到的文件为准,冲突时不覆盖端点结果。
for name, content in parse_deliverable_files(
state.result_text or "").items():
deliverable_files.setdefault(name, content)
return Rollout(
label=label, ok=state.status == "completed", delivered=delivered,
deliverable_text=state.result_text,
trajectory=done.trace_text or state.result_text,
cost_usd=done.cost_usd,
error="" if state.status == "completed" else (
state.error_text or f"task ended as {state.status}")[:400],
deliverable_files=deliverable_files,
)
def _collect_deliverable_files(self, task_id: str,
seed_files: dict[str, bytes]) -> dict[str, str]:
"""经 GET /tasks/{id}/files 读探针 worktree 里新增的文件。
取 seed 里没有的新文件,以及内容被改过的 seed 文件(与
seed 字节比对,不一致才算产物);跳过隐藏文件、最多收
8 个、最多读 16 个;端点不可用(Docker/跨机 409、读错)
时返回空 dict——文件产物是 enrichment,绝不能因此炸轮次。
"""
try:
listing = self.client.get_task_files(task_id)
except Exception:
return {}
out: dict[str, str] = {}
reads = 0
for entry in listing.get("entries") or []:
if not isinstance(entry, dict):
continue
path = entry.get("path") or ""
if not path or path.startswith("."):
continue
if len(out) >= 8 or reads >= 16:
break
try:
data = self.client.get_task_files(task_id, path)
except Exception:
continue
reads += 1
content = data.get("content")
if not isinstance(content, str):
continue
seed = seed_files.get(path)
if seed is not None and seed.decode("utf-8", errors="replace") == content:
continue
out[path] = content
return out
@dataclass
class CodexEditor:
"""编辑器:一个 codex 任务,证据 seed 进 worktree,改动以文件块回读。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def edit(self, mission: str, context_dir: Path, label: str) -> EditResult:
"""编辑任务:格式错误给一次带反馈的纠正机会,再错才如实回落。
背景(2026-10-29 两轮真实轮次):编辑在 <notes> 里声称改
动、却不发文件块,旧契约把 notes-only 当“无需改动”采纳,
改动无声丢失;且方法层的阶段循环里编辑异常是直接 break(不
重试),所以纠正必须发生在本层。格式重试用独立幂等键
(…-edit-{label}.fmt2),不复用首个任务、不重复计费到同
一任务;任务本身失败(非 completed)仍直接抛错、不重试。
"""
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
last_err = ""
total_cost = 0.0
for attempt in (1, 2):
prompt = mission + _EDITOR_OUTPUT_FORMAT
if last_err:
prompt += (
"\n\n# YOUR PREVIOUS REPLY WAS REJECTED\n\n"
f"{last_err}\n\nFix the output format and reply again: "
"changed files MUST appear as === HARNESS FILE === blocks "
"with their COMPLETE new content, or, if truly no change "
"is justified, emit <no-change>...</no-change>.")
done = run_task(
self.client, project_id=self.project_id,
prompt=prompt,
files=files,
idempotency_key=f"h2-{self.run_tag}-edit-{label}"
+ ("" if attempt == 1 else ".fmt2"),
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
raise RuntimeError(
f"editor task {done.task_id} ended as {state.status}: "
f"{state.error_text}"[:400])
total_cost += done.cost_usd
text = state.result_text or ""
try:
changed = parse_harness_files(text)
notes = parse_notes(text)
no_change = parse_no_change(text)
except ValueError as exc:
last_err = f"output-format error: {exc}"
continue
if changed and no_change:
last_err = ("output-format error: reply contains both harness "
"file blocks and a <no-change> marker")
continue
if not changed and not no_change:
last_err = ("output-format error: reply contains neither "
"harness file blocks nor a <no-change> marker; "
"a change described only in <notes> is discarded")
continue
base_tree = read_tree(Path(context_dir) / "harness")
merged = dict(base_tree)
merged.update(changed)
return EditResult(tree=merged, notes=notes, cost_usd=total_cost)
raise ValueError(
f"editor reply failed output-format validation twice: {last_err}")
@dataclass
class CodexSelector:
"""选择器:一个 codex 任务,候选产物 seed 进 worktree,只解析最终回复。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def select(self, mission: str, context_dir: Path) -> tuple[str, str, float]:
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
done = run_task(
self.client, project_id=self.project_id,
prompt=mission + _SELECTOR_OUTPUT_FORMAT,
files=files,
idempotency_key=f"h2-{self.run_tag}-select-{Path(context_dir).name}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
raise RuntimeError(
f"selector task {done.task_id} ended as {state.status}: "
f"{state.error_text}"[:400])
choice = parse_choice(state.result_text or "")
if not choice:
raise ValueError("selector task returned no ```choice block")
return choice, parse_why(state.result_text or ""), done.cost_usd
@dataclass
class CodexReflector:
"""反思器:一个 codex 任务,解析 <experience>;三项不全返回 {}。"""
client: TaskClient
project_id: str
run_tag: str
environment_id: str | None = None
executor: str | None = None
model: str | None = None
effort: str | None = None
approval: str | None = None
no_time_limit: bool = False
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS
def reflect(self, mission: str, context_dir: Path) -> dict:
files = _flatten_dir(context_dir, prefix="context/")
files["context/MISSION.md"] = mission.encode("utf-8")
done = run_task(
self.client, project_id=self.project_id,
prompt=mission + _REFLECTOR_OUTPUT_FORMAT,
files=files,
idempotency_key=f"h2-{self.run_tag}-reflect-{Path(context_dir).name}",
model=self.model, effort=self.effort, approval=self.approval,
environment_id=self.environment_id, executor=self.executor,
no_time_limit=self.no_time_limit,
timeout_seconds=self.timeout_seconds)
state = done.state
if state.status != "completed":
return {}
return parse_experience(state.result_text or "")
def run_improvement_via_tasks(
client: TaskClient,
*,
project_id: str,
task_id: str,
prompt: str,
base_harness: Tree,
run_tag: str,
work_dir: Path,
domain: str = "general",
source_files: dict[str, bytes] | None = None,
k: int = 1,
mode: str = "parallel",
library_root: Path | None = None,
budget_usd: float | None = None,
max_sessions: int | None = None,
model: str | None = None,
role_models: dict[str, str | None] | None = None,
effort: str | None = None,
approval: str | None = None,
environment_id: str | None = None,
executor: str | None = None,
no_time_limit: bool = False,
timeout_seconds: int = DEFAULT_TASK_TIMEOUT_SECONDS,
):
"""一站式:用真实 codex 任务跑一轮完整改进,返回 ImprovementResult。
library_root 为空时不建经验库(只跑改进、不入库);非空时按
domain 建库。auto_apply 恒为 False——采纳的树只入库待评审,
不自动成为后续任务的 base(见 docs/harness2.md 安全一节)。
分角色模型:role_models 键为 probe / editor / selector /
reflector,非空值覆盖该角色的 model;缺键或空值时回落到
共享 model;两者都空时不传 model(服务端默认模型)。默认
行为与此前完全一致——本参数只提供分流能力,不替用户选
模型(有价模型会产生真实成本,见 docs/harness2.md)。
"""
from .harness2 import Library
roles = role_models or {}
def _role_model(role: str) -> str | None:
return roles.get(role) or model
adapter = CodexProbeAdapter(
client=client, project_id=project_id, task_id=task_id, prompt=prompt,
base=base_harness, run_tag=run_tag,
source_files=source_files or {}, environment_id=environment_id,
executor=executor, model=_role_model("probe"), effort=effort,
approval=approval,
no_time_limit=no_time_limit, timeout_seconds=timeout_seconds,
domain=domain)
shared = dict(environment_id=environment_id, executor=executor,
effort=effort, approval=approval, no_time_limit=no_time_limit,
timeout_seconds=timeout_seconds)
editor = CodexEditor(client=client, project_id=project_id, run_tag=run_tag,
model=_role_model("editor"), **shared)
selector = CodexSelector(client=client, project_id=project_id, run_tag=run_tag,
model=_role_model("selector"), **shared)
reflector = CodexReflector(client=client, project_id=project_id, run_tag=run_tag,
model=_role_model("reflector"), **shared)
library = Library(library_root, domain) if library_root is not None else None
validate_run_tag(run_tag)
return improve_task(
adapter, editor, selector=selector, reflector=reflector,
work_dir=work_dir, k=k, mode=mode, library=library,
auto_apply=False, budget_usd=budget_usd, max_sessions=max_sessions)
def run_sweep(client, *, project_id, runs, work_root, library_root=None):
"""批量跑多轮改进:共享经验库、单轮失败隔离、汇总报告。
runs:每轮一个 dict,键为 run_improvement_via_tasks 的参数,
run_tag 必填;client / project_id / work_dir / library_root
由本函数统管,出现在 spec 里会被忽略(不报错)。每轮
work_dir = work_root/<run_tag>。library_root 非空时所有轮
共享同一库——已入库的经验会被后续轮读到,即上游 sweep 的
library restore 语义;为空时各轮都不入库(仍汇总)。
单轮抛错只记入报告的 error,不中断后续轮(dead 隔离)。
返回报告 dict:runs 逐轮(ok / chosen_label / deliverable
前 500 字 / cost / library_entry / error)+ totals(轮数、
成功轮数、总成本)。
"""
work_root = Path(work_root)
report_runs = []
total_cost = 0.0
n_ok = 0
for spec in runs:
spec = dict(spec)
run_tag = spec.pop("run_tag", "")
for owned in ("client", "project_id", "work_dir", "library_root"):
spec.pop(owned, None)
entry = {"run_tag": run_tag, "ok": False, "chosen_label": None,
"deliverable_text": "", "total_cost_usd": 0.0,
"library_entry": None, "error": None}
try:
if not run_tag:
raise ValueError("sweep 每轮必须给 run_tag(用作 work_dir 子目录)")
validate_run_tag(run_tag)
result = run_improvement_via_tasks(
client, project_id=project_id, run_tag=run_tag,
work_dir=work_root / run_tag,
library_root=library_root, **spec)
entry["ok"] = bool(result.ok)
entry["chosen_label"] = result.selection.chosen_label
chosen = result.mode_result.rollouts.get(result.selection.chosen_label)
if chosen is not None:
entry["deliverable_text"] = (chosen.deliverable_text or "")[:500]
entry["total_cost_usd"] = result.mode_result.total_cost_usd
entry["library_entry"] = result.library_entry
total_cost += result.mode_result.total_cost_usd
n_ok += 1 if result.ok else 0
except Exception as exc:
entry["error"] = f"{type(exc).__name__}: {exc}"[:400]
report_runs.append(entry)
return {"runs": report_runs,
"totals": {"n_runs": len(report_runs), "n_ok": n_ok,
"total_cost_usd": total_cost}}
__all__ = [
"TERMINAL_STATUSES", "DEFAULT_TASK_TIMEOUT_SECONDS", "DEFAULT_POLL_SECONDS",
"TRACE_MAX_CHARS", "TaskState", "TaskClient", "build_multipart",
"HttpTaskClient", "CompletedTask", "run_task",
"CodexProbeAdapter", "CodexEditor", "CodexSelector", "CodexReflector",
"run_improvement_via_tasks", "run_sweep", "validate_run_tag", "preflight_checks",
]