成本配额与文件只读标记:task-cost/quota-status/task-files

This commit is contained in:
host committed 2026-10-30 05:11:06 +08:00
1 parent 2fd73c1121
commit 9d6430a4d0
5 files changed
+711 -14

No files matched your search

+248 -12
View File
@@ -154,8 +154,10 @@ from cloud.marker_tools import check_control_gate as _marker_gate
from cloud.marker_tools import count_direct_spawn_children as _marker_count_direct
from cloud.marker_tools import fetch_task_snapshot as _marker_snapshot
from cloud.marker_tools import format_snapshot as _marker_format
from cloud.marker_tools import format_cost as _format_cost
from cloud.marker_tools import format_task as _format_task
from cloud.marker_tools import format_todo as _format_todo
from cloud.marker_tools import safe_worktree_path as _safe_worktree_path
from cloud.marker_tools import marker_report_header as _marker_header
from cloud.todos import create_todo as _todo_create_db
from cloud.todos import get_todo as _todo_get_db
@@ -1804,7 +1806,11 @@ class Worker:
"```task-list",
'{"status": "failed"}',
"```",
"(不传 status 时查全部状态并附各状态计数。)"]
"(不传 status 时查全部状态并附各状态计数。)",
"成本、配额、子任务文件同款标记块:task-cost"
" {\"task_id\": \"...\"}、quota-status {}、"
"task-files {\"task_id\": \"...\", \"path\": \"\"}"
"(path 空串列顶层;只读派生链内或同创建人的任务)。"]
prefix = "\n".join(lines)
new_task = dict(task)
new_task["prompt"] = prefix + "\n\n" + (task.get("prompt") or "")
@@ -3366,19 +3372,57 @@ class Worker:
return None, "权限检查失败:%s" % exc
return bool(ok), ""
def _task_files_worktree(self, target):
"""task-files 用的目标 worktree 解析,返回 (path, None)
或 (None, reason)。
候选顺序与服务端 _task_worktree_for_files 同款:租户
Docker 任务走 tenants_root/<安全项目名>/work/
host-worktrees/<安全任务名>,本地任务走 host.py 同级
work/host-worktrees/<安全任务名>;只接受已存在的目录,
不存在时按“没有本地 worktree”报告,绝不把共享挂载点
本身或默认仓库路径当成目标任务的目录(那才是错指的
来源)。cloud 任务与 worktree 在别机的任务直接拒绝。"""
if target.get("executor") == "cloud":
return None, "cloud 任务的 worktree 不在本机"
worktree_host = target.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return None, ("任务 worktree 在主机 %s,不在本机"
% worktree_host)
safe_task = re.sub(r"[^A-Za-z0-9_.-]", "-",
str(target["id"]))
try:
safe_project = self._tenant_safe_name(
target.get("project_id"))
except Exception:
safe_project = None
if safe_project:
tenant_path = os.path.join(
self.tenants_root, safe_project, "work",
"host-worktrees", safe_task)
if os.path.isdir(tenant_path):
return tenant_path, None
host_dir = os.path.dirname(
os.path.abspath(self.host_py_path))
local_path = os.path.join(host_dir, "work",
"host-worktrees", safe_task)
if os.path.isdir(local_path):
return local_path, None
return None, "目标没有本地 worktree(未启动或目录已清理)"
def _deliver_marker_tools(self, task, result):
"""执行本轮 agent 输出里的标记工具块(见 marker_tools)。
只执行 completed 轮次的块;goal 任务与协调员任务不解析
(只收不发,与 peer 同款);父任务被取消时不执行。
执行顺序:创建 → 取消 → 重试 → 待办创建 → 待办更新 →
待办列表 → 任务列表 → 轮询 → 等待——创建在前让同一轮
刚创建的任务立刻能被轮询,控制操作在快照之前让轮询/
等待读到取消或重试后的新状态;待办列表放在待办写操作
之后,让同一轮刚建/刚改的待办立刻能在列表里看到。
全部报告拼成一条系统后续消息排给父任务自己,由排空
循环在轮次边界跑下一轮;拒绝只记报告与审计,不炸
run_one。"""
待办列表 → 任务列表 → 任务成本 → 配额 → 任务文件 →
轮询 → 等待——创建在前让同一轮刚创建的任务立刻能被
轮询,控制操作在快照之前让轮询/等待读到取消或重试后
的新状态;待办列表放在待办写操作之后,让同一轮刚建/
刚改的待办立刻能在列表里看到。全部报告拼成一条系统
后续消息排给父任务自己,由排空循环在轮次边界跑下
一轮;拒绝只记报告与审计,不炸 run_one。"""
if not result or result.get("status") != "completed":
return
if self._was_cancelled(task["id"]):
@@ -3396,9 +3440,13 @@ class Worker:
todo_creates = result.get("todo_creates") or []
todo_updates = result.get("todo_updates") or []
task_lists = result.get("task_lists") or []
task_costs = result.get("task_costs") or []
quota_status = result.get("quota_status") or []
task_files = result.get("task_files") or []
if not (creates or polls or waits or cancels or retries
or todo_lists or todo_creates or todo_updates
or task_lists):
or task_lists or task_costs or quota_status
or task_files):
return
reports = []
created = 0
@@ -3581,7 +3629,8 @@ class Worker:
# 一律拒绝,不静默放宽。跨项目待办按“不存在”拒绝;
# 关联任务 id 跨项目时同样拒绝,避免把别的项目的任务
# 挂到本项目待办上。
if todo_lists or todo_creates or todo_updates or task_lists:
if (todo_lists or todo_creates or todo_updates or task_lists
or task_costs or quota_status or task_files):
read_ok, _read_reason = self._marker_auth(task, "read")
write_ok, _write_reason = self._marker_auth(
task, "create_task")
@@ -3747,6 +3796,190 @@ class Worker:
lines.append("(还有 %d 条未列出。)"
% (len(tasks) - block["limit"]))
reports.append("\n".join(lines))
for block in task_costs:
if read_ok is not True:
reports.append("查任务 %s 成本:拒绝(发送方"
"创建人无 read 权限)。"
% block["task_id"])
rejected += 1
continue
target = self.get_task(block["task_id"])
if (target is None
or target.get("project_id")
!= task["project_id"]):
reports.append("查任务 %s 成本:不存在(或"
"属于别的项目),已跳过。"
% block["task_id"])
rejected += 1
continue
try:
conn = _db_connect(self.db_path)
try:
relation = _marker_ancestry(
conn, task, target)
finally:
conn.close()
except Exception:
relation = "other"
if relation not in ("descendant", "same_creator"):
reports.append("查任务 %s 成本:拒绝(目标不"
"在派生链内且创建人不同)。"
% block["task_id"])
rejected += 1
continue
try:
store = CostStore(self.db_path)
try:
report = store.report_by_task(
block["task_id"])
finally:
try:
store._conn.close()
except Exception:
pass
reports.append(_format_cost(block["task_id"],
report))
except Exception as exc:
reports.append("查任务 %s 成本失败:%s"
% (block["task_id"], exc))
rejected += 1
for _block in quota_status:
if read_ok is not True:
reports.append("查配额:拒绝(发送方创建人"
"无 read 权限)。")
rejected += 1
continue
owner = task.get("created_by")
if not owner and task.get("parent_task_id"):
parent = self.get_task(task["parent_task_id"])
if parent is not None:
owner = parent.get("created_by")
if not owner:
reports.append("查配额:拒绝(发送方无创建"
"人,无法确定配额属主)。")
rejected += 1
continue
try:
quota, used, month, unpriced = (
self._quota_limit_and_used(owner))
except Exception as exc:
reports.append("查配额失败:%s" % exc)
rejected += 1
continue
if quota is None:
reports.append("配额(%s):不限额,已用"
" $%.4f,无价 actual token %d。"
% (month, used / 1000000.0,
unpriced))
else:
reports.append("配额(%s):总额 $%.4f,已用"
" $%.4f,剩余 $%.4f,无价"
" actual token %d。"
% (month, quota / 1000000.0,
used / 1000000.0,
(quota - used) / 1000000.0,
unpriced))
for block in task_files:
if read_ok is not True:
reports.append("读任务 %s 文件:拒绝(发送方"
"创建人无 read 权限)。"
% block["task_id"])
rejected += 1
continue
target = self.get_task(block["task_id"])
if (target is None
or target.get("project_id")
!= task["project_id"]):
reports.append("读任务 %s 文件:不存在(或"
"属于别的项目),已跳过。"
% block["task_id"])
rejected += 1
continue
try:
conn = _db_connect(self.db_path)
try:
relation = _marker_ancestry(
conn, task, target)
finally:
conn.close()
except Exception:
relation = "other"
if relation not in ("descendant", "same_creator"):
reports.append("读任务 %s 文件:拒绝(目标不"
"在派生链内且创建人不同)。"
% block["task_id"])
rejected += 1
continue
worktree, reason = self._task_files_worktree(target)
if worktree is None:
reports.append("读任务 %s 文件:拒绝(%s)。"
% (block["task_id"], reason))
rejected += 1
continue
safe = _safe_worktree_path(worktree, block["path"])
if safe is None:
reports.append("读任务 %s 文件 %s:拒绝"
"(路径逃逸出 worktree)。"
% (block["task_id"],
block["path"] or "(顶层)"))
rejected += 1
continue
if os.path.isdir(safe):
try:
names = sorted(os.listdir(safe))[:50]
except Exception as exc:
reports.append("列任务 %s 目录失败:%s"
% (block["task_id"], exc))
rejected += 1
continue
lines = ["任务 %s 目录 %s(共 %d 项,"
"只列前 50 项):"
% (block["task_id"],
block["path"] or "(顶层)",
len(names))]
for name in names:
full = os.path.join(safe, name)
lines.append(name + ("/" if os.path.isdir(
full) else ""))
reports.append("\n".join(lines))
elif os.path.isfile(safe):
try:
with open(safe, "rb") as fh:
raw = fh.read(20001)
except Exception as exc:
reports.append("读任务 %s 文件 %s 失败:"
"%s" % (block["task_id"],
block["path"], exc))
rejected += 1
continue
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = None
if text is None or "\x00" in text:
# NUL 字节是二进制文件的可靠信号:纯控制
# 字符也能通过 utf-8 解码,只靠解码失败
# 拦不住,必须另查 NUL。
reports.append("读任务 %s 文件 %s:拒绝"
"(二进制文件,不读)。"
% (block["task_id"],
block["path"]))
rejected += 1
continue
truncated = len(raw) > 20000
if truncated:
text = text[:20000]
reports.append("任务 %s 文件 %s%s:\n%s"
% (block["task_id"],
block["path"],
"(已截断前 20000 字)"
if truncated else "",
text))
else:
reports.append("读任务 %s 文件 %s:不存在。"
% (block["task_id"],
block["path"]))
rejected += 1
for poll in polls:
try:
reports.append(self._poll_task_snapshot(task, poll))
@@ -3763,11 +3996,14 @@ class Worker:
self._queue_self_followup(task, body)
detail = ("%s: spawned=%d rejected=%d polls=%d waits=%d "
"cancels=%d retries=%d todo_lists=%d "
"todo_creates=%d todo_updates=%d task_lists=%d"
"todo_creates=%d todo_updates=%d task_lists=%d "
"task_costs=%d quota=%d task_files=%d"
% (task["id"], created, rejected, len(polls),
len(waits), len(cancels), len(retries),
len(todo_lists), len(todo_creates),
len(todo_updates), len(task_lists)))
len(todo_updates), len(task_lists),
len(task_costs), len(quota_status),
len(task_files)))
self._audit(task["project_id"], "worker_marker_tools", detail)
def _deliver_peer_messages(self, task, result):