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:
1 parent
9e444eca38
commit
c20192bce8
8 files changed
+856
-28
No files matched your search
@@ -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:真正的交替历史,一次性
|
||||
按时间交错拼好返回,前端不再猜顺序。
|
||||
|
||||
Reference in new issue
Block a user