Merge pull request #146 from openqj/feat-task-files-tenant

fix: task files endpoint resolves tenant docker worktrees
This commit is contained in:
openqj authored and GitHub committed 2026-10-30 00:04:37 +08:00
commit 3696dad7c8
4 files changed
+76 -25

No files matched your search

+10 -10
View File
@@ -8,8 +8,8 @@
GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源) GET /tasks/{id}/trace 轨迹(探针的 trajectory 来源)
GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD, GET /tasks/{id}/cost 成本(usd_micros / 1e6;无价 token 不计入 USD,
这是现有 cost 模型的边界,不是本层漏算) 这是现有 cost 模型的边界,不是本层漏算)
GET /tasks/{id}/files 读本地任务 worktree 文件(产物资产端点; GET /tasks/{id}/files 读任务 worktree 文件(产物资产端点;
Docker/跨机任务回 409,见下) cloud/跨机任务回 409,见下)
POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱 POST /tasks/{id}/cancel 超时取消,不留孤儿任务继续烧钱
认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、 认证:X-API-Key 请求头;密钥只由调用方持有,本模块不读、不存、
不打印密钥。 不打印密钥。
@@ -21,12 +21,12 @@
harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见 harness 下跑。prompt = 原任务 prompt + 一段交付脚注(见
_PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须 _PROBE_DELIVERABLE_FOOTER),因为评审只看最终回复:agent 必须
把交付物完整写在最终回复里,不能只说“文件已写好”。 把交付物完整写在最终回复里,不能只说“文件已写好”。
文件型产物走两层(产物资产):①本地任务经 GET /tasks/{id}/files 文件型产物走两层(产物资产):①经 GET /tasks/{id}/files 读
读 worktree 新增/改动文件(端点为准);②所有执行器都可用 worktree 新增/改动文件(端点为准;本地任务走本地 worktree,
回复里的 DELIVERABLE FILE 块(agent 自报,端点结果优先)。 租户 Docker 任务走 tenants 下的任务级子目录);②所有执行器
边界仍在:租户 Docker 下 /app/work 是整项目共享目录,按任务 都可用回复里的 DELIVERABLE FILE 块(agent 自报,端点结果
归属读文件会错指,端点对 Docker 任务一律 409——这时只有第 优先)。边界仍在:cloud/跨机任务端点回 409,此时只有第②层
②层可用,且内容以 agent 自报为准(可能截断,截断有标记)。 可用,且内容以 agent 自报为准(可能截断,截断有标记)。
- 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md / - 编辑(CodexEditor.edit):bundle = context/ 前缀的证据树(task.md /
rollouts / deliverables / harness / library / pool / decisions)。 rollouts / deliverables / harness / library / pool / decisions)。
agent 在 worktree 的 context/harness/ 里改文件,最终回复里用 agent 在 worktree 的 context/harness/ 里改文件,最终回复里用
@@ -290,7 +290,7 @@ class TaskClient(Protocol):
def get_task_files(self, task_id: str, path: str = "") -> dict: def get_task_files(self, task_id: str, path: str = "") -> dict:
"""GET /tasks/{id}/files:不传 path 列 worktree 文件,传 path """GET /tasks/{id}/files:不传 path 列 worktree 文件,传 path
读单个文件。服务端对 Docker/跨机任务回 409,调用方须把 读单个文件。服务端对 cloud/跨机任务回 409,调用方须把
409 当成“无文件通道”、不得当成探针失败。""" 409 当成“无文件通道”、不得当成探针失败。"""
... ...
@@ -638,7 +638,7 @@ class CodexProbeAdapter:
取 seed 里没有的新文件,以及内容被改过的 seed 文件(与 取 seed 里没有的新文件,以及内容被改过的 seed 文件(与
seed 字节比对,不一致才算产物);跳过隐藏文件、最多收 seed 字节比对,不一致才算产物);跳过隐藏文件、最多收
8 个、最多读 16 个;端点不可用(Docker/跨机 409、读错) 8 个、最多读 16 个;端点不可用(cloud/跨机 409、读错)
时返回空 dict——文件产物是 enrichment,绝不能因此炸轮次。 时返回空 dict——文件产物是 enrichment,绝不能因此炸轮次。
""" """
try: try:
+40 -13
View File
@@ -445,6 +445,38 @@ def _thread_worktree_for_task(server, task):
return os.path.join(base, safe) return os.path.join(base, safe)
def _task_worktree_for_files(server, task):
"""文件端点用的任务 worktree 解析。
本地任务走 _thread_worktree_for_task;租户 Docker 任务走
tenants_root/<安全项目名>/work/host-worktrees/<安全任务名>——
worker 在租户 work 目录下按任务建子目录(生产实查 wwww 租户
下 62 个任务目录皆如此,安全名规则与 worker 侧一致)。绝不
直接 serves 共享的 /app/work 挂载点本身,只 serves 任务级子
目录,所以不存在按任务错指。cloud 任务与跨机 worktree 一律
409;两个候选路径都没有时 404(任务未启动或目录已清理)。
返回 (path, None) 或 (None, (status, msg))。
"""
if task.get("executor") == "cloud":
return None, (409, "cloud task worktrees are not on this host")
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return None, (409, "task worktree is on host %s;"
" files must be read there" % worktree_host)
safe_task = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"]))
safe_project = _tenant_safe_project_name(task.get("project_id"))
if safe_project:
tenant_path = os.path.join(
_tenants_root_for(server), safe_project, "work",
"host-worktrees", safe_task)
if os.path.isdir(tenant_path):
return tenant_path, None
local_path = _thread_worktree_for_task(server, task)
if os.path.isdir(local_path):
return local_path, None
return None, (404, "no local worktree for this task")
def _safe_worktree_path(worktree, rel): def _safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:空串返回 worktree 本身; """worktree 内相对路径的安全解析:空串返回 worktree 本身;
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后 绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
@@ -4753,13 +4785,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
}) })
def _task_files(self, user_id, task_id, query): def _task_files(self, user_id, task_id, query):
"""GET /tasks/{id}/files:读本地任务 worktree 里的文件。 """GET /tasks/{id}/files:读任务 worktree 里的文件。
只做 local 执行器任务(复用 thread 操作的执行器门): 解析见 _task_worktree_for_files:本地任务走本地 worktree,
Docker 任务的 worktree 在容器内、且租户 Docker 下 /app/work 租户 Docker 任务走 tenants 下的任务级子目录(只 serves
是整项目共享目录,按任务归属读文件会错指,一律 409 明 host-worktrees/<任务id>,绝不 serves 共享的 /app/work 本
说;cloud 任务与跨机 worktree 同理。executor 为空的老 身);cloud 任务与跨机 worktree 一律 409,解析不到时 404。
任务放行(与 thread 门同款),解析不到 worktree 时 404。
?path=<相对路径>:不传列 worktree 顶层(递归、最多 200 ?path=<相对路径>:不传列 worktree 顶层(递归、最多 200
条,只含文件路径与大小,不跟符号链接);传目录列该目录 条,只含文件路径与大小,不跟符号链接);传目录列该目录
@@ -4772,13 +4803,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if err is not None: if err is not None:
self._send_json(err[0], {"error": err[1]}) self._send_json(err[0], {"error": err[1]})
return return
guard = self._thread_task_guard(task) worktree, werr = _task_worktree_for_files(self.server, task)
if guard is not None: if werr is not None:
self._send_json(guard[0], {"error": guard[1]}) self._send_json(werr[0], {"error": werr[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 return
rel = (query.get("path") or [""])[0] rel = (query.get("path") or [""])[0]
target = _safe_worktree_path(worktree, rel) target = _safe_worktree_path(worktree, rel)
+25 -1
View File
@@ -37,6 +37,8 @@ class TaskFilesTestCase(unittest.TestCase):
self.worktrees = os.path.join(self.tmp.name, "host-worktrees") self.worktrees = os.path.join(self.tmp.name, "host-worktrees")
os.makedirs(self.worktrees) os.makedirs(self.worktrees)
self.server.host_worktrees_dir = self.worktrees self.server.host_worktrees_dir = self.worktrees
self.tenants = os.path.join(self.tmp.name, "tenants")
self.server.tenants_root = self.tenants
self.port = self.server.server_address[1] self.port = self.server.server_address[1]
self.thread = threading.Thread( self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True) target=self.server.serve_forever, daemon=True)
@@ -61,6 +63,14 @@ class TaskFilesTestCase(unittest.TestCase):
Path(self.tmp.name, "outside.txt").write_text("secret\n", Path(self.tmp.name, "outside.txt").write_text("secret\n",
encoding="utf-8") encoding="utf-8")
self._task("task-docker-1", executor="docker") self._task("task-docker-1", executor="docker")
tenant_wt = os.path.join(
self.tenants, self.project["id"], "work",
"host-worktrees", "task-docker-1")
os.makedirs(tenant_wt)
Path(tenant_wt, "report.md").write_text("Total: 85.15\n",
encoding="utf-8")
self._task("task-docker-nodir", executor="docker")
self._task("task-cloud-1", executor="cloud")
self._task("task-nowt-1", executor=None) self._task("task-nowt-1", executor=None)
def tearDown(self): def tearDown(self):
@@ -150,9 +160,23 @@ class TaskFilesTestCase(unittest.TestCase):
status, body = self._files("task-files-1", "big.txt") status, body = self._files("task-files-1", "big.txt")
self.assertEqual(status, 413, body) self.assertEqual(status, 413, body)
def test_docker_task_409_and_auth(self): def test_tenant_docker_task_files(self):
# 租户 Docker 任务走 tenants 下的任务级子目录,不再 409。
status, body = self._files("task-docker-1") status, body = self._files("task-docker-1")
self.assertEqual(status, 200, body)
self.assertEqual([e["path"] for e in body["entries"]],
["report.md"])
status, body = self._files("task-docker-1", "report.md")
self.assertEqual(status, 200, body)
self.assertEqual(body["content"], "Total: 85.15\n")
def test_docker_task_without_worktree_404_and_cloud_409(self):
status, _ = self._files("task-docker-nodir")
self.assertEqual(status, 404)
status, body = self._files("task-cloud-1")
self.assertEqual(status, 409, body) self.assertEqual(status, 409, body)
def test_auth(self):
status, _ = self._req("GET", "/tasks/task-files-1/files") status, _ = self._req("GET", "/tasks/task-files-1/files")
self.assertEqual(status, 401) self.assertEqual(status, 401)
status, _ = self._files("task-files-1", key=self.outsider_key) status, _ = self._files("task-files-1", key=self.outsider_key)
+1 -1
View File
@@ -35,7 +35,7 @@ benchmark 上的,不是本项目的承诺——本项目没有 benchmark,接
| Ledger(会话/探针账本)与成本 | 改写:成本汇总进 `ModeResult.total_cost_usd`,由调用方接现有 `cloud/cost.py` / 租户配额计费;另有 `budget_usd` 超支门与 `max_sessions` 会话数门(计探针 + 阶段尝试会话;edit 内 fmt2 纠正与选择/反思会话不计入):任一超支后不再调用编辑器/探针,剩余阶段回落,且该轮不入库;上游的 R=4k/P=3k 事后强校验未引(本项目结构与上游计数不完全同构,门控代替断言) | | Ledger(会话/探针账本)与成本 | 改写:成本汇总进 `ModeResult.total_cost_usd`,由调用方接现有 `cloud/cost.py` / 租户配额计费;另有 `budget_usd` 超支门与 `max_sessions` 会话数门(计探针 + 阶段尝试会话;edit 内 fmt2 纠正与选择/反思会话不计入):任一超支后不再调用编辑器/探针,剩余阶段回落,且该轮不入库;上游的 R=4k/P=3k 事后强校验未引(本项目结构与上游计数不完全同构,门控代替断言) |
| run_tag 形状与建任务前预检 | 对照补齐:`validate_run_tag`(字符集、首字符不为 -、不以 s 数字段开头)与 `preflight_checks`(base 目录与 AGENTS.md、source 目录、提示、tag、控制面 health 探针);CLI `--check` 只预检不建任务 | | run_tag 形状与建任务前预检 | 对照补齐:`validate_run_tag`(字符集、首字符不为 -、不以 s 数字段开头)与 `preflight_checks`(base 目录与 AGENTS.md、source 目录、提示、tag、控制面 health 探针);CLI `--check` 只预检不建任务 |
| 分角色模型(SOLVER / IMPROVER / JUDGE) | 已引(机制):`run_improvement_via_tasks` 的 `role_models`(probe / editor / selector / reflector)+ CLI `--model-<角色>`;非空值覆盖该角色,缺键回落共享 `--model`,两者都空走服务端默认——默认行为不变,模型选择权仍在调用方(有价模型产生真实成本) | | 分角色模型(SOLVER / IMPROVER / JUDGE) | 已引(机制):`run_improvement_via_tasks` 的 `role_models`(probe / editor / selector / reflector)+ CLI `--model-<角色>`;非空值覆盖该角色,缺键回落共享 `--model`,两者都空走服务端默认——默认行为不变,模型选择权仍在调用方(有价模型产生真实成本) |
| 产物资产(deliverable_files / deliverable_assets / images)与选择 assets note | 已引(两层):①服务端 `GET /tasks/{id}/files`(本地任务 worktree 读取,路径安全解析、文本 ≤256KB、列表 ≤200 条;Docker/跨机任务 409);②探针回复里的 `DELIVERABLE FILE` 块(所有执行器可用,端点结果优先、坏块丢弃不炸轮次)。产物进 `Rollout.deliverable_files`,并写入选择/编辑/反思上下文的 `deliverables/<label>/files/`。边界:租户 Docker 下 `/app/work` 是整项目共享目录,按任务归属读文件会错指,故端点对 Docker 任务只 409、不给错数据;此时只有 agent 自报层可用。images(二进制产物)未引:端点只读 UTF-8 文本 | | 产物资产(deliverable_files / deliverable_assets / images)与选择 assets note | 已引(两层):①服务端 `GET /tasks/{id}/files`(任务 worktree 读取,路径安全解析、文本 ≤256KB、列表 ≤200 条;本地任务走本地 worktree,租户 Docker 任务走 `tenants/<项目>/work/host-worktrees/<任务id>`——worker 按任务建子目录,只 serves 任务级子目录、绝不 serves 共享的 `/app/work` 本身;cloud/跨机任务 409);②探针回复里的 `DELIVERABLE FILE` 块(所有执行器可用,端点结果优先、坏块丢弃不炸轮次)。产物进 `Rollout.deliverable_files`,并写入选择/编辑/反思上下文的 `deliverables/<label>/files/`。images(二进制产物)未引:端点只读 UTF-8 文本 |
| sweep 层(多任务批量、跨任务 library restore、dead 隔离、report 汇总) | 已引:`run_sweep` + CLI `examples/harness2_sweep.py`(共享 library_root、单轮失败隔离、sweep-report.json 汇总;manifest 预检不过不建任务,默认只打印计划,`--yes` 才真跑) | | sweep 层(多任务批量、跨任务 library restore、dead 隔离、report 汇总) | 已引:`run_sweep` + CLI `examples/harness2_sweep.py`(共享 library_root、单轮失败隔离、sweep-report.json 汇总;manifest 预检不过不建任务,默认只打印计划,`--yes` 才真跑) |
| 提示词 | 改写短版(中文项目语境下的英文任务提示,结构同上游,措辞自写),不抄上游长提示 | | 提示词 | 改写短版(中文项目语境下的英文任务提示,结构同上游,措辞自写),不抄上游长提示 |
| Benchmark 适配器(JobBench / LAB / WorkBuddy)、评分器、rubric | 不引。任务就是一个普通 codex 任务,delivered 由 adapter 定义(如产物存在且非空) | | Benchmark 适配器(JobBench / LAB / WorkBuddy)、评分器、rubric | 不引。任务就是一个普通 codex 任务,delivered 由 adapter 定义(如产物存在且非空) |