feat: project directory tree + first-round snapshot

- cloud/tree.py: structured worktree tree (dirs first, ignore
  .git/node_modules, symlinks not followed, entry/depth
  truncation flags, text render for injection)
- GET /tasks/{id}/tree with /files auth + worktree resolution,
  max_depth 1..5, max_entries 1..500, escape 400, missing 404,
  cloud/cross-host 409
- MCP get_task_tree translating to the new endpoint
- worker first-round injection of a repo-checkout top-level
  snapshot (2 layers, 120 entries) after repo resolution,
  labelled as checkout snapshot not worktree; skipped for
  coordinator/resume/fork, silent when no host source
- docs/project-tree.md; tests for tree, endpoint, injection,
  MCP translation
This commit is contained in:
host committed 2026-10-30 14:46:11 +08:00
1 parent 85f280e5e7
commit ce7fb3afd6
9 files changed
+865

No files matched your search

+109
View File
@@ -166,6 +166,10 @@ from cloud.todos import create_todo as _todo_create_db
from cloud.todos import get_todo as _todo_get_db
from cloud.todos import list_todos as _todo_list_db
from cloud.todos import update_todo as _todo_update_db
from cloud.tree import INJECTION_MAX_DEPTH as _TREE_INJ_DEPTH
from cloud.tree import INJECTION_MAX_ENTRIES as _TREE_INJ_ENTRIES
from cloud.tree import build_tree as _build_tree
from cloud.tree import render_tree_text as _render_tree_text
from cloud.marker_tools import new_child_id as _marker_new_id
from cloud.marker_tools import parent_spawn_generation as _marker_generation
from cloud.marker_tools import MAX_DIRECT_CHILDREN as _MARKER_MAX_DIRECT
@@ -1836,6 +1840,104 @@ class Worker:
task["id"])
return new_task
def _tree_snapshot_source(self, task):
"""首轮目录树快照的宿主侧来源目录,或 None。
时序诚实点:认领时任务 worktree 还没建出来(派发时
host.py 才按检出建 worktree),所以这里快照的不是
worktree 本身,而是派发源——即 _apply_project_repo
已解析好的检出,worktree 正是由它建出;注入文本里
明写这一点,附件 seed 与 agent 新建文件不在快照内。
候选顺序(只接受宿主上已存在的目录,容器内路径绝
不当宿主路径用):
1. task["repo"] 本身已是宿主目录(非租户、环境任务);
2. 租户 + repo_url:tenants_root/<safe>/<safe>(与
repos.checkout_dir 同规则,_tenant_dirs 的
repo_checkout);
3. 租户 + repo_url 为空:_tenant_dirs 的 test_repo
(派发源就是该模板副本)。
任一候选都不存在时返回 None,调用方静默跳过——
快照缺失绝不炸掉任务认领,agent 仍可用 shell 自查。
"""
repo = task.get("repo")
if repo and os.path.isdir(repo):
return repo
if not (self.tenant_isolation
and isinstance(self.executor, DockerExecutor)):
return None
try:
safe = self._tenant_safe_name(task.get("project_id"))
except Exception:
return None
checkout = os.path.join(self.tenants_root, safe, safe)
if os.path.isdir(checkout):
return checkout
# 检出缺失但项目确有 repo_url 时绝不回落到 test_repo:
# 那说明检出被删/解析出了问题,贴模板快照会误导;
# 只有 repo_url 为空(仓库门本就走 test_repo 路)时
# 才回落。查不到 repo_url 时仓库门同款走 test_repo,
# 故 None 在这里同样回落,不另作区分。
if self._project_repo_url(task.get("project_id")):
return None
try:
dirs = self._tenant_dirs(task.get("project_id"))
except Exception:
return None
test_repo = dirs.get("test_repo") if isinstance(dirs, dict) \
else None
if test_repo and os.path.isdir(test_repo):
return test_repo
return None
def _apply_tree_injection(self, task):
"""首轮注入项目目录结构:只改内存副本,原 prompt/goal
不落库;失败静默跳过。
跳过:协调员任务、续跑与断点续跑、分支任务
(forked_from:其 worktree 来自分支,不是检出快照,
贴检出树会误导)。goal 任务不跳过——结构是数据不是
目标,与项目注入同款同时改内存里的 goal。注入在
run_one 里 _apply_project_repo 之后调用(检出已解析),
不在认领时的注入块里(那时 task["repo"] 还没定)。
上限:2 层、120 条(见 tree.INJECTION_*),深层用
shell 或任务外 get_task_tree 再查;空目录(无条目)
不贴空章节。
"""
if task.get("coordinator_parts"):
return task
if task.get("_resume") or task.get("_goal_resume_breakpoint"):
return task
if task.get("forked_from"):
return task
try:
source = self._tree_snapshot_source(task)
if not source:
return task
tree = _build_tree(source, max_depth=_TREE_INJ_DEPTH,
max_entries=_TREE_INJ_ENTRIES)
except Exception as exc:
try:
self.logger.warning("tree injection failed: %s", exc)
except Exception:
pass
return task
if not tree.get("entries"):
return task
lines = ["[项目目录结构:仓库检出顶层快照,任务 worktree "
"由此建出;附件 seed 与本轮新建文件不在内,"
"深层结构用 shell 或 get_task_tree 再查]",
_render_tree_text(tree)]
prefix = "\n".join(lines)
new_task = dict(task)
new_task["prompt"] = prefix + "\n\n" + (task.get("prompt") or "")
if task.get("goal_mode") or task.get("verify_command"):
new_task["goal"] = prefix + "\n\n" + (
task.get("goal") or task.get("prompt") or "")
new_task["_tree_injected"] = True
self._audit(task["project_id"], "worker_tree_injected",
task["id"])
return new_task
def process_auto_memories(self, now=None, force=False,
owner_id=None):
"""扫 completed 任务并提取记忆(run_loop 轮次边界调用)。
@@ -5510,6 +5612,13 @@ class Worker:
"stdout": "", "error": repo_error, "events": [],
"cost_record_id": None}
# 目录树注入:仓库门之后、真正执行之前——只有这里
# task["repo"] 已解析,_tree_snapshot_source 才能找到
# 宿主侧检出;认领时的注入块里 repo 还没定,贴不了。
# 只改内存副本,原 prompt/goal 不落库;快照缺失时
# 静默跳过,不影响配额门与后续执行。
task = self._apply_tree_injection(task)
# 配额认领门:排队期间配额被花完(或被调低)时,任务不能
# 再开跑——直接标 interrupted + 配额原因,不记成本(还没花
# 钱),续跑消息不消费,顶上来后重试/续跑可再跑。与 key