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.
This commit is contained in:
host committed 2026-10-29 16:32:58 +08:00
1 parent 9e444eca38
commit c20192bce8
8 files changed
+856 -28

No files matched your search

+99
View File
@@ -445,6 +445,22 @@ def _thread_worktree_for_task(server, task):
return os.path.join(base, safe)
def _safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
再校验 commonpath,目录内的符号链接指回内部时放行(读到
的仍是 worktree 内的内容),指出去时拦住。"""
if not rel:
return worktree
if os.path.isabs(rel):
return None
base = os.path.realpath(worktree)
target = os.path.realpath(os.path.join(base, rel))
if os.path.commonpath([base, target]) != base:
return None
return target
def _extract_thread_list(result):
"""从 thread-list 的 CLI 结果里取 thread 列表:标准形态是
ThreadListResponse 的 data;threads 键与 result 包裹是
@@ -2152,6 +2168,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._list_followups(user_id, rest[: -len("/followups")])
elif rest.endswith("/timeline") and method == "GET":
self._task_timeline(user_id, rest[: -len("/timeline")])
elif rest.endswith("/files") and method == "GET":
self._task_files(user_id, rest[: -len("/files")], query)
elif rest.endswith("/chat-history") and method == "GET":
self._task_chat_history(
user_id, rest[: -len("/chat-history")])
@@ -4698,6 +4716,87 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
for r in cp_rows],
})
def _task_files(self, user_id, task_id, query):
"""GET /tasks/{id}/files:读本地任务 worktree 里的文件。
只做 local 执行器任务(复用 thread 操作的执行器门):
Docker 任务的 worktree 在容器内、且租户 Docker 下 /app/work
是整项目共享目录,按任务归属读文件会错指,一律 409 明
说;cloud 任务与跨机 worktree 同理。executor 为空的老
任务放行(与 thread 门同款),解析不到 worktree 时 404。
?path=<相对路径>:不传列 worktree 顶层(递归、最多 200
条,只含文件路径与大小,不跟符号链接);传目录列该目录
(非递归,含 is_dir);传文件读文本内容(UTF-8、≤256KB,
二进制或超大一律 409/413 明说,不截断冒充全文)。路径
解析一律走 _safe_worktree_path,防 .. 与符号链接逃逸。
读操作不限制任务状态(进行中任务读到的是中间态)。
"""
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
guard = self._thread_task_guard(task)
if guard is not None:
self._send_json(guard[0], {"error": guard[1]})
return
worktree = _thread_worktree_for_task(self.server, task)
if not os.path.isdir(worktree):
self._send_json(404, {"error": "no local worktree for this task"})
return
rel = (query.get("path") or [""])[0]
target = _safe_worktree_path(worktree, rel)
if target is None:
self._send_json(400, {"error": "path escapes task worktree"})
return
if os.path.isdir(target):
entries = []
if not rel:
for root, dirs, files in os.walk(target):
dirs[:] = [d for d in dirs
if not os.path.islink(os.path.join(root, d))]
dirs.sort()
for name in sorted(files):
fpath = os.path.join(root, name)
if os.path.islink(fpath):
continue
entries.append({
"path": os.path.relpath(fpath, worktree),
"size": os.path.getsize(fpath),
})
if len(entries) >= 200:
break
if len(entries) >= 200:
break
else:
for name in sorted(os.listdir(target)):
fpath = os.path.join(target, name)
is_link = os.path.islink(fpath)
entries.append({
"path": os.path.relpath(fpath, worktree),
"size": os.path.getsize(fpath)
if os.path.isfile(fpath) and not is_link else 0,
"is_dir": os.path.isdir(fpath) and not is_link,
})
self._send_json(200, {"task_id": task_id, "path": rel,
"entries": entries})
return
if not os.path.isfile(target):
self._send_json(404, {"error": "no such file"})
return
size = os.path.getsize(target)
if size > 256 * 1024:
self._send_json(413, {"error": "file too large for inline read"})
return
try:
with open(target, encoding="utf-8") as fh:
content = fh.read()
except (OSError, UnicodeDecodeError):
self._send_json(409, {"error": "file is not readable UTF-8 text"})
return
self._send_json(200, {"task_id": task_id, "path": rel,
"size": size, "content": content})
def _task_chat_history(self, user_id, task_id):
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
按时间交错拼好返回,前端不再猜顺序。