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:
1 parent
85f280e5e7
commit
ce7fb3afd6
9 files changed
+865
No files matched your search
+109
@@ -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
|
||||
|
||||
Reference in new issue
Block a user