6886 lines
351 KiB
Python
6886 lines
351 KiB
Python
"""云端 worker:把控制面的 queued 任务接通到 host.py 执行。
|
||
|
||
零第三方依赖,只用 Python 标准库;不改 host.py 行为、不动
|
||
~/.codex 配置、不碰 wechat-bridge。
|
||
|
||
流程(与 server.py 的任务状态机对齐):
|
||
queued -> running -> completed / failed / interrupted
|
||
|
||
1. 认领:pick_next() 用 queue.ready_tasks() 按依赖拓扑 + 优先级
|
||
排序,跳过依赖未完成(未 completed/merged)的任务;再过
|
||
queue.budget_exceeded() 预算门,超限不派发、任务保持 queued。
|
||
claim() 用 `WHERE status='queued'` 的 CAS 更新把任务标 running
|
||
并记 last_heartbeat / updated_at,避免两个 worker 重复认领。
|
||
2. 缺 key 门(执行前必查):只检查配置的 env_key 名在进程环境
|
||
里是否存在且非空,绝不读出、打印、写进文件或日志 key 的值。
|
||
缺 key 时任务直接标 failed,错误固定为
|
||
`Missing environment variable: <env_key 名>`,与之前
|
||
real-nightly-tests automation 的失败模式一致,绝不静默假装跑了。
|
||
env_key 名的来源:显式参数 > codex 配置文件里的 env_key 行
|
||
(只读名字)> 默认 XCM_API_KEY(中转站 relay 的约定名)。
|
||
3. 执行:经 LocalExecutor 调 host.py 真实子命令。dispatch
|
||
(建 host 任务并跑一轮 turn)是首次派发;dispatch 报
|
||
“任务已存在”时改用 run(在已有 host 任务上再跑一轮)续跑。
|
||
host 任务名由 cloud 任务 id 派生(见 sandbox._host_name),
|
||
同一任务每次算出同一名字,续跑才能找到同一个 host 任务。
|
||
4. 写回:server 的 tasks 表没有 result/error 列,所以执行结果
|
||
的持久化分三处,各复用已有结构、不另起字段名:
|
||
- tasks 表:最终状态 + updated_at(取消时为 interrupted);
|
||
- cost.py CostStore:每轮实际执行记一条成本记录;执行器带回
|
||
上游真实用量(host.py 从 tokenUsage/updated 事件汇总)时记
|
||
source="actual"(input/output/cached 全是实测值,usd 按价目
|
||
表算、无价模型记 None);没带回用量时回落 cost.py 估算记
|
||
estimated,估算绝不冒充 actual;total_tokens 由
|
||
CostStore.record 按 input+output 算;
|
||
- models.py:可 fallback 的失败(404/429/5xx/timeout,401/400
|
||
不算)。默认(未配 --fallback-models)时 worker 只记录
|
||
fallback 事件(record_fallback() 记 model_fallback 事件,
|
||
fallback_count 写进成本记录),不自动换模型重试——自动重试
|
||
等于未经批准烧 relay 额度。显式配了 --fallback-models 时,
|
||
第一次失败且可 fallback 就真的换列表里的第一个模型再跑一次
|
||
(最多 1 次,同一任务、同一 worktree/分支,dispatch 报
|
||
“任务已存在”时走现有的 run 续跑路径,模型经 host.py 的
|
||
顶层 --model 参数传过去,不改 host.py 签名);第二次的结果
|
||
就是最终结果,actual_model 写成最后跑的模型,fallback_count=1。
|
||
审计:认领/完成/失败/取消/缺 key 各记一条 AuthStore 审计
|
||
(user_id="system",与 server.sweep_stale_tasks 的做法一致),
|
||
审计失败不影响任务状态(best-effort)。
|
||
|
||
取消:worker 的 cancel() 走 server 的 cancel 语义:把
|
||
queued/running 任务直接标 interrupted(已终态的任务不动),
|
||
且正在跑的子进程会真杀——子进程以进程组启动(start_new_session),
|
||
取消时先 SIGTERM 给整个进程组、等 2 秒、仍活着再 SIGKILL,
|
||
杀完后任务保持 interrupted,执行结果不覆盖它。run_one() 在
|
||
执行返回后会先查任务是否已被取消,已被取消的一律保持
|
||
interrupted,不用执行结果覆盖它。
|
||
|
||
任务级超时与失败重试(生产级,见 Worker.__init__ 参数):
|
||
- task_timeout(默认 1800 秒,CLI --task-timeout 可调)是
|
||
worker 级的每任务截止时间,与下面两个参数不要混淆:
|
||
--docker-timeout 是执行器级的单次 docker run 超时(Docker
|
||
Executor 自身的 timeout),--max-seconds 是 run_loop 的
|
||
整体墙钟上限(不是每任务)。三者互不覆盖:Docker 路径下
|
||
worker 取 min(task_timeout, docker_timeout)(docker_timeout
|
||
未设时取 task_timeout)作为本次 docker run 的超时,超时后
|
||
真杀容器、任务置 failed(超时是失败,不是取消,绝不标
|
||
interrupted);Local popen 路径下 worker 在轮询循环里到
|
||
截止时间就杀进程组、同样置 failed;Local 同步路径(假
|
||
runner / 无超时的 subprocess.run)无法在中途抢占一个已阻塞
|
||
的调用,超时只能靠执行器自身的 timeout,这是诚实边界,
|
||
不要假装同步路径也能中途杀掉。
|
||
- 对齐 host.py 的 turn_timeout 约定:task_timeout 传 0 时
|
||
归一化为 None(不限时,worker 级不设截止),与 host 的
|
||
“0 = 不限时”同约定;默认 1800 不变,不改默认就不会悄悄
|
||
挂起自动化。worker 级不限时时,Docker 路径仍受执行器
|
||
docker_timeout 约束(timeout=None 时 _call_runner 用
|
||
executor 自身的 timeout),不是全链路无限。
|
||
- max_retries(默认 1 次重试,CLI --max-retries 可调,0 表示
|
||
不重试)是对模型任务失败的有限重试:取消不重试;可 fallback
|
||
的失败优先走真实 fallback(见下),不再做同模型重试(fallback
|
||
本身就是模型级的重试,再同模型重试会重复烧额度);其它失败
|
||
(含 worker 超时)重试前按 retry_backoff_seconds(默认 5 秒,
|
||
测试可注入 0)等待,重试次数记在审计(worker_retry)与
|
||
最终 error_text([retries=N] 前缀)里,不另起新表;重试
|
||
耗尽后任务才最终 failed,成本记录与 usage_turns 只记一次
|
||
- 真实 fallback(CLI --fallback-models,默认空 = 关闭):一次
|
||
尝试失败且可 fallback(404/429/5xx/timeout,401/400 不算,
|
||
取消不算)时,若配了 fallback 列表,就记 model_fallback 事件
|
||
与 worker_fallback 审计,然后换列表里的第一个模型再跑一次
|
||
(最多 1 次,与 max_retries 无关,max_retries=0 时 fallback
|
||
照样跑);第二次的结果就是最终结果,actual_model 写成最后
|
||
跑的模型,fallback_count=1,两次都失败时 error_text 同时写清
|
||
两次原因;未配列表时保持老行为(只记事件、直接 failed)。
|
||
(按最终结果记,不按尝试次数重复记)。
|
||
- 容器清理兜底:Worker.cleanup_stale_containers() 列出
|
||
codex- 前缀的残留容器并逐个 kill+rm,失败只记日志不炸
|
||
worker;run_loop 启动时与 CLI 启动时各调一次(run_all /
|
||
__init__ 里不调,避免单测构造 Worker 时多余 I/O)。
|
||
|
||
可注入:runner(假 CLI,见 sandbox.LocalExecutor)与 env 映射
|
||
均可注入,单测不跑真实模型回合、不联网、不登录任何服务。
|
||
|
||
执行器选择:Worker 默认用 LocalExecutor;传入 DockerExecutor
|
||
(或 CLI --executor docker)时,派发走容器内 host.py,取消时
|
||
按任务派生的容器名 docker kill,不只改数据库状态;本机无
|
||
docker 时 DockerExecutor 在调 runner 前就明确报错,任务标
|
||
failed 且 error_text 含 docker 缺失,绝不静默降级为本地执行。
|
||
|
||
项目仓库:run_one 派发前查任务所属项目的 repo_url(projects
|
||
表),非空时经 cloud.repos.resolve_repo 在 repos_dir(CLI
|
||
--repos-dir,默认 ./repos)下 clone/fetch 该项目仓库,设
|
||
task["repo"] 再交给执行器;Local 与 Docker 执行器都已读
|
||
task["repo"],同一条路径。repo_url 为空时走原有默认 repo;
|
||
解析失败时任务直接 failed,错误文本写清是仓库解析失败。
|
||
Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下,
|
||
否则容器内 repo 路径不存在(见 cloud/README.md)。
|
||
推送到远端不在本轮范围:任务在本地检出上执行、本地 worktree
|
||
合并仍按 host.py 现有流程,git push 需要仓库写凭证,控制面
|
||
不存、不推。
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from datetime import datetime, timezone
|
||
import re
|
||
import shutil
|
||
import signal
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from types import SimpleNamespace
|
||
|
||
from cloud.auth import AuthStore
|
||
from cloud.coordinator import build_report as _coord_build_report
|
||
from cloud.coordinator import DEFAULT_POLL_INTERVAL as _COORD_POLL_INTERVAL
|
||
from cloud.coordinator import DEFAULT_TIMEOUT_SECONDS as _COORD_TIMEOUT
|
||
from cloud.coordinator import ensure_columns as _coord_ensure_columns
|
||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||
from cloud.coordinator import nonterminal_children as _coord_nonterminal
|
||
from cloud.cost import CostStore, estimate_tokens, price_usd_micros, \
|
||
sum_usage, unpriced_token_cap
|
||
from cloud.db import connect as _db_connect
|
||
from cloud.goal_command import parse_goal_command as _parse_goal_command
|
||
from cloud.logging_config import get_logger, log_event, setup_logging
|
||
from cloud.models import ModelRouter, is_fallbackable, record_fallback
|
||
from cloud.auto_memory import AutoMemoryStore
|
||
from cloud.auto_memory import build_injection_prefix as _auto_prefix
|
||
from cloud.auto_memory import process_due_tasks as _auto_process_due
|
||
from cloud.automations import create_automation as _auto_create
|
||
from cloud.lifecycle import bind_automation as _life_bind
|
||
from cloud.marker_tools import ancestry_relation as _marker_ancestry
|
||
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
|
||
from cloud.todos import list_todos as _todo_list_db
|
||
from cloud.todos import update_todo as _todo_update_db
|
||
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
|
||
from cloud.marker_tools import MAX_SPAWN_DEPTH as _MARKER_MAX_DEPTH
|
||
from cloud.marker_tools import TERMINAL_STATUSES as _MARKER_TERMINAL
|
||
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
|
||
from cloud.peer_messages import ensure_followups_table as _ensure_followups_table
|
||
from cloud.peer_messages import peer_message_prefix as _peer_prefix
|
||
from cloud.peer_messages import row_get as _row_get
|
||
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
|
||
from cloud.websearch import ensure_codex_home_websearch as _ensure_websearch_mcp
|
||
from cloud.websearch import ensure_websearch_mcp_env as _ensure_websearch_mcp_env
|
||
from cloud.imagegen import ensure_codex_home_imagegen as _ensure_imagegen_mcp
|
||
from cloud.imagegen import ensure_imagegen_mcp_env as _ensure_imagegen_mcp_env
|
||
from cloud.playwright_browser import ensure_codex_home_playwright as _ensure_playwright_mcp
|
||
from cloud.playwright_browser import ensure_playwright_mcp_env as _ensure_playwright_mcp_env
|
||
from cloud.skill_seed import ensure_codex_home_skills as _ensure_skills
|
||
from cloud.sub2api import gateway_name as _gateway_name
|
||
from cloud.sub2api import gateway_origin as _gateway_origin
|
||
from cloud.sub2api import list_gateway_models as _list_gateway_models
|
||
from cloud.pr import PrSkipped, PrStepError
|
||
from cloud.pr_exec import (claim_pr_job, execute_task_pr, finish_pr_job,
|
||
mark_pr_job_needs_local_worker)
|
||
from cloud.queue import budget_exceeded, ready_tasks
|
||
from cloud.repos import RepoError, checkout_dir, resolve_repo
|
||
from cloud.sandbox import CloudExecutor, DockerExecutor, LocalExecutor
|
||
from cloud.uploads import upload_root as _upload_root
|
||
from cloud.sandbox import _default_runner as _sandbox_default_runner
|
||
from cloud.caps import effective_capacity, resolve_capacity
|
||
from cloud.workers import heartbeat_worker, register_worker
|
||
|
||
# 与 server.TERMINAL_STATUSES 对齐:这些状态的任务不再被认领或取消。
|
||
TERMINAL_STATUSES = frozenset(
|
||
{"completed", "failed", "interrupted", "merged", "merge-failed"})
|
||
COMPLETED_STATUSES = frozenset({"completed", "merged"})
|
||
DEFAULT_ENV_KEY = "XCM_API_KEY"
|
||
# 默认模型(2026-10-27 起为 gpt-5.6-terra):此前默认的
|
||
# gpt-6.1-sol 路由当日实测持续 503,terra 同日实测 1 秒完成;
|
||
# 任务未指定模型时 worker 与 host.py 都落到这里。
|
||
DEFAULT_MODEL = "gpt-5.6-terra"
|
||
# P1 执行面:goal 轮次上限(有限轮次形态)。goal_mode=1 的 0 轮
|
||
# (模型宣告才结束)不在此限——它的上界是配额(配额看门狗与
|
||
# 认领/边界门),不是轮次;旧验收形态的 goal_max_rounds 必须
|
||
# 在 1..GOAL_MAX_ROUNDS_CAP 之间,超限的任务 worker 在认领后
|
||
# 直接 failed(建任务时的 400 校验在 server.py,属另一路文件,
|
||
# 本路只做执行侧防线,见 run_one 的 goal 上限门)。
|
||
GOAL_MAX_ROUNDS_CAP = 100
|
||
# P1 执行面:无配额行时的默认配额(微美元),与 server.py
|
||
# _default_quota_usd_micros() 同值($10/月,环境变量
|
||
# CLOUD_DEFAULT_QUOTA_USD_MICROS 可覆盖)。P0 前“无行 =
|
||
# 不限额”已废:worker 侧查不到行时按此默认额度算,显式
|
||
# NULL 行才是不限额(与服务端 _quota_status 同口径)。
|
||
# host.py dispatch 的原话是 "task 已存在: <name>,先换名或 discard 它"
|
||
#(英文 task + 已存在),取共有子串做判断,不依赖标点。
|
||
_ALREADY_EXISTS_MARKER = "已存在"
|
||
|
||
|
||
def _uuid_hex():
|
||
"""uuid hex(task_steps id 用,两后端都不依赖自增语义)。"""
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
def _worker_host_name(executor, task):
|
||
"""host 任务名:优先 executor._host_name(与 sandbox 同款
|
||
派生),没有 executor 时回落任务 id(uuid hex 本就是安全名)。"""
|
||
fn = getattr(executor, "_host_name", None)
|
||
if callable(fn):
|
||
try:
|
||
return fn(task)
|
||
except Exception:
|
||
pass
|
||
return str(task.get("id"))
|
||
|
||
|
||
def _default_quota_usd_micros():
|
||
"""无配额行时的默认配额(微美元),与 server 侧同值。
|
||
|
||
server.py 的 _default_quota_usd_micros() 在另一路文件里,
|
||
worker 不跨模块 import 它(避免循环 import),这里复刻
|
||
同款解析:环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS,非法
|
||
值回落 10_000_000。"""
|
||
try:
|
||
return max(0, int(os.environ.get(
|
||
"CLOUD_DEFAULT_QUOTA_USD_MICROS", "10000000")))
|
||
except (TypeError, ValueError):
|
||
return 10000000
|
||
|
||
|
||
def _code_signature(host_py_path=None):
|
||
"""多文件签名:worker/sandbox/coordinator/cost/server/host
|
||
各取 (mtime_ns, size) 组成的元组;任一文件取不到即 None。
|
||
|
||
P1 执行面扩面:旧版只签 worker.py,部署只换了 sandbox 或
|
||
host.py 时旧 worker 会继续用旧代码抢任务。host.py 取
|
||
host_py_path(真实文件时用它,否则用 cloud/ 上一级的
|
||
host.py);单测常传 /fake/host.py 这种不存在的路径,此时
|
||
回落到仓库 host.py,不因此判签名缺失。取不到时返回 None,
|
||
调用方必须 fail-closed(不认领、不执行),绝不按“没变”
|
||
放行——旧版“取不到当没变”是 fail-open,已废。"""
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
candidates = [
|
||
os.path.join(here, "worker.py"),
|
||
os.path.join(here, "sandbox.py"),
|
||
os.path.join(here, "coordinator.py"),
|
||
os.path.join(here, "cost.py"),
|
||
os.path.join(here, "server.py"),
|
||
]
|
||
if host_py_path and os.path.isfile(host_py_path):
|
||
candidates.append(os.path.abspath(host_py_path))
|
||
else:
|
||
candidates.append(os.path.join(os.path.dirname(here), "host.py"))
|
||
sig = []
|
||
try:
|
||
for path in candidates:
|
||
st = os.stat(path)
|
||
sig.append((st.st_mtime_ns, st.st_size))
|
||
except OSError:
|
||
return None
|
||
return tuple(sig)
|
||
|
||
|
||
def _missing_key_error(env_key):
|
||
"""缺 key 的标准错误:只含 env_key 名,与 relay 侧的报错一致。"""
|
||
return "Missing environment variable: %s" % env_key
|
||
|
||
|
||
def resolve_env_key(env_key=None, codex_config_path=None):
|
||
"""确定模型 key 对应的环境变量名(只返回名字,从不返回值)。
|
||
|
||
显式参数优先;其次读 codex 配置文件(如 relay 的 config.toml)
|
||
里的 `env_key = "..."` 行,只取名字;文件不存在或无此行时
|
||
用默认名。绝不读 ~/.codex/config.toml 之外的默认位置——
|
||
调用方要读哪个配置文件,必须显式传 codex_config_path。
|
||
"""
|
||
if env_key:
|
||
return env_key
|
||
if codex_config_path:
|
||
try:
|
||
with open(codex_config_path, encoding="utf-8") as f:
|
||
text = f.read()
|
||
except (OSError, UnicodeDecodeError):
|
||
text = ""
|
||
match = re.search(
|
||
r'^\s*env_key\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||
if match:
|
||
return match.group(1)
|
||
return DEFAULT_ENV_KEY
|
||
|
||
|
||
def _merge_env(base, override):
|
||
"""两份 env 合并:override 优先;任一为空时返回另一份原样
|
||
(保持旧调用在双 None 时传 None,不改执行器行为)。"""
|
||
if not base:
|
||
return override
|
||
if not override:
|
||
return base
|
||
merged = dict(base)
|
||
merged.update(override)
|
||
return merged
|
||
|
||
|
||
def _resolve_model(model=None, config_path=None):
|
||
"""模型名:显式参数 > host 配置文件里的 model > host 默认值。"""
|
||
if model:
|
||
return model
|
||
if config_path:
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
except (OSError, ValueError):
|
||
cfg = {}
|
||
if isinstance(cfg, dict) and cfg.get("model"):
|
||
return cfg["model"]
|
||
return DEFAULT_MODEL
|
||
|
||
|
||
def _task_from_row(row):
|
||
depends_on = row["depends_on"]
|
||
try:
|
||
depends_on = json.loads(depends_on) if depends_on else []
|
||
except (TypeError, ValueError):
|
||
depends_on = []
|
||
if not isinstance(depends_on, list):
|
||
depends_on = [depends_on]
|
||
def _col(name, default=None):
|
||
try:
|
||
return row[name]
|
||
except (KeyError, IndexError, TypeError):
|
||
return default
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"prompt": row["prompt"],
|
||
"status": row["status"],
|
||
"idempotency_key": row["idempotency_key"],
|
||
"depends_on": depends_on,
|
||
"priority": row["priority"],
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
"last_heartbeat": row["last_heartbeat"],
|
||
# 老库行缺 executor 列时给 None(表示任意 worker 可认领),
|
||
# 不炸;列本身由 _ensure_result_columns 在写回时补。
|
||
"executor": _col("executor"),
|
||
# goal 字段同款 _col 容错:老库行与 load_tasks 的显式列清单
|
||
# 里没有时给 None(= 普通任务),不炸;run_one 认领后
|
||
# get_task 重读 SELECT *,goal 任务一定能拿到完整字段。
|
||
"goal": _col("goal"),
|
||
"verify_command": _col("verify_command"),
|
||
"goal_max_rounds": _col("goal_max_rounds"),
|
||
# 新 goal 语义:goal_mode=1 时 prompt 即目标,无验收命令、
|
||
# 不限轮次;老行缺列时 None = 按 verify_command 判定。
|
||
"goal_mode": _col("goal_mode"),
|
||
# 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。
|
||
"model": _col("model"),
|
||
"effort": _col("effort"),
|
||
# 派生默认:老行缺列时 None = 没有单独声明,派生时
|
||
# 先取它、再取本任务的 model / effort。
|
||
"child_model": _col("child_model"),
|
||
"child_effort": _col("child_effort"),
|
||
"approval": _col("approval"),
|
||
"plan": _col("plan"),
|
||
"forked_from": _col("forked_from"),
|
||
# 环境:老行缺列时 None = 不指定环境,走项目仓库路径。
|
||
"environment_id": _col("environment_id"),
|
||
# 续跑消息:老行缺列时 None = 没有待续跑消息。
|
||
"resume_message": _col("resume_message"),
|
||
# G10:goal 断点续跑标记,老行缺列时 0 = 普通排队。
|
||
"goal_resume_pending": _col("goal_resume_pending") or 0,
|
||
# 长任务自动恢复:用户暂停标记、中断原因、自动续跑
|
||
# 次数、不限时标记,老行缺列时按默认(可恢复、不限时关)。
|
||
"paused_by_user": _col("paused_by_user") or 0,
|
||
"interrupted_reason": _col("interrupted_reason"),
|
||
"auto_resume_count": _col("auto_resume_count") or 0,
|
||
"no_time_limit": _col("no_time_limit") or 0,
|
||
# 协调员:老行缺列时 coordinator_parts 为 [](= 普通任务),
|
||
# parent_task_id/coordinator_part 缺列时为 None。
|
||
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
|
||
"parent_task_id": _col("parent_task_id"),
|
||
"coordinator_part": _col("coordinator_part"),
|
||
}
|
||
|
||
|
||
class Worker:
|
||
"""单机 worker:一轮认领一个 queued 任务并执行到底。
|
||
|
||
前置条件:db_path 须已由 cloud.server.init_db() 建好 tasks 表;
|
||
本模块不自建 server 的表(成本表与审计表由 CostStore/AuthStore
|
||
在首次使用时自建,与 server 的做法一致)。
|
||
"""
|
||
|
||
def __init__(self, db_path, host_py_path, config_path=None, repo=None,
|
||
runner=None, env=None, env_key=None,
|
||
codex_config_path=None, model=None, router=None,
|
||
max_turns=None, max_cost_usd=None, executor=None,
|
||
cost_store=None, process_factory=None,
|
||
task_timeout=1800, max_retries=1,
|
||
retry_backoff_seconds=5.0, repos_dir="./repos",
|
||
repos_runner=None, heartbeat_file=None,
|
||
worker_id=None, capacity=None, fallback_models=None,
|
||
tenant_isolation=False, tenants_root=None,
|
||
tenant_template_home=None, tenant_template_repo=None,
|
||
egress_proxy_port=0, claim_executor_only=False,
|
||
websearch_mcp=False, seed_skills=False,
|
||
imagegen_mcp=False, playwright_mcp=False,
|
||
host_name=None, pr_jobs_only=False,
|
||
upload_dir=None,
|
||
digest_interval_seconds=3600,
|
||
outbox_backoff_seconds=60,
|
||
outbox_max_attempts=3,
|
||
quota_poll_interval_seconds=30,
|
||
auto_resume_max=50,
|
||
ignore_host_budget=True):
|
||
self.db_path = str(db_path)
|
||
self.host_py_path = host_py_path
|
||
self.config_path = config_path
|
||
self.repo = repo
|
||
# G11/G12:摘要与 outbox 的后台节奏。digest_interval 是
|
||
# worker 循环里两次摘要发送的最短间隔(默认 3600 秒,
|
||
# 与 push.DIGEST_INTERVAL_SECONDS 同值);outbox 的重试
|
||
# 节奏由每行 next_attempt_at 控(默认 60 秒退避),
|
||
# 这里的两个参数只透传给 process_outbox_due。
|
||
self.digest_interval_seconds = digest_interval_seconds
|
||
self.outbox_backoff_seconds = outbox_backoff_seconds
|
||
self.outbox_max_attempts = outbox_max_attempts
|
||
# G8:配额轮询间隔(秒),Local popen 与 Docker 路径共用,
|
||
# 缺省 30;单测可传很小的值加速,生产不要改小(会打爆库)。
|
||
# None 当 30 用。
|
||
self.quota_poll_interval_seconds = (
|
||
30 if quota_poll_interval_seconds is None
|
||
else quota_poll_interval_seconds)
|
||
# G9:worker 路径默认忽略 host 的 max_daily_turns 预算。
|
||
# 云端已有 per-user USD 配额 + worker 认领/轮询门,host
|
||
# 的单机 turn 数预算再拦一次只会误拦云端已批的任务;
|
||
# standalone 直接跑 host.py 时不受此开关影响,预算照常。
|
||
self.ignore_host_budget = bool(ignore_host_budget)
|
||
self._last_digest_sent = 0.0
|
||
# env 只用于 key 存在性检查:默认接进程环境,单测可注入
|
||
# 一个普通 dict;worker 永远不把 env 里的值写出。
|
||
self._env = env if env is not None else os.environ
|
||
self.env_key = resolve_env_key(env_key, codex_config_path)
|
||
self.model = _resolve_model(model, config_path)
|
||
if router is not None and not isinstance(router, ModelRouter):
|
||
raise ValueError("router 必须是 cloud.models.ModelRouter")
|
||
self.router = router
|
||
# 真实 fallback 换模型列表:默认空 = 关闭,只记事件不换模型
|
||
# (老行为,生产 worker 未配该参数时绝不突然多花模型钱)。
|
||
# 归一化:去空白、去重保序、去掉与主模型相同的项(换成同一
|
||
# 个模型不叫 fallback,只会白烧一次额度);fallback 总次数
|
||
# 最多 1 次,所以运行期只用列表的第一个元素,后面的只是
|
||
# 备用声明,不会被依次试下去。
|
||
self.fallback_models = []
|
||
for _m in (fallback_models or []):
|
||
if not isinstance(_m, str):
|
||
continue
|
||
_m = _m.strip()
|
||
if _m and _m != self.model and _m not in self.fallback_models:
|
||
self.fallback_models.append(_m)
|
||
self.max_turns = max_turns
|
||
self.max_cost_usd = max_cost_usd
|
||
self.usage_turns = 0
|
||
self.usage_cost_usd = 0.0
|
||
if executor is not None:
|
||
self.executor = executor
|
||
else:
|
||
self.executor = LocalExecutor(
|
||
host_py_path, config_path=config_path, runner=runner,
|
||
repo=repo, ignore_host_budget=self.ignore_host_budget)
|
||
# 外部传入的执行器(CLI 的 DockerExecutor 等)同样同步
|
||
# G9 开关:worker 的 ignore_host_budget 是唯一口径,执行
|
||
# 器自己的缺省值不另起一套。假执行器没有该属性时跳过。
|
||
if hasattr(self.executor, "ignore_host_budget"):
|
||
try:
|
||
self.executor.ignore_host_budget = self.ignore_host_budget
|
||
except Exception:
|
||
pass
|
||
self._cost_store = cost_store
|
||
self._cancel_requested = set()
|
||
# 真取消用:process_factory 可注入(单测用它返回一个假长跑
|
||
# 子进程,真实执行时走 LocalExecutor.popen_task 进程组启动)。
|
||
self.process_factory = process_factory
|
||
# 并发运行状态:task_id -> {"proc": Popen|None,
|
||
# "container": 容器名|None},受 _running_lock 保护。
|
||
# 旧版只有单任务三个字段(_running_proc 等),capacity=1
|
||
# 时够用;capacity>1 后多个任务同时跑,单字段会被后
|
||
# 一个任务覆盖,导致 cancel 杀错、退出时漏杀。所有读写
|
||
# 都走下面的 _running_set/_running_clear/_running_snapshot。
|
||
self._running = {}
|
||
self._running_lock = threading.Lock()
|
||
# 容量门被拦日志的节流时间戳:_claim 的容量门在池线程
|
||
# 自旋时会高频触发,不节流会把日志打爆;30 秒内只记
|
||
# 一次,够排查“worker 活着却再也认领不了”即可。
|
||
self._capacity_refused_logged_at = 0.0
|
||
# DB 写锁:cost 记录共用一条连接(CostStore._conn),
|
||
# usage 计数也是共享变量;模型回合本身在子进程/容器里
|
||
# 跑不受这把锁影响,锁只覆盖毫秒级的写库操作。
|
||
self._db_lock = threading.RLock()
|
||
self._loop_stop = False
|
||
self.logger = get_logger("cloud.worker")
|
||
# 任务级超时 / 失败重试(见模块 docstring 的生产级说明)。
|
||
# 对齐 host turn_timeout:0 归一化为 None(不限时),
|
||
# 负数同理;默认 1800 不变。归一化后 _run_attempt 里
|
||
# task_timeout is None 的分支本来就走“无 worker 级截止”
|
||
#(Docker 路径仍受 executor docker_timeout 约束),
|
||
# 不用再改各执行路径;不归一化的话 0 会在 popen 轮询
|
||
# 里第一轮就触发“已到截止时间”而立即超时,是反直觉的 bug。
|
||
if task_timeout is not None and task_timeout <= 0:
|
||
task_timeout = None
|
||
self.task_timeout = task_timeout
|
||
self.max_retries = max_retries
|
||
self.retry_backoff_seconds = retry_backoff_seconds
|
||
# 长任务自动恢复上限(次数,不是时间):同一任务被
|
||
# 系统中断后自动续跑超过该次数即停住、等人工处理,
|
||
# 防确定性失败(每次续跑立刻又中断)无限烧额度;
|
||
# 正常部署重启/偶发中断远达不到 50 次。
|
||
self.auto_resume_max = auto_resume_max
|
||
# 项目仓库检出根目录:CLI --repos-dir 可调(默认 ./repos);
|
||
# Docker 执行器下它必须放在 extra-mounts 已挂载的目录下,
|
||
# 否则容器内 task["repo"] 路径不存在——见 cloud/README.md。
|
||
# repos_runner 可注入(单测用假 git runner),缺省走真实 git。
|
||
self.repos_dir = repos_dir
|
||
self.repos_runner = repos_runner
|
||
# 多租户隔离(仅 Docker 执行器有效,Local 执行器在本机
|
||
# 直接跑 host.py,同一用户下本来就没有容器边界,flag
|
||
# 对它不起作用):开启后每个项目用独立的宿主目录
|
||
# tenants_root/<project_id>/{work,codex-home,test-repo,
|
||
# <project_id>},容器内路径保持与原来完全一致(/app/work、
|
||
# /var/lib/.../codex-home 等),只是宿主侧的挂载源按
|
||
# 项目分开——容器内看不到其它项目的任何文件。codex-home
|
||
# 只播种 config.toml(relay 配置,不含任何密钥值),
|
||
# 绝不复制 thread_history(那是其它任务的 prompt 与结果)。
|
||
self.tenant_isolation = tenant_isolation
|
||
self.tenants_root = tenants_root or \
|
||
"/var/lib/codex-app-server/tenants"
|
||
self.tenant_template_home = tenant_template_home or \
|
||
"/var/lib/codex-app-server/codex-home"
|
||
self.tenant_template_repo = tenant_template_repo or \
|
||
"/var/lib/codex-app-server/test-repo"
|
||
# egress 代理端口(0 = 不启用):启用后 Docker 任务容器
|
||
# 的 HTTP(S) 出口全部走宿主上的过滤代理(网关 IP + 该
|
||
# 端口),代理只放行白名单域名;网关查不到时任务直接
|
||
# failed,绝不无代理跑(无代理等于绕过白名单)。
|
||
self.egress_proxy_port = egress_proxy_port or 0
|
||
# 严格 executor 认领:只认领 executor 恰好等于本 worker
|
||
# 类型的任务,绝不认领 executor 为 NULL 的任务。默认
|
||
# False(老行为:NULL 任务任意 worker 可认领)。宿主级
|
||
# 沙箱 worker 必须开这个,否则它会抢走本该进 Docker
|
||
# 容器的 NULL 任务(那些任务在宿主上跑就丢了 Docker
|
||
# 外层,违背“按任务二选一”的路由意图)。
|
||
self.claim_executor_only = bool(claim_executor_only)
|
||
# 网页搜索 MCP:默认关(老行为:租户 codex-home 只有
|
||
# 模板 config.toml)。开启后 _tenant_dirs 在播种 config
|
||
# 之后调用 websearch 的播种助手,把 websearch.py /
|
||
# websearch_mcp.py 拷进租户 codex-home 并追加
|
||
# [mcp_servers.websearch](容器内路径,见 _tenant_dirs)。
|
||
# 老租户下一次任务自动合并,已有 section 不重写。
|
||
self.websearch_mcp = bool(websearch_mcp)
|
||
# 图片生成 MCP:默认关(老行为:租户 codex-home 只有
|
||
# 模板 config.toml + 可选的 websearch MCP)。开启后
|
||
# _tenant_dirs 在 websearch 播种之后调用 imagegen 的
|
||
# 播种助手,把 imagegen.py / imagegen_mcp.py 拷进租户
|
||
# codex-home 并追加 [mcp_servers.imagegen];_prepare_tenant
|
||
# 在容器启动前写 [mcp_servers.imagegen.env](系统级
|
||
# IMAGE_GEN_API_KEY + 代理 + NO_PROXY 网关 host)。
|
||
# 未配 IMAGE_GEN_API_KEY 时工具会明确报错“未配置”,
|
||
# 不会静默回退到任务自己的分组 2 key(那会 404)。
|
||
self.imagegen_mcp = bool(imagegen_mcp)
|
||
# 浏览器自动化 MCP:默认关(老行为不变)。开启后
|
||
# _tenant_dirs 在 imagegen 播种之后调用 playwright 的
|
||
# 播种助手,把 playwright_browser.py / playwright_mcp.py
|
||
# 拷进租户 codex-home 并追加 [mcp_servers.playwright];
|
||
# _prepare_tenant 在容器启动前写 [mcp_servers.playwright.env]
|
||
# (代理 + PLAYWRIGHT_BROWSERS_PATH,MCP 子进程不继承
|
||
# Codex 进程 env,所以镜像里的 ENV 在这里必须重写一遍)。
|
||
# 浏览器二进制在镜像里,不在 codex-home 里。
|
||
self.playwright_mcp = bool(playwright_mcp)
|
||
# 技能播种:默认关(老行为:租户 codex-home 只有模板
|
||
# config.toml + 可选的 websearch MCP)。开启后
|
||
# _tenant_dirs 在 websearch 播种之后调用 skill_seed
|
||
# 的播种助手,把 cloud/skills/ 的 SKILL.md 拷进租户
|
||
# codex-home/skills/(用户级技能路径,实测见
|
||
# skill_seed.py 的路径说明)。老租户下一次任务自动
|
||
# 合并;租户改过的技能文件绝不覆盖,只上报 differs。
|
||
self.seed_skills = bool(seed_skills)
|
||
# HA 阶段 1:本 worker 所在主机名。认领任务时写进
|
||
# tasks.worktree_host,/pr 据此判断 worktree 在哪台主机。
|
||
# 容器内 worker 的 socket.gethostname() 是容器名、不是宿主名,
|
||
# 所以 autoscaler 必须显式传 --host-name(宿主名),见
|
||
# autoscaler._worker_command;单机/测试默认本机名即可。
|
||
self.host_name = host_name or socket.gethostname()
|
||
# 只跑 pr 任务(HA 阶段 1 的跨主机 /pr 演练与专用 pr-runner
|
||
# 用):不认领普通任务,只处理本主机的 pr_jobs。
|
||
self.pr_jobs_only = bool(pr_jobs_only)
|
||
# 附件暂存根:显式传入 > 环境变量 > 默认路径(与 server 侧
|
||
# 同款解析;两边不一致时 worker 找不到包,派发会静默无附件)。
|
||
self.upload_dir = str(upload_dir) if upload_dir else _upload_root()
|
||
# 运维心跳:路径优先显式参数,其次环境变量,默认当前目录下
|
||
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
|
||
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
|
||
# 文件里只有 last_seen 与 pid,绝无任何密钥。
|
||
self.heartbeat_file = heartbeat_file or \
|
||
os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \
|
||
"./worker-heartbeat.json"
|
||
# 多 worker 池身份:显式 --worker-id 优先(生产部署应配稳定
|
||
# id);未给时用“主机名-进程号”,重启后进程号会变,旧行
|
||
# 自然变 stale,不要用随机 uuid 让注册表无法对应同一台机器。
|
||
self.worker_id = worker_id or "%s-%d" % (
|
||
socket.gethostname(), os.getpid())
|
||
# 并发上限配置来源(见 cloud/caps.py):显式构造参数 /
|
||
# CLI --worker-capacity > 环境变量 CLOUD_WORKER_CAPACITY >
|
||
# 默认 1;非法值归一为 1。self._env 在上面已定好,单测
|
||
# 注入的普通 dict 同样可读环境变量(无该键时默认 1)。
|
||
self.capacity = resolve_capacity(capacity, env=self._env)
|
||
# 常驻启动时的代码签名:run_loop 每轮边界比对,变了就自退
|
||
# (见 run_loop 的代码变化自退检查);_claim 每次认领前
|
||
# 也比对(见 _code_signature_valid),不要只在轮次边界查。
|
||
self._code_signature = _code_signature(host_py_path)
|
||
# 配额看门狗命中过的任务(Docker 同步路径用):看门狗杀
|
||
# 容器后,同步返回的 failed 要被 _run_attempt 改写成
|
||
# quota_exceeded,否则 run_one 会把它当普通失败重试,
|
||
# 配额中断就续跑下去了。受 _quota_hit_lock 保护。
|
||
self._quota_hit_tasks = set()
|
||
self._quota_hit_lock = threading.Lock()
|
||
# 协调员子任务因配额跳过建任务时已记过审计的分工
|
||
# ((parent_id, part_name)):协调员每轮都会重调
|
||
# _coord_sync_children,超配额期间每轮都记审计会刷屏,
|
||
# 同一 worker 进程内同一分工只记一次。
|
||
self._coord_quota_skipped = set()
|
||
|
||
def _code_changed(self):
|
||
"""启动后关键代码是否被换过(部署换目录/手动改代码):
|
||
签名取不到时返回 False(是否“变了”无从判断),但这
|
||
不等于放行——放行与否看 _code_signature_valid。"""
|
||
now = _code_signature(self.host_py_path)
|
||
return (now is not None and self._code_signature is not None
|
||
and now != self._code_signature)
|
||
|
||
def _code_signature_valid(self):
|
||
"""签名是否可信且未变:启动签名与现签名都必须取得到
|
||
且相等,否则 False。False 时 _claim 拒绝认领、run_loop
|
||
自退(75)、_run_pr_jobs 不碰任务——fail-closed,绝不
|
||
让签名缺失的 worker 抢任务(旧版取不到当没变是 fail-open)。"""
|
||
now = _code_signature(self.host_py_path)
|
||
return (now is not None and self._code_signature is not None
|
||
and now == self._code_signature)
|
||
|
||
def _mark_quota_hit(self, task_id):
|
||
with self._quota_hit_lock:
|
||
self._quota_hit_tasks.add(task_id)
|
||
|
||
def _take_quota_hit(self, task_id):
|
||
"""取走一次配额看门狗命中(命中即消费,避免旧命中污染
|
||
同一任务的下一轮):返回 True 表示本轮确被看门狗杀过。"""
|
||
with self._quota_hit_lock:
|
||
if task_id in self._quota_hit_tasks:
|
||
self._quota_hit_tasks.discard(task_id)
|
||
return True
|
||
return False
|
||
|
||
def register(self):
|
||
"""向控制面注册本 worker(写 workers 表,失败只记日志)。
|
||
|
||
注册与心跳共用同一张表:心跳就是带 last_seen 刷新的注册,
|
||
未注册的 worker 发心跳时自动注册,不 404。
|
||
"""
|
||
try:
|
||
register_worker(
|
||
self.db_path, self.worker_id,
|
||
executor=self._executor_name(), capacity=self.capacity,
|
||
running=self._running_count())
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_register_failed",
|
||
message="worker register failed: %s" % exc)
|
||
|
||
def _registry_heartbeat(self):
|
||
"""向控制面写注册表心跳(与文件心跳并行,失败只记日志)。"""
|
||
try:
|
||
heartbeat_worker(
|
||
self.db_path, self.worker_id,
|
||
executor=self._executor_name(), capacity=self.capacity,
|
||
running=self._running_count())
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_registry_heartbeat_failed",
|
||
message="worker registry heartbeat failed: %s" % exc)
|
||
|
||
def _write_heartbeat(self):
|
||
"""写一次运维心跳:写失败只记日志,绝不炸 worker。
|
||
|
||
先写临时文件再 os.replace,保证 server 读到的要么是旧的
|
||
完整文件、要么是新的完整文件,不会读到写一半的 JSON。
|
||
"""
|
||
try:
|
||
payload = {"last_seen": time.time(), "pid": os.getpid()}
|
||
tmp = "%s.tmp" % self.heartbeat_file
|
||
parent = os.path.dirname(os.path.abspath(self.heartbeat_file))
|
||
os.makedirs(parent, exist_ok=True)
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
f.write(json.dumps(payload))
|
||
os.replace(tmp, self.heartbeat_file)
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_heartbeat_failed",
|
||
message="worker heartbeat write failed: %s" % exc)
|
||
|
||
# -- 成本存储(惰性建表,避免只认领不执行时也建表)--------------
|
||
@property
|
||
def costs(self):
|
||
if self._cost_store is None:
|
||
self._cost_store = CostStore(self.db_path)
|
||
return self._cost_store
|
||
|
||
def close(self):
|
||
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。"""
|
||
if self._cost_store is not None:
|
||
try:
|
||
self._cost_store._conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# -- DB 读写 ------------------------------------------------------
|
||
def load_tasks(self):
|
||
"""pick_next 的输入:只取 queued/completed/merged 三类任务。
|
||
|
||
旧实现是 SELECT * 全表:prompt/result_text/error_text
|
||
(result_text 可达 21000 字、error_text 可达 4000 字)
|
||
全量进内存,且每轮 poll 都来一次;
|
||
任务表随时间变大后这是纯浪费。pick_next 只需要 queued
|
||
任务和已完成 id(依赖判断),running/failed/interrupted
|
||
的任务对它既不是就绪、也不是已完成,排除它们不影响结果。
|
||
列清单必须覆盖 _task_from_row 直接取的全部字段(prompt、
|
||
idempotency_key 等),缺一列就会 KeyError;result_text、
|
||
error_text 等重字段不在此列,真正需要它们的 get_task
|
||
仍是 SELECT * 单行取。
|
||
"""
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT id, project_id, prompt, status, idempotency_key,"
|
||
" depends_on, priority, created_by, created_at, updated_at,"
|
||
" last_heartbeat, executor, goal, verify_command,"
|
||
" goal_max_rounds, goal_mode, environment_id FROM tasks"
|
||
" WHERE status IN ('queued', 'completed', 'merged')"
|
||
" ORDER BY created_at, id").fetchall()
|
||
finally:
|
||
conn.close()
|
||
return [_task_from_row(r) for r in rows]
|
||
|
||
def get_task(self, task_id):
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
return _task_from_row(row) if row is not None else None
|
||
|
||
def _claim(self, task_id, executor=None):
|
||
"""CAS 认领:仅当任务仍为 queued 时标 running 并记心跳。
|
||
|
||
executor 过滤(多 worker 池):executor 给定时,WHERE 里
|
||
再加 (executor IS NULL OR executor = ?),即只认领未指定
|
||
执行器、或指定为本 worker 执行器类型的任务;CAS 语义不变,
|
||
仍是“仅当 queued 时标 running”,两个同池 worker 抢同一
|
||
任务时只有一个能拿到 rowcount == 1。
|
||
claim_executor_only 开启时(宿主级沙箱 worker 用):WHERE
|
||
只加 executor = ?,NULL 任务绝不认领(否则宿主 worker
|
||
会抢走本该进 Docker 的任务)。
|
||
|
||
并发上限门(caps):CAS 之前先在 _running_lock 下占位。
|
||
running 数达到 self.capacity 时直接返回 False,任务保持
|
||
queued,不碰数据库;占位成功才走 CAS。同一任务已在
|
||
_running 里时不重复计数(重复占位不增槽)。CAS 失败
|
||
(已被别的 worker 抢走)时,只清掉本次新建的占位,绝
|
||
不动已在跑任务的条目。占位条目在执行路径的 finally
|
||
里清(本地 _execute_via_process / Docker 同步路径),
|
||
认领后提前返回的三道门(缺 key / 仓库 / 配额)在
|
||
run_one 里显式清占位;CAS 语义本身一句未改。
|
||
P1 签名门:占位之前先比对多文件签名,签名缺失或已变
|
||
时直接返回 False(fail-closed,不抢任务、不占槽),
|
||
并记日志;不要只在 run_loop 轮次边界查——并行池线程
|
||
直接调 run_one/_claim,轮次边界查不到它们。
|
||
"""
|
||
if not self._code_signature_valid():
|
||
log_event(self.logger, "worker_claim_refused_code_signature",
|
||
task_id=task_id,
|
||
message="code signature missing or changed,"
|
||
" refusing claim")
|
||
return False
|
||
capacity_refused = False
|
||
capacity_log = None
|
||
with self._running_lock:
|
||
already_running = task_id in self._running
|
||
if not already_running and \
|
||
len(self._running) >= effective_capacity(self.capacity):
|
||
# 容量门被拦以前是静默返回:worker 活着、心跳
|
||
# 新鲜、日志无错,却再也认领不了任务(协调员
|
||
# 槽位泄漏就是这么被掩盖的)。注意:节流的只
|
||
# 是日志,拒绝本身恒返回 False——若把节流套在
|
||
# 拒绝上,节流窗口内的任务会被 CAS 认领却不进
|
||
# _running,比静默更糟。
|
||
capacity_refused = True
|
||
now_gate = time.time()
|
||
last_gate = getattr(
|
||
self, "_capacity_refused_logged_at", 0.0)
|
||
if now_gate - last_gate >= 30.0:
|
||
self._capacity_refused_logged_at = now_gate
|
||
capacity_log = (
|
||
len(self._running),
|
||
effective_capacity(self.capacity))
|
||
else:
|
||
self._running.setdefault(task_id, {})
|
||
if capacity_log is not None:
|
||
log_event(self.logger, "worker_claim_refused_capacity",
|
||
task_id=task_id,
|
||
message="worker claim refused: capacity full,"
|
||
" running=%d capacity=%d" % capacity_log)
|
||
if capacity_refused:
|
||
return False
|
||
now = time.time()
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
if executor is None:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'running',"
|
||
" last_heartbeat = ?, updated_at = ?,"
|
||
" worktree_host = ?"
|
||
" WHERE id = ? AND status = 'queued'",
|
||
(now, now, self.host_name, task_id),
|
||
)
|
||
elif self.claim_executor_only:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'running',"
|
||
" last_heartbeat = ?, updated_at = ?,"
|
||
" worktree_host = ?"
|
||
" WHERE id = ? AND status = 'queued'"
|
||
" AND executor = ?",
|
||
(now, now, self.host_name, task_id, executor),
|
||
)
|
||
else:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'running',"
|
||
" last_heartbeat = ?, updated_at = ?,"
|
||
" worktree_host = ?"
|
||
" WHERE id = ? AND status = 'queued'"
|
||
" AND (executor IS NULL OR executor = ?)",
|
||
(now, now, self.host_name, task_id, executor),
|
||
)
|
||
conn.commit()
|
||
claimed = cur.rowcount == 1
|
||
if not claimed and not already_running:
|
||
self._running_clear(task_id)
|
||
return claimed
|
||
finally:
|
||
conn.close()
|
||
|
||
def _set_status(self, task_id, status):
|
||
now = time.time()
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET status = ?, last_heartbeat = ?,"
|
||
" updated_at = ? WHERE id = ?",
|
||
(status, now, now, task_id),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _mark_interrupted_reason(self, task_id, reason):
|
||
"""给已是 interrupted 的任务补中断原因(不改状态本身)。
|
||
|
||
只写 reason,不碰 paused_by_user:调用方是系统路径
|
||
(配额、sweep 已在 server 侧写、worker 退出、协调员
|
||
孤儿),用户暂停的 paused 标记由 server /pause、
|
||
/cancel 写,worker 侧绝不覆盖它。
|
||
老库缺列时先迁移,迁移失败则静默跳过(不炸终局路径)。
|
||
"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
conn.execute(
|
||
"UPDATE tasks SET interrupted_reason = ?"
|
||
" WHERE id = ? AND status = 'interrupted'",
|
||
(reason, task_id),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def auto_resume_interrupted(self):
|
||
"""长任务自动恢复:只接系统中断的 goal 任务。
|
||
|
||
只有用户暂停才暂停:paused_by_user=1(/pause、/cancel)
|
||
的任务绝不自动动;协调员孤儿(coordinator_orphan)
|
||
不接——要由父协调员收口,自动重跑会跑丢父子关系。
|
||
配额中断(quota)只在配额已恢复时接:每轮先跑
|
||
_quota_exceeded 现查,仍超限就留在 interrupted 不
|
||
重排队(不触发 autoscaler 拉 worker、不烧钱),等
|
||
下一轮再查;查不到配额或仍超限都不接。恢复的判定
|
||
是现查结果为 None(新月份、额度上调、无价上限重置
|
||
都算恢复),不是猜时间。
|
||
非 goal 任务不接:它没有断点语义,重跑等于从头再来,
|
||
可能重复副作用,留给人工 /retry 决定。
|
||
接到的任务 CAS 回 queued 并置 goal_resume_pending=1,
|
||
下一轮认领即走 G10 断点续跑(同一线程、同一 worktree、
|
||
从 goal_rounds_done 下一轮起),同时清旧结果、清
|
||
interrupted_reason、auto_resume_count+1;上限
|
||
self.auto_resume_max 到顶的任务停住并记审计,等人工。
|
||
P0 三件其二的熔断(与次数上限并存,防确定性失败空转):
|
||
- 退避:重排队时写 resume_after = now + min(300,
|
||
10 * 2**stall),未到时间不重排队,不再 12 秒一轮烧次数。
|
||
- 无进展熔断:库中断点轮次(task_checkpoints 最大
|
||
round_no)与上次重排队时记的基线相同 → stall_count+1;
|
||
stall 到 3 时停住,interrupted_reason 标
|
||
auto_resume_stalled 并记审计,等人工(任务 2 的
|
||
KeyError 空转:分类已把永久错误判 failed;万一分类
|
||
漏网,无进展熔断是第二道闸)。
|
||
返回自动续跑的任务 id 列表;任何异常只记日志,不炸
|
||
run_loop(恢复是辅助路径,不能带走常驻 worker)。
|
||
"""
|
||
resumed = []
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
rows = conn.execute(
|
||
"SELECT id, project_id, created_by, parent_task_id,"
|
||
" model, interrupted_reason, resume_after,"
|
||
" auto_resume_stall_count,"
|
||
" checkpoint_round_at_last_resume FROM tasks"
|
||
" WHERE status = 'interrupted'"
|
||
" AND (paused_by_user IS NULL OR paused_by_user = 0)"
|
||
" AND (interrupted_reason IS NULL OR"
|
||
" interrupted_reason NOT IN"
|
||
" ('coordinator_orphan'))"
|
||
" AND parent_task_id IS NULL"
|
||
" AND (goal_mode = 1 OR verify_command IS NOT NULL"
|
||
" OR goal IS NOT NULL)"
|
||
" AND (auto_resume_count IS NULL OR"
|
||
" auto_resume_count < ?)",
|
||
(self.auto_resume_max,),
|
||
).fetchall()
|
||
now = time.time()
|
||
for row in rows:
|
||
task_id = row[0]
|
||
project_id = row[1]
|
||
if row[6] and now < row[6]:
|
||
continue
|
||
if row[5] == "quota":
|
||
# 配额中断:现查配额,仍超限或查不到
|
||
# 就留在 interrupted,下轮再查,不重排队。
|
||
try:
|
||
_qerr = self._quota_exceeded(
|
||
{"created_by": row[2],
|
||
"parent_task_id": row[3],
|
||
"model": row[4]},
|
||
None, on_error="closed")
|
||
except Exception:
|
||
_qerr = "quota_check_failed"
|
||
if _qerr is not None:
|
||
continue
|
||
try:
|
||
cp = conn.execute(
|
||
"SELECT MAX(round_no) FROM task_checkpoints"
|
||
" WHERE task_id = ?", (task_id,)).fetchone()
|
||
cp_round = cp[0] if cp and cp[0] is not None else -1
|
||
except Exception:
|
||
cp_round = -1
|
||
prev = row[8]
|
||
stall = 0
|
||
if prev is not None:
|
||
stall = (row[7] or 0) + 1 if cp_round == prev else 0
|
||
if stall >= 3:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET interrupted_reason ="
|
||
" 'auto_resume_stalled',"
|
||
" auto_resume_stall_count = ?,"
|
||
" updated_at = ?"
|
||
" WHERE id = ? AND status = 'interrupted'",
|
||
(stall, now, task_id))
|
||
if cur.rowcount == 1:
|
||
self._audit(
|
||
project_id, "worker_auto_resume_stalled",
|
||
task_id)
|
||
continue
|
||
backoff = min(300, 10 * (2 ** stall))
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" goal_resume_pending = 1,"
|
||
" result_text = NULL, error_text = NULL,"
|
||
" actual_model = NULL,"
|
||
" interrupted_reason = NULL,"
|
||
" auto_resume_count ="
|
||
" COALESCE(auto_resume_count, 0) + 1,"
|
||
" auto_resume_stall_count = ?,"
|
||
" checkpoint_round_at_last_resume = ?,"
|
||
" resume_after = ?,"
|
||
" updated_at = ?"
|
||
" WHERE id = ? AND status = 'interrupted'"
|
||
" AND (paused_by_user IS NULL"
|
||
" OR paused_by_user = 0)",
|
||
(stall, cp_round, now + backoff, now, task_id),
|
||
)
|
||
if cur.rowcount == 1:
|
||
resumed.append(task_id)
|
||
self._audit(project_id, "worker_auto_resumed",
|
||
task_id)
|
||
self._record_step(
|
||
conn, task_id, "auto_resume",
|
||
"自动续跑(无进展计数 %d,下次退避 %d 秒)"
|
||
% (stall, backoff))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_auto_resume_failed",
|
||
message="auto resume failed: %s" % exc)
|
||
return resumed
|
||
|
||
def _record_step(self, conn, task_id, kind, summary, step_no=None,
|
||
state_sha=None):
|
||
"""向 task_steps 写一行(P0 三件其三,时间线事实源)。
|
||
|
||
best-effort:写失败只记日志,绝不影响调用方(认领/
|
||
恢复/终局)的正常流程。step_no 未给时按该任务已有
|
||
最大 step_no + 1 自增(kind=round 的调用方显式传
|
||
round_no,保证 host 轮次与时间线 step_no 一致)。
|
||
"""
|
||
try:
|
||
if step_no is None:
|
||
row = conn.execute(
|
||
"SELECT MAX(step_no) FROM task_steps"
|
||
" WHERE task_id = ?", (task_id,)).fetchone()
|
||
step_no = (row[0] or 0) + 1 if row and row[0] else 1
|
||
conn.execute(
|
||
"INSERT INTO task_steps(id, task_id, step_no, kind,"
|
||
" summary, state_sha, created_at) VALUES(?, ?, ?, ?,"
|
||
" ?, ?, ?)",
|
||
(_uuid_hex(), task_id, step_no, kind, summary[:500]
|
||
if summary else None, state_sha, time.time()))
|
||
except Exception as exc:
|
||
log_event(self.logger, "task_step_record_failed",
|
||
message="step record failed: %s" % exc)
|
||
|
||
def _checkpoint_round(self, conn, task_id):
|
||
"""库中断点最大轮次(无断点时 -1);表缺失时也返回 -1。"""
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT MAX(round_no) FROM task_checkpoints"
|
||
" WHERE task_id = ?", (task_id,)).fetchone()
|
||
return row[0] if row and row[0] is not None else -1
|
||
except Exception:
|
||
return -1
|
||
|
||
def _checkpoint_mirror_loop(self, task, stop):
|
||
"""断点镜像线程(P0 三件其一):goal 任务执行期间每 30 秒
|
||
把磁盘 host-state 断点镜像进库一次;执行结束由
|
||
_run_attempt 的 finally 再补一次终局镜像。线程本身绝不
|
||
抛(_mirror_checkpoint_once 已全量兜底),stop 后即退。
|
||
"""
|
||
try:
|
||
self._mirror_checkpoint_once(task)
|
||
except Exception:
|
||
pass
|
||
while not stop.wait(30):
|
||
try:
|
||
self._mirror_checkpoint_once(task)
|
||
except Exception:
|
||
pass
|
||
|
||
def _host_state_path_for_task(self, task):
|
||
"""断点镜像的 host-state 文件路径(P0 三件其一)。
|
||
|
||
租户 Docker:tenants/<safe>/work/host-state.json;
|
||
非租户 Docker:executor extra_mounts 里 dest=/app/work
|
||
的宿主路径(若无则回落 /opt/codex-app-server/work);
|
||
Local:读 executor config 的 state 项(相对配置目录)。
|
||
找不到时返回 None,镜像静默跳过(不影响任务执行)。
|
||
"""
|
||
try:
|
||
project_id = task.get("project_id")
|
||
if getattr(self, "tenant_isolation", False) and project_id:
|
||
safe = self._tenant_safe_name(project_id)
|
||
base = os.path.join(self.tenants_root, safe, "work")
|
||
return os.path.join(base, "host-state.json")
|
||
mounts = getattr(self.executor, "extra_mounts", None) or []
|
||
for item in mounts:
|
||
src, dst = (item if isinstance(item, tuple)
|
||
else (None, None))
|
||
if dst == "/app/work" and src:
|
||
return os.path.join(src, "host-state.json")
|
||
cfg = getattr(self.executor, "config_path", None)
|
||
if cfg and os.path.exists(cfg):
|
||
with open(cfg, encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
state = data.get("state") or "host-state.json"
|
||
if not os.path.isabs(state):
|
||
state = os.path.join(
|
||
os.path.dirname(os.path.abspath(cfg)), state)
|
||
return state
|
||
return os.path.join("/opt/codex-app-server/work",
|
||
"host-state.json")
|
||
except Exception:
|
||
return None
|
||
|
||
def _mirror_checkpoint_once(self, task):
|
||
"""把磁盘 host-state 里的本任务断点镜像进库(单次)。
|
||
|
||
读 state → 取 tasks[host 名] 条目 → (task_id, round_no)
|
||
upsert 进 task_checkpoints(state_sha 是条目 JSON 的
|
||
sha256,轮次相同但线程/状态变了也能看出来)→ 同时
|
||
导入同目录 host-steps/<任务>.jsonl 里的 round 步骤。
|
||
任何异常静默(镜像是辅助路径,执行本身不受影响)。
|
||
返回镜像到的轮次(-1 = 没镜像到)。
|
||
"""
|
||
try:
|
||
state_path = self._host_state_path_for_task(task)
|
||
if not state_path or not os.path.exists(state_path):
|
||
return -1
|
||
with open(state_path, encoding="utf-8") as fh:
|
||
state = json.load(fh)
|
||
host_name = _worker_host_name(self.executor, task)
|
||
entry = (state.get("tasks") or {}).get(host_name)
|
||
if not isinstance(entry, dict):
|
||
return -1
|
||
rounds = int(entry.get("goal_rounds_done") or 0)
|
||
# host-state 条目写的是 status(goal-running/goal-met…),
|
||
# 不是 goal_status:实弹里镜像到的 goal_status 全是
|
||
# None——取 status 作回落,标签才不是空的(轮次与
|
||
# thread_id 等关键字段不受影响,熔断只看轮次)。
|
||
goal_status = entry.get("goal_status") or entry.get("status")
|
||
state_sha = hashlib.sha256(
|
||
json.dumps(entry, sort_keys=True,
|
||
default=str).encode("utf-8")).hexdigest()
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
existing = conn.execute(
|
||
"SELECT 1 FROM task_checkpoints"
|
||
" WHERE task_id = ? AND round_no = ?",
|
||
(task["id"], rounds)).fetchone()
|
||
if existing:
|
||
conn.execute(
|
||
"UPDATE task_checkpoints SET thread_id = ?,"
|
||
" goal_status = ?, state_sha = ?,"
|
||
" verify_out = ?, created_at = ?"
|
||
" WHERE task_id = ? AND round_no = ?",
|
||
(entry.get("thread_id"),
|
||
goal_status,
|
||
state_sha,
|
||
(entry.get("goal_last_verify") or "")[:500],
|
||
time.time(), task["id"], rounds))
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO task_checkpoints(task_id, round_no,"
|
||
" thread_id, goal_status, state_sha, verify_out,"
|
||
" created_at) VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||
(task["id"], rounds, entry.get("thread_id"),
|
||
goal_status, state_sha,
|
||
(entry.get("goal_last_verify") or "")[:500],
|
||
time.time()))
|
||
self._import_host_steps(conn, task, state_path)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return rounds
|
||
except Exception:
|
||
return -1
|
||
|
||
def _import_host_steps(self, conn, task, state_path):
|
||
"""把 host-steps/<任务>.jsonl 的 round 行导入 task_steps。
|
||
|
||
按 (task_id, kind='round', step_no) 去重:已入库的
|
||
step_no 跳过;文件不存在或格式坏行都跳过不炸。
|
||
"""
|
||
try:
|
||
steps_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(state_path)),
|
||
"host-steps", "%s.jsonl" % _worker_host_name(
|
||
self.executor, task))
|
||
if not os.path.exists(steps_path):
|
||
return
|
||
existing = {row[0] for row in conn.execute(
|
||
"SELECT step_no FROM task_steps WHERE task_id = ?"
|
||
" AND kind = 'round'", (task["id"],)).fetchall()}
|
||
with open(steps_path, encoding="utf-8") as fh:
|
||
for line in fh:
|
||
try:
|
||
item = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
step_no = item.get("round")
|
||
if not isinstance(step_no, int) or step_no in existing:
|
||
continue
|
||
conn.execute(
|
||
"INSERT INTO task_steps(id, task_id, step_no,"
|
||
" kind, summary, state_sha, created_at)"
|
||
" VALUES(?, ?, ?, 'round', ?, NULL, ?)",
|
||
(_uuid_hex(), task["id"], step_no,
|
||
("第 %d 轮 turn=%s verify=%s" % (
|
||
step_no, item.get("turn_status"),
|
||
(item.get("verify_out") or "")[:200])),
|
||
item.get("ts") or time.time()))
|
||
existing.add(step_no)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rebuild_host_state_from_checkpoint(self, task):
|
||
"""断点丢失时的重建(P0 三件其一,任务 2 类故障的解法)。
|
||
|
||
host-state 里没有本任务条目、但库里有断点时:从库里
|
||
最新断点 + tasks 行的 goal/verify_command/max_rounds
|
||
拼一个最小 host-state 条目写回(goal_status=interrupted,
|
||
轮次与 thread_id 以库为准),再由正常 goal-resume
|
||
路径续跑。拼不出来(库里也无断点)返回 False,
|
||
调用方走原有拒绝/失败路径,不造假断点。
|
||
"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
row = conn.execute(
|
||
"SELECT round_no, thread_id, goal_status"
|
||
" FROM task_checkpoints WHERE task_id = ?"
|
||
" ORDER BY round_no DESC LIMIT 1",
|
||
(task["id"],)).fetchone()
|
||
if not row:
|
||
return False
|
||
trow = conn.execute(
|
||
"SELECT goal, verify_command, goal_max_rounds"
|
||
" FROM tasks WHERE id = ?", (task["id"],)).fetchone()
|
||
if not trow:
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
state_path = self._host_state_path_for_task(task)
|
||
if not state_path:
|
||
return False
|
||
host_name = _worker_host_name(self.executor, task)
|
||
if os.path.exists(state_path):
|
||
with open(state_path, encoding="utf-8") as fh:
|
||
state = json.load(fh)
|
||
else:
|
||
state = {"version": 1, "tasks": {}}
|
||
tasks_map = state.setdefault("tasks", {})
|
||
if host_name in tasks_map:
|
||
return True
|
||
tasks_map[host_name] = {
|
||
"goal": trow[0],
|
||
"verify_command": trow[1],
|
||
"goal_max_rounds": trow[2] or 0,
|
||
"goal_rounds_done": row[0],
|
||
"thread_id": row[1],
|
||
"goal_status": "interrupted",
|
||
"goal_last_verify": "",
|
||
"rebuilt_from_checkpoint": True,
|
||
}
|
||
os.makedirs(os.path.dirname(
|
||
os.path.abspath(state_path)), exist_ok=True)
|
||
tmp_path = state_path + ".tmp"
|
||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||
json.dump(state, fh)
|
||
os.replace(tmp_path, state_path)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _task_never_started(self, task):
|
||
"""从未启动判据(2026-10-29 实测任务 A 的教训)。
|
||
|
||
四者全无才算从未启动:host-state 里无本任务条目、
|
||
库里无断点行、state 同目录 host-worktrees 下无本任务
|
||
worktree、host-traces 下无本任务链路文件。任一存在
|
||
都说明执行过,绝不能当成全新任务重跑(会撞已存在
|
||
或丢进度),调用方保持原有拒绝/重建路径。
|
||
任何一步查不出来(state 路径不明、文件损坏、库异常)
|
||
都返回 False——fail-closed,宁可拒绝也不误重跑。
|
||
"""
|
||
try:
|
||
state_path = self._host_state_path_for_task(task)
|
||
if not state_path:
|
||
return False
|
||
host_name = _worker_host_name(self.executor, task)
|
||
if os.path.exists(state_path):
|
||
with open(state_path, encoding="utf-8") as fh:
|
||
state = json.load(fh)
|
||
if host_name in (state.get("tasks") or {}):
|
||
return False
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
row = conn.execute(
|
||
"SELECT 1 FROM task_checkpoints WHERE task_id = ?"
|
||
" LIMIT 1", (task["id"],)).fetchone()
|
||
if row:
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
state_dir = os.path.dirname(os.path.abspath(state_path))
|
||
if os.path.isdir(os.path.join(state_dir, "host-worktrees",
|
||
host_name)):
|
||
return False
|
||
if os.path.exists(os.path.join(state_dir, "host-traces",
|
||
host_name + ".jsonl")):
|
||
return False
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _resolve_breakpoint_resume(self, task):
|
||
"""断点续跑解析:先重建,建不出且从未启动时回落全新跑。
|
||
|
||
正常情况(条目在,或库里有断点可重建)原样返回,
|
||
调用方走 goal-resume 断点续跑。只有重建失败(库里
|
||
也无断点)且 _task_never_started 全无执行痕迹时,
|
||
才清掉断点标记、改走全新 goal:此时 goal-resume 注定
|
||
被 host 拒绝 → failed,而全新跑与首轮完全相同
|
||
(worktree 本就不存在,不会覆盖任何进度)。
|
||
次数上限与无进展熔断仍在上游卡着,不会空转。
|
||
手动 /goal-resume 同样走这里:对从未跑过的任务,
|
||
全新跑也是唯一有意义的结果。
|
||
"""
|
||
try:
|
||
rebuilt = self._rebuild_host_state_from_checkpoint(task)
|
||
except Exception:
|
||
rebuilt = False
|
||
if not rebuilt and self._task_never_started(task):
|
||
task = dict(task)
|
||
task["_goal_resume_breakpoint"] = False
|
||
self._audit(task.get("project_id"),
|
||
"worker_fresh_restart", task["id"])
|
||
try:
|
||
log_event(self.logger, "worker_fresh_restart",
|
||
task_id=task["id"],
|
||
project_id=task.get("project_id"))
|
||
except Exception:
|
||
pass
|
||
return task
|
||
|
||
def _ensure_result_columns(self, conn):
|
||
"""worker 侧的迁移兜底:与 server.migrate_tasks_table 同列。
|
||
|
||
worker 的库理论上已由 server.init_db 迁过;这里再查一次
|
||
是为了 worker 直连老库(测试里手工建的表)时也不炸。
|
||
"""
|
||
try:
|
||
cols = {row[1] for row in conn.execute(
|
||
"PRAGMA table_info(tasks)").fetchall()}
|
||
except Exception:
|
||
return
|
||
for name, coltype in {
|
||
"result_text": "TEXT", "result_text_full": "TEXT",
|
||
"error_text": "TEXT",
|
||
"actual_model": "TEXT", "started_at": "REAL",
|
||
"finished_at": "REAL", "child_pid": "INTEGER",
|
||
"executor": "TEXT", "pr_url": "TEXT",
|
||
"pr_number": "INTEGER", "goal": "TEXT",
|
||
"verify_command": "TEXT",
|
||
"goal_max_rounds": "INTEGER",
|
||
"goal_mode": "INTEGER",
|
||
"worktree_host": "TEXT",
|
||
"model": "TEXT",
|
||
"effort": "TEXT",
|
||
"child_model": "TEXT",
|
||
"child_effort": "TEXT",
|
||
"approval": "TEXT",
|
||
"plan": "INTEGER",
|
||
"forked_from": "TEXT",
|
||
"environment_id": "TEXT",
|
||
"resume_message": "TEXT",
|
||
# 任务列表显示 turn 用:本任务最近一轮产出过
|
||
# turn_id 的回合编号(local/Docker 两路都经
|
||
# _result_from_runner/_parse_host_result 提取)。
|
||
"turn_id": "TEXT",
|
||
"root_turn_id": "TEXT",
|
||
"coordinator_parts": "TEXT",
|
||
"parent_task_id": "TEXT",
|
||
"coordinator_part": "TEXT",
|
||
"goal_resume_pending": "INTEGER DEFAULT 0",
|
||
"paused_by_user": "INTEGER DEFAULT 0",
|
||
"interrupted_reason": "TEXT",
|
||
"auto_resume_count": "INTEGER DEFAULT 0",
|
||
"no_time_limit": "INTEGER DEFAULT 0",
|
||
"resume_after": "DOUBLE PRECISION DEFAULT 0",
|
||
"auto_resume_stall_count": "INTEGER DEFAULT 0",
|
||
"checkpoint_round_at_last_resume": "INTEGER",
|
||
"last_error_signature": "TEXT"}.items():
|
||
if name not in cols:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE tasks ADD COLUMN %s %s"
|
||
% (name, coltype))
|
||
except Exception:
|
||
pass
|
||
# P0 三件的表(worker 直连老库时也补,与 server ensure 同 DDL)。
|
||
try:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_checkpoints("
|
||
"task_id TEXT, "
|
||
"round_no INTEGER, "
|
||
"thread_id TEXT, "
|
||
"goal_status TEXT, "
|
||
"state_sha TEXT, "
|
||
"verify_out TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"PRIMARY KEY(task_id, round_no))")
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_steps("
|
||
"id TEXT PRIMARY KEY, "
|
||
"task_id TEXT, "
|
||
"step_no INTEGER, "
|
||
"kind TEXT, "
|
||
"summary TEXT, "
|
||
"state_sha TEXT, "
|
||
"created_at DOUBLE PRECISION)")
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
|
||
" ON task_steps(task_id, step_no)")
|
||
except Exception:
|
||
pass
|
||
|
||
def _set_status_if_running(self, task_id, status):
|
||
"""仅当任务仍为 running 时改状态,返回是否改成功。
|
||
|
||
并发 + 优雅排空下,后台线程可能在任务已被取消
|
||
(标 interrupted)后才跑完;此时最终写回绝不能把
|
||
interrupted 盖回 completed/failed。正常流程里任务
|
||
到最终写回时仍是 running,CAS 一定成功。
|
||
"""
|
||
now = time.time()
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = ?, last_heartbeat = ?,"
|
||
" updated_at = ? WHERE id = ? AND status = 'running'",
|
||
(status, now, now, task_id),
|
||
)
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
|
||
def _write_execution_result(self, task_id, result_text=None,
|
||
error_text=None, actual_model=None,
|
||
started_at=None, finished_at=None,
|
||
child_pid=None, turn_id=None,
|
||
root_turn_id=None, only_if_status=None,
|
||
result_full=None):
|
||
# result_full:完整 stdout(dispatch/docker 路由 sandbox
|
||
# 的 stdout_full);截断版 result_text 保持 4000 不变作
|
||
# 摘要,完整版另存 result_text_full 供详情/端点取。
|
||
"""把执行结果写进 tasks 新列;老库缺列时先迁移再写。
|
||
|
||
turn_id/root_turn_id 只在新值非空时覆盖:某轮没产出
|
||
turn(如提前失败门、解析不到 JSON)时保留上一轮的值,
|
||
列表里始终显示最近一个真实回合的编号,而不是被空值
|
||
洗掉。
|
||
P0 安全批后:only_if_status 非空时写回带状态守卫
|
||
(UPDATE ... WHERE id = ? AND status = ?),状态已
|
||
变(例如迟到的旧进程在任务已被取消/新一轮认领后才
|
||
写回)时不覆盖新结果,返回 False;不传时保持旧行为
|
||
并返回 True。调用方在终局路径必须传守卫,不能只靠
|
||
写回前的 _set_status_if_running(两者之间仍有竞态窗)。
|
||
"""
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
status_clause = ""
|
||
extra = ()
|
||
if only_if_status is not None:
|
||
status_clause = " AND status = ?"
|
||
extra = (only_if_status,)
|
||
full = (result_full[:200000] if result_full else None)
|
||
short = ((result_text or "")[:21000] if result_text else None)
|
||
err_short = ((error_text or "")[:4000]
|
||
if error_text else None)
|
||
now = time.time()
|
||
if child_pid is None:
|
||
sql_full = (
|
||
"UPDATE tasks SET result_text = ?,"
|
||
" result_text_full = ?, error_text = ?,"
|
||
" actual_model = ?, started_at = ?, finished_at = ?,"
|
||
" turn_id = COALESCE(?, turn_id),"
|
||
" root_turn_id = COALESCE(?, root_turn_id),"
|
||
" updated_at = ? WHERE id = ?" + status_clause)
|
||
params_full = (short, full, err_short, actual_model,
|
||
started_at, finished_at, turn_id,
|
||
root_turn_id, now, task_id) + extra
|
||
sql_old = sql_full.replace(
|
||
" result_text_full = ?,", "")
|
||
params_old = (short, err_short, actual_model,
|
||
started_at, finished_at, turn_id,
|
||
root_turn_id, now, task_id) + extra
|
||
else:
|
||
sql_full = (
|
||
"UPDATE tasks SET result_text = ?,"
|
||
" result_text_full = ?, error_text = ?,"
|
||
" actual_model = ?, started_at = ?, finished_at = ?,"
|
||
" child_pid = ?,"
|
||
" turn_id = COALESCE(?, turn_id),"
|
||
" root_turn_id = COALESCE(?, root_turn_id),"
|
||
" updated_at = ? WHERE id = ?" + status_clause)
|
||
params_full = (short, full, err_short, actual_model,
|
||
started_at, finished_at, child_pid,
|
||
turn_id, root_turn_id, now, task_id) + extra
|
||
sql_old = sql_full.replace(
|
||
" result_text_full = ?,", "")
|
||
params_old = (short, err_short, actual_model,
|
||
started_at, finished_at, child_pid,
|
||
turn_id, root_turn_id, now, task_id) + extra
|
||
try:
|
||
cur = conn.execute(sql_full, params_full)
|
||
except Exception as exc:
|
||
# 老库/被 mock 掉迁移的库没有 result_text_full 列:
|
||
# 完整版丢弃、摘要照写,绝不让新列拖垮终局写回
|
||
# (P0 取消守卫测试就是 mock 掉迁移的手工表)。
|
||
if "result_text_full" not in str(exc):
|
||
raise
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
cur = conn.execute(sql_old, params_old)
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
|
||
def _set_child_pid(self, task_id, pid):
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
conn.execute(
|
||
"UPDATE tasks SET child_pid = ? WHERE id = ?",
|
||
(pid, task_id),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _kill_proc_group(self, proc):
|
||
"""真杀:先 SIGTERM 给整个进程组,等 2 秒,仍活着再 SIGKILL。
|
||
|
||
proc 须是 start_new_session 启动的 Popen(自成进程组),
|
||
杀组才能连同它拉起的子孙一起杀掉,不只杀父进程。
|
||
"""
|
||
try:
|
||
pgid = os.getpgid(proc.pid)
|
||
except Exception:
|
||
return
|
||
try:
|
||
os.killpg(pgid, signal.SIGTERM)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
return
|
||
deadline = time.time() + 2.0
|
||
while time.time() < deadline:
|
||
try:
|
||
os.killpg(pgid, 0)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
return
|
||
time.sleep(0.05)
|
||
try:
|
||
os.killpg(pgid, signal.SIGKILL)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
pass
|
||
|
||
def heartbeat(self, task_id):
|
||
"""刷新 running 任务的心跳;任务不在 running 时返回 False。"""
|
||
now = time.time()
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET last_heartbeat = ?, updated_at = ?"
|
||
" WHERE id = ? AND status = 'running'",
|
||
(now, now, task_id),
|
||
)
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
|
||
def _audit(self, project_id, action, detail=""):
|
||
"""best-effort 审计:失败不影响任务状态。"""
|
||
try:
|
||
store = AuthStore(self.db_path)
|
||
try:
|
||
store.audit("system", project_id, action, detail)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
def _notify_task_finished(self, task_id, project_id, event_type,
|
||
error=None):
|
||
"""任务终态推送:best-effort,绝不因通知失败影响任务状态。
|
||
|
||
只发终态事件:completed/failed/goal-unmet
|
||
(push.ALLOWED_PUSH_EVENTS 的子集;interrupted 不在
|
||
事件集合里,取消不通知)。无订阅时
|
||
dispatch 直接返回空 sent,不联网。同步发送、单次 10 秒
|
||
超时:慢订阅会拖住 worker 循环最多几秒,换取投递结果
|
||
可观测(失败记日志,不静默丢);后台队列是后续优化,
|
||
不在本轮。
|
||
"""
|
||
try:
|
||
from cloud.push import dispatch_event
|
||
result = dispatch_event(
|
||
self.db_path, event_type, project_id,
|
||
{"task_id": task_id, "status": event_type,
|
||
"executor": self._executor_name(), "error": error})
|
||
if result.get("errors"):
|
||
log_event(self.logger, "worker_notify_failed",
|
||
task_id=task_id, project_id=project_id,
|
||
status=event_type,
|
||
message="notify errors: %s" % (result["errors"],))
|
||
except Exception as exc: # noqa: BLE001 -- 通知失败绝不炸任务
|
||
log_event(self.logger, "worker_notify_failed",
|
||
task_id=task_id, project_id=project_id,
|
||
status=event_type,
|
||
message="notify exception: %s" % exc)
|
||
|
||
# -- 自动记忆 ------------------------------------------------------
|
||
# 注入失败绝不挡任务(fail-open:无记忆跑也比不跑强);提取
|
||
# 失败只记日志(run_loop 轮次边界已包一层,见 process 方法)。
|
||
|
||
def _apply_auto_memory_injection(self, task):
|
||
"""首轮注入记忆:只改内存副本,原 prompt/goal 不落库。
|
||
|
||
跳过:协调员任务(不跑模型回合)、续跑与断点续跑(已有
|
||
会话历史)、无属主、use_memories 关、无记忆。goal 任务
|
||
同时改内存里的 goal(执行器取 goal 优先于 prompt),
|
||
前缀里写明“仅供参考,不得改写本任务目标”。
|
||
"""
|
||
if task.get("coordinator_parts"):
|
||
return task
|
||
if task.get("_resume") or task.get("_goal_resume_breakpoint"):
|
||
return task
|
||
owner = task.get("created_by")
|
||
if not owner:
|
||
return task
|
||
try:
|
||
store = AutoMemoryStore(self.db_path)
|
||
try:
|
||
flags = store.get_task_flags(task["id"])
|
||
finally:
|
||
store.close()
|
||
prefix = _auto_prefix(
|
||
self.db_path, owner, task["project_id"], flags=flags)
|
||
except Exception as exc:
|
||
try:
|
||
self.logger.warning("auto memory injection failed: %s",
|
||
exc)
|
||
except Exception:
|
||
pass
|
||
return task
|
||
if not prefix:
|
||
return task
|
||
new_task = dict(task)
|
||
prompt = task.get("prompt") or ""
|
||
new_task["prompt"] = prefix + "\n\n" + prompt
|
||
if task.get("goal_mode") or task.get("verify_command"):
|
||
new_task["goal"] = prefix + "\n\n" + (
|
||
task.get("goal") or prompt)
|
||
new_task["_memory_injected"] = True
|
||
self._audit(task["project_id"], "worker_memory_injected",
|
||
task["id"])
|
||
return new_task
|
||
|
||
def _apply_todo_injection(self, task):
|
||
"""首轮注入未完成待办:只改内存副本,原 prompt 不落库。
|
||
|
||
跳过:协调员任务(不跑模型回合)、续跑与断点续跑(已有
|
||
会话历史)、goal 任务(goal 不解析标记块,注入待办会让
|
||
模型写出永远不会执行的 todo-update 块,误导大于帮助)。
|
||
无未完成待办时原样返回,不贴空章节。注入的是数据(标题
|
||
与状态),不是完整工具指南;标记块格式只给三行示例,
|
||
完整格式见 docs/marker-tools.md。注入失败时静默跳过,
|
||
绝不因待办查询失败而炸掉任务认领。
|
||
上限 20 条、标题单行截断 100 字,避免待办板把 prompt
|
||
撑大;超出时写明还有几条未列出,模型可用 todo-list
|
||
块再查。"""
|
||
if task.get("coordinator_parts"):
|
||
return task
|
||
if task.get("_resume") or task.get("_goal_resume_breakpoint"):
|
||
return task
|
||
if task.get("verify_command") or task.get("goal_mode"):
|
||
return task
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
all_todos = _todo_list_db(conn, task["project_id"])
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
try:
|
||
self.logger.warning("todo injection failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
return task
|
||
open_todos = [t for t in all_todos
|
||
if t.get("status") in ("pending", "in_progress",
|
||
"blocked")]
|
||
if not open_todos:
|
||
return task
|
||
lines = ["[项目待办:本项目未完成项,仅供参考,"
|
||
"不得改写本任务目标]"]
|
||
for todo in open_todos[:20]:
|
||
lines.append(_format_todo(todo))
|
||
if len(open_todos) > 20:
|
||
lines.append("(还有 %d 条未列出,可用 todo-list 块再查。)"
|
||
% (len(open_todos) - 20))
|
||
lines.append("改待办状态时在回合输出里写标记块,下一轮由"
|
||
" worker 执行并回报告,例如:")
|
||
lines.append("```todo-update")
|
||
lines.append('{"id": "<待办 id>", "status": "completed"}')
|
||
lines.append("```")
|
||
lines.append("查待办用 todo-list 块,建待办用 todo-create 块"
|
||
"(title 必填)。")
|
||
new_task = dict(task)
|
||
new_task["prompt"] = ("\n".join(lines) + "\n\n"
|
||
+ (task.get("prompt") or ""))
|
||
new_task["_todo_injected"] = True
|
||
self._audit(task["project_id"], "worker_todo_injected",
|
||
task["id"])
|
||
return new_task
|
||
|
||
def _apply_project_injection(self, task):
|
||
"""首轮注入项目上下文:只改内存副本,原 prompt/goal 不落库。
|
||
|
||
跳过:协调员任务(不跑模型回合)、续跑与断点续跑(已有
|
||
会话历史)。goal 任务不跳过——项目上下文是数据不是目标,
|
||
与自动记忆同款同时改内存里的 goal(执行器取 goal 优先
|
||
于 prompt,只改 prompt 的话 goal 任务看不到)。
|
||
贴三行数据:项目名、仓库(未绑定时明写未绑定,不编一个
|
||
地址)、环境(有 environment_id 时写环境名/分支/状态,
|
||
否则写无);再加一行 task-list 格式示例,让模型在不知道
|
||
任务 id 时也能查本项目任务。项目行查不到、查询失败时
|
||
原样返回,不贴半截上下文、不炸认领。"""
|
||
if task.get("coordinator_parts"):
|
||
return task
|
||
if task.get("_resume") or task.get("_goal_resume_breakpoint"):
|
||
return task
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT name, repo_url FROM projects WHERE id = ?",
|
||
(task["project_id"],)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
try:
|
||
self.logger.warning("project injection failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
return task
|
||
if row is None:
|
||
return task
|
||
try:
|
||
project_name = row["name"] or task["project_id"]
|
||
except (KeyError, IndexError, TypeError):
|
||
project_name = task["project_id"]
|
||
try:
|
||
repo_url = row["repo_url"] or None
|
||
except (KeyError, IndexError, TypeError):
|
||
repo_url = None
|
||
env_line = "无(使用默认仓库)"
|
||
env_id = task.get("environment_id")
|
||
if env_id:
|
||
env = self._fetch_environment(env_id)
|
||
if env is not None:
|
||
env_line = "%s(分支 %s,状态 %s)" % (
|
||
env.get("name") or env_id,
|
||
env.get("branch") or "未指定",
|
||
env.get("status") or "未知")
|
||
if env.get("repo_url"):
|
||
repo_url = env["repo_url"]
|
||
else:
|
||
env_line = "%s(查不到该环境)" % env_id
|
||
lines = ["[项目上下文]",
|
||
"项目:%s(id=%s)" % (project_name,
|
||
task["project_id"]),
|
||
"仓库:%s" % (repo_url or "未绑定"),
|
||
"环境:%s" % env_line,
|
||
"查本项目任务用 task-list 标记块,例如:",
|
||
"```task-list",
|
||
'{"status": "failed"}',
|
||
"```",
|
||
"(不传 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 "")
|
||
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["_project_injected"] = True
|
||
self._audit(task["project_id"], "worker_project_injected",
|
||
task["id"])
|
||
return new_task
|
||
|
||
def process_auto_memories(self, now=None, force=False,
|
||
owner_id=None):
|
||
"""扫 completed 任务并提取记忆(run_loop 轮次边界调用)。
|
||
|
||
返回 process_due_tasks 的结果列表;提取本身失败时返回
|
||
空列表并只记日志,不炸循环。提取成功的任务各补一条
|
||
worker 侧审计(服务端手动触发路径另有自己的审计)。
|
||
"""
|
||
try:
|
||
results = _auto_process_due(
|
||
self.db_path, now=now, force=force, owner_id=owner_id)
|
||
except Exception as exc:
|
||
try:
|
||
self.logger.warning("auto memory sweep failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
return []
|
||
for item in results:
|
||
if item.get("status") == "extracted":
|
||
# project_id 不在结果里:审计 detail 只带任务 id,
|
||
# project 字段用空串(与系统审计同款占位)。
|
||
self._audit("", "worker_memory_extracted",
|
||
item.get("task_id") or "")
|
||
return results
|
||
|
||
# -- 认领 ----------------------------------------------------------
|
||
def _budget_exceeded(self):
|
||
return budget_exceeded(self.usage_turns, self.max_turns,
|
||
self.usage_cost_usd, self.max_cost_usd)
|
||
|
||
def _quota_limit_and_used(self, owner):
|
||
"""查某人当月配额与已记账用量,返回
|
||
(quota, used, month, unpriced_used)。
|
||
|
||
quota 为 None 表示不限额(只限行里 quota 为 NULL 的
|
||
显式不限额;无配额行按默认配额
|
||
_default_quota_usd_micros() 算,与服务端 _quota_status
|
||
的 P0 口径一致)。查询失败时抛异常,由调用方按
|
||
fail-open / fail-closed 分层决定——本函数自己绝不吞
|
||
异常后冒充“不限额”,否则调用方分不清“真不限额”与
|
||
“查不出来”。
|
||
unpriced_used 是当月 usd IS NULL 且 source='actual'
|
||
的成本行 input + output 之和(与服务端
|
||
_check_unpriced_token_cap 同口径,估算行不计);
|
||
quota 为 None 时它仍返回实数,但调用方
|
||
_quota_exceeded 在 quota 为 None 时先返回 None,
|
||
所以显式不限额用户不受无价上限约束,与服务端一致。
|
||
"""
|
||
month = datetime.now(timezone.utc).strftime("%Y-%m")
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS user_quotas("
|
||
"user_id TEXT PRIMARY KEY, quota_usd_micros BIGINT,"
|
||
" updated_at DOUBLE PRECISION)")
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT quota_usd_micros FROM user_quotas"
|
||
" WHERE user_id = ?",
|
||
(owner,)).fetchone()
|
||
if row is None:
|
||
# P0/P1 口径:无行按默认额度,不是不限额(只有
|
||
# 显式 NULL 行才是不限额,见下)。
|
||
quota = _default_quota_usd_micros()
|
||
else:
|
||
quota = row[0]
|
||
if quota is None:
|
||
return None, 0, month, 0
|
||
# cost_records 由 CostStore 懒建:全新库上它还不存
|
||
# 在,直接 JOIN 会报错——先实例化一次建表再查(与
|
||
# 服务端 _quota_status 同款做法),否则 quota=0
|
||
#(一分钱也不能花)在全新库上会被误放行。
|
||
_cost_store = CostStore(self.db_path)
|
||
try:
|
||
_cost_store._conn.close()
|
||
except Exception:
|
||
pass
|
||
used_row = conn.execute(
|
||
"SELECT COALESCE(SUM(c.usd_micros), 0)"
|
||
" FROM cost_records c JOIN tasks t"
|
||
" ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?",
|
||
(owner, month + "%")).fetchone()
|
||
used = used_row[0] if used_row else 0
|
||
unpriced_row = conn.execute(
|
||
"SELECT COALESCE(SUM(c.input_tokens + c.output_tokens), 0)"
|
||
" FROM cost_records c JOIN tasks t"
|
||
" ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?"
|
||
" AND c.usd_micros IS NULL AND c.source = 'actual'",
|
||
(owner, month + "%")).fetchone()
|
||
unpriced_used = unpriced_row[0] if unpriced_row else 0
|
||
return quota, int(used or 0), month, int(unpriced_used or 0)
|
||
finally:
|
||
conn.close()
|
||
|
||
def _quota_exceeded(self, task, usage_total, on_error="open"):
|
||
"""配额加固的执行时检查:返回超限时的错误文本,否则 None。
|
||
|
||
口径与服务端准入(_quota_status)一致:按任务创建者算,
|
||
当月(UTC)已记账用量 + 本轮已累计用量(按价目表换算,
|
||
无价模型按 0 计,与服务端 SUM(usd NULL 不计)一致)。
|
||
另有一道无价 token 上限(与服务端
|
||
_check_unpriced_token_cap 同口径):USD 配额不计量无价
|
||
模型,默认模型 terra 无价,若无此上限则配额对默认模型
|
||
永远不生效;有额度约束的用户(无行按默认额度)当月无价
|
||
token(input +
|
||
output,已记账 + 本轮)达到上限即停。无价上限只计
|
||
source='actual' 的行:估算行(estimated)不是真实花费,
|
||
计入上限会把“没花钱”算成“花超了”,且与 cost.py
|
||
_report 的 unpriced_actual_tokens 口径一致。
|
||
属主与额度(P1 口径,与服务端 _quota_status 一致):
|
||
- 属主:任务 created_by 为空时先查父任务(协调员子任务
|
||
行可能无 created_by,属主跟父任务走);仍查不到时
|
||
拒绝新回合,不再返回 None 放行。created_by 字面为
|
||
"system" 的系统任务按普通属主算,不另设无限额的
|
||
系统预算(G14 的 system 豁免与 P1 冲突,本次未合入)。
|
||
- 无配额行:按默认配额 _default_quota_usd_micros()
|
||
($10/月)算;只有显式 NULL 行才是不限额。
|
||
查库失败分层(G7 + P1):认领门传 on_error="closed",
|
||
返回 "quota_check_unavailable: ..."(调用方标
|
||
interrupted,可重试/续跑);默认 "open" 返回
|
||
"quota_check_failed: ..."——新回合边界(直接调用)
|
||
见此串即拒绝,执行中轮询经 _quota_hit 闭包过滤此串、
|
||
只记警告不杀当前轮。查询抖动不应杀掉已在跑的回合,
|
||
但下一轮边界仍会拒绝。
|
||
"""
|
||
owner = (task or {}).get("created_by")
|
||
if not owner and (task or {}).get("parent_task_id"):
|
||
# 协调员子任务属主跟父任务走(与 _gateway_context
|
||
# 同款回查);查不到时不猜,直接走下面的拒绝分支。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT created_by FROM tasks WHERE id = ?",
|
||
(task["parent_task_id"],)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if row is not None:
|
||
try:
|
||
owner = row[0]
|
||
except (TypeError, IndexError):
|
||
owner = None
|
||
except Exception:
|
||
owner = None
|
||
if not owner:
|
||
return ("quota_exceeded: 任务缺少属主,无法查配额,"
|
||
"拒绝新回合(请归属真实用户后再排队)")
|
||
try:
|
||
quota, used, month, unpriced_used = \
|
||
self._quota_limit_and_used(owner)
|
||
except Exception as exc:
|
||
# 查库失败分层(见本函数 docstring):closed(认领门)
|
||
# 回 quota_check_unavailable,调用方标 interrupted;
|
||
# open 回 quota_check_failed,新回合边界拒绝,执行中
|
||
# 轮询经 _quota_hit 闭包过滤、只警告不杀轮。
|
||
try:
|
||
self.logger.warning("quota check failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
if on_error == "closed":
|
||
return "quota_check_unavailable: 配额查询失败,任务未开跑"
|
||
return ("quota_check_failed: 配额查询失败,拒绝新回合"
|
||
"(查库异常,非配额用完)")
|
||
if quota is None:
|
||
return None
|
||
if usage_total:
|
||
model = (task or {}).get("model") or self.model
|
||
micros = price_usd_micros(
|
||
model, usage_total.get("input_tokens", 0),
|
||
usage_total.get("cached_input_tokens", 0),
|
||
usage_total.get("output_tokens", 0))
|
||
used += micros or 0
|
||
if micros is None:
|
||
# 无价模型:本轮 token 计入无价上限(USD 口
|
||
# 径里它是 0,不会在上面那道里被拦住)。
|
||
unpriced_used += (
|
||
max(0, int(usage_total.get("input_tokens", 0)))
|
||
+ max(0, int(usage_total.get("output_tokens", 0))))
|
||
if used >= quota:
|
||
return ("quota_exceeded: 本月配额已用完(已用 %d / "
|
||
"配额 %d 微美元,%s)" % (used, quota, month))
|
||
cap = unpriced_token_cap()
|
||
if unpriced_used >= cap:
|
||
return ("quota_exceeded: 本月无价模型 token 已用完"
|
||
"(已用 %d / 上限 %d,%s)"
|
||
% (unpriced_used, cap, month))
|
||
return None
|
||
|
||
def _ready_tasks(self):
|
||
"""当前就绪任务列表(pick_next 的批量版)。
|
||
|
||
逻辑与 pick_next 完全一致(预算门、依赖满足、执行器
|
||
过滤),只是返回全部而不是第一个。自动扩缩容用它算
|
||
队列深度:只数真正能立刻派的任务,被依赖卡住的
|
||
queued 任务不算,否则 scaler 会为永远派不出去的任务
|
||
扩容。依赖成环时返回 [](与 pick_next 的 None 一致)。
|
||
"""
|
||
if self._budget_exceeded():
|
||
return []
|
||
tasks = self.load_tasks()
|
||
completed_ids = {t["id"] for t in tasks
|
||
if t["status"] in COMPLETED_STATUSES}
|
||
try:
|
||
ready = ready_tasks(tasks, completed_ids)
|
||
except ValueError:
|
||
return []
|
||
mine = self._executor_name()
|
||
if self.claim_executor_only:
|
||
return [t for t in ready if t.get("executor") == mine]
|
||
return [t for t in ready
|
||
if t.get("executor") in (None, "", mine)]
|
||
|
||
def pick_next(self):
|
||
"""下一个可派任务,或 None(无就绪任务 / 预算超限 / 依赖成环)。
|
||
|
||
依赖满足的定义:依赖任务状态为 completed 或 merged。
|
||
依赖成环时 ready_tasks 抛 ValueError,worker 视为无就绪任务
|
||
(成环任务本就永远派不出去,run_all 不应因此崩溃)。
|
||
"""
|
||
ready = self._ready_tasks()
|
||
return ready[0] if ready else None
|
||
|
||
# -- 并发运行状态 --------------------------------------------------
|
||
def _running_set(self, task_id, proc=None, container=None):
|
||
with self._running_lock:
|
||
entry = self._running.setdefault(task_id, {})
|
||
if proc is not None:
|
||
entry["proc"] = proc
|
||
if container is not None:
|
||
entry["container"] = container
|
||
|
||
def _running_clear(self, task_id):
|
||
with self._running_lock:
|
||
self._running.pop(task_id, None)
|
||
|
||
def _running_snapshot(self):
|
||
with self._running_lock:
|
||
return {tid: dict(entry)
|
||
for tid, entry in self._running.items()}
|
||
|
||
def _running_count(self):
|
||
"""当前占用槽位数(_running_lock 下计数)。
|
||
|
||
启动中的任务已在 _claim 里占位(空条目),所以这里数
|
||
到的既包括已起进程 / 容器的任务,也包括已认领未起
|
||
进程的任务;注册表心跳与认领门共用这个口径。
|
||
"""
|
||
with self._running_lock:
|
||
return len(self._running)
|
||
|
||
def _wait_for_running(self, timeout=None):
|
||
"""等 _running 变空;timeout 为 None 时无限等。
|
||
|
||
返回 True 表示已空,False 表示超时仍有任务在跑。
|
||
run_loop 退出时的优雅排空用它:先等任务自然结束,
|
||
超时后调用方再杀残留并标 interrupted。
|
||
"""
|
||
deadline = None if timeout is None else time.time() + timeout
|
||
while True:
|
||
with self._running_lock:
|
||
if not self._running:
|
||
return True
|
||
if deadline is not None and time.time() >= deadline:
|
||
return False
|
||
time.sleep(0.2)
|
||
|
||
def _heartbeat_running_tasks(self):
|
||
"""给所有正在跑的任务刷 last_heartbeat。
|
||
|
||
run_loop 的心跳线程调它:任务在跑时心跳保持新鲜,
|
||
自动陈旧回收(sweep)才不会误杀活任务;worker 真死后
|
||
心跳停了,任务超过阈值才会被别的 worker 回收。
|
||
"""
|
||
for task_id in self._running_snapshot():
|
||
try:
|
||
self.heartbeat(task_id)
|
||
except Exception:
|
||
pass
|
||
|
||
# -- 取消(server cancel 语义,见模块 docstring)-------------------
|
||
def cancel(self, task_id):
|
||
"""把 queued/running 任务标 interrupted;已终态或不存在返回 False。
|
||
|
||
正在跑的子进程要真杀:先杀进程组(见 _kill_proc_group),
|
||
再标状态;已终态或不存在返回 False。
|
||
"""
|
||
task = self.get_task(task_id)
|
||
if task is None or task["status"] in TERMINAL_STATUSES:
|
||
return False
|
||
self._cancel_requested.add(task_id)
|
||
entry = self._running_snapshot().get(task_id) or {}
|
||
if entry.get("proc") is not None:
|
||
self._kill_proc_group(entry["proc"])
|
||
try:
|
||
entry["proc"].wait(timeout=3)
|
||
except Exception:
|
||
pass
|
||
# Docker 取消:按任务派生的容器名真杀,不只改数据库状态。
|
||
# 同步路径(runner 阻塞在 docker run 里)下 proc 为空,
|
||
# 此时靠容器名杀容器;kill 尽力而为,失败也不影响后续
|
||
# 的状态标记。
|
||
if entry.get("container") is not None and \
|
||
hasattr(self.executor, "kill_container"):
|
||
try:
|
||
self.executor.kill_container(entry["container"])
|
||
except Exception:
|
||
pass
|
||
self._running_clear(task_id)
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted', updated_at = ?"
|
||
" WHERE id = ? AND status NOT IN"
|
||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||
" 'merge-failed')",
|
||
(time.time(), task_id),
|
||
)
|
||
conn.commit()
|
||
claimed = cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
if not claimed:
|
||
# 并发竞态:run_one 的轮询已看到本次取消的标记、先一步
|
||
# 把任务标成 interrupted——中断确实由本次取消触发,返回 True。
|
||
current = self.get_task(task_id)
|
||
if current is not None and current["status"] == "interrupted":
|
||
claimed = True
|
||
if claimed:
|
||
self._audit(task["project_id"], "worker_cancelled", task_id)
|
||
log_event(self.logger, "worker_cancelled", task_id=task_id,
|
||
project_id=task["project_id"], status="interrupted")
|
||
return claimed
|
||
|
||
def _docker_cancel_watchdog(self, task_id, container_name,
|
||
stop_event):
|
||
"""Docker 同步路径的取消看门狗(_run_attempt 起的线程)。
|
||
|
||
同步路径阻塞在 docker run 里,worker 的 cancel() 在
|
||
另一线程只能改库、清 _running 项,阻塞中的 run 本身
|
||
不会醒——P0 安全批前这就是“取消杀不掉容器”的根因。
|
||
本线程每 1 秒查一次库:任务变 interrupted 或本进程
|
||
收到取消标志时,按容器名真杀容器(kill+rm,尽力而
|
||
为),然后退出;调用方在 _run_attempt 结束时置
|
||
stop_event 并 join。查库失败时当没取消,不误杀。
|
||
"""
|
||
while not stop_event.wait(1.0):
|
||
try:
|
||
if task_id in self._cancel_requested:
|
||
cancelled = True
|
||
else:
|
||
db_task = self.get_task(task_id)
|
||
cancelled = (db_task is not None and
|
||
db_task.get("status") == "interrupted")
|
||
except Exception:
|
||
cancelled = False
|
||
if not cancelled:
|
||
continue
|
||
try:
|
||
kill = getattr(self.executor, "kill_container", None)
|
||
if kill is not None and container_name:
|
||
kill(container_name)
|
||
log_event(self.logger, "worker_docker_killed",
|
||
task_id=task_id,
|
||
message="cancel watchdog killed container "
|
||
"%s (task interrupted/cancelled)"
|
||
% container_name)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
def _docker_quota_watchdog(self, task_id, container_name,
|
||
stop_event, quota_check, interval=30.0):
|
||
"""Docker 同步路径的配额看门狗(P1,复用取消看门狗模式)。
|
||
|
||
同步路径阻塞在 docker run 里,run_one 的边界检查要等
|
||
本轮返回才跑得到——配额在别处被花完时,本轮会继续烧
|
||
到自然结束。本线程每 interval 秒(默认 30 秒,不超过
|
||
30 秒)用 quota_check 查一次已记账配额:命中时按容器
|
||
名真杀容器并记 _quota_hit_tasks,_run_attempt 在同步
|
||
返回后把结果改写成 quota_exceeded,run_one 走统一的
|
||
配额中断收尾(interrupted + 配额原因 + 已花成本照记,
|
||
下一轮不再续跑)。查库失败时 quota_check 返回 False
|
||
(run_one 的 _quota_hit 会把 quota_check_failed 前缀
|
||
过滤掉,见 _quota_exceeded docstring),本线程只继续
|
||
轮询、不杀当前轮;下一轮边界仍会拒绝。诚实边界:看
|
||
门狗只能看到已记账用量 + 往轮累计用量,本轮容器内尚
|
||
未回传的 token 它看不到——那部分在结果返回后由边界
|
||
门拦住,不会悄悄续跑下一轮。
|
||
"""
|
||
while not stop_event.wait(interval):
|
||
try:
|
||
over = bool(quota_check()) if quota_check else False
|
||
except Exception:
|
||
over = False
|
||
if not over:
|
||
continue
|
||
self._mark_quota_hit(task_id)
|
||
try:
|
||
kill = getattr(self.executor, "kill_container", None)
|
||
if kill is not None and container_name:
|
||
kill(container_name)
|
||
log_event(self.logger, "worker_docker_killed",
|
||
task_id=task_id,
|
||
message="quota watchdog killed container %s "
|
||
"(quota exceeded)" % container_name)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
def cleanup_stale_containers(self):
|
||
"""启动清理兜底:清掉上一轮残留的 codex- 容器。
|
||
|
||
只在执行器有 cleanup_stale_containers 时做(Docker);
|
||
列出或删除失败只记日志,绝不抛异常炸掉 worker。
|
||
返回已清理的容器名列表(非 Docker 执行器返回 [])。
|
||
"""
|
||
cleanup = getattr(self.executor, "cleanup_stale_containers", None)
|
||
if cleanup is None:
|
||
return []
|
||
try:
|
||
cleaned = cleanup()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_cleanup_failed",
|
||
message="cleanup stale containers failed: %s" % exc)
|
||
return []
|
||
if cleaned:
|
||
log_event(self.logger, "worker_cleanup",
|
||
message="cleaned stale containers: %s"
|
||
% ",".join(cleaned))
|
||
return cleaned
|
||
|
||
def _was_cancelled(self, task_id):
|
||
if task_id in self._cancel_requested:
|
||
return True
|
||
task = self.get_task(task_id)
|
||
return task is not None and task["status"] == "interrupted"
|
||
|
||
# -- 缺 key 门 ------------------------------------------------------
|
||
def _executor_name(self):
|
||
"""执行器名:DockerExecutor -> docker,CloudExecutor -> cloud,
|
||
其它 -> local。cloud 绝不能回落成 local,否则 cloud worker
|
||
会认领 local 池的任务,池过滤就失效了。"""
|
||
if isinstance(self.executor, DockerExecutor):
|
||
return "docker"
|
||
if isinstance(self.executor, CloudExecutor):
|
||
return "cloud"
|
||
return "local"
|
||
def key_available(self):
|
||
"""只查存在性:名在环境里且值非空即 True;值本身绝不返回。"""
|
||
try:
|
||
value = self._env.get(self.env_key)
|
||
except Exception:
|
||
return False
|
||
return value is not None and value != ""
|
||
|
||
def _use_popen(self):
|
||
"""是否走进程模式:注入了 process_factory,或执行器是
|
||
默认 runner 的 LocalExecutor(真实 subprocess)。"""
|
||
if self.process_factory is not None:
|
||
return True
|
||
return (isinstance(self.executor, LocalExecutor)
|
||
and getattr(self.executor, "runner", None)
|
||
is _sandbox_default_runner
|
||
and hasattr(self.executor, "popen_task"))
|
||
|
||
def _gateway_context(self, task):
|
||
"""网关注入:xcmapi -> (None, None);sub2api -> (provider, env)。
|
||
|
||
sub2api 时按任务属主(tasks.created_by)幂等开通并取 key;
|
||
开通失败直接抛(任务失败,不跑错网关)。env 含
|
||
SUB2API_API_KEY、SUB2API_BASE_URL,并把网关 host 追加进
|
||
NO_PROXY/no_proxy(否则 codex 经 egress 代理访问内网网关
|
||
会被挡;proxy_env 的 NO_PROXY 会被这份 env 覆盖,所以这里
|
||
直接给超集 localhost,127.0.0.1,<网关 host>)。
|
||
"""
|
||
if _gateway_name() != "sub2api":
|
||
return None, None
|
||
origin = _gateway_origin()
|
||
if not origin:
|
||
raise RuntimeError("网关为 sub2api 但 SUB2API_BASE_URL 未配置")
|
||
owner = task.get("created_by")
|
||
if not owner and task.get("parent_task_id"):
|
||
# 协调员子任务:属主跟父任务走(子任务行可能无 created_by)。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT created_by FROM tasks WHERE id = ?",
|
||
(task["parent_task_id"],)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if row is not None:
|
||
try:
|
||
owner = row[0]
|
||
except (TypeError, IndexError):
|
||
owner = None
|
||
except Exception:
|
||
owner = None
|
||
if not owner:
|
||
raise RuntimeError("任务缺少 created_by,无法解析网关属主")
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
key = _ensure_sub2api_key(conn, owner)
|
||
finally:
|
||
conn.close()
|
||
host = origin.split("://", 1)[-1].split("/", 1)[0]
|
||
host = host.split(":", 1)[0]
|
||
no_proxy = "localhost,127.0.0.1"
|
||
if host and host not in ("localhost", "127.0.0.1"):
|
||
no_proxy += "," + host
|
||
env = {
|
||
"SUB2API_API_KEY": key,
|
||
"SUB2API_BASE_URL": origin,
|
||
"NO_PROXY": no_proxy,
|
||
"no_proxy": no_proxy,
|
||
}
|
||
return "sub2api", env
|
||
|
||
def _ensure_gateway(self, task):
|
||
"""任务 dict 上的网关缓存:已算过直接取,否则算一次并存回。
|
||
|
||
键是 _gateway_provider/_gateway_env(与 _resume 同款下划线
|
||
私有键约定,不进库)。run_one 算过后 _run_attempt 不再
|
||
重算;_run_fork 直达 _run_attempt 时在这里补算。
|
||
"""
|
||
if "_gateway_provider" in task:
|
||
return task["_gateway_provider"], task["_gateway_env"]
|
||
provider, env = self._gateway_context(task)
|
||
task["_gateway_provider"] = provider
|
||
task["_gateway_env"] = env
|
||
return provider, env
|
||
|
||
def _sub2api_default_model(self, gateway_env):
|
||
"""sub2api 默认模型:环境变量优先,否则取该用户网关模型首个。
|
||
|
||
网关模型目录因用户而异(key 绑定的组决定),worker 构造时
|
||
没有属主,只能到任务时现取;取不到(组里无模型)就 loud
|
||
fail,不让任务拿着 xcmapi 的模型名去撞 sub2api。
|
||
"""
|
||
raw = os.environ.get("CLOUD_SUB2API_DEFAULT_MODEL", "").strip()
|
||
if raw:
|
||
return raw
|
||
origin = _gateway_origin()
|
||
key = (gateway_env or {}).get("SUB2API_API_KEY")
|
||
if not origin or not key:
|
||
raise RuntimeError("sub2api 默认模型无法解析:缺少网关 key")
|
||
models = _list_gateway_models(origin, key)
|
||
if not models:
|
||
raise RuntimeError(
|
||
"sub2api 网关暂无可用模型(组里未挂上游账号?)")
|
||
return models[0]
|
||
|
||
def _task_seed_dir(self, task):
|
||
"""任务附件暂存目录(派发时 seed 用):无附件返回 None;
|
||
有附件行但目录已不在(如终局后已清理)也返回 None——
|
||
续跑任务不需要重 seed(文件已在 worktree 里),静默跳过
|
||
比报错更正确(重试走已存在续跑时包早已删了)。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT bundle_id FROM task_attachments"
|
||
" WHERE task_id = ? LIMIT 1",
|
||
(task["id"],),
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
return None
|
||
if row is None:
|
||
return None
|
||
bundle_id = row[0] if isinstance(row, tuple) else row["bundle_id"]
|
||
bundle_dir = os.path.join(self.upload_dir, bundle_id)
|
||
return bundle_dir if os.path.isdir(bundle_dir) else None
|
||
|
||
def _cleanup_task_seed(self, task):
|
||
"""删任务附件暂存目录:终局清理的唯一入口。
|
||
|
||
正常终局、缺 key 门、仓库解析失败三路都要走这里——
|
||
后两路在 run_one 里提前返回,够不到正常终局后的清理
|
||
(2026-10-27 实弹:仓库解析失败的任务暂存目录残留)。
|
||
取消走 server 侧删,这里删不到也不报错;删失败不影响
|
||
任务结果本身。
|
||
"""
|
||
try:
|
||
_seed = self._task_seed_dir(task)
|
||
if _seed:
|
||
shutil.rmtree(_seed, ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
|
||
def _execute_via_process(self, task, kind="dispatch",
|
||
deadline_seconds=None, seed_host_dir=None,
|
||
quota_check=None):
|
||
"""进程模式执行一轮:启动、轮询、取消时杀进程组。
|
||
|
||
返回与同步路径同款 result dict(status/stdout/stderr/
|
||
host_status/returncode)。取消时返回 status='cancelled'
|
||
的标记,调用方据此保持 interrupted;deadline_seconds
|
||
到期时杀进程组并返回 status='timeout' 的标记,调用方
|
||
据此置 failed(超时是失败,不是取消)。quota_check 不为
|
||
空时每 quota_poll_interval_seconds(缺省 30 秒)查一次
|
||
配额(轮询本身 50 毫秒一圈,逐圈查会把库打爆),超限时
|
||
同样杀进程组并返回 status='quota_exceeded' 的内部标记
|
||
——调用方据此走配额中断收尾,绝不能把这个标记原样写
|
||
进 tasks.status(不在合法状态集里)。Docker 路径见
|
||
_docker_call_with_quota_poll(同款轮询);同步路径没有
|
||
轮询钩子,只走边界检查。
|
||
"""
|
||
task_id = task["id"]
|
||
gateway_provider, gateway_env = self._ensure_gateway(task)
|
||
if self.process_factory is not None:
|
||
proc = self.process_factory(task)
|
||
else:
|
||
# seed 只对 dispatch/goal 首轮有意义:run 续跑不传
|
||
# (文件已在 worktree 里);popen_task 内部同样只在
|
||
# dispatch/goal 命令上追加 --seed-dir。
|
||
proc = self.executor.popen_task(
|
||
task, kind=kind, seed_dir=seed_host_dir,
|
||
extra_env=gateway_env, model_provider=gateway_provider)
|
||
self._running_set(task_id, proc=proc)
|
||
try:
|
||
self._set_child_pid(task_id, getattr(proc, "pid", None))
|
||
except Exception:
|
||
pass
|
||
cancelled = False
|
||
timed_out = False
|
||
quota_hit = False
|
||
started = time.time()
|
||
last_quota_check = 0.0
|
||
last_db_cancel_check = 0.0
|
||
quota_interval = getattr(
|
||
self, "quota_poll_interval_seconds", 30)
|
||
if quota_interval is None:
|
||
quota_interval = 30
|
||
try:
|
||
while proc.poll() is None:
|
||
if task_id in self._cancel_requested or self._loop_stop:
|
||
cancelled = True
|
||
self._kill_proc_group(proc)
|
||
break
|
||
# P0 安全批后:popen 轮询也要轮询库状态,不只
|
||
# 看本进程标志——控制台取消 / sweep 是另一进程
|
||
# 改库,旧代码只看本进程标志时杀不掉本进程的
|
||
# 子进程。节流到每 1 秒查一次库,避免 0.05 秒
|
||
# 轮询把库打爆;查库失败时当没取消,不误杀。
|
||
if time.time() - last_db_cancel_check >= 1.0:
|
||
last_db_cancel_check = time.time()
|
||
try:
|
||
db_task = self.get_task(task_id)
|
||
except Exception:
|
||
db_task = None
|
||
if db_task is not None and \
|
||
db_task.get("status") == "interrupted":
|
||
cancelled = True
|
||
self._kill_proc_group(proc)
|
||
break
|
||
if deadline_seconds is not None and \
|
||
time.time() - started >= deadline_seconds:
|
||
timed_out = True
|
||
self._kill_proc_group(proc)
|
||
break
|
||
if quota_check is not None and \
|
||
time.time() - last_quota_check >= quota_interval:
|
||
last_quota_check = time.time()
|
||
try:
|
||
over = quota_check()
|
||
except Exception:
|
||
over = False
|
||
if over:
|
||
quota_hit = True
|
||
self._kill_proc_group(proc)
|
||
break
|
||
time.sleep(0.05)
|
||
try:
|
||
out, err = proc.communicate(timeout=5)
|
||
except Exception:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
out, err = proc.communicate()
|
||
finally:
|
||
self._running_clear(task_id)
|
||
if cancelled:
|
||
return {"executor": "local", "task_id": task_id,
|
||
"status": "cancelled", "stdout": out or "",
|
||
"stderr": err or "", "returncode": proc.returncode,
|
||
"host_status": None}
|
||
if quota_hit:
|
||
return {"executor": "local", "task_id": task_id,
|
||
"status": "quota_exceeded", "stdout": out or "",
|
||
"stderr": err or "", "returncode": proc.returncode,
|
||
"host_status": None}
|
||
if timed_out:
|
||
return {"executor": "local", "task_id": task_id,
|
||
"status": "timeout", "stdout": out or "",
|
||
"stderr": err or "", "returncode": proc.returncode,
|
||
"host_status": None}
|
||
fake = SimpleNamespace(returncode=proc.returncode,
|
||
stdout=out or "", stderr=err or "")
|
||
if kind in ("goal", "goal-resume", "goal-resume-breakpoint") \
|
||
and hasattr(
|
||
self.executor, "_goal_result_from_runner"):
|
||
return self.executor._goal_result_from_runner(task_id, fake)
|
||
if hasattr(self.executor, "_result_from_runner"):
|
||
return self.executor._result_from_runner(task_id, fake)
|
||
return {"executor": "local", "task_id": task_id,
|
||
"status": "completed" if proc.returncode == 0 else "failed",
|
||
"stdout": out or "", "stderr": err or "",
|
||
"returncode": proc.returncode, "host_status": None}
|
||
|
||
# -- 多租户隔离 ----------------------------------------------------
|
||
def _tenant_safe_name(self, project_id):
|
||
"""项目 id 的安全目录名:与 repos.checkout_dir 同规则。"""
|
||
if project_id is None:
|
||
raise ValueError("project_id 缺失,无法确定租户目录")
|
||
raw = str(project_id)
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw).strip(".")
|
||
if not safe or safe in (".", ".."):
|
||
raise ValueError("project_id 无法映射为安全的租户目录名: %r"
|
||
% raw)
|
||
return safe
|
||
|
||
def _tenant_dirs(self, project_id):
|
||
"""某项目的租户宿主目录(不存在时创建并播种)。
|
||
|
||
返回 dict:work / codex_home / repo_checkout / test_repo
|
||
(均为宿主绝对路径)。播种规则:
|
||
- codex-home:只复制模板的 config.toml(relay 端点与
|
||
provider 名,不含密钥值);模板缺 config.toml 时直接
|
||
报错——空 home 会让 codex 去 api.openai.com 且无 auth,
|
||
绝不能静默放行(见 PR #18 的教训)。
|
||
- test-repo:整目录复制模板(它是 sample.py 夹具,含
|
||
.git 历史也无妨,本来就是公开的起始代码)。
|
||
- work / repo_checkout:只建目录。
|
||
并发下两个同项目任务同时播种是安全的:mkdir 用
|
||
exist_ok,复制前判存在且复制内容相同(模板),即使
|
||
race 到重复复制也不会写坏租户自己的数据(租户数据
|
||
在复制完成后才产生)。
|
||
"""
|
||
import shutil
|
||
|
||
safe = self._tenant_safe_name(project_id)
|
||
base = os.path.join(self.tenants_root, safe)
|
||
dirs = {
|
||
"work": os.path.join(base, "work"),
|
||
"codex_home": os.path.join(base, "codex-home"),
|
||
"repo_checkout": os.path.join(base, safe),
|
||
"test_repo": os.path.join(base, "test-repo"),
|
||
}
|
||
# 注意绝不 mkdir repo_checkout:repo_url 任务的检出由
|
||
# resolve_repo 负责 clone,空目录先占位会导致它误判
|
||
# “目录已存在但不是 git 仓库”而拒绝 clone;_tenant_mounts
|
||
# 里按该目录是否存在决定是否挂载它(不存在说明任务
|
||
# 不用它,走 test-repo)。
|
||
for key in ("work", "codex_home", "test_repo"):
|
||
os.makedirs(dirs[key], exist_ok=True)
|
||
home_config = os.path.join(dirs["codex_home"], "config.toml")
|
||
if not os.path.exists(home_config):
|
||
template = os.path.join(self.tenant_template_home,
|
||
"config.toml")
|
||
if not os.path.exists(template):
|
||
raise RuntimeError(
|
||
"租户 codex-home 模板缺 config.toml:%s(空 home"
|
||
" 会让 codex 去 api.openai.com 且无 auth,拒绝"
|
||
" 派发)" % template)
|
||
shutil.copyfile(template, home_config)
|
||
if self.websearch_mcp:
|
||
# Docker 租户任务的 codex-home 会原样挂载进容器,
|
||
# 所以这里写容器内路径;宿主侧脚本文件由播种助手
|
||
# 从运行中代码的 cloud/ 目录拷过来(生产即
|
||
# /opt/codex-app-server/cloud/)。播种失败直接
|
||
# 抛错、任务不派发——静默无搜索比 loud 失败更糟,
|
||
# 尤其对已宣传“有搜索”的任务。
|
||
_ensure_websearch_mcp(
|
||
dirs["codex_home"],
|
||
command_args=[
|
||
"python3",
|
||
"/var/lib/codex-app-server/codex-home/"
|
||
"websearch_mcp.py"])
|
||
if self.imagegen_mcp:
|
||
# 与 websearch 同款:播种失败直接抛错、任务不派发
|
||
# ——静默无生图比 loud 失败更糟,尤其对已宣传
|
||
# “能生图”的任务。key 不在这里写,只写
|
||
# command/args;key 由 _prepare_tenant 的 env 块给。
|
||
_ensure_imagegen_mcp(
|
||
dirs["codex_home"],
|
||
command_args=[
|
||
"python3",
|
||
"/var/lib/codex-app-server/codex-home/"
|
||
"imagegen_mcp.py"])
|
||
if self.playwright_mcp:
|
||
# 与 imagegen 同款:播种失败直接抛错、任务不派发
|
||
# ——静默无浏览器比 loud 失败更糟,尤其对已宣传
|
||
# “能开浏览器”的任务。代理与浏览器路径由
|
||
# _prepare_tenant 的 env 块给。MCP 脚本是系统文件:
|
||
# 播种器在版本变化时刷新老租户的旧脚本(见
|
||
# ensure_codex_home_playwright),刷新记录打日志。
|
||
_seeded = _ensure_playwright_mcp(
|
||
dirs["codex_home"],
|
||
command_args=[
|
||
"python3",
|
||
"/var/lib/codex-app-server/codex-home/"
|
||
"playwright_mcp.py"])
|
||
if _seeded.get("script_refreshed"):
|
||
self.logger.info(
|
||
"playwright MCP scripts refreshed for project %s: %s",
|
||
task.get("project_id"),
|
||
",".join(_seeded["script_refreshed"]))
|
||
if self.seed_skills:
|
||
# 技能播种失败绝不阻塞任务:技能是可选的流程
|
||
# 说明,不是 websearch 那种“已宣传就必须有”的
|
||
# 工具;播种助手本身对缺包、已存在、租户改动
|
||
# 都是静默处理,只在文件系统本身出错时才抛,
|
||
# 那种情况下任务仍应派发(技能缺失只影响流程,
|
||
# 不影响 agent 本身跑起来)。
|
||
try:
|
||
_ensure_skills(dirs["codex_home"])
|
||
except Exception as exc:
|
||
# 只记日志不阻塞:技能缺失只影响流程说明,
|
||
# 不应把一次可选播种失败变成任务派发失败。
|
||
# 但绝不静默吞掉——日志里要留下来供排查。
|
||
try:
|
||
self.logger.warning(
|
||
"skill seeding failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
if not os.path.exists(os.path.join(dirs["test_repo"], ".git")) \
|
||
and not os.path.exists(dirs["test_repo"] + "/sample.py"):
|
||
# 目标为空目录(刚 mkdir 出来的)才播种;已有内容
|
||
# 时绝不覆盖(那是该项目自己的改动或上次播种结果)。
|
||
if not os.listdir(dirs["test_repo"]):
|
||
if not os.path.exists(self.tenant_template_repo):
|
||
raise RuntimeError(
|
||
"租户 test-repo 模板不存在:%s"
|
||
% self.tenant_template_repo)
|
||
os.rmdir(dirs["test_repo"])
|
||
shutil.copytree(self.tenant_template_repo,
|
||
dirs["test_repo"])
|
||
return dirs
|
||
|
||
def _tenant_mounts(self, project_id):
|
||
"""某项目任务的 Docker 挂载列表(字符串,供执行器解析)。
|
||
|
||
容器内路径与非租户模式完全一致,只是宿主源按项目分开:
|
||
/app/work、codex-home、repos/<pid>、test-repo 全部指向
|
||
该项目的租户目录;代码目录 /app:ro 由执行器基础挂载
|
||
提供,不在这里重复。静态 --docker-extra-mounts 在租户
|
||
模式下被这份列表替换(否则共享挂载会继续泄漏)。
|
||
repos 挂载只在检出目录已存在时才加(repo_url 任务在
|
||
派发前已由 _apply_project_repo clone 好;test-repo 任务
|
||
不用它,不存在时硬挂载会被执行器的存在性检查拒绝)。
|
||
"""
|
||
dirs = self._tenant_dirs(project_id)
|
||
mounts = [
|
||
"%s:/app/work:rw" % dirs["work"],
|
||
"%s:/var/lib/codex-app-server/codex-home:rw"
|
||
% dirs["codex_home"],
|
||
"%s:/var/lib/codex-app-server/test-repo:rw"
|
||
% dirs["test_repo"],
|
||
]
|
||
if os.path.exists(dirs["repo_checkout"]):
|
||
mounts.append(
|
||
"%s:/var/lib/codex-app-server/repos/%s:rw"
|
||
% (dirs["repo_checkout"],
|
||
self._tenant_safe_name(project_id)))
|
||
# 环境检出:整目录挂载(按项目分,与 repo_checkout 同款
|
||
# 隔离语义)。环境任务派发前 worker 已准备好检出,目录
|
||
# 存在时才挂载;不存在说明该项目暂无环境,不硬挂载。
|
||
# base = work 的父目录(即租户项目根目录)。
|
||
envs_dir = os.path.join(os.path.dirname(dirs["work"]), "envs")
|
||
if os.path.isdir(envs_dir):
|
||
mounts.append(
|
||
"%s:/var/lib/codex-app-server/envs:rw" % envs_dir)
|
||
return mounts
|
||
|
||
def _tenant_network(self, project_id):
|
||
"""某项目的 Docker 网络名(容器跨项目网络隔离用)。"""
|
||
return "codex-" + self._tenant_safe_name(project_id)
|
||
|
||
# -- 环境 ----------------------------------------------------------
|
||
def _env_host_dir(self, env):
|
||
"""环境的宿主检出目录(worker 准备检出时写这里)。
|
||
|
||
非租户:repos_dir/envs/<env_id>(与项目检出同根,Docker
|
||
非租户挂载 repos 根目录时它自然在容器可见路径下)。
|
||
租户 + Docker:tenants_root/<项目>/envs/<env_id>,由
|
||
_tenant_mounts 挂到容器 /var/lib/codex-app-server/envs。
|
||
env_id 是 server 生成的 hex,仍按 checkout_dir 同款规则
|
||
过滤安全字符,防路径穿越。"""
|
||
if self.tenant_isolation and isinstance(
|
||
self.executor, DockerExecutor):
|
||
safe_proj = self._tenant_safe_name(env["project_id"])
|
||
return checkout_dir(
|
||
os.path.join(self.tenants_root, safe_proj, "envs"),
|
||
env["id"])
|
||
return checkout_dir(
|
||
os.path.join(self.repos_dir, "envs"), env["id"])
|
||
|
||
def _env_repo_path(self, env):
|
||
"""任务派发时传给执行器的 repo 路径:租户 Docker 用容器
|
||
内路径,其余用宿主路径——与 _apply_project_repo 的项目
|
||
检出路径约定完全一致,执行器不用为环境另起分支。"""
|
||
if self.tenant_isolation and isinstance(
|
||
self.executor, DockerExecutor):
|
||
return "/var/lib/codex-app-server/envs/%s" % env["id"]
|
||
return self._env_host_dir(env)
|
||
|
||
def _fetch_environment(self, env_id):
|
||
"""读环境行;表不存在(老库未跑 server init_db)或查不到
|
||
时返回 None,调用方按“环境不存在”处理,绝不炸 worker。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT * FROM environments WHERE id = ?",
|
||
(env_id,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
return None
|
||
if row is None:
|
||
return None
|
||
|
||
def _get(name):
|
||
try:
|
||
return row[name]
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
|
||
try:
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"name": row["name"],
|
||
"branch": _get("branch"),
|
||
"executor": _get("executor"),
|
||
"status": row["status"],
|
||
"error_text": _get("error_text"),
|
||
"repo_url": _get("repo_url"),
|
||
}
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
|
||
def _run_git(self, cmd):
|
||
"""跑一条 git 命令:repos_runner 可注入(单测用假 runner,
|
||
接受 cmd 或 (cmd, timeout) 两种签名,与 repos._run 同款
|
||
兼容);缺省走真实 subprocess。返回带 returncode/stdout/
|
||
stderr 的结果对象。"""
|
||
if self.repos_runner is not None:
|
||
try:
|
||
return self.repos_runner(cmd)
|
||
except TypeError:
|
||
return self.repos_runner(cmd, timeout=None)
|
||
return subprocess.run(cmd, capture_output=True, text=True)
|
||
|
||
def _prepare_environment(self, env):
|
||
"""准备一个环境的检出,返回错误文本或 None。
|
||
|
||
幂等:目录不存在时 clone,存在时先校验它是 git 仓库且
|
||
origin 与环境 repo_url 一致(不一致绝不强制改 remote,
|
||
直接失败),再 fetch;分支非空时 checkout 该分支(不存
|
||
在则失败,绝不自动建分支——分支名是用户选的,不应由
|
||
准备过程发明)。检出本身不做 reset/clean:任务 worktree
|
||
由 host.py 从检出 HEAD 新建,检出保持干净即可。"""
|
||
repo_url = env.get("repo_url")
|
||
if not repo_url:
|
||
return "environment has no repo_url"
|
||
target = self._env_host_dir(env)
|
||
if not os.path.exists(target):
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
result = self._run_git(
|
||
["git", "clone", repo_url, target])
|
||
if getattr(result, "returncode", 1) != 0:
|
||
err = (getattr(result, "stderr", "") or "").strip()
|
||
return "git clone 失败: %s" % (err or repo_url)
|
||
else:
|
||
git_path = os.path.join(target, ".git")
|
||
if not (os.path.isdir(git_path)
|
||
or os.path.isfile(git_path)):
|
||
return "检出目录已存在但不是 git 仓库,拒绝覆盖: %s" \
|
||
% target
|
||
origin = self._run_git(
|
||
["git", "-C", target, "remote", "get-url", "origin"])
|
||
if getattr(origin, "returncode", 1) != 0:
|
||
return "检出目录 %s 取不到 remote origin,拒绝准备" \
|
||
% target
|
||
if (getattr(origin, "stdout", "") or "").strip() != repo_url:
|
||
return "检出目录 origin 与环境 repo_url 不一致,拒绝准备"
|
||
fetched = self._run_git(
|
||
["git", "-C", target, "fetch", "origin"])
|
||
if getattr(fetched, "returncode", 1) != 0:
|
||
err = (getattr(fetched, "stderr", "") or "").strip()
|
||
return "git fetch origin 失败: %s" % (err or target)
|
||
branch = env.get("branch")
|
||
if branch:
|
||
checked = self._run_git(
|
||
["git", "-C", target, "checkout", branch])
|
||
if getattr(checked, "returncode", 1) != 0:
|
||
err = (getattr(checked, "stderr", "") or "").strip()
|
||
return "git checkout %s 失败: %s" % (branch, err or target)
|
||
return None
|
||
|
||
def _prepare_pending_environments(self, limit=5):
|
||
"""准备所有 status=creating 的环境(run_all 每轮开头调)。
|
||
|
||
每条环境独立 try:一条失败只把自己标 failed,不挡其它
|
||
环境和任务。状态写回用 CAS(WHERE status='creating'):
|
||
准备期间用户归档了环境时 CAS 改不到行,准备结果就此
|
||
作废、状态保持 archived,绝不把归档覆盖回 ready。worker
|
||
中途死了、环境仍是 creating 时下一轮会重新准备(准备
|
||
本身幂等),没有“preparing 卡死”这种中间态。返回
|
||
[(env_id, status, error_or_None), ...] 供日志与单测。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT * FROM environments WHERE status = 'creating'"
|
||
" ORDER BY created_at, id LIMIT ?",
|
||
(limit,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_env_list_failed",
|
||
message="worker env list failed: %s" % exc)
|
||
return []
|
||
results = []
|
||
for row in rows:
|
||
try:
|
||
env = {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"name": row["name"],
|
||
"branch": row["branch"] if "branch" in row.keys()
|
||
else None,
|
||
"executor": row["executor"] if "executor" in row.keys()
|
||
else None,
|
||
"repo_url": row["repo_url"] if "repo_url" in row.keys()
|
||
else None,
|
||
}
|
||
except Exception:
|
||
continue
|
||
try:
|
||
error = self._prepare_environment(env)
|
||
except Exception as exc:
|
||
error = str(exc)
|
||
status = "failed" if error else "ready"
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE environments SET status = ?,"
|
||
" error_text = ?, updated_at = ?"
|
||
" WHERE id = ? AND status = 'creating'",
|
||
(status, error, time.time(), env["id"]))
|
||
conn.commit()
|
||
if cur.rowcount == 0:
|
||
# 准备期间被归档:状态以归档为准,不覆盖。
|
||
status = "archived"
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_env_update_failed",
|
||
message="worker env update failed: %s" % exc)
|
||
continue
|
||
results.append((env["id"], status, error))
|
||
return results
|
||
|
||
def _apply_environment_repo(self, task):
|
||
"""任务带 environment_id 时的派发:环境必须已 ready 且
|
||
属于本任务项目,执行器冲突时拒绝;通过后 task["repo"]
|
||
指到环境检出(租户 Docker 为容器内路径),后续执行器
|
||
与无环境任务完全同款——host.py 从检出 HEAD 为任务建
|
||
worktree,任务隔离、合并、提 PR 都不变。环境未 ready
|
||
时返回错误文本,调用方把任务标 failed(仓库解析失败
|
||
绝不重试,见 _apply_project_repo 的约定)。"""
|
||
env_id = task.get("environment_id")
|
||
env = self._fetch_environment(env_id)
|
||
if env is None:
|
||
return "environment not found: %s" % env_id
|
||
if env["project_id"] != task.get("project_id"):
|
||
return "environment belongs to another project"
|
||
if env["status"] != "ready":
|
||
return "environment not ready: %s" % env["status"]
|
||
task_executor = task.get("executor")
|
||
if env["executor"] and task_executor \
|
||
and env["executor"] != task_executor:
|
||
return "executor does not match environment executor"
|
||
task["repo"] = self._env_repo_path(env)
|
||
return None
|
||
|
||
# -- 项目仓库 ------------------------------------------------------
|
||
def _project_repo_url(self, project_id):
|
||
"""查任务所属项目的 repo_url;查不到项目、列缺失、表缺失时
|
||
一律返回 None(走原有默认 repo,不报错、不炸掉派发)。
|
||
repo_url 本身只在 projects 表里,绝不从任务 prompt 里猜。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT repo_url FROM projects WHERE id = ?",
|
||
(project_id,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
return None
|
||
if row is None:
|
||
return None
|
||
try:
|
||
url = row["repo_url"]
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
return url or None
|
||
|
||
def _apply_project_repo(self, task):
|
||
"""派发前按项目 repo_url 解析仓库,返回错误文本或 None。
|
||
|
||
repo_url 为空:非租户模式不动 task,返回 None(走原有
|
||
默认 repo);租户模式则把 task["repo"] 指到该项目的
|
||
test-repo 副本(容器内路径与原来一致,宿主源已按项目
|
||
分开),否则容器会 fallback 到共享 test-repo。
|
||
repo_url 非空:调 cloud.repos.resolve_repo 得到本地检出,
|
||
设 task["repo"] 再交给执行器——LocalExecutor 与
|
||
DockerExecutor 的 dispatch_task 都已读 task["repo"],同一条
|
||
路径,不另起分支。租户模式下检出放在租户目录,task
|
||
["repo"] 设为容器内路径(挂载目标),而不是宿主检出
|
||
路径(worker 侧不再用 task["repo"] 当宿主路径)。
|
||
resolve 失败返回错误文本,调用方据此把任务置 failed
|
||
(仓库解析失败绝不标 completed,也不在此重试,避免对
|
||
不可用的仓库反复 clone 烧时间)。
|
||
|
||
任务带 environment_id 时不走项目仓库路径,直接走
|
||
_apply_environment_repo(环境检出已由 worker 准备好,
|
||
这里只校验 ready/归属/执行器并指过去,不再 clone)。
|
||
"""
|
||
if task.get("environment_id"):
|
||
return self._apply_environment_repo(task)
|
||
use_tenant = self.tenant_isolation and \
|
||
isinstance(self.executor, DockerExecutor)
|
||
repo_url = self._project_repo_url(task.get("project_id"))
|
||
if not repo_url:
|
||
if use_tenant:
|
||
try:
|
||
self._tenant_dirs(task.get("project_id"))
|
||
except (ValueError, RuntimeError) as exc:
|
||
return "租户目录准备失败: %s" % exc
|
||
task["repo"] = "/var/lib/codex-app-server/test-repo"
|
||
return None
|
||
try:
|
||
if use_tenant:
|
||
safe = self._tenant_safe_name(task.get("project_id"))
|
||
resolve_repo(
|
||
repo_url, os.path.join(self.tenants_root, safe),
|
||
task.get("project_id"), runner=self.repos_runner)
|
||
task["repo"] = \
|
||
"/var/lib/codex-app-server/repos/%s" % safe
|
||
else:
|
||
task["repo"] = resolve_repo(
|
||
repo_url, self.repos_dir, task.get("project_id"),
|
||
runner=self.repos_runner)
|
||
except (RepoError, ValueError) as exc:
|
||
return "仓库解析失败: %s" % exc
|
||
except Exception as exc:
|
||
return "仓库解析失败: %s" % exc
|
||
return None
|
||
|
||
# -- 执行 ----------------------------------------------------------
|
||
def _docker_task_context(self, task):
|
||
"""Docker 任务的租户挂载/网络/代理准备(_run_attempt 与
|
||
_run_fork 共用)。成功返回 (True, mounts, network,
|
||
proxy_env, None);失败返回 (False, None, None, None,
|
||
error_text)。网络建失败绝不降级默认网络跑容器(降级会
|
||
让隔离悄悄失效)。"""
|
||
tenant_mounts = None
|
||
tenant_network = None
|
||
proxy_env = None
|
||
if self.tenant_isolation:
|
||
try:
|
||
tenant_mounts = self._tenant_mounts(
|
||
task.get("project_id"))
|
||
tenant_network = self._tenant_network(
|
||
task.get("project_id"))
|
||
except (ValueError, RuntimeError) as exc:
|
||
return (False, None, None, None,
|
||
"租户隔离准备失败: %s" % exc)
|
||
if not self.executor.ensure_network(tenant_network):
|
||
return (False, None, None, None,
|
||
"租户 Docker 网络创建失败: %s" % tenant_network)
|
||
if self.egress_proxy_port:
|
||
# 代理在宿主上,容器经所在网络的网关访问;租户模式
|
||
# 用项目网络,非租户用默认 bridge。
|
||
gw = self.executor.network_gateway(tenant_network)
|
||
if not gw:
|
||
return (False, None, None, None,
|
||
"egress 代理网关解析失败,拒绝无代理"
|
||
"运行(避免绕过出口白名单)")
|
||
proxy_url = "http://%s:%s" % (gw, self.egress_proxy_port)
|
||
proxy_env = {
|
||
"HTTP_PROXY": proxy_url,
|
||
"HTTPS_PROXY": proxy_url,
|
||
"http_proxy": proxy_url,
|
||
"https_proxy": proxy_url,
|
||
"NO_PROXY": "localhost,127.0.0.1",
|
||
"no_proxy": "localhost,127.0.0.1",
|
||
}
|
||
# 搜索 env 透传:Docker 容器不继承 worker 进程环境,只拿
|
||
# extra_env(-e),所以 worker 环境里的搜索配置若不显式
|
||
# 放进这里,容器内 codex -> MCP 永远看不到 SEARXNG_URL,
|
||
# auto 链会跳过 SearXNG、只剩 DuckDuckGo(实弹 e2e 已证实)。
|
||
# 只透传已设置的项,值绝不进日志;付费 key 与网关 key 一样
|
||
# 走 extra_env(容器内进程本就需要它调 API)。
|
||
search_env = {
|
||
k: os.environ[k]
|
||
for k in ("SEARXNG_URL", "SEARXNG_ENGINES",
|
||
"PERPLEXITY_API_KEY", "EXA_API_KEY",
|
||
"TAVILY_API_KEY", "BRAVE_API_KEY")
|
||
if os.environ.get(k)
|
||
}
|
||
if search_env:
|
||
proxy_env = _merge_env(proxy_env, search_env)
|
||
# MCP env must live in config.toml, not just docker -e:
|
||
# Codex's MCP subprocess does not inherit the Codex
|
||
# process env (2026-10-30实测:Codex bash 里有
|
||
# SEARXNG_URL + proxy,MCP 链却只有 duckduckgo,
|
||
# DNS 瞬间失败;给 config 加 [mcp_servers.websearch.env]
|
||
# 后同一任务立刻返回真实 SearXNG 结果)。网关在此时
|
||
# 才已知,所以这里、容器启动前写租户 config,绝不
|
||
# 在播种时写死一个网关。写失败只记日志,不阻塞任务。
|
||
if self.tenant_isolation and proxy_env:
|
||
try:
|
||
home = self._tenant_dirs(
|
||
task.get("project_id"))["codex_home"]
|
||
_ensure_websearch_mcp_env(home, proxy_env)
|
||
except Exception as exc:
|
||
self.logger.warning(
|
||
"websearch MCP env write failed: %s" % exc)
|
||
if self.tenant_isolation and self.imagegen_mcp:
|
||
# 图片生成 MCP env:系统级 IMAGE_GEN_*(worker 环境,
|
||
# 即主机 env 文件)+ 代理(供下载返回的图片 URL)+
|
||
# NO_PROXY(供直连本地网关 origin,不走 egress 代理)。
|
||
# 未配 IMAGE_GEN_API_KEY 时仍写 base/model,工具会
|
||
# 明确报“未配置”;绝不把任务自己的分组 2 key 塞进来。
|
||
try:
|
||
# env 组装在 imagegen.build_mcp_env 里(可单测):
|
||
# base 必须在这里算好写进 MCP env,MCP 进程只读
|
||
# config.toml 里的 env,看不到 worker 的
|
||
# SUB2API_BASE_URL;不写的话 imagegen.py 会回落
|
||
# 到公网 sub2.diante.me,而它不在 Tinyproxy
|
||
# 白名单里,实测 403 Filtered(2026-10-30 首轮
|
||
# 生产验收即因此失败)。本地网关 origin 优先,
|
||
# 因为它走 NO_PROXY 直连,不依赖白名单。
|
||
from cloud.imagegen import build_mcp_env as _build_mcp_env
|
||
imagegen_env = _build_mcp_env(os.environ, proxy_env)
|
||
home = self._tenant_dirs(
|
||
task.get("project_id"))["codex_home"]
|
||
_ensure_imagegen_mcp_env(home, imagegen_env)
|
||
except Exception as exc:
|
||
self.logger.warning(
|
||
"imagegen MCP env write failed: %s" % exc)
|
||
if self.tenant_isolation and self.playwright_mcp:
|
||
# 浏览器 MCP env:代理(Chromium 显式走 egress 代理,
|
||
# 不在白名单的域名会 403)+ 浏览器路径。MCP 子进程
|
||
# 不继承 Codex 进程 env,所以镜像里的
|
||
# PLAYWRIGHT_BROWSERS_PATH 在这里必须重写一遍;
|
||
# 不写的话容器用户无家目录,Playwright 会去
|
||
# /.cache 找浏览器而报 Executable doesn't exist。
|
||
try:
|
||
playwright_env = dict(proxy_env or {})
|
||
playwright_env["PLAYWRIGHT_BROWSERS_PATH"] = (
|
||
"/opt/ms-playwright")
|
||
home = self._tenant_dirs(
|
||
task.get("project_id"))["codex_home"]
|
||
_ensure_playwright_mcp_env(home, playwright_env)
|
||
except Exception as exc:
|
||
self.logger.warning(
|
||
"playwright MCP env write failed: %s" % exc)
|
||
return (True, tenant_mounts, tenant_network, proxy_env, None)
|
||
|
||
def _run_fork(self, task, quota_check=None):
|
||
"""分支任务执行:host.py fork 建分支 thread,再跑首轮。
|
||
返回 (result, error),语义同 _run_attempt。fork 本身不
|
||
跑模型;首轮走 _run_attempt(dispatch 撞已存在 → run
|
||
续跑,三路取消/超时处理与普通任务一致)。首轮失败不
|
||
自动重试/fallback:失败后走重试入口重跑首轮(已存在
|
||
续跑),不重复 fork。"""
|
||
task_id = task["id"]
|
||
source_id = task.get("forked_from")
|
||
if not hasattr(self.executor, "fork_task"):
|
||
return ({"executor": self._executor_name(),
|
||
"task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None, "host_status": None,
|
||
"stderr": ""},
|
||
"executor 不支持 fork")
|
||
is_docker = isinstance(self.executor, DockerExecutor)
|
||
if is_docker:
|
||
(ok, tenant_mounts, tenant_network, proxy_env,
|
||
prep_err) = self._docker_task_context(task)
|
||
if not ok:
|
||
return ({"executor": "docker",
|
||
"task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None, "host_status": None,
|
||
"stderr": ""},
|
||
prep_err)
|
||
else:
|
||
tenant_mounts = tenant_network = proxy_env = None
|
||
try:
|
||
fork_res = self.executor.fork_task(
|
||
task, source_id,
|
||
timeout=(self.task_timeout if is_docker else None),
|
||
extra_mounts=tenant_mounts,
|
||
network=tenant_network,
|
||
extra_env=proxy_env)
|
||
except Exception as exc:
|
||
return ({"executor": self._executor_name(),
|
||
"task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None, "host_status": None,
|
||
"stderr": ""},
|
||
str(exc))
|
||
if (fork_res or {}).get("status") != "completed":
|
||
err = ((fork_res or {}).get("stderr", "") or
|
||
(fork_res or {}).get("stdout", "") or "fork failed")
|
||
err = err.strip()
|
||
return (fork_res,
|
||
err[-1000:] if err else "fork failed")
|
||
return self._run_attempt(task, quota_check=quota_check)
|
||
|
||
def _claim_next_followup(self, task_id):
|
||
"""取下一条后续消息:direct 优先、队内按创建时间先来
|
||
先服务。CAS 认领(pending → running),抢不到或表不
|
||
在返回 None(表由 server init_db 建;worker 直连老库
|
||
时缺表不炸 run_one,只当没有后续消息)。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
except Exception:
|
||
return None
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT * FROM task_followups"
|
||
" WHERE task_id = ? AND status = 'pending'"
|
||
" ORDER BY CASE WHEN mode = 'direct' THEN 0 ELSE 1 END,"
|
||
" created_at, id LIMIT 1",
|
||
(task_id,)).fetchone()
|
||
if row is None:
|
||
return None
|
||
cur = conn.execute(
|
||
"UPDATE task_followups SET status = 'running',"
|
||
" updated_at = ? WHERE id = ? AND status = 'pending'",
|
||
(time.time(), row["id"]))
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
return None
|
||
return {"id": row["id"], "body": row["body"],
|
||
"mode": row["mode"],
|
||
"sender_task_id": _row_get(row, "sender_task_id")}
|
||
except Exception:
|
||
return None
|
||
finally:
|
||
conn.close()
|
||
|
||
def _finish_followup(self, fu_id, status):
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE task_followups SET status = ?,"
|
||
" updated_at = ? WHERE id = ?",
|
||
(status, time.time(), fu_id))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _convert_running_task_to_goal(self, task_id, target):
|
||
"""把正在跑的主线在轮次边界转成新语义 goal 任务。
|
||
|
||
只在排空循环里调用:本轮首轮已 completed、任务在库里
|
||
仍是 running,CAS 把它改回 queued 并写 goal 字段与
|
||
resume_message——下次 worker 认领时走 /continue 同款
|
||
goal-resume,在同一 thread 上开新目标循环(不是新任务、
|
||
不是分支)。CAS 失败(任务已被取消/回收、状态已不是
|
||
running)返回 False,调用方把 followup 放回 pending,
|
||
绝不把取消过的任务悄悄改回 queued。
|
||
成功返回 True。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET goal_mode = 1, goal = ?,"
|
||
" verify_command = NULL, goal_max_rounds = 0,"
|
||
" status = 'queued', resume_message = ?,"
|
||
" result_text = NULL, error_text = NULL,"
|
||
" actual_model = NULL, updated_at = ?"
|
||
" WHERE id = ? AND status = 'running'",
|
||
(target, target, time.time(), task_id))
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
return False
|
||
|
||
def _restore_followup(self, fu_id):
|
||
"""取消时把跑中的后续消息放回 pending,留给重试入口。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE task_followups SET status = 'pending',"
|
||
" updated_at = ? WHERE id = ? AND status = 'running'",
|
||
(time.time(), fu_id))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _queue_self_followup(self, task, body):
|
||
"""给父任务自己排一条系统后续消息(标记工具报告 / 轮询快照)。
|
||
|
||
sender_task_id 置空,排空时不加“来自任务 X 的消息”前缀,
|
||
正文自带 [任务工具报告] 抬头即可区分。排空循环(run_one
|
||
的后续消息段)会在轮次边界认领它:父任务运行中直接跑下
|
||
一轮,父任务已终态时下次认领走续跑——与任务间消息的复活
|
||
语义同款,只是不经过 peer 的“不能发给自己”拒绝。"""
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
_ensure_followups_table(conn)
|
||
conn.execute(
|
||
"INSERT INTO task_followups(id, task_id, project_id,"
|
||
" body, mode, status, created_by, sender_task_id,"
|
||
" created_at, updated_at)"
|
||
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, NULL,"
|
||
" ?, ?)",
|
||
(uuid.uuid4().hex, task["id"],
|
||
task["project_id"], body, task.get("created_by"),
|
||
time.time(), time.time()),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
return False
|
||
|
||
def _poll_task_snapshot(self, task, poll):
|
||
"""轮询一个目标任务:每 interval 查一次库状态,直到终态、
|
||
超时或父任务被取消。等待期间不烧模型费,但占着本 worker
|
||
的槽位(与协调员等齐同款代价)。返回报告正文。"""
|
||
target_id = poll["task_id"]
|
||
interval = poll["interval_seconds"]
|
||
timeout = poll["timeout_seconds"]
|
||
chars = poll["chars"]
|
||
started = time.time()
|
||
snapshot = None
|
||
timed_out = False
|
||
cancelled = False
|
||
while True:
|
||
if self._was_cancelled(task["id"]):
|
||
cancelled = True
|
||
break
|
||
# 等候中父任务自己刷心跳:标记等待在 _run_attempt 返回后
|
||
# 才跑,那时 _running 已被清掉,run_loop 的心跳线程刷
|
||
# 不到它;不自刷的话,sweep(默认 300 秒)会在父任务
|
||
# 等到 3600 秒上限之前把它当死任务误杀。每次循环刷一次,
|
||
# 间隔至多 interval(钳制后 <=60 秒),远小于 300 秒阈值。
|
||
try:
|
||
self.heartbeat(task["id"])
|
||
except Exception:
|
||
pass
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
snapshot = _marker_snapshot(conn, target_id)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
snapshot = None
|
||
if snapshot is None:
|
||
return "轮询任务 %s:不存在(同项目内也查不到)。" % target_id
|
||
if snapshot["project_id"] != task["project_id"]:
|
||
return ("轮询任务 %s:拒绝,跨项目任务不可读。"
|
||
% target_id)
|
||
if target_id == task["id"]:
|
||
return "轮询任务 %s:拒绝,不能轮询自己。" % target_id
|
||
if snapshot["status"] in _MARKER_TERMINAL:
|
||
break
|
||
if timeout <= 0:
|
||
break
|
||
elapsed = time.time() - started
|
||
if elapsed >= timeout:
|
||
timed_out = True
|
||
break
|
||
time.sleep(min(interval, max(timeout - elapsed, 0.05)))
|
||
if snapshot is None:
|
||
return "轮询任务 %s:读取失败。" % target_id
|
||
return _marker_format(snapshot, chars, timed_out=timed_out,
|
||
cancelled=cancelled)
|
||
|
||
def _wait_task_targets(self, task, wait):
|
||
"""等一批目标任务:每 interval 并行查一次全部目标,直到
|
||
全部终态、超时或父任务被取消。缺失、跨项目、自等待的
|
||
目标当场记为已处理(写清原因),不让它们把整批拖到超时。
|
||
轮询/等待目标是只读的,goal 任务也可以等;取消与重试
|
||
的 goal 拒绝只在控制门禁里,不在这里。返回报告行列表。"""
|
||
target_ids = wait["task_ids"]
|
||
interval = wait["interval_seconds"]
|
||
timeout = wait["timeout_seconds"]
|
||
chars = wait["chars"]
|
||
started = time.time()
|
||
timed_out = False
|
||
cancelled = False
|
||
resolved = {} # task_id -> ("snapshot", snap) | ("note", text)
|
||
latest = {} # task_id -> 最近一次快照(超时报告用)
|
||
while True:
|
||
if self._was_cancelled(task["id"]):
|
||
cancelled = True
|
||
break
|
||
# 与 _poll_task_snapshot 同款:等候中父任务自己刷心跳,
|
||
# 否则 sweep(默认 300 秒)会误杀最长可等 3600 秒的父任务。
|
||
try:
|
||
self.heartbeat(task["id"])
|
||
except Exception:
|
||
pass
|
||
pending = [tid for tid in target_ids if tid not in resolved]
|
||
if not pending:
|
||
break
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
for tid in pending:
|
||
snap = _marker_snapshot(conn, tid)
|
||
if snap is None:
|
||
resolved[tid] = (
|
||
"note",
|
||
"等待任务 %s:不存在(同项目内也查不到)。"
|
||
% tid)
|
||
elif snap["project_id"] != task["project_id"]:
|
||
resolved[tid] = (
|
||
"note",
|
||
"等待任务 %s:拒绝,跨项目任务不可读。"
|
||
% tid)
|
||
elif tid == task["id"]:
|
||
resolved[tid] = (
|
||
"note",
|
||
"等待任务 %s:拒绝,不能等待自己。" % tid)
|
||
else:
|
||
latest[tid] = snap
|
||
if snap["status"] in _MARKER_TERMINAL:
|
||
resolved[tid] = ("snapshot", snap)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
if all(tid in resolved for tid in target_ids):
|
||
break
|
||
if timeout <= 0:
|
||
break
|
||
elapsed = time.time() - started
|
||
if elapsed >= timeout:
|
||
timed_out = True
|
||
break
|
||
time.sleep(min(interval, max(timeout - elapsed, 0.05)))
|
||
lines = ["[等待 %d 个任务]" % len(target_ids)]
|
||
for tid in target_ids:
|
||
kind, value = resolved.get(tid, (None, None))
|
||
if kind == "note":
|
||
lines.append(value)
|
||
elif kind == "snapshot":
|
||
lines.append(_marker_format(value, chars))
|
||
else:
|
||
snap = latest.get(tid)
|
||
if snap is None:
|
||
lines.append("等待任务 %s:读取失败。" % tid)
|
||
else:
|
||
lines.append(_marker_format(
|
||
snap, chars, timed_out=timed_out,
|
||
cancelled=cancelled))
|
||
if cancelled:
|
||
lines.append("说明:等待期间父任务被取消,等待已中止。")
|
||
elif timed_out:
|
||
lines.append("说明:等待超时,仍有目标未终态,可再等一次。")
|
||
return lines
|
||
|
||
def _marker_auth(self, task, action):
|
||
"""标记控制操作的权限层,返回 (ok_or_None, reason)。
|
||
|
||
ok=True:发送方创建人有该权限;False:明确无权限;
|
||
None:权限检查本身抛异常(查不到),调用方必须收紧到
|
||
仅派生链内可用,绝不能静默放宽。"""
|
||
creator = task.get("created_by")
|
||
if not creator:
|
||
return False, "发送方无创建人,无法查权限"
|
||
try:
|
||
store = AuthStore(self.db_path)
|
||
try:
|
||
ok = store.check_permission(
|
||
creator, task["project_id"], action)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
except Exception as exc:
|
||
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。
|
||
goal-convert 不在这里直接转:它排成一条“/goal <目标>”
|
||
的自后续消息,放在报告消息之后——排空先跑报告那轮
|
||
(agent 看到自动化等工具的确认),再跑 /goal 那条,
|
||
走已有的 followup /goal 转换路径(plan 冲突、CAS、
|
||
终局 queued 审计都在那条路里,不在这里另起一套)。
|
||
这样转 goal 后报告不会丢在 goal 循环外,也不会出现
|
||
两套转换逻辑打架。"""
|
||
if not result or result.get("status") != "completed":
|
||
return
|
||
if self._was_cancelled(task["id"]):
|
||
return
|
||
if task.get("verify_command") or task.get("goal_mode"):
|
||
return
|
||
if task.get("coordinator_parts"):
|
||
return
|
||
creates = result.get("task_creates") or []
|
||
polls = result.get("task_polls") or []
|
||
waits = result.get("wait_threads") or []
|
||
cancels = result.get("task_cancels") or []
|
||
retries = result.get("task_retries") or []
|
||
todo_lists = result.get("todo_lists") or []
|
||
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 []
|
||
goal_converts = result.get("goal_converts") or []
|
||
automation_creates = result.get("automation_creates") 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_costs or quota_status
|
||
or task_files or goal_converts or automation_creates):
|
||
return
|
||
reports = []
|
||
created = 0
|
||
rejected = 0
|
||
auto_created = 0
|
||
if creates:
|
||
quota_error = None
|
||
try:
|
||
quota_error = self._quota_exceeded(task, None)
|
||
except Exception as exc:
|
||
quota_error = "quota_check_failed: %s" % exc
|
||
if quota_error is not None:
|
||
reports.append("创建跳过:父任务配额检查未通过(%s),"
|
||
"本轮 %d 个创建块全部跳过。"
|
||
% (quota_error, len(creates)))
|
||
rejected += len(creates)
|
||
else:
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
generation = _marker_generation(conn, task["id"])
|
||
direct = _marker_count_direct(conn, task["id"])
|
||
now = time.time()
|
||
for create in creates:
|
||
new_depth = generation + 1
|
||
if new_depth > _MARKER_MAX_DEPTH:
|
||
reports.append(
|
||
"创建跳过:派生深度超限(上限 %d 代)。"
|
||
% _MARKER_MAX_DEPTH)
|
||
rejected += 1
|
||
continue
|
||
if direct >= _MARKER_MAX_DIRECT:
|
||
reports.append(
|
||
"创建跳过:直接派生子任务已达上限 %d 个。"
|
||
% _MARKER_MAX_DIRECT)
|
||
rejected += 1
|
||
continue
|
||
child_id = _marker_new_id()
|
||
# 派生模型/强度优先级:块里显式写 >
|
||
# 父任务的派生默认 child_model /
|
||
# child_effort > 父任务自身的
|
||
# model / effort > NULL(认领时走
|
||
# worker 默认)。子任务行上同时记下
|
||
# 父任务的派生默认,隔代派生不丢。
|
||
child_model = (create["model"]
|
||
or task.get("child_model")
|
||
or task.get("model"))
|
||
child_effort = (create["effort"]
|
||
or task.get("child_effort")
|
||
or task.get("effort"))
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt,"
|
||
" status, idempotency_key, depends_on,"
|
||
" priority, created_by, created_at,"
|
||
" updated_at, last_heartbeat, executor,"
|
||
" model, effort, environment_id,"
|
||
" parent_task_id, coordinator_part,"
|
||
" child_model, child_effort)"
|
||
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
|
||
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
|
||
" ?, ?)",
|
||
(child_id, task["project_id"],
|
||
create["prompt"],
|
||
task.get("priority", 0) or 0,
|
||
task.get("created_by"), now, now,
|
||
create["executor"]
|
||
or task.get("executor"),
|
||
child_model,
|
||
child_effort,
|
||
task.get("environment_id"),
|
||
task["id"],
|
||
task.get("child_model"),
|
||
task.get("child_effort")),
|
||
)
|
||
direct += 1
|
||
created += 1
|
||
label = create["name"] or "未命名"
|
||
model_note = ""
|
||
if child_model or child_effort:
|
||
model_note = ",模型 %s,强度 %s" % (
|
||
child_model or "默认",
|
||
child_effort or "默认")
|
||
reports.append(
|
||
"已创建任务 %s(%s),状态 queued%s,"
|
||
"可用 task-poll 按该 id 轮询。"
|
||
% (child_id, label, model_note))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
reports.append("创建失败:%s" % exc)
|
||
rejected += len(creates) - created
|
||
if automation_creates:
|
||
# 自动化创建:权限与配额是整轮门(与 task-create 同款,
|
||
# 配额超限时整轮跳过,不逐块半建);单块的首次时刻
|
||
# 已过去、重名等只拒该块。创建人跟父任务走,绝不
|
||
# 用 worker 自己的身份建——否则自动化到期产出的任务
|
||
# 会算到错误的人头上,配额门也绕过去了。
|
||
auth_ok, _auth_reason = self._marker_auth(
|
||
task, "create_task")
|
||
quota_error = None
|
||
try:
|
||
quota_error = self._quota_exceeded(task, None)
|
||
except Exception as exc:
|
||
quota_error = "quota_check_failed: %s" % exc
|
||
if auth_ok is not True:
|
||
reports.append(
|
||
"自动化创建跳过:发送方创建人无 create_task "
|
||
"权限或权限查不到,本轮 %d 个自动化块全部跳过。"
|
||
% len(automation_creates))
|
||
rejected += len(automation_creates)
|
||
elif quota_error is not None:
|
||
reports.append(
|
||
"自动化创建跳过:父任务配额检查未通过(%s),"
|
||
"本轮 %d 个自动化块全部跳过。"
|
||
% (quota_error, len(automation_creates)))
|
||
rejected += len(automation_creates)
|
||
else:
|
||
now = time.time()
|
||
for idx, block in enumerate(automation_creates):
|
||
first = block.get("first_run_at")
|
||
if first is not None and first < now - 60:
|
||
reports.append(
|
||
"自动化创建跳过:首次运行时刻已过去"
|
||
"(%s),请给一个未来时刻再写块。"
|
||
% block.get("prompt", "")[:40])
|
||
rejected += 1
|
||
continue
|
||
name = block.get("name") or (
|
||
"auto-%s-%d-%d"
|
||
% (task["id"][:8], int(now), idx))
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
auto = _auto_create(
|
||
conn, task["project_id"], name,
|
||
block["prompt"], task.get("created_by"),
|
||
block["interval_seconds"],
|
||
block.get("repo_url"), now=now,
|
||
first_run_at=first,
|
||
run_once=block.get("once", False))
|
||
bind = block.get("bind")
|
||
if bind:
|
||
try:
|
||
_life_bind(
|
||
conn, auto["id"],
|
||
task["project_id"], task["id"],
|
||
bind, now=now)
|
||
except Exception as exc:
|
||
reports.append(
|
||
"已创建自动化 %s(%s),但绑定"
|
||
"到本会话失败(%s),定义本身"
|
||
"仍有效,到期独立跑。"
|
||
% (auto["id"], name, exc))
|
||
auto_created += 1
|
||
continue
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
reports.append(
|
||
"自动化创建失败(%s):%s。"
|
||
% (block.get("prompt", "")[:40], exc))
|
||
rejected += 1
|
||
continue
|
||
auto_created += 1
|
||
nxt = auto.get("next_run_at")
|
||
nxt_text = (
|
||
time.strftime(
|
||
"%Y-%m-%d %H:%M", time.localtime(nxt))
|
||
if nxt else "不再到期")
|
||
reports.append(
|
||
"已创建自动化 %s(%s),间隔 %d 秒,"
|
||
"下次 %s%s,到期后由执行器经 claim 抢单"
|
||
"建任务%s。"
|
||
% (auto["id"], name,
|
||
block["interval_seconds"], nxt_text,
|
||
"(只跑一次)" if block.get("once") else "",
|
||
",已绑到本会话唤醒"
|
||
if block.get("bind") == "heartbeat" else ""))
|
||
for kind, blocks, action in (("取消", cancels, "create_task"),
|
||
("重试", retries, "approve_merge")):
|
||
for block in blocks:
|
||
target_id = block["task_id"]
|
||
try:
|
||
target = self.get_task(target_id)
|
||
except Exception as exc:
|
||
target = None
|
||
reports.append("%s任务 %s:读取失败(%s),已跳过。"
|
||
% (kind, target_id, exc))
|
||
rejected += 1
|
||
continue
|
||
relation = "other"
|
||
if target is not None:
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
relation = _marker_ancestry(
|
||
conn, task, target)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
relation = "other"
|
||
gate_kind = "cancel" if kind == "取消" else "retry"
|
||
ok, reason = _marker_gate(target, task, relation,
|
||
gate_kind)
|
||
if not ok:
|
||
reports.append("%s任务 %s:拒绝(%s)。"
|
||
% (kind, target_id, reason))
|
||
rejected += 1
|
||
continue
|
||
auth_ok, auth_reason = self._marker_auth(task, action)
|
||
if auth_ok is False:
|
||
reports.append("%s任务 %s:拒绝(发送方创建人无"
|
||
" %s 权限)。"
|
||
% (kind, target_id, action))
|
||
rejected += 1
|
||
continue
|
||
if auth_ok is None and relation != "descendant":
|
||
reports.append("%s任务 %s:拒绝(%s,仅派生链内"
|
||
"可用)。"
|
||
% (kind, target_id, auth_reason))
|
||
rejected += 1
|
||
continue
|
||
try:
|
||
if kind == "取消":
|
||
done = self.cancel(target_id)
|
||
if done:
|
||
reports.append("已取消任务 %s,状态为"
|
||
" interrupted。" % target_id)
|
||
else:
|
||
reports.append("取消任务 %s:未执行(目标"
|
||
"状态已变或不存在)。"
|
||
% target_id)
|
||
rejected += 1
|
||
else:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" result_text = NULL, error_text = NULL,"
|
||
" actual_model = NULL, updated_at = ?"
|
||
" WHERE id = ? AND status IN"
|
||
" ('failed', 'interrupted',"
|
||
" 'merge-failed')",
|
||
(time.time(), target_id))
|
||
conn.commit()
|
||
done = cur.rowcount == 1
|
||
finally:
|
||
conn.close()
|
||
if done:
|
||
self._audit(task["project_id"],
|
||
"worker_marker_retry",
|
||
target_id)
|
||
reports.append("已重试任务 %s,状态回"
|
||
" queued,上一轮结果已清空。"
|
||
% target_id)
|
||
else:
|
||
reports.append("重试任务 %s:未执行(目标"
|
||
"状态已变)。" % target_id)
|
||
rejected += 1
|
||
except Exception as exc:
|
||
reports.append("%s任务 %s:执行失败(%s)。"
|
||
% (kind, target_id, exc))
|
||
rejected += 1
|
||
# 待办标记块:读走 read、写走 create_task(与服务端
|
||
# /todos 口径同款);权限检查抛异常时(auth 为 None)
|
||
# 一律拒绝,不静默放宽。跨项目待办按“不存在”拒绝;
|
||
# 关联任务 id 跨项目时同样拒绝,避免把别的项目的任务
|
||
# 挂到本项目待办上。
|
||
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")
|
||
for block in todo_creates:
|
||
if write_ok is not True:
|
||
reports.append("创建待办“%s”:拒绝(发送方"
|
||
"创建人无 create_task 权限)。 "
|
||
% block["title"])
|
||
rejected += 1
|
||
continue
|
||
if block.get("task_id"):
|
||
target = self.get_task(block["task_id"])
|
||
if (target is None
|
||
or target.get("project_id")
|
||
!= task["project_id"]):
|
||
reports.append("创建待办“%s”:拒绝(关联"
|
||
"任务不存在或属于别的项目)。"
|
||
% block["title"])
|
||
rejected += 1
|
||
continue
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
todo = _todo_create_db(
|
||
conn, task["project_id"],
|
||
task.get("created_by"),
|
||
block["title"],
|
||
description=block.get("description"),
|
||
status=block.get("status"),
|
||
priority=block.get("priority"),
|
||
task_id=block.get("task_id"),
|
||
due_at=block.get("due_at"),
|
||
sort_order=block.get("sort_order"))
|
||
finally:
|
||
conn.close()
|
||
reports.append("已创建待办 %s"
|
||
% _format_todo(todo))
|
||
except Exception as exc:
|
||
reports.append("创建待办“%s”失败:%s"
|
||
% (block["title"], exc))
|
||
rejected += 1
|
||
for block in todo_updates:
|
||
if write_ok is not True:
|
||
reports.append("更新待办 %s:拒绝(发送方创建"
|
||
"人无 create_task 权限)。"
|
||
% block["id"])
|
||
rejected += 1
|
||
continue
|
||
fields = block["fields"]
|
||
if fields.get("task_id"):
|
||
target = self.get_task(fields["task_id"])
|
||
if (target is None
|
||
or target.get("project_id")
|
||
!= task["project_id"]):
|
||
reports.append("更新待办 %s:拒绝(关联"
|
||
"任务不存在或属于别的项目)。"
|
||
% block["id"])
|
||
rejected += 1
|
||
continue
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
todo = _todo_get_db(conn, block["id"])
|
||
if (todo is None
|
||
or todo.get("project_id")
|
||
!= task["project_id"]):
|
||
reports.append("更新待办 %s:不存在"
|
||
"(或属于别的项目),"
|
||
"已跳过。" % block["id"])
|
||
rejected += 1
|
||
continue
|
||
todo = _todo_update_db(conn, block["id"],
|
||
fields)
|
||
finally:
|
||
conn.close()
|
||
reports.append("已更新待办 %s"
|
||
% _format_todo(todo))
|
||
except Exception as exc:
|
||
reports.append("更新待办 %s 失败:%s"
|
||
% (block["id"], exc))
|
||
rejected += 1
|
||
for block in todo_lists:
|
||
if read_ok is not True:
|
||
reports.append("查待办:拒绝(发送方创建人无"
|
||
" read 权限)。")
|
||
rejected += 1
|
||
continue
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
if block.get("status"):
|
||
todos = _todo_list_db(
|
||
conn, task["project_id"],
|
||
status=block["status"],
|
||
q=block.get("q"))
|
||
else:
|
||
todos = [t for t in _todo_list_db(
|
||
conn, task["project_id"],
|
||
q=block.get("q"))
|
||
if t.get("status") in (
|
||
"pending", "in_progress",
|
||
"blocked")]
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
reports.append("查待办失败:%s" % exc)
|
||
rejected += 1
|
||
continue
|
||
if not todos:
|
||
reports.append("待办列表:无匹配项。")
|
||
else:
|
||
lines = ["待办列表(共 %d 条):" % len(todos)]
|
||
for todo in todos[:20]:
|
||
lines.append(_format_todo(todo))
|
||
if len(todos) > 20:
|
||
lines.append("(还有 %d 条未列出。)"
|
||
% (len(todos) - 20))
|
||
reports.append("\n".join(lines))
|
||
for block in task_lists:
|
||
if read_ok is not True:
|
||
reports.append("查任务列表:拒绝(发送方创建"
|
||
"人无 read 权限)。")
|
||
rejected += 1
|
||
continue
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT * FROM tasks"
|
||
" WHERE project_id = ?"
|
||
" ORDER BY created_at DESC",
|
||
(task["project_id"],)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
reports.append("查任务列表失败:%s" % exc)
|
||
rejected += 1
|
||
continue
|
||
tasks = [_task_from_row(row) if not isinstance(
|
||
row, dict) else row for row in rows]
|
||
counts = {}
|
||
for t in tasks:
|
||
counts[t.get("status")] = counts.get(
|
||
t.get("status"), 0) + 1
|
||
if block.get("status"):
|
||
tasks = [t for t in tasks
|
||
if t.get("status") == block["status"]]
|
||
if block.get("q"):
|
||
ql = block["q"].lower()
|
||
tasks = [t for t in tasks
|
||
if ql in (t.get("prompt") or "").lower()]
|
||
if not tasks:
|
||
reports.append("任务列表:无匹配项。")
|
||
else:
|
||
lines = ["任务列表(共 %d 条,状态计数:%s):"
|
||
% (len(tasks), ", ".join(
|
||
"%s=%d" % kv for kv in sorted(
|
||
counts.items())))]
|
||
for t in tasks[:block["limit"]]:
|
||
lines.append(_format_task(t))
|
||
if len(tasks) > block["limit"]:
|
||
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))
|
||
except Exception as exc:
|
||
reports.append("轮询任务 %s 失败:%s"
|
||
% (poll["task_id"], exc))
|
||
for wait in waits:
|
||
try:
|
||
reports.extend(self._wait_task_targets(task, wait))
|
||
except Exception as exc:
|
||
reports.append("等待任务失败:%s" % exc)
|
||
if reports:
|
||
body = _marker_header() + "\n" + "\n".join(reports)
|
||
self._queue_self_followup(task, body)
|
||
if goal_converts:
|
||
# 顺序关键:报告消息先排、/goal 消息后排,排空先跑
|
||
# 报告那轮、再转 goal;反过来排会先转 goal,报告
|
||
# 就送不到 goal 循环里了。plan 冲突等拒绝由排空的
|
||
# /goal 路径处理(标 failed 停排空),这里不预判。
|
||
goal_target = goal_converts[0]["goal"]
|
||
self._queue_self_followup(
|
||
task, "/goal %s" % goal_target)
|
||
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 "
|
||
"task_costs=%d quota=%d task_files=%d "
|
||
"automations=%d goal_converts=%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(task_costs), len(quota_status),
|
||
len(task_files), auto_created,
|
||
len(goal_converts)))
|
||
self._audit(task["project_id"], "worker_marker_tools", detail)
|
||
|
||
def _deliver_peer_messages(self, task, result):
|
||
"""投递本轮 agent 输出里的互发消息标记块(见 peer_messages)。
|
||
|
||
只投递 completed 轮次的块:失败 / 超时 / 取消 / 配额中断
|
||
的轮次不投递;任务执行期间被取消时也不投递。投递本身
|
||
不中断任何任务:对方运行中/排队中排成后续消息(轮次边
|
||
界执行),对方终态则续跑复活;拒绝(自己、跨项目、
|
||
goal 目标、已合并、复活位被占等)只记审计,不炸 run_one。
|
||
投递不查发送方配额(发送本身不花钱),目标任务执行时
|
||
的配额门照常生效——互发消息不构成配额绕过。"""
|
||
if not result or result.get("status") != "completed":
|
||
return
|
||
if self._was_cancelled(task["id"]):
|
||
return
|
||
messages = result.get("task_messages") or []
|
||
if not messages:
|
||
return
|
||
delivered = 0
|
||
rejected = []
|
||
for msg in messages:
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
status, detail = _deliver_peer_message(
|
||
conn, task["id"], msg["to_task_id"],
|
||
msg["body"], msg["mode"])
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
status, detail = "rejected", "delivery error: %s" % exc
|
||
if status in ("delivered", "revived"):
|
||
delivered += 1
|
||
else:
|
||
rejected.append("%s: %s" % (msg["to_task_id"], detail))
|
||
detail = "%s: delivered=%d rejected=%d" % (
|
||
task["id"], delivered, len(rejected))
|
||
if rejected:
|
||
detail += " (" + "; ".join(rejected) + ")"
|
||
self._audit(task["project_id"], "worker_peer_messages", detail)
|
||
|
||
def _docker_call_with_quota_poll(self, task, fn, quota_check=None):
|
||
"""Docker 同步调用包一层配额轮询(G8),与 popen 路径同款。
|
||
|
||
fn 在后台线程里跑(DockerExecutor 的调用本身是阻塞的
|
||
docker run,无法像 popen 那样在主循环里轮询);主循环
|
||
每 quota_poll_interval_seconds(缺省 30 秒)调一次
|
||
quota_check,确认超限时按任务容器名真杀容器,fn 返回
|
||
后把结果状态改写成内部标记 'quota_exceeded',调用方
|
||
(run_one)据此走统一的配额中断收尾:状态 interrupted、
|
||
已花成本照记、续跑消息保留。quota_check 为 None 时
|
||
直接调 fn,不起线程。quota_check 本身抛异常时按
|
||
fail-open 处理(只记 warning,不杀容器),只有确认
|
||
超限才杀;取消时 cancel() 已按容器名杀过容器,线程
|
||
随之结束,轮询循环不再多杀一次。fn 抛异常时在主线程
|
||
原样重抛,由 _run_attempt 外层的 except 写成 failed,
|
||
与不包轮询时的行为一致。注意:轮询查的是已记账用量
|
||
(与 popen 路径同款),在跑回合的 token 只在回合结束
|
||
才回传,所以掐断粒度仍是轮询间隔,不是 token 级。
|
||
"""
|
||
if quota_check is None:
|
||
return fn()
|
||
box = {}
|
||
|
||
def _target():
|
||
try:
|
||
box["result"] = fn()
|
||
except Exception as exc: # noqa: BLE001 -- 主线程重抛
|
||
box["error"] = exc
|
||
|
||
thread = threading.Thread(target=_target, daemon=True)
|
||
thread.start()
|
||
interval = getattr(self, "quota_poll_interval_seconds", 30)
|
||
if interval is None:
|
||
interval = 30
|
||
last_check = 0.0
|
||
quota_hit = False
|
||
while thread.is_alive():
|
||
if task["id"] in self._cancel_requested or self._loop_stop:
|
||
break
|
||
now = time.time()
|
||
if now - last_check >= interval:
|
||
last_check = now
|
||
try:
|
||
over = bool(quota_check())
|
||
except Exception as exc:
|
||
try:
|
||
self.logger.warning(
|
||
"docker quota poll failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
over = False
|
||
if over:
|
||
quota_hit = True
|
||
container = None
|
||
try:
|
||
container = self.executor.container_name(task)
|
||
except Exception:
|
||
container = None
|
||
if container is not None and hasattr(
|
||
self.executor, "kill_container"):
|
||
try:
|
||
self.executor.kill_container(container)
|
||
except Exception:
|
||
pass
|
||
break
|
||
thread.join(timeout=0.05)
|
||
thread.join()
|
||
if "error" in box:
|
||
raise box["error"]
|
||
result = box.get("result")
|
||
if quota_hit and isinstance(result, dict):
|
||
result = dict(result, status="quota_exceeded")
|
||
return result
|
||
|
||
def _run_attempt(self, task, quota_check=None):
|
||
"""单次执行尝试(不含认领、key 门、重试、成本与最终审计)。
|
||
|
||
返回 (result, error):result 是执行器结果 dict,error 是
|
||
runner 异常时的文本(无异常时 None)。Docker 路径下把
|
||
worker 级 task_timeout 与执行器级 docker_timeout 取较小
|
||
值作为本次 docker run 超时(docker_timeout 未设时取
|
||
task_timeout);Local popen 路径下把 task_timeout 作为
|
||
轮询截止时间;Local 同步路径无法中途抢占,超时只能靠
|
||
执行器自身 timeout(见模块 docstring 的诚实边界)。
|
||
调用方负责:取消优先、超时转 failed、重试、成本与审计。
|
||
"""
|
||
task_id = task["id"]
|
||
result = None
|
||
error = None
|
||
# goal 模式:goal_mode=1(新语义,prompt 即目标)或带
|
||
# verify_command(旧语义)时走 host.py goal 循环,不走
|
||
# 普通 dispatch。goal 循环内部已多轮重试,worker 级
|
||
# 的已存在续跑(run)对它没有意义(goal 没有 run 语义)。
|
||
goal_mode = bool(task.get("goal_mode")
|
||
or task.get("verify_command"))
|
||
# 附件 seed:dispatch/goal 首轮才用;run 续跑不传(文件已
|
||
# 在 worktree 里)。cloud 执行器没有 worktree 可 seed,
|
||
# 创建时本应已拒绝,这里是兜底防线(直接失败,不静默跑)。
|
||
seed_host_dir = self._task_seed_dir(task)
|
||
if seed_host_dir and isinstance(self.executor, CloudExecutor):
|
||
return ({"executor": "cloud", "task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None, "host_status": None,
|
||
"stderr": ""},
|
||
"cloud 执行器不支持附件")
|
||
# Docker 同步路径:dispatch 阻塞在 docker run 里时,取消
|
||
# 线程靠容器名杀容器(见 cancel())。容器名按任务派生,
|
||
# 与执行器本身无关,所以这里只记名不碰执行器状态。
|
||
if hasattr(self.executor, "container_name"):
|
||
try:
|
||
self._running_set(
|
||
task_id,
|
||
container=self.executor.container_name(task))
|
||
except Exception:
|
||
pass
|
||
# 长任务不限时:任务带 no_time_limit=1 时,本次 worker
|
||
# 级与 Docker 级超时一律按 None 算(不限时),不改全局
|
||
# task_timeout(其它任务仍走默认 1800 秒);host 单轮
|
||
# turn_timeout(默认 600 秒)不在 worker 侧传参,仍是
|
||
# 单轮护栏——不限的是任务总墙钟与总轮次,不是单轮无限。
|
||
_no_time_limit = bool(task.get("no_time_limit"))
|
||
_effective_task_timeout = None if _no_time_limit \
|
||
else self.task_timeout
|
||
# Docker 超时取较小值:两个超时都不应静默延长另一个。
|
||
# 注意绝不能改共享的 executor.timeout(并发下两个任务会
|
||
# 互相覆盖对方的超时);超时只作为本次调用的参数传下去。
|
||
effective_docker_timeout = None
|
||
is_docker = isinstance(self.executor, DockerExecutor)
|
||
if is_docker and _effective_task_timeout is not None:
|
||
if self.executor.timeout is None:
|
||
effective_docker_timeout = _effective_task_timeout
|
||
else:
|
||
try:
|
||
effective_docker_timeout = min(
|
||
float(_effective_task_timeout),
|
||
float(self.executor.timeout))
|
||
except (TypeError, ValueError):
|
||
effective_docker_timeout = _effective_task_timeout
|
||
# P0 安全批后:Docker 同步路径起取消看门狗线程
|
||
# (阻塞在 docker run 里时,库状态变 interrupted 就
|
||
# 按容器名真杀;非 Docker 路径不启动,避免空线程)。
|
||
_watchdog_stop = None
|
||
_watchdog_thread = None
|
||
_quota_watchdog_thread = None
|
||
# P0 三件其一:goal 任务起断点镜像线程(任何执行器);
|
||
# 非 goal 任务没有断点语义,不起。终局镜像在 finally 补。
|
||
_mirror_stop = None
|
||
_mirror_thread = None
|
||
if goal_mode:
|
||
try:
|
||
_mirror_stop = threading.Event()
|
||
_mirror_thread = threading.Thread(
|
||
target=self._checkpoint_mirror_loop,
|
||
args=(task, _mirror_stop), daemon=True)
|
||
_mirror_thread.start()
|
||
except Exception:
|
||
_mirror_stop = None
|
||
_mirror_thread = None
|
||
if is_docker and hasattr(self.executor, "kill_container"):
|
||
try:
|
||
_container_name = self.executor.container_name(task)
|
||
except Exception:
|
||
_container_name = None
|
||
if _container_name:
|
||
_watchdog_stop = threading.Event()
|
||
_watchdog_thread = threading.Thread(
|
||
target=self._docker_cancel_watchdog,
|
||
args=(task_id, _container_name, _watchdog_stop),
|
||
daemon=True)
|
||
_watchdog_thread.start()
|
||
# P1 配额看门狗:只在同步 Docker 路径起(进程
|
||
# 模式的 _execute_via_process 已每 30 秒轮询
|
||
# quota_check,不重复起)。quota_check 为 None
|
||
# 时不起(无配额检查可做,不空转线程)。
|
||
if quota_check is not None and not self._use_popen():
|
||
_quota_watchdog_thread = threading.Thread(
|
||
target=self._docker_quota_watchdog,
|
||
args=(task_id, _container_name,
|
||
_watchdog_stop, quota_check),
|
||
daemon=True)
|
||
_quota_watchdog_thread.start()
|
||
# 启动前分段日志(2026-10-29 任务 A):认领后 61 秒中断,
|
||
# 但阻塞的 docker run 中途没有任何标记、容器 --rm 后输出
|
||
# 也没了,事后完全无法定位卡在哪一段。派发开始/结束进
|
||
# 审计(PG 落盘,容器没了也丢不了),下次卡死时最后一个
|
||
# 标记直接说明卡在哪一段;kind/容器名/超时不含 prompt/key。
|
||
_exec_kind = ("goal-resume-breakpoint"
|
||
if task.get("_goal_resume_breakpoint")
|
||
else "goal-resume" if task.get("_resume")
|
||
else "goal" if goal_mode else "dispatch")
|
||
_exec_container = None
|
||
if hasattr(self.executor, "container_name"):
|
||
try:
|
||
_exec_container = self.executor.container_name(task)
|
||
except Exception:
|
||
_exec_container = None
|
||
_exec_t0 = time.time()
|
||
_exec_error = None
|
||
_exec_detail = "task_id=%s kind=%s executor=%s timeout=%s" % (
|
||
task_id, _exec_kind, self._executor_name(),
|
||
"None" if _effective_task_timeout is None
|
||
else _effective_task_timeout)
|
||
if _exec_container:
|
||
_exec_detail += " container=%s" % _exec_container
|
||
self._audit(task.get("project_id"), "worker_exec_start",
|
||
_exec_detail)
|
||
log_event(self.logger, "worker_exec_start", task_id=task_id,
|
||
message="worker exec start: %s" % _exec_detail)
|
||
try:
|
||
gateway_provider, gateway_env = self._ensure_gateway(task)
|
||
if self._use_popen():
|
||
if goal_mode and task.get("_goal_resume_breakpoint"):
|
||
# G10 断点:无 --goal-text,不开新循环。
|
||
popen_kind = "goal-resume-breakpoint"
|
||
elif goal_mode and task.get("_resume"):
|
||
popen_kind = "goal-resume"
|
||
elif goal_mode:
|
||
popen_kind = "goal"
|
||
else:
|
||
popen_kind = "dispatch"
|
||
result = self._execute_via_process(
|
||
task, kind=popen_kind,
|
||
deadline_seconds=_effective_task_timeout,
|
||
seed_host_dir=seed_host_dir,
|
||
quota_check=quota_check)
|
||
combined = (result.get("stdout", "") or "") + \
|
||
(result.get("stderr", "") or "")
|
||
if not goal_mode and result["status"] not in (
|
||
"completed", "cancelled", "timeout",
|
||
"quota_exceeded") and \
|
||
_ALREADY_EXISTS_MARKER in combined:
|
||
result = self._execute_via_process(
|
||
task, kind="run",
|
||
deadline_seconds=_effective_task_timeout,
|
||
quota_check=quota_check)
|
||
else:
|
||
if is_docker:
|
||
(ok, tenant_mounts, tenant_network, proxy_env,
|
||
prep_err) = self._docker_task_context(task)
|
||
if not ok:
|
||
# 提前返回会跳过下面的 finally(清容器
|
||
# 标记),这里先手动清掉,否则 _running
|
||
# 里永远残留该任务,心跳与退出清理
|
||
# 都会把它当在跑任务。
|
||
self._running_clear(task_id)
|
||
return ({"executor": "docker",
|
||
"task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None, "host_status": None,
|
||
"stderr": ""},
|
||
prep_err)
|
||
if goal_mode:
|
||
if task.get("_goal_resume_breakpoint"):
|
||
# G10 断点:Docker 路用无 --goal-text 的
|
||
# goal-resume-breakpoint 命令。
|
||
result = self._docker_call_with_quota_poll(
|
||
task,
|
||
lambda: self.executor.goal_resume_breakpoint_task(
|
||
task,
|
||
timeout=effective_docker_timeout,
|
||
extra_mounts=tenant_mounts,
|
||
network=tenant_network,
|
||
extra_env=_merge_env(proxy_env,
|
||
gateway_env),
|
||
model_provider=gateway_provider),
|
||
quota_check)
|
||
elif task.get("_resume"):
|
||
# P1 断点续跑传 None(不传 --goal-text,
|
||
# host 沿用原 goal+verify);开新目标
|
||
# 才传本轮 prompt 当新 goal_text。
|
||
_resume_text = None if task.get(
|
||
"_resume_kind") == "breakpoint" \
|
||
else task["prompt"]
|
||
result = self._docker_call_with_quota_poll(
|
||
task,
|
||
lambda: self.executor.goal_resume_task(
|
||
task, _resume_text,
|
||
timeout=effective_docker_timeout,
|
||
extra_mounts=tenant_mounts,
|
||
network=tenant_network,
|
||
extra_env=_merge_env(proxy_env,
|
||
gateway_env),
|
||
model_provider=gateway_provider),
|
||
quota_check)
|
||
else:
|
||
result = self._docker_call_with_quota_poll(
|
||
task,
|
||
lambda: self.executor.goal_task(
|
||
task, timeout=effective_docker_timeout,
|
||
extra_mounts=tenant_mounts,
|
||
network=tenant_network,
|
||
extra_env=_merge_env(proxy_env,
|
||
gateway_env),
|
||
seed_host_dir=seed_host_dir,
|
||
model_provider=gateway_provider),
|
||
quota_check)
|
||
else:
|
||
result = self._docker_call_with_quota_poll(
|
||
task,
|
||
lambda: self.executor.dispatch_task(
|
||
task, timeout=effective_docker_timeout,
|
||
extra_mounts=tenant_mounts,
|
||
network=tenant_network,
|
||
extra_env=_merge_env(proxy_env,
|
||
gateway_env),
|
||
seed_host_dir=seed_host_dir,
|
||
model_provider=gateway_provider),
|
||
quota_check)
|
||
else:
|
||
# seed 参数只传给 LocalExecutor:CloudExecutor
|
||
# 基类与测试假执行器的 goal_task/dispatch_task
|
||
# 只有 (task) 一个参数,多传 seed_dir 会直接
|
||
# TypeError(2026-10-27 实测炸掉 3 个旧测试);
|
||
# Docker 路径上面已有 is_docker 守卫,不走这里。
|
||
# 网关任务在非 Local 执行器上无法注入 key 与
|
||
# provider,必须 loud fail,不静默跑错网关。
|
||
if gateway_provider and not isinstance(
|
||
self.executor, LocalExecutor):
|
||
raise RuntimeError(
|
||
"sub2api 网关不支持该执行器:%s"
|
||
% type(self.executor).__name__)
|
||
if goal_mode:
|
||
|
||
if task.get("_goal_resume_breakpoint") and isinstance(
|
||
self.executor, LocalExecutor):
|
||
result = self.executor.goal_resume_breakpoint_task(
|
||
task, model_provider=gateway_provider)
|
||
elif task.get("_goal_resume_breakpoint"):
|
||
# 假执行器/CloudExecutor 没有断点方法时
|
||
# 绝不回落成 goal(会撞已存在)或
|
||
# goal-resume --goal-text(会开新循环):
|
||
# 明确失败,等支持断点的执行器再跑。
|
||
result = {"executor": self._executor_name(),
|
||
"task_id": task_id,
|
||
"status": "failed", "stdout": "",
|
||
"returncode": None,
|
||
"host_status": None, "stderr": "",
|
||
"goal_met": None,
|
||
"goal_error": "executor 不支持"
|
||
" goal 断点续跑"}
|
||
error = "executor 不支持 goal 断点续跑"
|
||
elif task.get("_resume") and isinstance(
|
||
self.executor, LocalExecutor):
|
||
_resume_text = None if task.get(
|
||
"_resume_kind") == "breakpoint" \
|
||
else task["prompt"]
|
||
result = self.executor.goal_resume_task(
|
||
task, _resume_text,
|
||
extra_env=gateway_env,
|
||
model_provider=gateway_provider)
|
||
elif isinstance(self.executor, LocalExecutor):
|
||
result = self.executor.goal_task(
|
||
task, seed_dir=seed_host_dir,
|
||
extra_env=gateway_env,
|
||
model_provider=gateway_provider)
|
||
else:
|
||
result = self.executor.goal_task(task)
|
||
else:
|
||
if isinstance(self.executor, LocalExecutor):
|
||
result = self.executor.dispatch_task(
|
||
task, seed_dir=seed_host_dir,
|
||
extra_env=gateway_env,
|
||
model_provider=gateway_provider)
|
||
else:
|
||
result = self.executor.dispatch_task(task)
|
||
combined = (result.get("stdout", "") or "") + \
|
||
(result.get("stderr", "") or "")
|
||
if not goal_mode and result["status"] not in (
|
||
"completed", "quota_exceeded") and \
|
||
_ALREADY_EXISTS_MARKER in combined:
|
||
if is_docker:
|
||
result = self._docker_call_with_quota_poll(
|
||
task,
|
||
lambda: self.executor.run_task(
|
||
task, timeout=effective_docker_timeout,
|
||
extra_mounts=tenant_mounts
|
||
if self.tenant_isolation else None,
|
||
network=tenant_network
|
||
if self.tenant_isolation else None,
|
||
extra_env=_merge_env(
|
||
proxy_env if self.egress_proxy_port
|
||
else None, gateway_env),
|
||
model_provider=gateway_provider),
|
||
quota_check)
|
||
else:
|
||
if gateway_provider and not isinstance(
|
||
self.executor, LocalExecutor):
|
||
raise RuntimeError(
|
||
"sub2api 网关不支持该执行器:%s"
|
||
% type(self.executor).__name__)
|
||
result = self.executor.run_task(
|
||
task, extra_env=gateway_env,
|
||
model_provider=gateway_provider) \
|
||
if isinstance(self.executor, LocalExecutor) \
|
||
else self.executor.run_task(task)
|
||
except Exception as exc: # runner 异常也如实写回 failed
|
||
result = {"executor": "local", "task_id": task_id,
|
||
"status": "failed", "stdout": "", "returncode": None,
|
||
"host_status": None, "stderr": ""}
|
||
error = str(exc)
|
||
_exec_error = exc
|
||
finally:
|
||
if _watchdog_stop is not None:
|
||
_watchdog_stop.set()
|
||
if _watchdog_thread is not None:
|
||
_watchdog_thread.join(timeout=2)
|
||
if _quota_watchdog_thread is not None:
|
||
_quota_watchdog_thread.join(timeout=2)
|
||
if _mirror_stop is not None:
|
||
_mirror_stop.set()
|
||
if _mirror_thread is not None:
|
||
_mirror_thread.join(timeout=2)
|
||
# 终局镜像:执行结束时的断点(最后一轮)必须落库,
|
||
# 否则中断后库断点比实际少一轮;失败不影响结果写回。
|
||
try:
|
||
self._mirror_checkpoint_once(task)
|
||
except Exception:
|
||
pass
|
||
# 同步路径执行结束:清掉取消用的容器标记(取消路径里
|
||
# 若已清过,这里再清一次无害)。进程模式的 proc 由
|
||
# _execute_via_process 自己的 finally 清,这里只清
|
||
# 容器项:_running_clear 整个删掉该任务项会连 proc
|
||
# 也一起丢,所以这里只在 proc 为空时清容器标记——
|
||
# 实际做法是:如果该任务项里没有 proc,就整项清除;
|
||
# 有 proc(进程模式)时不动它,等它自己的 finally。
|
||
entry = self._running_snapshot().get(task_id) or {}
|
||
if entry.get("proc") is None:
|
||
self._running_clear(task_id)
|
||
# 启动前分段日志(续):派发结束标记与失败输出尾巴。结束
|
||
# 标记与开始标记配对:只有开始没有结束 = 卡在执行器调用
|
||
# 里;失败/中断时的容器输出尾巴记进 worker 日志——任务 A
|
||
# 当时这部分输出随 --rm 消失,是事后无法定位的另一半。
|
||
_exec_elapsed_ms = int((time.time() - _exec_t0) * 1000)
|
||
if _exec_error is not None:
|
||
self._audit(task.get("project_id"), "worker_exec_error",
|
||
"task_id=%s elapsed_ms=%d error=%s"
|
||
% (task_id, _exec_elapsed_ms,
|
||
_exec_error.__class__.__name__))
|
||
log_event(self.logger, "worker_exec_error", task_id=task_id,
|
||
message="worker exec error: elapsed_ms=%d error=%s"
|
||
% (_exec_elapsed_ms,
|
||
_exec_error.__class__.__name__))
|
||
else:
|
||
self._audit(task.get("project_id"), "worker_exec_done",
|
||
"task_id=%s elapsed_ms=%d status=%s returncode=%s"
|
||
% (task_id, _exec_elapsed_ms, result.get("status"),
|
||
result.get("returncode")))
|
||
log_event(self.logger, "worker_exec_done", task_id=task_id,
|
||
status=result.get("status"),
|
||
message="worker exec done: elapsed_ms=%d status=%s"
|
||
% (_exec_elapsed_ms, result.get("status")))
|
||
_exec_out = result.get("stdout", "") or ""
|
||
if result.get("status") in ("failed", "interrupted") and _exec_out:
|
||
self.logger.info(
|
||
"worker_exec_stdout_tail task_id=%s tail=%s",
|
||
task_id, _exec_out[-2000:])
|
||
# Docker 超时标记:执行器已在超时时 kill+rm 容器并返回
|
||
# failed(stdout 含 timeout),这里把状态统一成 timeout,
|
||
# 调用方据此置 failed 并记 worker_timeout 审计。
|
||
if result is not None and result.get("status") == "failed" and \
|
||
"docker run timeout" in (result.get("stdout", "") or ""):
|
||
result = dict(result, status="timeout")
|
||
# P1 配额看门狗命中改写:同步路径被看门狗杀容器后,
|
||
# 执行器只返回普通 failed,run_one 无法区分“模型失败”
|
||
# 与“配额杀容器”——命中时改写成 quota_exceeded,run_one
|
||
# 走统一配额中断收尾(interrupted + 配额原因 + 成本照记,
|
||
# 不重试、不续跑下一轮)。改写前先取走命中标记,避免
|
||
# 旧命中污染同一任务的后续尝试;超时标记优先于配额
|
||
# 改写的情况不存在(看门狗杀容器时执行器不会同时报超时)。
|
||
if result is not None and self._take_quota_hit(task_id):
|
||
result = dict(result, status="quota_exceeded")
|
||
if error is None:
|
||
error = ("quota_exceeded: Docker 配额看门狗已杀容器"
|
||
"(配额中断,下一轮不再续跑)")
|
||
return result, error
|
||
|
||
def _attempt_error(self, result, error, token):
|
||
"""一次尝试失败的可读原因(供 fallback 两次原因拼接用)。
|
||
|
||
超时用统一的超时文本;runner 异常用异常文本;同步路径的
|
||
stdout 是 host.py 的 JSON 时取它的 text 字段(比整段 JSON
|
||
可读),执行器已提取出 text 时直接用该文本;最后退到
|
||
host_status token 或通用文本。绝不返回
|
||
任何密钥(stdout 里本就不应含密钥,text 字段同样不含)。
|
||
"""
|
||
if result is not None and result.get("status") == "timeout":
|
||
return "task timeout after %s seconds" % self.task_timeout
|
||
if error:
|
||
return error
|
||
stdout = (result or {}).get("stdout", "") or ""
|
||
try:
|
||
parsed = json.loads(stdout)
|
||
except (TypeError, ValueError):
|
||
parsed = None
|
||
if isinstance(parsed, dict) and parsed.get("text"):
|
||
return str(parsed["text"])
|
||
# dispatch 输出现在带 error 字段(host.py 从 thread_history
|
||
# 库里捞回的失败原因,如 404);text 为空时优先用它,
|
||
# 否则两次失败的拼接里只剩 host_status=404 这种代号。
|
||
if isinstance(parsed, dict) and parsed.get("error"):
|
||
err = parsed["error"]
|
||
if isinstance(err, dict):
|
||
err = err.get("message") or str(err)
|
||
return str(err)[:1000]
|
||
# host.py 崩溃时 stdout 常为空、真正的报错在 stderr(例如
|
||
# “缺少 --repo”、Read-only file system);只用 stdout 时
|
||
# 最终错误会被写成通用的 host execution failed,排障无从
|
||
# 下手,所以 stdout 为空时取 stderr 尾部(实际报错行在尾)。
|
||
stderr = (result or {}).get("stderr", "") or ""
|
||
if not stdout.strip() and stderr.strip():
|
||
return stderr.strip()[-1000:]
|
||
# 执行器现在返回的是解析出的 text(不再是整段 JSON):
|
||
# JSON 解析失败且 stdout 非空时,stdout 本身就是报错
|
||
# 文本,绝不能丢掉它退到 host_status=404 这种代号
|
||
# (2026-10-30 全量因此丢了 "primary 404 boom")。
|
||
if stdout.strip():
|
||
return stdout.strip()[:1000]
|
||
if token is not None:
|
||
return "host_status=%s" % token
|
||
return stdout.strip() or "host execution failed"
|
||
|
||
def _fallback_token(self, result, error):
|
||
"""一次失败尝试的可 fallback 信号。
|
||
|
||
host_status 的真实含义是 host.py 回合状态(completed /
|
||
failed / timeout / interrupted),不是 HTTP 状态码——
|
||
真实世界里 host.py 从不返回 status=\"404\",只有单测里的
|
||
假 runner 才会这么写。所以这里不能把 host_status 原样
|
||
当 token:host_status 本身已是可 fallback 码(404/429/
|
||
5xx/timeout,含历史假 runner 形态)时直接返回;否则
|
||
(最常见:host_status=\"failed\")从回合 JSON 的 text/error
|
||
字段、stdout、stderr、runner 异常里找显式 HTTP 状态码
|
||
或“model not found”类文本(按 404 计),找到且可
|
||
fallback 才返回;找不到显式信号时返回 None,绝不凭
|
||
“host execution failed”这类通用文本猜测,否则任何执行
|
||
器故障都会被误当成模型故障换模型烧额度。thread/start
|
||
崩溃(host.py 非 0 退出、不打印 JSON)时 host_status
|
||
为 None,同样走这条提取路径。
|
||
"""
|
||
host_status = (result or {}).get("host_status")
|
||
if host_status is not None and is_fallbackable(host_status):
|
||
return host_status
|
||
combined_parts = [
|
||
(result or {}).get("stdout", "") or "",
|
||
(result or {}).get("stderr", "") or "",
|
||
error or "",
|
||
]
|
||
combined = " ".join(combined_parts)
|
||
if not combined:
|
||
return None
|
||
lowered = combined.lower()
|
||
if "model not found" in lowered or "unknown model" in lowered \
|
||
or "no such model" in lowered:
|
||
return "404"
|
||
match = re.search(
|
||
r"(?:http/|status[\"':\s]{0,10}|code[\"':\s]{0,10}|error[\"':\s]{0,10})"
|
||
r"(404|429|500|502|503|504)\b",
|
||
combined, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
match = re.search(r"\b(404|429|500|502|503|504)\b", combined)
|
||
if match:
|
||
return match.group(1)
|
||
return None
|
||
|
||
# -- 协调员 ---------------------------------------------------------
|
||
def _coord_sync_children(self, task, parts):
|
||
"""按分工建/复用子任务,返回 [{name, id}]。
|
||
|
||
已有子任务按 coordinator_part(分工名)匹配:已完成的直接
|
||
复用;失败/中断的回 queued 重跑(旧输出清掉);未终态的
|
||
不动(正被别的 worker 跑或排队);缺失的才新建。重跑协调
|
||
员因此不会重复建子任务。子任务是普通任务:执行器过滤、
|
||
配额门、key 门都在子任务认领时照常生效。
|
||
"""
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
_coord_ensure_columns(conn)
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
rows = conn.execute(
|
||
"SELECT id, coordinator_part, status FROM tasks"
|
||
" WHERE parent_task_id = ?",
|
||
(task["id"],)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
existing = {}
|
||
for row in rows:
|
||
try:
|
||
existing[row["coordinator_part"]] = {
|
||
"id": row["id"], "status": row["status"]}
|
||
except (KeyError, IndexError, TypeError):
|
||
continue
|
||
now = time.time()
|
||
children = []
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
for part in parts:
|
||
name = part["name"]
|
||
prev = existing.get(name)
|
||
if prev is not None:
|
||
if prev["status"] in TERMINAL_STATUSES and \
|
||
prev["status"] != "completed":
|
||
conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" result_text = NULL, error_text = NULL,"
|
||
" updated_at = ? WHERE id = ?",
|
||
(now, prev["id"]))
|
||
self._audit(
|
||
task["project_id"],
|
||
"coordinator_child_requeued",
|
||
"%s -> %s" % (name, prev["id"]))
|
||
children.append({"name": name, "id": prev["id"]})
|
||
continue
|
||
# 自动化入闸(创建时门):子任务认领时虽有配额门,
|
||
# 但不建任务才是扇出不烧钱的硬保证。父任务创建人
|
||
# 超配额时跳过建这个子任务、记审计,父任务本身不
|
||
# 失败(协调员按缺失子任务的既有语义等超时);同
|
||
# 一 worker 进程内同一分工只记一次审计,避免每轮
|
||
# 重调都刷屏。查库失败时 _quota_exceeded 同样返回
|
||
# 非空串(fail-closed),一并跳过。
|
||
gate = self._quota_exceeded(
|
||
{"created_by": task["created_by"]}, None)
|
||
if gate is not None:
|
||
key = (task["id"], name)
|
||
if key not in self._coord_quota_skipped:
|
||
self._coord_quota_skipped.add(key)
|
||
self._audit(
|
||
task["project_id"],
|
||
"coordinator_child_quota_skipped",
|
||
"%s: %s" % (name, gate))
|
||
continue
|
||
child_id = uuid.uuid4().hex
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat, executor,"
|
||
" model, effort, environment_id, parent_task_id,"
|
||
" coordinator_part, child_model, child_effort)"
|
||
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
|
||
" NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(child_id, task["project_id"], part["prompt"],
|
||
task.get("priority", 0) or 0, task["created_by"],
|
||
now, now,
|
||
part.get("executor") or task.get("executor"),
|
||
# P1:分工未指定模型/强度时继承父任务(旧版
|
||
# 直接写 part 的 None,子任务会落到 worker
|
||
# 默认模型/强度,与父任务的显式选择脱节,
|
||
# 既改成本也改行为,且排查时看不出继承关系)。
|
||
# 派生默认优先于父任务自身:分工显式写 >
|
||
# 协调员的 child_model / child_effort >
|
||
# 协调员自身的 model / effort。
|
||
part.get("model") or task.get("child_model")
|
||
or task.get("model"),
|
||
part.get("effort") or task.get("child_effort")
|
||
or task.get("effort"),
|
||
task.get("environment_id"), task["id"], name,
|
||
task.get("child_model"),
|
||
task.get("child_effort")))
|
||
self._audit(
|
||
task["project_id"], "coordinator_child_created",
|
||
"%s -> %s" % (name, child_id))
|
||
children.append({"name": name, "id": child_id})
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return children
|
||
|
||
def _coord_child_rows(self, child_ids):
|
||
"""子任务当前行:{id: {status, result_text, error_text}}。
|
||
|
||
已删或查不到的子任务按 interrupted 终态合成一行(error_text
|
||
注明行已消失):等齐判断里缺失不再等到超时,合流报告里
|
||
该子任务标 interrupted 且整体不为 completed;孤儿标记不
|
||
会对不存在的行再 UPDATE(rowcount 本来就是 0)。行消失
|
||
不是完成,绝不能静默当 completed 计。
|
||
"""
|
||
rows = {}
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
for cid in child_ids:
|
||
row = conn.execute(
|
||
"SELECT id, status, result_text, error_text FROM tasks"
|
||
" WHERE id = ?", (cid,)).fetchone()
|
||
if row is None:
|
||
rows[cid] = {
|
||
"status": "interrupted",
|
||
"result_text": None,
|
||
"error_text": "子任务行已消失(已删除)"}
|
||
continue
|
||
try:
|
||
rows[row["id"]] = {
|
||
"status": row["status"],
|
||
"result_text": row["result_text"],
|
||
"error_text": row["error_text"]}
|
||
except (KeyError, IndexError, TypeError):
|
||
continue
|
||
finally:
|
||
conn.close()
|
||
return rows
|
||
|
||
def _coord_mark_orphans(self, task, child_ids):
|
||
"""把仍未终态的子任务标 interrupted 并记审计(P1 孤儿收口)。
|
||
|
||
只对 CAS 改到行的子任务记审计(rowcount==1):已被别的
|
||
worker 终态化的子任务不动,避免覆盖它的结果。返回被标
|
||
记的子任务 id 列表。正在跑的子任务被标 interrupted 后,
|
||
跑它的 worker 会在轮询里看到库状态并杀进程/容器(popen
|
||
每 1 秒查库、Docker 取消看门狗每 1 秒查库),不会无声
|
||
继续烧钱;若那个 worker 也已死,子任务至少已是终态,
|
||
不会再被认领烧钱。
|
||
"""
|
||
marked = []
|
||
rows = self._coord_child_rows(child_ids)
|
||
pending = _coord_nonterminal([
|
||
{"id": cid, "status": (rows.get(cid) or {}).get("status")}
|
||
for cid in child_ids])
|
||
if not pending:
|
||
return marked
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
for ch in pending:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted',"
|
||
" interrupted_reason = 'coordinator_orphan',"
|
||
" updated_at = ? WHERE id = ? AND status NOT IN"
|
||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||
" 'merge-failed')",
|
||
(time.time(), ch["id"]))
|
||
if cur.rowcount == 1:
|
||
marked.append(ch["id"])
|
||
self._audit(
|
||
task["project_id"], "coordinator_child_orphaned",
|
||
"%s -> %s (parent %s)"
|
||
% (ch["id"], "interrupted", task["id"]))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return marked
|
||
|
||
def _reap_kill_requests(self, sweep_threshold=300):
|
||
"""跨 worker 真 kill 的收殓器(run loop 每轮调一次)。
|
||
|
||
分工(与 owner 活着时的 1 秒看门狗互补,绝不抢杀):
|
||
owner 活着时,它的 _docker_cancel_watchdog 在任务变
|
||
interrupted 后 1 秒内按容器名真杀,本函数不碰;只有
|
||
owner 已死(任务心跳停滞超过 sweep_threshold,与陈旧
|
||
回收同一阈值、同一口径 COALESCE(last_heartbeat,
|
||
updated_at))时,本函数才动手——因为这时已没有任何
|
||
进程会来杀这个容器,不杀就是一直烧钱。
|
||
|
||
判定链(任一不满足即跳过,请求留待下轮;任务行失踪
|
||
或任务已不在 interrupted 时请求直接终结,避免复活的
|
||
任务(同 id 重回 queued)被旧请求误杀):
|
||
1. 请求 status 仍为 pending;
|
||
2. 任务存在且 status == 'interrupted';
|
||
3. 任务心跳停滞超过 sweep_threshold;
|
||
4. 本机 executor 有 container_name/kill_container
|
||
(docker 执行器才有;local 执行器无容器名,跳过)。
|
||
杀本身按任务派生容器名(executor.container_name,
|
||
codex-<安全字符任务 id>),绝不按前缀扫、绝不杀自由
|
||
传入的名字;本机无此容器时 kill_container 返回 False
|
||
也不报错,请求照常终结。终结时写 kill_request_reaped
|
||
审计;单条请求异常只记日志、不炸整轮。
|
||
返回终结的请求 id 列表。
|
||
"""
|
||
done = []
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
except Exception as exc:
|
||
self.logger.warning("kill reap: db connect failed: %s", exc)
|
||
return done
|
||
try:
|
||
try:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS kill_requests("
|
||
"id TEXT PRIMARY KEY, "
|
||
"task_id TEXT, "
|
||
"requested_by TEXT, "
|
||
"status TEXT, "
|
||
"requested_at DOUBLE PRECISION, "
|
||
"done_at DOUBLE PRECISION)")
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
reqs = conn.execute(
|
||
"SELECT id, task_id FROM kill_requests"
|
||
" WHERE status = 'pending'").fetchall()
|
||
except Exception as exc:
|
||
self.logger.warning("kill reap: list failed: %s", exc)
|
||
return done
|
||
now = time.time()
|
||
kill = getattr(self.executor, "kill_container", None)
|
||
name_of = getattr(self.executor, "container_name", None)
|
||
for req in reqs:
|
||
req_id = req["id"]
|
||
task_id = req["task_id"]
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT status, project_id, last_heartbeat,"
|
||
" updated_at FROM tasks WHERE id = ?",
|
||
(task_id,)).fetchone()
|
||
if row is None:
|
||
# 任务行失踪:请求已无意义,直接终结。
|
||
conn.execute(
|
||
"UPDATE kill_requests SET status = 'done',"
|
||
" done_at = ? WHERE id = ?",
|
||
(now, req_id))
|
||
conn.commit()
|
||
done.append(req_id)
|
||
continue
|
||
if row["status"] != "interrupted":
|
||
# 任务已不在 interrupted(重回 queued 等):
|
||
# 旧请求直接终结,绝不能杀新跑。
|
||
conn.execute(
|
||
"UPDATE kill_requests SET status = 'done',"
|
||
" done_at = ? WHERE id = ?",
|
||
(now, req_id))
|
||
conn.commit()
|
||
done.append(req_id)
|
||
self._audit(row["project_id"],
|
||
"kill_request_stale",
|
||
"%s not_interrupted" % task_id)
|
||
continue
|
||
hb = row["last_heartbeat"]
|
||
if hb is None:
|
||
hb = row["updated_at"]
|
||
if hb is not None and hb >= now - sweep_threshold:
|
||
# owner 还活着:留待下轮,绝不抢杀。
|
||
continue
|
||
# owner 已死:按任务派生容器名在本机真杀。
|
||
# 容器名只由任务 id 派生,不接受外部输入;
|
||
# 本机无此容器时 kill 返回 False 也不报错。
|
||
if kill is not None and name_of is not None:
|
||
try:
|
||
kill({"id": task_id})
|
||
except Exception:
|
||
pass
|
||
conn.execute(
|
||
"UPDATE kill_requests SET status = 'done',"
|
||
" done_at = ? WHERE id = ?",
|
||
(now, req_id))
|
||
conn.commit()
|
||
done.append(req_id)
|
||
self._audit(row["project_id"], "kill_request_reaped",
|
||
task_id)
|
||
except Exception as exc:
|
||
self.logger.warning(
|
||
"kill reap: request %s failed: %s", req_id, exc)
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
continue
|
||
finally:
|
||
conn.close()
|
||
return done
|
||
|
||
def sweep_orphaned_coordinator_children(self):
|
||
"""常驻循环每轮的孤儿扫尾(P1):父已终态或父行失踪、子
|
||
仍未终态时,把子标 interrupted 并记审计。
|
||
|
||
父仍在 running/queued 时绝不动子(协调员等齐期间子任务
|
||
未终态是正常状态)。父行失踪(被删)同样算孤儿:没有
|
||
父会再来合流,子任务继续跑只是烧钱。返回被标记的
|
||
(parent_id, child_id) 对列表;查库失败时返回 [] 且只记
|
||
日志,不炸循环。
|
||
"""
|
||
marked = []
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT id, parent_task_id, project_id FROM tasks"
|
||
" WHERE parent_task_id IS NOT NULL AND status NOT IN"
|
||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||
" 'merge-failed')").fetchall()
|
||
parents = {}
|
||
for row in rows:
|
||
try:
|
||
pid = row["parent_task_id"]
|
||
except (KeyError, IndexError, TypeError):
|
||
continue
|
||
if pid not in parents:
|
||
prow = conn.execute(
|
||
"SELECT status FROM tasks WHERE id = ?",
|
||
(pid,)).fetchone()
|
||
try:
|
||
parents[pid] = prow["status"] \
|
||
if prow is not None else None
|
||
except (KeyError, IndexError, TypeError):
|
||
parents[pid] = None
|
||
for row in rows:
|
||
try:
|
||
cid = row["id"]
|
||
pid = row["parent_task_id"]
|
||
proj = row["project_id"]
|
||
except (KeyError, IndexError, TypeError):
|
||
continue
|
||
pstatus = parents.get(pid)
|
||
if pstatus is not None and \
|
||
pstatus not in TERMINAL_STATUSES:
|
||
continue
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted',"
|
||
" interrupted_reason = 'coordinator_orphan',"
|
||
" updated_at = ? WHERE id = ? AND status NOT IN"
|
||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||
" 'merge-failed')",
|
||
(time.time(), cid))
|
||
if cur.rowcount == 1:
|
||
marked.append((pid, cid))
|
||
self._audit(
|
||
proj, "coordinator_child_orphaned",
|
||
"%s -> interrupted (parent %s, parent"
|
||
" status %s)" % (cid, pid, pstatus))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_orphan_sweep_failed",
|
||
message="orphan sweep failed: %s" % exc)
|
||
return marked
|
||
|
||
def _run_coordinator(self, task, started_at, poll_interval=None,
|
||
timeout=None):
|
||
"""协调员执行:建子任务 → 等齐 → 合流汇总写回。
|
||
|
||
占一个 worker 槽位直到子任务全部终态:等齐期间每轮刷心跳
|
||
(sweep 不会误回收);子任务本身由各 worker 并行认领。
|
||
占位由调用方 run_one 的 finally 在终局后释放,本函数
|
||
不重复清(旧版直接 return 导致占位永不释放,见上)。
|
||
取消与超时都会尽力取消未终态的子任务,不留孤儿继续烧
|
||
模型费用。返回 run_one 同款结果 dict(无模型花费,
|
||
cost_record_id 为 None)。
|
||
"""
|
||
task_id = task["id"]
|
||
project_id = task["project_id"]
|
||
parts = task.get("coordinator_parts") or []
|
||
if poll_interval is None:
|
||
poll_interval = _COORD_POLL_INTERVAL
|
||
if timeout is None:
|
||
timeout = _COORD_TIMEOUT
|
||
children = self._coord_sync_children(task, parts)
|
||
child_ids = [c["id"] for c in children]
|
||
deadline = time.time() + timeout
|
||
cancelled = False
|
||
timed_out = False
|
||
while True:
|
||
try:
|
||
self.heartbeat(task_id)
|
||
except Exception:
|
||
pass
|
||
# 行消失防线:协调员本体行被删时 _was_cancelled 查不到
|
||
# 行会返回 False(get_task 为 None),不能因此等到
|
||
# deadline——行没了即按取消处理,立即跳出放槽;终局
|
||
# CAS 写不回去,最终状态按 interrupted 计(见循环后
|
||
# final_written 分支),不写回、不抛错、不当完成。
|
||
if self.get_task(task_id) is None:
|
||
cancelled = True
|
||
break
|
||
if self._was_cancelled(task_id):
|
||
cancelled = True
|
||
break
|
||
# 等齐判断:_coord_child_rows 已把行消失的子任务合成
|
||
# interrupted 终态,缺失不再拖住循环等到超时。
|
||
rows = self._coord_child_rows(child_ids)
|
||
if all((rows.get(cid) or {}).get("status") in TERMINAL_STATUSES
|
||
for cid in child_ids):
|
||
break
|
||
if time.time() >= deadline:
|
||
timed_out = True
|
||
break
|
||
time.sleep(poll_interval)
|
||
orphaned = []
|
||
if cancelled or timed_out:
|
||
rows = self._coord_child_rows(child_ids)
|
||
for cid in child_ids:
|
||
if (rows.get(cid) or {}).get("status") \
|
||
not in TERMINAL_STATUSES:
|
||
try:
|
||
self.cancel(cid)
|
||
except Exception:
|
||
pass
|
||
# P1:cancel() 对已终态/不存在的子任务返回 False,
|
||
# 跨 worker 在跑的子任务也可能因竞态仍未终态——
|
||
# cancel 之后仍未终态的一律直接标 interrupted 并记
|
||
# 审计(_coord_mark_orphans),绝不留孤儿无声烧钱;
|
||
# 标记结果写进汇总报告,调用方在详情里看得到。
|
||
try:
|
||
orphaned = self._coord_mark_orphans(task, child_ids)
|
||
except Exception:
|
||
orphaned = []
|
||
rows = self._coord_child_rows(child_ids)
|
||
merged = []
|
||
for ch in children:
|
||
row = rows.get(ch["id"]) or {}
|
||
merged.append({
|
||
"name": ch["name"], "id": ch["id"],
|
||
"status": row.get("status") or "?",
|
||
"result_text": row.get("result_text"),
|
||
"error_text": row.get("error_text")})
|
||
report, overall = _coord_build_report(merged)
|
||
if orphaned:
|
||
report += ("\n\n孤儿子任务已标 interrupted:%s"
|
||
% "、".join(orphaned))
|
||
if cancelled:
|
||
final = "interrupted"
|
||
error = "cancelled"
|
||
elif timed_out:
|
||
final = "failed"
|
||
pending = [m["name"] for m in merged
|
||
if m["status"] not in TERMINAL_STATUSES]
|
||
error = "coordinator timeout: %s 未完成" % "、".join(pending)
|
||
report = error + "\n\n" + report
|
||
else:
|
||
final = overall
|
||
error = None
|
||
if final != "completed":
|
||
failed = [m["name"] for m in merged
|
||
if m["status"] != "completed"]
|
||
error = "coordinator: %s 未完成" % "、".join(failed)
|
||
final_written = self._set_status_if_running(task_id, final)
|
||
if not final_written:
|
||
final = "interrupted"
|
||
finished_at = time.time()
|
||
# P0 安全批后:CAS 没改到行时不写回(见终局路径注释)。
|
||
if final_written:
|
||
self._write_execution_result(
|
||
task_id, result_text=report, error_text=error,
|
||
actual_model=None, started_at=started_at,
|
||
finished_at=finished_at, only_if_status=final)
|
||
self._cleanup_task_seed(task)
|
||
if final_written:
|
||
self._audit(project_id,
|
||
"worker_completed" if final == "completed"
|
||
else ("worker_interrupted"
|
||
if final == "interrupted"
|
||
else "worker_failed"),
|
||
task_id)
|
||
log_event(self.logger,
|
||
"worker_completed" if final == "completed"
|
||
else ("worker_interrupted"
|
||
if final == "interrupted"
|
||
else "worker_failed"),
|
||
task_id=task_id, project_id=project_id, status=final)
|
||
if final in ("completed", "failed"):
|
||
self._notify_task_finished(task_id, project_id, final,
|
||
error=error)
|
||
else:
|
||
self._audit(project_id, "worker_interrupted", task_id)
|
||
# P0 三件其三:时间线终局步骤(best-effort)。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
self._record_step(conn, task_id, "final",
|
||
"终态 %s" % final)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
host_name = self.executor._host_name(task) \
|
||
if hasattr(self.executor, "_host_name") else task_id
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": host_name, "status": final,
|
||
"executor": self._executor_name(), "stdout": report,
|
||
"error": error, "events": [], "cost_record_id": None}
|
||
|
||
def _record_usage_cost(self, task, actual_model, usage_total,
|
||
stdout, fallback_count):
|
||
"""记一条任务成本并累加 worker 级用量(调用方须持 _db_lock)。
|
||
|
||
P1 成本修齐:旧版 max_cost_usd 参数存在但 usage_cost_usd
|
||
从未累加——_budget_exceeded 的成本门永远是 0,等于没设。
|
||
本函数是唯一记账点(终局块与取消分支共用):actual 行
|
||
按价目表算 usd_micros(无价模型为 None),有价行才累加
|
||
usage_cost_usd(无价行不折算成 0 美元,仍受无价上限管);
|
||
无用量时回落字符估算记 estimated,绝不冒充 actual。
|
||
cache_write_input_tokens 不进这里:它只在 usage dict 里
|
||
做观测(见 cost.py 的不计费声明),不加进 total、不用
|
||
于定价。返回 cost record id。
|
||
"""
|
||
task_id = task["id"]
|
||
if usage_total and (usage_total.get("total_tokens") or 0) > 0:
|
||
micros = price_usd_micros(
|
||
actual_model, usage_total.get("input_tokens", 0),
|
||
usage_total.get("cached_input_tokens", 0),
|
||
usage_total.get("output_tokens", 0))
|
||
cost_id = self.costs.record(
|
||
task_id, task["project_id"], actual_model,
|
||
input_tokens=usage_total.get("input_tokens", 0),
|
||
output_tokens=usage_total.get("output_tokens", 0),
|
||
cached_input_tokens=usage_total.get(
|
||
"cached_input_tokens", 0),
|
||
usd_micros=micros,
|
||
source="actual", fallback_count=fallback_count,
|
||
completions=usage_total.get("completions", 0))
|
||
else:
|
||
micros = None
|
||
total = estimate_tokens(task.get("prompt") or "", stdout)
|
||
cost_id = self.costs.record(
|
||
task_id, task["project_id"], actual_model,
|
||
input_tokens=total, output_tokens=0, usd_micros=None,
|
||
source="estimated", fallback_count=fallback_count)
|
||
if micros is not None:
|
||
# max_cost_usd 不再是死参数:只有有价花费才累加,
|
||
# _budget_exceeded 据此在下一认领前挡住超支 worker。
|
||
self.usage_cost_usd += micros / 1000000.0
|
||
return cost_id
|
||
|
||
def run_one(self, task=None):
|
||
"""认领并执行一个任务,返回结果 dict(见模块 docstring)。
|
||
|
||
未派发时返回 {"dispatched": False, "reason": ...} 且不动任务;
|
||
派发后(即使缺 key 失败)返回 dispatched=True 与最终状态。
|
||
"""
|
||
if self._budget_exceeded():
|
||
return {"dispatched": False, "reason": "budget_exceeded"}
|
||
if task is None:
|
||
task = self.pick_next()
|
||
if task is None:
|
||
return {"dispatched": False, "reason": "no_ready_task"}
|
||
task_id = task["id"]
|
||
if not self._claim(task_id, executor=self._executor_name()):
|
||
return {"dispatched": False, "reason": "already_claimed",
|
||
"task_id": task_id}
|
||
# P0 三件其三:时间线 claim 步骤(best-effort,写失败
|
||
# 不影响执行;step_no 由 _record_step 自增)。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
self._record_step(conn, task_id, "claim", "worker 认领")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
task = self.get_task(task_id) or task
|
||
# G10:goal 断点续跑标记——认领后立即清掉库里标记(本轮
|
||
# 已认领为 running,不会重复触发;本轮若再被杀回
|
||
# interrupted,用户需重新调 /goal-resume 才会再置标记,
|
||
# 不会因旧标记残留而误把一次普通重跑当成断点续跑)。
|
||
# 断点优先于续跑消息:本轮不把 prompt 换成 resume_message,
|
||
# 也不消费它(局部变量置 None,终局清理不删库里那条),
|
||
# 避免断点续跑把一条待续跑消息悄悄吃掉。
|
||
if task.get("goal_resume_pending"):
|
||
task = dict(task)
|
||
task["_goal_resume_breakpoint"] = True
|
||
# P0 三件其一:断点丢失重建——host-state 里条目已丢
|
||
# 但库里有断点时,先按库断点拼最小条目写回,再由
|
||
# 正常 goal-resume 续跑;库里也没有断点时返回 False。
|
||
# 从未启动回落(2026-10-29 实测任务 A):重建失败
|
||
# 且四者全无执行痕迹时,goal-resume 注定被 host
|
||
# 拒绝 → failed,改走全新 goal(见
|
||
# _resolve_breakpoint_resume),不造假断点。
|
||
task = self._resolve_breakpoint_resume(task)
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET goal_resume_pending = 0"
|
||
" WHERE id = ?", (task_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
resume_message = None
|
||
else:
|
||
resume_message = task.get("resume_message")
|
||
# 续跑消息:旧任务继续会话时本轮要说的话——本轮 prompt 换
|
||
# 成它(只换内存里的副本,原 prompt 不动);_resume 标记
|
||
# 让 _run_attempt 在 goal 模式下走 goal-resume 而不是 goal
|
||
# (goal 会撞已存在,只有 goal-resume 能在旧线程上开新循环)。
|
||
# P1 goal-resume 分流(断点 vs 开新目标,三处一致的 worker 侧):
|
||
# - 旧验收形态(verify_command 非空):一律断点续跑——
|
||
# 绝不能把续跑消息当新 goal_text 传给 host.py,否则
|
||
# host 的 goal-resume --goal-text 分支会用 verify=None
|
||
# 开新循环,把原 verify_command 覆盖掉;断点调用不传
|
||
# --goal-text,host 沿用原 goal + 原 verify + 原轮次,
|
||
# 从 goal_rounds_done 的下一轮续跑。续跑消息本轮不
|
||
# 当 prompt 用(断点语义就是续原目标),跑完照常清掉。
|
||
# - goal_mode=1(无验收):开新目标——续跑消息就是新
|
||
# goal_text(服务端 /continue 带 goal_mode=1 时已把
|
||
# task.goal 写成该消息,这里与之对齐)。
|
||
# - 非 goal 任务:沿用旧行为(prompt 换成续跑消息,走
|
||
# 已存在续跑 run 路径)。
|
||
if resume_message:
|
||
task = dict(task)
|
||
if task.get("verify_command"):
|
||
task["_resume"] = True
|
||
task["_resume_kind"] = "breakpoint"
|
||
elif task.get("goal_mode"):
|
||
task["prompt"] = resume_message
|
||
task["_resume"] = True
|
||
task["_resume_kind"] = "new-goal"
|
||
else:
|
||
task["prompt"] = resume_message
|
||
task["_resume"] = True
|
||
# 自动记忆注入(官方 use_memories):首轮才注入,见
|
||
# _apply_auto_memory_injection 的跳过规则;只改内存副本。
|
||
task = self._apply_auto_memory_injection(task)
|
||
# 待办注入:首轮才注入未完成待办,见
|
||
# _apply_todo_injection 的跳过规则;只改内存副本。
|
||
task = self._apply_todo_injection(task)
|
||
# 项目注入:首轮才注入项目上下文,见
|
||
# _apply_project_injection 的跳过规则;只改内存副本;
|
||
# 最后贴,排在待办与记忆之前(最通用上下文在最前)。
|
||
task = self._apply_project_injection(task)
|
||
self._audit(task["project_id"], "worker_claim",
|
||
"%s executor=%s" % (task_id, self._executor_name()))
|
||
# log_event 只接受 task_id/project_id/status/message 四个
|
||
# 字段,executor 只进审计 detail,不进日志调用(否则
|
||
# TypeError 会炸掉整个 run_one)。
|
||
log_event(self.logger, "worker_claim", task_id=task_id,
|
||
project_id=task["project_id"], status="running")
|
||
started_at = time.time()
|
||
|
||
# 缺 key 门:认领之后、调 host.py 之前;缺 key 直接 failed,
|
||
# 不调 runner(假 CLI 单测可断言 runner 未被调用)。
|
||
if not self.key_available():
|
||
error = _missing_key_error(self.env_key)
|
||
self._set_status(task_id, "failed")
|
||
self._write_execution_result(
|
||
task_id, error_text=error, actual_model=self.model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
only_if_status="failed")
|
||
# 缺 key 门提前返回:同样要清暂存,否则与仓库解析
|
||
# 失败一路同款残留(见 _cleanup_task_seed)。
|
||
self._cleanup_task_seed(task)
|
||
self._audit(task["project_id"], "worker_missing_key", error)
|
||
log_event(self.logger, "worker_missing_key", task_id=task_id,
|
||
project_id=task["project_id"], status="failed")
|
||
self._notify_task_finished(
|
||
task_id, task["project_id"], "failed", error=error)
|
||
# 认领占位在此释放:缺 key 门在执行器起进程之前返回,
|
||
# 不清占位会把槽位漏到 worker 退出(见 _claim 文档)。
|
||
self._running_clear(task_id)
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "failed", "executor": self._executor_name(),
|
||
"stdout": "", "error": error, "events": [],
|
||
"cost_record_id": None}
|
||
|
||
# 项目仓库解析:认领 + 缺 key 门之后、真正执行之前。
|
||
# repo_url 为空时不动(默认 repo);解析失败时任务直接 failed,
|
||
# 错误文本写清是仓库解析失败,绝不标 completed,也不在此重试。
|
||
repo_error = self._apply_project_repo(task)
|
||
if repo_error is not None:
|
||
self._set_status(task_id, "failed")
|
||
self._write_execution_result(
|
||
task_id, error_text=repo_error, actual_model=self.model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
only_if_status="failed")
|
||
# 仓库解析失败提前返回:够不到正常终局后的清理,
|
||
# 这里必须先清暂存,否则 bundle 目录永久残留。
|
||
self._cleanup_task_seed(task)
|
||
self._audit(task["project_id"], "worker_repo_failed", repo_error)
|
||
log_event(self.logger, "worker_repo_failed", task_id=task_id,
|
||
project_id=task["project_id"], status="failed")
|
||
self._notify_task_finished(
|
||
task_id, task["project_id"], "failed", error=repo_error)
|
||
# 同缺 key 门:仓库解析失败在起进程之前返回,先清占位。
|
||
self._running_clear(task_id)
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "failed", "executor": self._executor_name(),
|
||
"stdout": "", "error": repo_error, "events": [],
|
||
"cost_record_id": None}
|
||
|
||
# 配额认领门:排队期间配额被花完(或被调低)时,任务不能
|
||
# 再开跑——直接标 interrupted + 配额原因,不记成本(还没花
|
||
# 钱),续跑消息不消费,顶上来后重试/续跑可再跑。与 key
|
||
# 门、仓库门同款提前返回形状,区别是状态为 interrupted
|
||
#(可续跑)而非 failed。G7 分层:认领门用 closed——
|
||
# 查询异常时同样标 interrupted(quota_check_unavailable,
|
||
# 可重试/续跑),绝不在“不知道超没超”时开跑;轮询与
|
||
# 轮次边界仍用默认 open,查询抖动不杀在跑任务。
|
||
quota_error = self._quota_exceeded(task, None, on_error="closed")
|
||
if quota_error is not None:
|
||
self._set_status(task_id, "interrupted")
|
||
# 配额中断不自动恢复:要等配额恢复/调高,自动
|
||
# 回 queued 只会让 autoscaler 按队列深度白拉 worker。
|
||
self._mark_interrupted_reason(task_id, "quota")
|
||
self._write_execution_result(
|
||
task_id, error_text=quota_error, actual_model=self.model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
only_if_status="interrupted")
|
||
self._audit(task["project_id"], "worker_quota_stop",
|
||
"%s: %s" % (task_id, quota_error))
|
||
log_event(self.logger, "worker_quota_stop", task_id=task_id,
|
||
project_id=task["project_id"],
|
||
status="interrupted")
|
||
# 同缺 key 门:配额门在起进程之前返回,先清占位。
|
||
self._running_clear(task_id)
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "interrupted",
|
||
"executor": self._executor_name(),
|
||
"stdout": "", "error": quota_error, "events": [],
|
||
"cost_record_id": None}
|
||
|
||
# P1 goal 轮次上限门(执行侧防线):旧验收形态的
|
||
# goal_max_rounds 超过 GOAL_MAX_ROUNDS_CAP 时直接 failed,
|
||
# 不进 host.py goal 循环烧轮次;goal_mode=1 的 0(模型宣告
|
||
# 才结束)豁免轮次上限(上界是配额),但 0 配上旧验收命令
|
||
# 时同样 failed(服务端建任务时 0 本就 400,老行若有 0
|
||
# 验收形态属于脏数据,不按不限轮次跑)。建任务时的 400
|
||
# 校验在 server.py(另一路文件),这里是执行侧第二道门,
|
||
# 错误文本写明上限与“请新建合规任务”,不静默截断轮次
|
||
# (截断会改任务语义,调用方看不出被改过)。
|
||
_goal_rounds = task.get("goal_max_rounds")
|
||
if (task.get("verify_command") or task.get("goal_mode")) \
|
||
and _goal_rounds is not None:
|
||
_goal_cap_error = None
|
||
try:
|
||
_goal_rounds_int = int(_goal_rounds)
|
||
except (TypeError, ValueError):
|
||
_goal_rounds_int = None
|
||
if _goal_rounds_int is not None \
|
||
and _goal_rounds_int > GOAL_MAX_ROUNDS_CAP:
|
||
_goal_cap_error = (
|
||
"goal_max_rounds 超过上限 %d 轮(当前 %d),"
|
||
"请新建轮次合规的任务" % (
|
||
GOAL_MAX_ROUNDS_CAP, _goal_rounds_int))
|
||
elif _goal_rounds_int == 0 and task.get("verify_command") \
|
||
and not task.get("goal_mode"):
|
||
_goal_cap_error = (
|
||
"旧验收形态 goal_max_rounds=0 表示不限轮次,"
|
||
"已禁用(上限 %d 轮),请新建轮次合规的任务"
|
||
% GOAL_MAX_ROUNDS_CAP)
|
||
if _goal_cap_error is not None:
|
||
self._set_status(task_id, "failed")
|
||
self._write_execution_result(
|
||
task_id, error_text=_goal_cap_error,
|
||
actual_model=self.model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
only_if_status="failed")
|
||
self._audit(task["project_id"], "worker_goal_cap_rejected",
|
||
"%s: %s" % (task_id, _goal_cap_error))
|
||
log_event(self.logger, "worker_goal_cap_rejected",
|
||
task_id=task_id, project_id=task["project_id"],
|
||
status="failed")
|
||
self._running_clear(task_id)
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "failed",
|
||
"executor": self._executor_name(),
|
||
"stdout": "", "error": _goal_cap_error, "events": [],
|
||
"cost_record_id": None}
|
||
|
||
# 协调员分支:不跑模型回合,改为按分工建并行子任务、等齐、
|
||
# 合流汇总。已过 key 门与配额门;仓库门已对协调员任务本身
|
||
# 跑过(项目仓库解析失败会在前面直接 failed,不会建一堆
|
||
# 注定失败的子任务);子任务各自再过 key/配额/仓库门。
|
||
# 槽位清理:普通任务在 _run_attempt 的 finally 里清
|
||
# _running 占位,协调员不走 _run_attempt——旧版这里直接
|
||
# return _run_coordinator,协调员完成后续占位永不释放,
|
||
# 同进程 worker 的有效容量会逐个协调员递减直至再也
|
||
# 认领不了(2026-10-28 40 人与 18 人旧轮的第 4 槽/C-6
|
||
# 未认领即此漏)。finally 清槽,异常路径同样不漏。
|
||
if task.get("coordinator_parts"):
|
||
try:
|
||
return self._run_coordinator(task, started_at)
|
||
finally:
|
||
self._running_clear(task_id)
|
||
|
||
events = []
|
||
fallback_count = 0
|
||
result = None
|
||
error = None
|
||
retry_count = 0
|
||
# 最后真正跑过的模型:主模型起手,fallback 后换成 fallback 模型。
|
||
actual_model = self.model
|
||
fallback_model = None
|
||
first_error = None
|
||
# 模型优先级:任务级 > worker 主模型 > 配置文件(host.py
|
||
# 再兜底)。此前这里无条件写成 worker 主模型,导致控制
|
||
# 台选的模型被悄悄覆盖(任务级模型实际不生效);修掉。
|
||
# fallback 时下面再把 task["model"] 换成 fallback 模型。
|
||
# 网关门:sub2api 时先给属主开通网关账号(幂等)并解析默认
|
||
# 模型;失败直接 failed,不进执行器(避免跑错网关或拿着
|
||
# xcmapi 模型名去撞 sub2api)。xcmapi 下 _ensure_gateway
|
||
# 直接返回 (None, None),不做任何事。注意:这里改的是
|
||
# 原 task dict,随后 task = dict(task) 会原样拷走。
|
||
try:
|
||
gateway_provider, gateway_env = self._ensure_gateway(task)
|
||
if not task.get("model") and gateway_provider == "sub2api":
|
||
task["model"] = self._sub2api_default_model(gateway_env)
|
||
except Exception as exc:
|
||
error = "网关准备失败: %s" % exc
|
||
self._set_status(task_id, "failed")
|
||
self._write_execution_result(
|
||
task_id, error_text=error, actual_model=self.model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
only_if_status="failed")
|
||
self._audit(task["project_id"], "worker_gateway_failed",
|
||
"%s: %s" % (task_id, error))
|
||
log_event(self.logger, "worker_gateway_failed",
|
||
task_id=task_id, project_id=task["project_id"],
|
||
status="failed")
|
||
self._notify_task_finished(
|
||
task_id, task["project_id"], "failed", error=error)
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "failed", "executor": self._executor_name(),
|
||
"stdout": "", "error": error, "events": [],
|
||
"cost_record_id": None}
|
||
|
||
task = dict(task)
|
||
if not task.get("model"):
|
||
task["model"] = self.model
|
||
actual_model = task["model"]
|
||
# 有限重试与真实 fallback 的优先级:取消不重试;一次尝试失败
|
||
# 且可 fallback、且显式配了 fallback_models 时,优先真换模型
|
||
# 再跑一次(最多 1 次,同一任务、同一 worktree/分支,dispatch
|
||
# 报“任务已存在”时 _run_attempt 内部走现有的 run 续跑路径),
|
||
# 第二次的结果就是最终结果,不再做同模型重试;未配
|
||
# fallback_models 时保持老行为(router 给出下一模型时只记事件
|
||
# 不重试);其它失败(含 worker 超时)按 max_retries 做同模型
|
||
# 重试,退避等待后重跑一次 _run_attempt。
|
||
# 各次尝试的真实用量都要算进成本(失败尝试花的钱也是钱):
|
||
# 主尝试与 fallback 尝试的结果都先累加,最后只记一条汇总。
|
||
usage_total = None
|
||
# 配额中断状态:任一边界(重试/fallback/后续消息/轮询掐
|
||
# 断)命中后置位,跳过后续轮次,走统一的配额中断收尾
|
||
#(interrupted + 配额原因 + 已花成本照记 + 续跑消息保留)。
|
||
quota_stop = False
|
||
quota_error = None
|
||
|
||
def _quota_hit():
|
||
# 闭包读当前值:usage_total 与 task 在重试/fallback
|
||
# 时会被重绑,轮询掐断要按最新值判断。
|
||
# P1 分流:执行中轮询只认真正的 quota_exceeded,
|
||
# quota_check_failed(查库失败)不杀当前轮——查询
|
||
# 抖动不应杀掉已在跑的回合;但它在重试/fallback/
|
||
# 后续消息等新回合边界仍是拒绝(那些边界直接调
|
||
# _quota_exceeded,不走这个过滤闭包)。
|
||
err = self._quota_exceeded(task, usage_total)
|
||
return err is not None and \
|
||
not err.startswith("quota_check_failed:")
|
||
|
||
# 分支任务:先 fork 再跑首轮(见 _run_fork);首轮失败
|
||
# 不自动重试/fallback,失败后走重试入口重跑首轮(已存在
|
||
# 续跑),不重复 fork。range(0) 让下面的尝试循环跳过,
|
||
# 直接进统一的收尾(超时/取消/成本/审计/通知)逻辑。
|
||
if task.get("forked_from"):
|
||
result, error = self._run_fork(task, quota_check=_quota_hit)
|
||
usage_total = sum_usage(usage_total,
|
||
(result or {}).get("usage"))
|
||
self._deliver_peer_messages(task, result)
|
||
self._deliver_marker_tools(task, result)
|
||
if (result or {}).get("status") == "quota_exceeded":
|
||
quota_error = self._quota_exceeded(task, usage_total) or \
|
||
"quota_exceeded: 本月配额已用完"
|
||
quota_stop = True
|
||
for attempt in range(0 if task.get("forked_from")
|
||
else self.max_retries + 1):
|
||
if quota_stop:
|
||
break
|
||
if attempt > 0:
|
||
# 重试边界:上一轮已花钱,跑下一轮前再查一次。
|
||
quota_error = self._quota_exceeded(task, usage_total)
|
||
if quota_error is not None:
|
||
quota_stop = True
|
||
break
|
||
result, error = self._run_attempt(task, quota_check=_quota_hit)
|
||
usage_total = sum_usage(usage_total, (result or {}).get("usage"))
|
||
self._deliver_peer_messages(task, result)
|
||
self._deliver_marker_tools(task, result)
|
||
if (result or {}).get("status") == "quota_exceeded":
|
||
# 轮询掐断:进程已被杀,配额原因以现查为准。
|
||
quota_error = self._quota_exceeded(task, usage_total) or \
|
||
"quota_exceeded: 本月配额已用完"
|
||
quota_stop = True
|
||
break
|
||
if self._was_cancelled(task_id) or \
|
||
result.get("status") == "cancelled":
|
||
break
|
||
if task.get("verify_command") or task.get("goal_mode"):
|
||
# goal 模式只跑一次:循环内部已多轮重试(turn→验测→
|
||
# 喂回,或新形态的模型自决多轮),worker 级重试/fallback
|
||
# 不再叠加——同任务重跑会撞 host 任务名已存在,第二
|
||
# 次尝试必失败且污染错误文本。
|
||
# P1 唯一例外:goal 首轮撞“已存在”时(重试入口
|
||
# 重排同一 goal 任务的典型死法),不再直接 failed,
|
||
# 而是走一次断点续跑(goal-resume 不传 --goal-text,
|
||
# 沿用原 goal+verify,从断点续跑);断点续跑的
|
||
# 结果即本轮结果(成功则完成,仍失败则以续跑的
|
||
# 错误为准,不再拼“已存在”)。已是续跑的任务
|
||
# (_resume)不再二次回落,避免断点循环。
|
||
# 分支任务(forked_from)不走这里:fork 首轮的
|
||
# 已存在语义是 dispatch 续跑,已在 _run_attempt
|
||
# 内处理,不叠加 goal 断点。
|
||
if not task.get("_resume") and not task.get("forked_from"):
|
||
_combined = ((result or {}).get("stdout", "") or "") \
|
||
+ ((result or {}).get("stderr", "") or "")
|
||
_goal_err = (result or {}).get("goal_error") or ""
|
||
if _ALREADY_EXISTS_MARKER in _combined or \
|
||
_ALREADY_EXISTS_MARKER in _goal_err or \
|
||
_ALREADY_EXISTS_MARKER in (error or ""):
|
||
self._audit(task["project_id"],
|
||
"worker_goal_resume_fallback",
|
||
"%s: goal 已存在,走断点续跑" % task_id)
|
||
_resume_task = dict(task)
|
||
_resume_task["_resume"] = True
|
||
_resume_task["_resume_kind"] = "breakpoint"
|
||
result, error = self._run_attempt(
|
||
_resume_task, quota_check=_quota_hit)
|
||
usage_total = sum_usage(
|
||
usage_total, (result or {}).get("usage"))
|
||
self._deliver_peer_messages(task, result)
|
||
self._deliver_marker_tools(task, result)
|
||
if (result or {}).get("status") == "quota_exceeded":
|
||
quota_error = self._quota_exceeded(
|
||
task, usage_total) or \
|
||
"quota_exceeded: 本月配额已用完"
|
||
quota_stop = True
|
||
break
|
||
if result.get("status") == "completed":
|
||
break
|
||
host_status_try = self._fallback_token(result, error)
|
||
if host_status_try is None and \
|
||
result.get("status") == "timeout":
|
||
# 进程/容器超时路径的 host_status 为空,超时本身就是
|
||
# 可 fallback 的失败 token(models.FALLBACKABLE 含它)。
|
||
host_status_try = "timeout"
|
||
if fallback_model is None and self.fallback_models \
|
||
and host_status_try is not None \
|
||
and is_fallbackable(host_status_try):
|
||
fb = self.fallback_models[0]
|
||
record = dict(task)
|
||
record["events"] = events
|
||
# record_fallback 本身会 append 到 events,
|
||
# 这里不要再 append 一次,否则事件会重复。
|
||
record_fallback(
|
||
record, self.model, fb, str(host_status_try))
|
||
fallback_count = 1
|
||
self._audit(task["project_id"], "worker_fallback",
|
||
"%s -> %s" % (self.model, fb))
|
||
first_error = self._attempt_error(
|
||
result, error, host_status_try)
|
||
fallback_model = fb
|
||
actual_model = fb
|
||
task = dict(task)
|
||
task["model"] = fb
|
||
# fallback 边界:换模型再跑一轮前先查配额,超限
|
||
# 时不烧第二份钱,直接走配额中断收尾。
|
||
quota_error = self._quota_exceeded(task, usage_total)
|
||
if quota_error is not None:
|
||
quota_stop = True
|
||
break
|
||
result, error = self._run_attempt(task, quota_check=_quota_hit)
|
||
usage_total = sum_usage(usage_total,
|
||
(result or {}).get("usage"))
|
||
self._deliver_peer_messages(task, result)
|
||
self._deliver_marker_tools(task, result)
|
||
if (result or {}).get("status") == "quota_exceeded":
|
||
quota_error = self._quota_exceeded(task, usage_total) or \
|
||
"quota_exceeded: 本月配额已用完"
|
||
quota_stop = True
|
||
break
|
||
if self.router is not None and host_status_try is not None \
|
||
and is_fallbackable(host_status_try) and \
|
||
self.router.next_after(
|
||
self.model, host_status_try) is not None:
|
||
break
|
||
if attempt < self.max_retries:
|
||
retry_count += 1
|
||
self._audit(
|
||
task["project_id"], "worker_retry",
|
||
"%s: attempt %d failed, retry %d/%d"
|
||
% (task_id, attempt + 1, retry_count,
|
||
self.max_retries))
|
||
if self.retry_backoff_seconds:
|
||
time.sleep(self.retry_backoff_seconds)
|
||
continue
|
||
break
|
||
# 超时转 failed:超时是失败,不是取消;最终状态必须是
|
||
# VALID_STATUSES 里的 failed,绝不能把 "timeout" 写进库。
|
||
if result is not None and result.get("status") == "timeout":
|
||
result = dict(result, status="failed")
|
||
error = "task timeout after %s seconds" % self.task_timeout
|
||
self._audit(task["project_id"], "worker_timeout", task_id)
|
||
|
||
# 后续消息(排队/直接发送):首轮 completed 且非 goal 时,
|
||
# 按 direct 优先、队内先来先服务逐条再跑一轮(同一 thread,
|
||
# 历史与 worktree 改动都在)。直接发送在 POST 时已进链路,
|
||
# 这里只负责把轮次跑出来。一条失败即停:任务记 failed,
|
||
# 剩余 pending 留给重试入口(重试走已存在续跑,跑完继续
|
||
# 排空剩余消息);取消时该条回 pending,任务走中断收尾。
|
||
# /goal 命令不在这里当普通轮次跑:排空到它时把主线转
|
||
# 成 goal 任务并回 queued,下次认领走 goal-resume 开
|
||
# 新目标循环(服务端已在 followup/thread 入口把 running
|
||
# 的 /goal 排成 followup,见 server._create_followup)。
|
||
goal_converted = False
|
||
if (result is not None and result.get("status") == "completed"
|
||
and not quota_stop
|
||
and not task.get("verify_command")
|
||
and not task.get("goal_mode")):
|
||
while True:
|
||
fu = self._claim_next_followup(task_id)
|
||
if fu is None:
|
||
break
|
||
goal_target = _parse_goal_command(fu["body"])
|
||
if goal_target is not None:
|
||
if not goal_target or task.get("plan"):
|
||
# 服务端已拦空目标与 plan 冲突,这里是
|
||
# 兜底:不跑、不转,标 failed 停排空。
|
||
self._finish_followup(fu["id"], "failed")
|
||
break
|
||
if self._convert_running_task_to_goal(
|
||
task_id, goal_target):
|
||
self._finish_followup(fu["id"], "done")
|
||
self._audit(task["project_id"],
|
||
"worker_goal_converted",
|
||
"%s:goal=%s" % (task_id, goal_target))
|
||
goal_converted = True
|
||
else:
|
||
self._restore_followup(fu["id"])
|
||
break
|
||
# 后续消息执行时查配额:超限时该条回 pending(留
|
||
# 给重试入口),任务走配额中断收尾,不再排空剩余;
|
||
# 建消息时不查(排队本身不花钱,顶上来后可跑)。
|
||
quota_error = self._quota_exceeded(task, usage_total)
|
||
if quota_error is not None:
|
||
self._restore_followup(fu["id"])
|
||
quota_stop = True
|
||
break
|
||
fu_task = dict(task)
|
||
# 任务间消息的接收方提示:带发送方 id,接收方
|
||
# agent 凭此知道回给哪个任务;用户后续消息无前缀。
|
||
fu_prompt = fu["body"]
|
||
if fu.get("sender_task_id"):
|
||
fu_prompt = _peer_prefix(fu["sender_task_id"]) + fu_prompt
|
||
fu_task["prompt"] = fu_prompt
|
||
# 后续消息轮不重复加指南:首轮指南已在同一
|
||
# thread 历史里,后续轮靠历史想起工具;每轮
|
||
# 都加会让指南在历史里越积越多。
|
||
fu_result, fu_error = self._run_attempt(
|
||
fu_task, quota_check=_quota_hit)
|
||
usage_total = sum_usage(
|
||
usage_total, (fu_result or {}).get("usage"))
|
||
self._deliver_peer_messages(task, fu_result)
|
||
self._deliver_marker_tools(task, fu_result)
|
||
if (fu_result or {}).get("status") == "quota_exceeded":
|
||
self._restore_followup(fu["id"])
|
||
quota_error = self._quota_exceeded(task, usage_total) or \
|
||
"quota_exceeded: 本月配额已用完"
|
||
quota_stop = True
|
||
break
|
||
if self._was_cancelled(task_id) or \
|
||
(fu_result or {}).get("status") == "cancelled":
|
||
self._restore_followup(fu["id"])
|
||
result = fu_result
|
||
break
|
||
if (fu_result or {}).get("status") == "timeout":
|
||
fu_result = dict(fu_result, status="failed")
|
||
fu_error = ("task timeout after %s seconds"
|
||
% self.task_timeout)
|
||
if (fu_result or {}).get("status") == "completed":
|
||
self._finish_followup(fu["id"], "done")
|
||
result = fu_result
|
||
error = None
|
||
continue
|
||
self._finish_followup(fu["id"], "failed")
|
||
result = fu_result
|
||
error = fu_error or self._attempt_error(
|
||
fu_result, None, fu_result.get("host_status"))
|
||
break
|
||
|
||
# 取消优先:执行期间被 cancel 的任务保持 interrupted,
|
||
# 绝不用执行结果覆盖它(见模块 docstring 的取消说明)。
|
||
if self._was_cancelled(task_id) or result.get("status") == "cancelled":
|
||
self._set_status(task_id, "interrupted")
|
||
self._write_execution_result(
|
||
task_id, result_text=(result or {}).get("stdout", ""),
|
||
error_text="cancelled", actual_model=actual_model,
|
||
started_at=started_at, finished_at=time.time(),
|
||
turn_id=(result or {}).get("turn_id"),
|
||
root_turn_id=(result or {}).get("root_turn_id"),
|
||
only_if_status="interrupted",
|
||
result_full=(result or {}).get("stdout_full"))
|
||
# P1:取消分支先记一条成本再返回(与配额中断收尾
|
||
# 同款记账点)。旧版这里直接返回 cost_record_id=None,
|
||
# 被取消前已花的模型费用就从账上丢了;取消不等于没
|
||
# 花钱。记账失败不挡取消返回(costs.record 异常时
|
||
# cost_id 置 None,任务状态仍是 interrupted)。
|
||
try:
|
||
with self._db_lock:
|
||
_cancel_cost_id = self._record_usage_cost(
|
||
task, actual_model, usage_total,
|
||
(result or {}).get("stdout", ""), fallback_count)
|
||
except Exception:
|
||
_cancel_cost_id = None
|
||
log_event(self.logger, "worker_cancelled", task_id=task_id,
|
||
project_id=task["project_id"], status="interrupted")
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": "interrupted", "executor": self._executor_name(),
|
||
"stdout": (result or {}).get("stdout", ""),
|
||
"error": "cancelled", "events": [],
|
||
"cost_record_id": _cancel_cost_id}
|
||
|
||
stdout = result.get("stdout", "") or ""
|
||
host_status = result.get("host_status")
|
||
final = result["status"]
|
||
if quota_stop:
|
||
# 配额中断收尾:状态一律 interrupted + 配额原因,不跑
|
||
# 失败原因重算(否则 fallback 拼接会把配额原因盖掉),
|
||
# 已花成本仍按 usage_total 照记(下面成本块),续跑
|
||
# 消息保留(下面清消息时跳过),不发终态推送(与取消
|
||
# 同款:interrupted 不在推送事件集里)。quota_exceeded
|
||
# 这个内部标记绝不能原样写进 tasks.status。
|
||
final = "interrupted"
|
||
error = quota_error
|
||
if final != "completed" and not quota_stop:
|
||
if fallback_model is not None:
|
||
# 真 fallback 后第二次也失败:错误文本必须同时写清
|
||
# 两次原因,不能只留第二次的(超时转换已在前面把
|
||
# error 置成超时文本,那时 second_error 就是它)。
|
||
second_error = error or self._attempt_error(
|
||
result, None, host_status)
|
||
error = ("primary model %s failed: %s; "
|
||
"fallback model %s failed: %s"
|
||
% (self.model, first_error,
|
||
fallback_model, second_error))
|
||
else:
|
||
if result.get("goal_error"):
|
||
error = result["goal_error"]
|
||
if error is None:
|
||
stderr = (result or {}).get("stderr", "") or ""
|
||
error = stdout.strip() or \
|
||
(stderr.strip()[-1000:] if stderr.strip() else "") \
|
||
or "host execution failed"
|
||
if retry_count:
|
||
error = "[retries=%d] %s" % (retry_count, error)
|
||
# 未配 fallback_models 时的老行为:只记事件、不重试。
|
||
# 注意用提取出的 token(真实回合失败时 host_status
|
||
# 只是 "failed",真正的 404 藏在回合 JSON 里),
|
||
# 不能直接用原始 host_status 判断。
|
||
_token = self._fallback_token(result, error)
|
||
if self.router is not None and _token is not None \
|
||
and is_fallbackable(_token):
|
||
nxt = self.router.next_after(self.model, _token)
|
||
if nxt is not None:
|
||
record = dict(task)
|
||
record["events"] = events
|
||
# record_fallback 本身会 append 到 events,
|
||
# 这里不要再 append 一次,否则事件会重复。
|
||
record_fallback(
|
||
record, self.model, nxt, str(_token))
|
||
fallback_count = 1
|
||
self._audit(task["project_id"], "worker_fallback",
|
||
"%s -> %s" % (self.model, nxt))
|
||
final_written = self._set_status_if_running(task_id, final)
|
||
if quota_stop and final_written and final == "interrupted":
|
||
self._mark_interrupted_reason(task_id, "quota")
|
||
finished_at = time.time()
|
||
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时
|
||
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
|
||
# 文本;CAS 成功时写回也要带状态守卫。
|
||
if final_written:
|
||
self._write_execution_result(
|
||
task_id, result_text=stdout, error_text=error,
|
||
actual_model=actual_model, started_at=started_at,
|
||
finished_at=finished_at,
|
||
turn_id=(result or {}).get("turn_id"),
|
||
root_turn_id=(result or {}).get("root_turn_id"),
|
||
only_if_status=final,
|
||
result_full=(result or {}).get("stdout_full"))
|
||
# 附件暂存清理:任务终局后删 bundle 目录(重试走已存在
|
||
# 续跑,文件已在 worktree 里,不再需要暂存)。
|
||
self._cleanup_task_seed(task)
|
||
with self._db_lock:
|
||
self.usage_turns += 1
|
||
# 成本记账走 _record_usage_cost(终局与取消分支共用
|
||
# 的唯一记账点):actual/estimated 分流、usage_cost_usd
|
||
# 累加都在里面;cost 记录共用一条连接,并发下必须
|
||
# 持锁写,否则两个线程共用同一 sqlite 连接会直接报错。
|
||
cost_id = self._record_usage_cost(
|
||
task, actual_model, usage_total, stdout, fallback_count)
|
||
if final_written:
|
||
# 审计名须与终态一致:interrupted 任务记 worker_failed
|
||
# 会把“可续跑的中断”误读成失败,误导后续排查。
|
||
# 终态审计细节(2026-10-29 任务 A 教训):interrupted /
|
||
# failed 时只记 task_id,事后完全无法区分是取消、
|
||
# 配额、host 返回 interrupted 还是其它失败——且自动
|
||
# 续跑会把 error_text 覆盖掉,第一轮的原因就此丢失。
|
||
# 因此非完成终态的审计 detail 带上 error(截 500 字、
|
||
# 去换行,不含 prompt/key),完成态仍只记 task_id。
|
||
_final_detail = task_id
|
||
if final in ("interrupted", "failed") and error:
|
||
_final_detail = "%s: %s" % (
|
||
task_id, str(error)[:500].replace("\n", " "))
|
||
self._audit(task["project_id"],
|
||
"worker_quota_stop" if quota_stop else
|
||
("worker_completed" if final == "completed"
|
||
else "worker_interrupted"
|
||
if final == "interrupted"
|
||
else "worker_failed"), _final_detail)
|
||
log_event(self.logger,
|
||
"worker_quota_stop" if quota_stop else
|
||
("worker_completed" if final == "completed"
|
||
else "worker_interrupted"
|
||
if final == "interrupted"
|
||
else "worker_failed"),
|
||
task_id=task_id, project_id=task["project_id"],
|
||
status=final)
|
||
# P0 三件其三:时间线终局步骤(best-effort)。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_result_columns(conn)
|
||
self._record_step(conn, task_id, "final",
|
||
"终态 %s" % final)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
if result.get("goal_met") is False:
|
||
# goal 未达只发 goal-unmet,不再重复发 failed:
|
||
# 同一结局两条通知会刷屏,digest 偏好下两者又都是
|
||
# 即时事件,更不能双发。
|
||
self._notify_task_finished(
|
||
task_id, task["project_id"], "goal-unmet", error=error)
|
||
elif final in ("completed", "failed"):
|
||
self._notify_task_finished(
|
||
task_id, task["project_id"], final, error=error)
|
||
else:
|
||
if goal_converted:
|
||
# /goal 转换:DB 状态已是本轮转换写回的 queued,
|
||
# _set_status_if_running 因状态不是 running 而
|
||
# 没改行是预期,不是取消/回收;绝不能把 local
|
||
# 的 final 改成 interrupted(那只会误导返回体,
|
||
# DB 本身已是 queued)。审计也按转换记,不按中断记。
|
||
final = "queued"
|
||
self._audit(task["project_id"], "worker_goal_converted",
|
||
task_id)
|
||
log_event(self.logger, "worker_goal_converted",
|
||
task_id=task_id, project_id=task["project_id"],
|
||
status="queued")
|
||
else:
|
||
# 已被取消/回收:DB 状态以 interrupted 为准,不再
|
||
# 补一条 worker_completed/failed 审计误导排查。
|
||
final = "interrupted"
|
||
self._audit(task["project_id"], "worker_interrupted", task_id)
|
||
if resume_message and not quota_stop:
|
||
# 本轮已跑完(无论成败):续跑消息已消费,清掉;进程
|
||
# 中途被杀时清不到,下次认领会重跑同一条消息——可接受
|
||
# 的续跑语义,不会静默丢消息。清失败不挡结果返回。
|
||
# 配额中断时不消费:顶上来后续跑还要说这句话。
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET resume_message = NULL"
|
||
" WHERE id = ?", (task_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
return {"dispatched": True, "task_id": task_id,
|
||
"host_name": self.executor._host_name(task)
|
||
if hasattr(self.executor, "_host_name") else task_id,
|
||
"status": final, "executor": self._executor_name(),
|
||
"stdout": stdout, "error": error, "events": events,
|
||
"actual_model": actual_model,
|
||
"cost_record_id": cost_id}
|
||
|
||
def _run_pr_jobs(self):
|
||
"""跑本主机的 pr 任务(HA 阶段 1),返回处理结果列表。
|
||
|
||
只有 local worker 跑:host-worktrees/ 与 tenants/ 在主机上
|
||
都可见,容器内 worker 看不到 host-worktrees/。每轮把本主机
|
||
的 queued 任务按 CAS 认领跑完(pr 任务稀少,串行即可);
|
||
execute_task_pr 内部已写回任务 pr_url/pr_number、记审计、
|
||
发 review-requested/merge-failed,这里只写回任务终态。
|
||
跳过(PrSkipped,如任务已不是 merged)与落盘失败都记
|
||
failed + error,任务本身保持 merged、可重调 /pr 再排队。
|
||
P1 非 local 通道:旧版非 local worker 直接返回 [],
|
||
排队的 pr 任务看起来像“没人处理”且无任何痕迹——
|
||
静默未完成。现在改为显式 needs_local_worker:只读
|
||
peek 本主机 queued 的 pr 任务,逐条把行状态持久化
|
||
为 needs_local_worker(CAS,只改仍是 queued 的行,
|
||
已被 local worker 认领的不覆盖),并记审计 + 日志
|
||
返回 needs_local_worker 条目。任务本身保持 merged,
|
||
pr 任务保持可被 local worker 认领(claim_pr_job
|
||
同时认 queued 与 needs_local_worker),绝不把它标
|
||
failed(failed 会让调用方以为 PR 已失败,且任务仍
|
||
可重调 /pr 的语义会被混淆)。签名无效时同样不碰
|
||
任务(返回 [],只记日志),fail-closed。
|
||
"""
|
||
if not self._code_signature_valid():
|
||
log_event(self.logger, "worker_pr_refused_code_signature",
|
||
message="code signature missing or changed,"
|
||
" refusing pr jobs")
|
||
return []
|
||
if self._executor_name() != "local":
|
||
results = []
|
||
try:
|
||
from cloud.pr_exec import ensure_pr_jobs_table
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
ensure_pr_jobs_table(conn)
|
||
rows = conn.execute(
|
||
"SELECT id, task_id, project_id FROM pr_jobs"
|
||
" WHERE host = ? AND status = 'queued'",
|
||
(self.host_name,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
for row in rows:
|
||
try:
|
||
job_id = row["id"]
|
||
task_id = row["task_id"]
|
||
project_id = row["project_id"]
|
||
except (KeyError, IndexError, TypeError):
|
||
continue
|
||
reason = ("PR 执行需要 local worker(host-worktrees"
|
||
" 只在主机上可见),本 worker 为 %s,"
|
||
"任务保持可认领等 local worker 认领"
|
||
% self._executor_name())
|
||
marked = False
|
||
try:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
marked = mark_pr_job_needs_local_worker(
|
||
conn, job_id, reason)
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
log_event(self.logger, "pr_job_mark_failed",
|
||
message="pr job mark failed: %s" % exc)
|
||
continue
|
||
if not marked:
|
||
# 已被 local worker 认领或已终态:不覆盖、不重复记。
|
||
continue
|
||
self._audit(project_id, "pr_job_needs_local_worker",
|
||
"%s: %s" % (job_id, reason))
|
||
log_event(self.logger, "pr_job_needs_local_worker",
|
||
task_id=task_id, status="needs_local_worker",
|
||
message=reason)
|
||
results.append({"pr_job_id": job_id,
|
||
"task_id": task_id,
|
||
"status": "needs_local_worker",
|
||
"error": reason})
|
||
except Exception as exc:
|
||
log_event(self.logger, "pr_job_peek_failed",
|
||
message="pr job peek failed: %s" % exc)
|
||
return results
|
||
results = []
|
||
while True:
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
job = claim_pr_job(conn, self.host_name)
|
||
finally:
|
||
conn.close()
|
||
if job is None:
|
||
break
|
||
job_id = job["id"]
|
||
task_id = job["task_id"]
|
||
try:
|
||
# G13:审计操作人用 job 的费用归属人(请求 /pr 的
|
||
# 用户),老行没有 created_by 时回落 "system"。
|
||
result = execute_task_pr(
|
||
self.db_path, task_id,
|
||
job.get("created_by") or "system")
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
finish_pr_job(conn, job_id, "completed",
|
||
result["pr_url"], result["pr_number"])
|
||
finally:
|
||
conn.close()
|
||
results.append({"pr_job_id": job_id, "task_id": task_id,
|
||
"status": "completed",
|
||
"pr_url": result["pr_url"]})
|
||
log_event(self.logger, "pr_job_completed",
|
||
task_id=task_id, status="completed")
|
||
except PrSkipped as exc:
|
||
err = "skipped: %s" % exc.reason
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
finish_pr_job(conn, job_id, "failed", error=err)
|
||
finally:
|
||
conn.close()
|
||
results.append({"pr_job_id": job_id, "task_id": task_id,
|
||
"status": "failed", "error": err})
|
||
except PrStepError as exc:
|
||
err = "%s: %s" % (exc.step, exc.message)
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
finish_pr_job(conn, job_id, "failed", error=err)
|
||
finally:
|
||
conn.close()
|
||
results.append({"pr_job_id": job_id, "task_id": task_id,
|
||
"status": "failed", "error": err})
|
||
except Exception as exc: # noqa: BLE001 -- 任务失败不能炸循环
|
||
err = str(exc)
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
finish_pr_job(conn, job_id, "failed", error=err)
|
||
finally:
|
||
conn.close()
|
||
results.append({"pr_job_id": job_id, "task_id": task_id,
|
||
"status": "failed", "error": err})
|
||
return results
|
||
|
||
def run_all(self, max_tasks=None):
|
||
"""循环认领执行,直到无就绪任务、预算超限或达到 max_tasks。
|
||
|
||
每轮都重新读库,所以上一轮刚 completed 的任务会解锁它的
|
||
依赖任务;每轮至少推进一个任务到终态,不会空转死循环。
|
||
|
||
capacity<=1 时走旧串行循环(行为与原来完全一致)。
|
||
capacity>1 时用线程池并行跑:同一 worker 内最多
|
||
capacity 个任务同时执行(capacity 从此是真实并发上限,
|
||
不再只是注册表展示)。认领本身是 CAS,两个线程同时
|
||
盯上同一任务时只有一个能拿到它,另一个的 run_one 返回
|
||
already_claimed,不计入结果、不死循环。依赖解锁靠
|
||
“等一个在跑任务完成后再认领”:pending 非空但暂无就绪
|
||
任务时,等一个完成再继续,而不是直接退出。
|
||
停机信号(_loop_stop/stop_event)只阻止新认领,不会
|
||
丢弃已提交的任务;排空等待由 run_loop 的 finally 负责。
|
||
"""
|
||
# 环境准备先跑:每轮先把 creating 的环境准备好(clone +
|
||
# checkout 分支),再认领普通任务;一条环境失败只标它
|
||
# 自己 failed,不挡任务——准备函数内部已隔离异常,这里
|
||
# 再包一层,防 DB 结构异常(如老库缺表)带走整轮任务。
|
||
try:
|
||
self._prepare_pending_environments()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_env_prepare_failed",
|
||
message="worker env prepare failed: %s" % exc)
|
||
# pr 任务先跑(HA 阶段 1):每轮先把本主机的 queued pr 任务
|
||
# 跑完再认领普通任务;pr_jobs_only 时只跑 pr 任务。
|
||
self._run_pr_jobs()
|
||
if self.pr_jobs_only:
|
||
return []
|
||
if self.capacity <= 1:
|
||
results = []
|
||
while max_tasks is None or len(results) < max_tasks:
|
||
result = self.run_one()
|
||
if not result["dispatched"]:
|
||
break
|
||
results.append(result)
|
||
return results
|
||
# capacity>1:手动线程池(不用 concurrent.futures)。
|
||
# 原因:本仓库有个单测把 cloud/ 插进 sys.path 后
|
||
# `import queue` 会解析到 cloud/queue.py 而不是标准库,
|
||
# concurrent.futures 内部依赖标准库 queue.SimpleQueue,
|
||
# 在该单测之后导入它会直接 AttributeError;手动用
|
||
# threading.Condition + 共享计数实现线程池,避开这个坑。
|
||
# 池线程数按队列深度自适应:每轮开始先数就绪任务,
|
||
# 线程数 = min(capacity, 就绪数)——队列只有 1 个任务时
|
||
# 只开 1 个线程,不为空转线程池买单;队列深时开满
|
||
# capacity。就绪数是快照,任务完成解锁依赖时,池线程
|
||
# 靠“无就绪但仍有在跑任务则等待重试”继续接新任务,
|
||
# 不会因为初始快照小就漏派。
|
||
# 池线程数 = capacity;每个池线程循环认领-执行,认领
|
||
# 本身是 CAS,两个线程盯上同一任务时只有一个能拿到。
|
||
# 依赖解锁:某线程暂无就绪任务时,若仍有线程在跑就等
|
||
# 条件变量(跑完的线程会 notify,可能解锁了依赖),
|
||
# 全部线程都空闲且无就绪任务时才退出。
|
||
# max_tasks 约束:已派发结果数 + 正在跑的任务数达到
|
||
# max_tasks 就不再认领,避免线程池提前提交导致超跑。
|
||
# 停机信号:只阻止新认领,不丢弃已在跑的任务;run_all
|
||
# 在停机时立刻返回(后台线程继续跑),run_loop 的
|
||
# finally 做带超时的排空,超时后才杀残留。
|
||
state_lock = threading.Condition()
|
||
shared = {"dispatched": 0, "active": 0, "stopped": False}
|
||
results = []
|
||
results_lock = threading.Lock()
|
||
|
||
def _pool_thread():
|
||
while True:
|
||
with state_lock:
|
||
if shared["stopped"] or self._loop_stop:
|
||
return
|
||
if max_tasks is not None and \
|
||
shared["dispatched"] + shared["active"] >= max_tasks:
|
||
return
|
||
try:
|
||
task = self.pick_next()
|
||
except Exception as exc:
|
||
# 认领读库失败(如缺表、锁冲突)不炸池线程:
|
||
# 等一轮再试,与 run_loop 的容错一致。
|
||
log_event(self.logger, "worker_pool_pick_error",
|
||
message="worker pool pick error: %s" % exc)
|
||
state_lock.wait(timeout=0.2)
|
||
continue
|
||
if task is None:
|
||
if shared["active"] > 0:
|
||
state_lock.wait(timeout=0.2)
|
||
continue
|
||
return
|
||
shared["active"] += 1
|
||
result = {"dispatched": False}
|
||
try:
|
||
result = self.run_one(task)
|
||
except Exception as exc:
|
||
# 单个任务的意外异常绝不能拖垮整个池(串行
|
||
# 模式下 run_loop 也是这么容错的);该任务已
|
||
# 认领为 running,之后由自动陈旧回收处理。
|
||
log_event(self.logger, "worker_pool_task_error",
|
||
message="worker pool task error: %s" % exc)
|
||
finally:
|
||
with state_lock:
|
||
shared["active"] -= 1
|
||
if result["dispatched"]:
|
||
shared["dispatched"] += 1
|
||
state_lock.notify_all()
|
||
if result["dispatched"]:
|
||
with results_lock:
|
||
results.append(result)
|
||
|
||
# 池线程数恒为 capacity,绝不按本轮开始时的就绪快照缩减。
|
||
# 快照缩减是 PM 等待死锁的根因:本轮开始时若只有 1 个就绪
|
||
# 任务(如刚认领的 PM),只开 1 个线程;该线程跑 PM 模型回合
|
||
# 后进入标记等待(最长 3600 秒),期间 PM 新建/重试的子任务
|
||
# 入队,却再无空闲线程认领它——run_loop 又被本轮 run_all 的
|
||
# join 挡住、开不了新一轮;若此时另一 worker 已缩容,就只剩
|
||
# 一个被等待占住的 worker,子任务等满等待超时也无人跑。
|
||
# 恒开 capacity 个线程没有空转代价:空闲线程在 task 为 None
|
||
# 且 active 为 0 时立刻退出,有在跑任务时才 0.2 秒轮询认领,
|
||
# 正好接住轮中新建的任务。
|
||
threads = [threading.Thread(target=_pool_thread, daemon=True)
|
||
for _ in range(self.capacity)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
# 正常情况等全部池线程结束;停机信号来时不等了,
|
||
# 立刻返回给 run_loop 做排空(后台线程是 daemon,
|
||
# 且 run_loop finally 会杀残留,不会悬着进程)。
|
||
while t.is_alive():
|
||
t.join(timeout=0.2)
|
||
if self._loop_stop:
|
||
break
|
||
if self._loop_stop:
|
||
break
|
||
return results
|
||
|
||
def _process_push_due(self, now=None):
|
||
"""G11/G12 后台投递:每轮循环调用,绝不中断在跑任务。
|
||
|
||
outbox 每轮都试到期的行(重试节奏由 next_attempt_at
|
||
控,默认 60 秒退避、最多 3 次,到上限后删行并记日志,
|
||
不丢任务状态);摘要只在距上次发送满 digest_interval
|
||
后才发一次(默认每小时),避免每轮都把摘要提前发掉。
|
||
全部异常只记日志,不抛、不炸循环。force 语义由调用方
|
||
通过把 _last_digest_sent 置 0 实现(单测用)。
|
||
"""
|
||
now = time.time() if now is None else now
|
||
try:
|
||
from cloud.push import process_outbox_due, send_digest_due
|
||
out = process_outbox_due(
|
||
self.db_path, now=now,
|
||
max_attempts=self.outbox_max_attempts,
|
||
backoff_seconds=self.outbox_backoff_seconds)
|
||
for failed in out.get("failed", []):
|
||
log_event(self.logger, "worker_push_outbox_dropped",
|
||
message="push outbox dropped after retries: %s"
|
||
% failed)
|
||
if now - self._last_digest_sent >= \
|
||
self.digest_interval_seconds:
|
||
self._last_digest_sent = now
|
||
digest = send_digest_due(self.db_path, now=now)
|
||
for err in digest.get("errors", []):
|
||
log_event(self.logger, "worker_push_digest_failed",
|
||
message="push digest send failed: %s" % err)
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_push_due_failed",
|
||
message="push due processing failed: %s" % exc)
|
||
|
||
def run_loop(self, interval=300, max_seconds=None, stop_event=None,
|
||
drain_timeout=60, sweep_threshold=300):
|
||
"""常驻循环:每 interval 秒跑一轮 run_all,空闲也不退出。
|
||
|
||
退出条件:stop_event 被置位、_loop_stop 被信号处理置位、
|
||
或 max_seconds 到期。退出时先优雅排空:等正在跑的任务
|
||
自然结束(最多 drain_timeout 秒),超时后才杀残留进程组/
|
||
容器并标 interrupted——以前是退出时立刻杀,部署重启会
|
||
无谓中断一个 16 秒就能跑完的任务。
|
||
|
||
运行中保障(与并发一起上的两件套):
|
||
- 心跳线程:每 30 秒给所有正在跑的任务刷 last_heartbeat,
|
||
否则长任务(>300 秒)在跑也会被陈旧回收误杀。
|
||
- 自动陈旧回收:每轮结束调一次 sweep_stale_tasks(阈值
|
||
sweep_threshold,默认 300 秒),把死 worker 遗留的
|
||
running 任务标 interrupted 并审计;活任务因心跳新鲜
|
||
绝不会被扫到。回收失败只记日志,不炸循环。
|
||
"""
|
||
# 常驻启动清理兜底:清掉上一轮残留的 codex- 容器,
|
||
# 失败只记日志(见 cleanup_stale_containers),不炸循环。
|
||
self.cleanup_stale_containers()
|
||
# 运维心跳:启动时先写一次,之后每轮结束再写,server 的
|
||
# /health 据此算 worker_ok(见 server.py 的健康检查)。
|
||
self._write_heartbeat()
|
||
# 多 worker 池注册:启动时注册一次,之后每轮结束写注册表
|
||
# 心跳(见本轮结束处);注册失败只记日志,不炸循环。
|
||
self.register()
|
||
started = time.time()
|
||
all_results = []
|
||
# 心跳线程:daemon,run_loop 退出即停,不阻塞进程退出。
|
||
hb_stop = threading.Event()
|
||
|
||
def _hb_loop():
|
||
while not hb_stop.is_set() and not self._loop_stop:
|
||
if stop_event is not None and stop_event.is_set():
|
||
break
|
||
self._heartbeat_running_tasks()
|
||
# 注册表心跳也每 30 秒刷一次:长任务跑满一轮时
|
||
# run_all 久久不返回,只靠轮次结束的心跳会让
|
||
# /workers 的 running 数长期停在旧值;这里刷的
|
||
# running 含启动中占位,与认领门口径一致。
|
||
self._registry_heartbeat()
|
||
hb_stop.wait(30)
|
||
|
||
hb_thread = threading.Thread(target=_hb_loop, daemon=True)
|
||
hb_thread.start()
|
||
try:
|
||
while not self._loop_stop:
|
||
if stop_event is not None and stop_event.is_set():
|
||
break
|
||
if max_seconds is not None and \
|
||
time.time() - started >= max_seconds:
|
||
break
|
||
# 代码变化自退:部署换目录后,旧代码 worker 若还抢
|
||
# 任务会污染生产(2026-10-27 实弹:05:51 的旧
|
||
# docker worker 成孤儿后继续用旧代码抢任务、按老路
|
||
# 失败且不删暂存)。检查点在轮次边界,绝不中断在跑
|
||
# 任务;退出码 75(EX_TEMPFAIL,失败类):unit 托管
|
||
# 的由 systemd on-failure 重拉新版,autoscaler 托管
|
||
# 的由 reconcile 重拉,孤儿直接消失。单次 run_all
|
||
# 不走这里(进程本来就退出)。P1 fail-closed:
|
||
# 签名缺失同样自退(旧版只在“变了”时退,取不到
|
||
# 签名时继续跑是 fail-open);每次认领前的签名门
|
||
# 在 _claim 里(池线程不走轮次边界也拦得住)。
|
||
if self._code_changed() or \
|
||
not self._code_signature_valid():
|
||
log_event(self.logger, "worker_code_changed_exit",
|
||
message="worker code changed, exiting "
|
||
"for restart")
|
||
raise SystemExit(75)
|
||
try:
|
||
results = self.run_all()
|
||
except Exception as exc:
|
||
# 常驻循环一轮炸了不带走整个 worker:记结构化日志,
|
||
# 睡完本轮间隔再试下一轮(缺表、锁冲突都属此类)。
|
||
log_event(self.logger, "worker_loop_error",
|
||
message="worker_loop_error: %s" % exc)
|
||
results = []
|
||
all_results.extend(results)
|
||
# G11/G12 后台投递:摘要(每小时)与 outbox 重试,
|
||
# 轮次边界做,不中断在跑任务,失败只记日志。
|
||
self._process_push_due()
|
||
# 自动记忆提取:同款轮次边界后台扫,失败只记日志
|
||
# (process_auto_memories 内部已兜底,不炸循环)。
|
||
self.process_auto_memories()
|
||
# 自动陈旧回收:死 worker 的 running 任务在这里被
|
||
# 标 interrupted;活任务心跳新鲜,不会被扫到。
|
||
if sweep_threshold is not None:
|
||
try:
|
||
from cloud.server import sweep_stale_tasks
|
||
sweep_stale_tasks(self.db_path, sweep_threshold)
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_sweep_failed",
|
||
message="worker sweep failed: %s" % exc)
|
||
# P1 孤儿扫尾(接在陈旧回收之后):协调员本身
|
||
# 被回收标 interrupted 后,它的未终态子任务
|
||
# 在这里被统一标 interrupted;协调员仍在跑时
|
||
# 本函数不动子任务,见 sweep 方法 docstring。
|
||
try:
|
||
self.sweep_orphaned_coordinator_children()
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_orphan_sweep_failed",
|
||
message="orphan sweep failed: %s" % exc)
|
||
# 跨 worker 真 kill 的收殓器:放在 sweep 块外、每轮
|
||
# 都跑(旧 PR 只在 sweep 块内跑,sweep 关掉时请
|
||
# 求会积压;本函数自带心跳停滞判定,sweep 关掉
|
||
# 也不抢杀活 owner)。陈旧回收刚把死 worker 的
|
||
# running 任务标 interrupted 并写了 kill 请求,
|
||
# 这里按请求杀本机遗留容器;owner 活着的任务心
|
||
# 跳新鲜,收殓器绝不抢杀。
|
||
try:
|
||
self._reap_kill_requests(
|
||
sweep_threshold
|
||
if sweep_threshold is not None else 300)
|
||
except Exception as exc:
|
||
log_event(self.logger, "worker_reap_failed",
|
||
message="worker reap failed: %s" % exc)
|
||
# 长任务自动恢复:接在 sweep 之后——刚被 sweep
|
||
# 标 interrupted 的系统中断 goal 任务,本轮即可
|
||
# 回 queued、下一轮认领即断点续跑;用户暂停、
|
||
# 配额、协调员孤儿一律不接(见方法 docstring)。
|
||
# 放在 sweep 块外:sweep 关掉时,已由其它路径
|
||
# (如 worker 退出)标 interrupted 的任务仍要恢复。
|
||
self.auto_resume_interrupted()
|
||
# 每轮结束写心跳:哪怕本轮一个任务都没派,worker 活着
|
||
# 这件事本身也要被 server 看到(文件心跳 + 注册表心跳)。
|
||
self._write_heartbeat()
|
||
self._registry_heartbeat()
|
||
# 按 0.2 秒切片睡,期间可响应 stop_event/_loop_stop。
|
||
deadline = time.time() + interval
|
||
while time.time() < deadline:
|
||
if self._loop_stop:
|
||
break
|
||
if stop_event is not None and stop_event.is_set():
|
||
break
|
||
if max_seconds is not None and \
|
||
time.time() - started >= max_seconds:
|
||
break
|
||
time.sleep(0.2)
|
||
finally:
|
||
hb_stop.set()
|
||
# 等心跳线程彻底退出再返回:否则它手头一次未写完的
|
||
# 注册表心跳可能在调用方清理 DB 目录时重建 control.db,
|
||
# 导致 tearDown rmtree 报 Directory not empty(全量套件
|
||
# 里 test_code_changed_exits_loop_before_claiming 偶发
|
||
# error 即此竞态)。daemon 线程 + set() 会唤醒 wait,
|
||
# 正常情况下 join 瞬间返回;5 秒超时兜底,避免写挂时
|
||
# 拖住退出。
|
||
hb_thread.join(timeout=5)
|
||
# 优雅排空:先等正在跑的任务自然结束,超时后才杀。
|
||
# 串行模式下 run_all 返回时任务已跑完,这里立刻通过;
|
||
# 并行模式下停机信号退出时后台线程可能还在跑,先等
|
||
# drain_timeout 秒,超时后按下面杀残留。
|
||
if drain_timeout is not None:
|
||
self._wait_for_running(timeout=drain_timeout)
|
||
for task_id, entry in self._running_snapshot().items():
|
||
if entry.get("proc") is not None:
|
||
self._kill_proc_group(entry["proc"])
|
||
try:
|
||
entry["proc"].wait(timeout=3)
|
||
except Exception:
|
||
pass
|
||
if entry.get("container") is not None and \
|
||
hasattr(self.executor, "kill_container"):
|
||
try:
|
||
self.executor.kill_container(entry["container"])
|
||
except Exception:
|
||
pass
|
||
self._running_clear(task_id)
|
||
self._set_status(task_id, "interrupted")
|
||
# worker 退出时的系统中断,可自动恢复。
|
||
self._mark_interrupted_reason(task_id, "worker_shutdown")
|
||
# 审计:此前这条路径只改状态不记审计,中断只能靠
|
||
# 重启时间 + 后续 auto_resumed 推断(2026-10-29
|
||
# 实弹);记一条 worker_shutdown_interrupted,
|
||
# detail 为任务 id,与 sweep_interrupted 同口径。
|
||
try:
|
||
_conn = _db_connect(self.db_path)
|
||
try:
|
||
_row = _conn.execute(
|
||
"SELECT project_id FROM tasks WHERE id = ?",
|
||
(task_id,)).fetchone()
|
||
finally:
|
||
_conn.close()
|
||
_pid = _row[0] if _row is not None else ""
|
||
if _pid is None:
|
||
_pid = ""
|
||
except Exception:
|
||
_pid = ""
|
||
self._audit(_pid, "worker_shutdown_interrupted",
|
||
task_id)
|
||
return all_results
|
||
|
||
|
||
def _build_cli_executor(args, host_py):
|
||
"""CLI 的执行器构造:local 时返回 None(Worker 默认 LocalExecutor),
|
||
docker 时返回 DockerExecutor(env_key 名用 Worker 的解析规则,
|
||
值本身绝不从这里传——DockerExecutor 只查存在性,-e 只传名),
|
||
cloud 时返回 CloudExecutor(通用 HTTP runner,未配 endpoint
|
||
时执行即明确失败,绝不降级)。"""
|
||
if getattr(args, "executor", "local") == "cloud":
|
||
return CloudExecutor()
|
||
if getattr(args, "executor", "local") != "docker":
|
||
return None
|
||
return DockerExecutor(
|
||
host_py, config_path=args.config, image=args.docker_image,
|
||
cpus=args.docker_cpus, memory=args.docker_memory,
|
||
user=args.docker_user, worktrees_root=args.docker_worktrees_root,
|
||
env_key=resolve_env_key(args.env_key, args.codex_config),
|
||
timeout=args.docker_timeout,
|
||
extra_mounts=args.docker_extra_mounts,
|
||
security_opts=args.docker_security_opt,
|
||
pids_limit=args.docker_pids_limit,
|
||
runtime=getattr(args, "docker_runtime", None),
|
||
readonly_root=getattr(args, "docker_readonly_root", False))
|
||
|
||
|
||
def main(argv=None):
|
||
"""worker CLI:默认单次 run_all;--loop 进常驻循环。
|
||
|
||
只打印任务结果摘要(状态、host 名、错误文本),绝不打印
|
||
任何环境变量的值。
|
||
"""
|
||
parser = argparse.ArgumentParser(description="cloud worker")
|
||
parser.add_argument("--db", default="cloud-control-plane.db")
|
||
parser.add_argument("--host-py", default=None,
|
||
help="host.py 路径,缺省为 cloud/ 上一级的 host.py")
|
||
parser.add_argument("--config", default=None)
|
||
parser.add_argument("--repo", default=None)
|
||
parser.add_argument("--heartbeat-file", default=None,
|
||
help="运维心跳文件路径(默认:环境变量 "
|
||
"CLOUD_WORKER_HEARTBEAT_FILE,未设则为 "
|
||
"./worker-heartbeat.json;主机部署时用此"
|
||
"参数指到 /var/lib/codex-app-server/ 下,"
|
||
"与 server 的 health 读取路径保持一致)")
|
||
parser.add_argument("--repos-dir", default="./repos",
|
||
help="项目仓库检出根目录(默认 ./repos);项目有"
|
||
" repo_url 时 worker 在此 clone/fetch 各项目"
|
||
"仓库;Docker 执行器下它必须放在"
|
||
" --docker-extra-mounts 已挂载的目录下,"
|
||
"否则容器内 task repo 路径不存在")
|
||
parser.add_argument("--tenant-isolation", action="store_true",
|
||
help="多租户隔离(仅 Docker 执行器):每个项目用"
|
||
"独立的 tenants_root/<项目>/work、codex-home、"
|
||
"test-repo 副本与项目独立 Docker 网络,容器"
|
||
"内路径不变,宿主挂载源按项目分开")
|
||
parser.add_argument("--tenants-root",
|
||
default="/var/lib/codex-app-server/tenants")
|
||
parser.add_argument("--tenant-template-home",
|
||
default="/var/lib/codex-app-server/codex-home",
|
||
help="租户 codex-home 播种模板:只取其中的"
|
||
"config.toml,绝不复制 thread_history")
|
||
parser.add_argument("--tenant-template-repo",
|
||
default="/var/lib/codex-app-server/test-repo",
|
||
help="租户 test-repo 播种模板(整目录复制)")
|
||
parser.add_argument("--env-key", default=None)
|
||
parser.add_argument("--codex-config", default=None,
|
||
help="含 env_key 行的 codex 配置文件(只读名字)")
|
||
parser.add_argument("--model", default=None)
|
||
parser.add_argument("--fallback-models", default=None,
|
||
help="真实 fallback 换模型列表,逗号分隔"
|
||
"(默认空 = 关闭,只记事件不换模型;"
|
||
"配了时主模型可 fallback 失败后最多换"
|
||
"列表里的第一个模型再跑一次,401/400"
|
||
"绝不 fallback,取消绝不 fallback)")
|
||
parser.add_argument("--max-turns", type=int, default=None)
|
||
parser.add_argument("--ignore-host-budget", dest="ignore_host_budget",
|
||
action="store_true", default=True,
|
||
help="G9(默认开):worker 路径忽略 host 的 "
|
||
"max_daily_turns 预算,云端 per-user USD "
|
||
"配额 + worker 门才是口径")
|
||
parser.add_argument("--no-ignore-host-budget",
|
||
dest="ignore_host_budget", action="store_false",
|
||
help="G9:关闭忽略,worker 调 host.py 时 host "
|
||
"的 max_daily_turns 预算照常生效(双重门)")
|
||
parser.add_argument("--max-tasks", type=int, default=None)
|
||
parser.add_argument("--executor", default="local",
|
||
choices=["local", "docker", "cloud"],
|
||
help="执行器:local 调 host.py 子进程;docker"
|
||
" 调 DockerExecutor(无 docker 时任务"
|
||
"明确失败,不静默降级);cloud 调通用"
|
||
" HTTP runner(需 CLOUD_RUNNER_ENDPOINT)")
|
||
parser.add_argument("--worker-id", default=None,
|
||
help="多 worker 池身份(默认:主机名-进程号;"
|
||
"生产部署应配稳定 id,否则重启后旧行变 stale)")
|
||
parser.add_argument("--worker-capacity", type=int, default=None,
|
||
help="worker 并发上限:同一 worker 内最多同时跑"
|
||
"几个任务(未给时读环境变量"
|
||
" CLOUD_WORKER_CAPACITY,未设则默认 1,"
|
||
"即旧串行行为;>1 时用线程池并行,认领"
|
||
"仍是 CAS,成本写库持锁)")
|
||
parser.add_argument("--host-name", default=None,
|
||
help="本 worker 所在主机名(HA 阶段 1):认领任务"
|
||
"时写进 tasks.worktree_host,/pr 据此判断"
|
||
"worktree 在哪台主机;容器内 worker 必须由"
|
||
"autoscaler 显式传宿主名,不能用容器名")
|
||
parser.add_argument("--pr-jobs-only", action="store_true",
|
||
help="只跑 pr 任务(HA 阶段 1 的跨主机 /pr 演练"
|
||
"与专用 pr-runner 用):不认领普通任务")
|
||
parser.add_argument("--docker-image", default="codex-app-server:latest")
|
||
parser.add_argument("--docker-cpus", default="2")
|
||
parser.add_argument("--docker-memory", default="2g")
|
||
parser.add_argument("--docker-user", default="1000:1000")
|
||
parser.add_argument("--docker-worktrees-root", default=None)
|
||
parser.add_argument("--docker-timeout", type=float, default=None)
|
||
parser.add_argument("--docker-extra-mounts", default=None,
|
||
help="docker 额外挂载,逗号分隔 host:container[:ro|rw],"
|
||
"用于容器内 host.py 写 work/、host-state.json,"
|
||
"以及挂载原仓库和 codex-home(同路径挂载,"
|
||
"容器内配置无需改路径)")
|
||
parser.add_argument("--docker-security-opt", action="append",
|
||
default=None,
|
||
help="docker --security-opt,可重复(如"
|
||
" apparmor=docker-codex;no-new-privileges"
|
||
" 已硬编码,重复传也不重复)")
|
||
parser.add_argument("--docker-pids-limit", type=int, default=512,
|
||
help="docker --pids-limit(默认 512,防 fork 炸弹;"
|
||
"codex 工具链正常用量远低于此)")
|
||
parser.add_argument("--docker-runtime", default=None,
|
||
help="docker --runtime(不传 = docker 默认 runc;"
|
||
"传 runsc = 任务容器走 gVisor 沙箱,需 daemon"
|
||
"已注册该 runtime,否则起容器失败)")
|
||
parser.add_argument("--docker-readonly-root", action="store_true",
|
||
help="docker --read-only:容器根只读(/work 挂载"
|
||
"仍可写、/tmp 与 /var/tmp 为 tmpfs)。默认关:"
|
||
"codex 可能往容器内 HOME 等路径写,开之前"
|
||
"需先在生产任务上实测,勿直接全量开")
|
||
parser.add_argument("--egress-proxy-port", type=int, default=0,
|
||
help="egress 过滤代理端口(0 = 不启用;生产配"
|
||
" 8888):启用后 Docker 任务容器的 HTTP(S)"
|
||
" 出口全部走宿主该端口的代理,代理只放行"
|
||
"白名单域名;网关查不到时任务直接 failed,"
|
||
"绝不无代理运行")
|
||
parser.add_argument("--claim-executor-only", action="store_true",
|
||
help="严格 executor 认领:只认领 executor 恰好"
|
||
"等于本 worker 类型的任务,绝不认领"
|
||
" executor 为 NULL 的任务。宿主级沙箱"
|
||
" worker 必须开,否则它会抢走本该进"
|
||
" Docker 容器的 NULL 任务")
|
||
parser.add_argument("--websearch-mcp", action="store_true",
|
||
help="租户 codex-home 自动播种网页搜索 MCP:"
|
||
"_tenant_dirs 在播种 config.toml 后追加"
|
||
" [mcp_servers.websearch] 并拷入"
|
||
" websearch.py / websearch_mcp.py;"
|
||
"默认关,老租户不受影响")
|
||
parser.add_argument("--seed-skills", action="store_true",
|
||
help="租户 codex-home 自动播种对齐技能包:"
|
||
"_tenant_dirs 把 cloud/skills/ 的 SKILL.md"
|
||
" 拷进租户 codex-home/skills/;"
|
||
"默认关,老租户不受影响;租户改过的"
|
||
"技能文件绝不覆盖")
|
||
parser.add_argument("--imagegen-mcp", action="store_true",
|
||
help="租户 codex-home 自动播种图片生成 MCP:"
|
||
"_tenant_dirs 追加 [mcp_servers.imagegen] 并拷入"
|
||
" imagegen.py / imagegen_mcp.py,_prepare_tenant"
|
||
" 写 [mcp_servers.imagegen.env](系统级"
|
||
" IMAGE_GEN_API_KEY + 代理 + NO_PROXY);"
|
||
"默认关,老租户不受影响;未配 key 时工具"
|
||
"明确报未配置,不回退任务自己的网关 key")
|
||
parser.add_argument("--playwright-mcp", action="store_true",
|
||
help="租户 codex-home 自动播种浏览器自动化 MCP:"
|
||
"_tenant_dirs 追加 [mcp_servers.playwright] 并拷入"
|
||
" playwright_browser.py / playwright_mcp.py,"
|
||
"_prepare_tenant 写 [mcp_servers.playwright.env]"
|
||
"(代理 + PLAYWRIGHT_BROWSERS_PATH);"
|
||
"默认关,老租户不受影响;浏览器二进制在镜像里")
|
||
parser.add_argument("--task-timeout", type=float, default=1800,
|
||
help="每任务截止时间(秒,默认 1800);0 表示不限时"
|
||
"(与 host --turn-timeout 0 同约定,worker 级"
|
||
"不设截止,但 Docker 路径仍受 --docker-timeout"
|
||
"约束);超时后真杀容器/进程组、任务置 failed;"
|
||
"与 --docker-timeout(单次 docker run 超时)取"
|
||
"较小值,与 --max-seconds(run_loop 整体墙钟上限)不同")
|
||
parser.add_argument("--max-retries", type=int, default=1,
|
||
help="模型任务失败重试次数(默认 1,0 表示不重试;"
|
||
"取消与已记 fallback 的失败不重试)")
|
||
parser.add_argument("--retry-backoff-seconds", type=float, default=5.0,
|
||
help="重试前等待秒数(默认 5)")
|
||
parser.add_argument("--auto-resume-max", type=int, default=50,
|
||
help="长任务自动恢复上限(次数,非时间,默认 50):"
|
||
"同一 goal 任务被系统中断后自动续跑超过该次数"
|
||
"即停住等人工,防确定性失败无限烧额度")
|
||
parser.add_argument("--loop", action="store_true",
|
||
help="常驻循环,每 --interval 秒跑一轮")
|
||
parser.add_argument("--interval", type=int, default=300)
|
||
parser.add_argument("--drain-timeout", type=float, default=60,
|
||
help="停机优雅排空等待上限(秒):SIGTERM/SIGINT"
|
||
"后先等在跑任务自然结束,超时后才杀残留"
|
||
"并标 interrupted;默认 60 秒(systemd"
|
||
"默认 90 秒停机时限内能完成)")
|
||
parser.add_argument("--sweep-threshold", type=float, default=300,
|
||
help="自动陈旧回收阈值(秒):running 任务心跳"
|
||
"超过该秒数未更新即被回收;活任务每 30"
|
||
"秒刷心跳,不会被误杀")
|
||
parser.add_argument("--max-seconds", type=float, default=None)
|
||
args = parser.parse_args(argv)
|
||
setup_logging()
|
||
host_py = args.host_py
|
||
if host_py is None:
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
host_py = os.path.join(os.path.dirname(here), "host.py")
|
||
worker = Worker(args.db, host_py, config_path=args.config,
|
||
repo=args.repo, env_key=args.env_key,
|
||
codex_config_path=args.codex_config, model=args.model,
|
||
fallback_models=(
|
||
[m.strip() for m in
|
||
args.fallback_models.split(",")]
|
||
if args.fallback_models else []),
|
||
max_turns=args.max_turns,
|
||
ignore_host_budget=args.ignore_host_budget,
|
||
executor=_build_cli_executor(args, host_py),
|
||
task_timeout=args.task_timeout,
|
||
max_retries=args.max_retries,
|
||
retry_backoff_seconds=args.retry_backoff_seconds,
|
||
auto_resume_max=args.auto_resume_max,
|
||
repos_dir=args.repos_dir,
|
||
heartbeat_file=args.heartbeat_file,
|
||
worker_id=args.worker_id,
|
||
capacity=args.worker_capacity,
|
||
host_name=args.host_name,
|
||
pr_jobs_only=args.pr_jobs_only,
|
||
tenant_isolation=args.tenant_isolation,
|
||
tenants_root=args.tenants_root,
|
||
tenant_template_home=args.tenant_template_home,
|
||
tenant_template_repo=args.tenant_template_repo,
|
||
egress_proxy_port=args.egress_proxy_port,
|
||
claim_executor_only=args.claim_executor_only,
|
||
websearch_mcp=args.websearch_mcp,
|
||
seed_skills=args.seed_skills,
|
||
imagegen_mcp=args.imagegen_mcp,
|
||
playwright_mcp=args.playwright_mcp)
|
||
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
|
||
worker.cleanup_stale_containers()
|
||
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
|
||
try:
|
||
from cloud.server import init_db as _init_db
|
||
_init_db(args.db)
|
||
except Exception as exc:
|
||
print("worker db 初始化失败:%s" % exc, file=sys.stderr)
|
||
raise SystemExit(1)
|
||
if args.loop:
|
||
def _on_signal(signum, frame):
|
||
worker._loop_stop = True
|
||
signal.signal(signal.SIGTERM, _on_signal)
|
||
signal.signal(signal.SIGINT, _on_signal)
|
||
try:
|
||
if args.loop:
|
||
results = worker.run_loop(interval=args.interval,
|
||
max_seconds=args.max_seconds,
|
||
drain_timeout=args.drain_timeout,
|
||
sweep_threshold=args.sweep_threshold)
|
||
else:
|
||
results = worker.run_all(max_tasks=args.max_tasks)
|
||
finally:
|
||
worker.close()
|
||
print(json.dumps(
|
||
[{"task_id": r.get("task_id"), "status": r.get("status"),
|
||
"reason": r.get("reason"), "error": r.get("error")}
|
||
for r in results],
|
||
indent=2, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|