Worker concurrency (capacity enforced), survival (heartbeat/sweep/drain), retry endpoint

1. Concurrency: capacity is now a real limit, not display-only.
   run_all with capacity>1 runs a manual thread pool (threading,
   not concurrent.futures: a test in this repo puts cloud/ on
   sys.path, shadowing stdlib queue and breaking
   concurrent.futures' SimpleQueue import). Claim stays CAS, so
   two threads racing one task still yield one owner; max_tasks
   counts dispatched+running to avoid overshoot; stop signals
   stop new claims only, running tasks are drained by run_loop.
   Running state moved from single fields to a _running dict
   (proc/container per task) so cancel kills the right task.
   Docker timeout is now a per-call argument, never a mutated
   shared executor field. CostStore opens a fresh connection per
   record/report (shared sqlite connection is thread-bound and
   raised ProgrammingError under the pool; :memory: keeps the
   shared connection because a fresh one is an empty DB).
   Final status write is CAS on status='running' so a background
   thread cannot overwrite an interrupted task with completed.
2. Survival: run_loop starts a daemon heartbeat thread (every
   30s, refreshes running tasks' last_heartbeat) and sweeps stale
   tasks every round (threshold 300s, best-effort); live tasks
   never go stale now, dead workers' tasks are reclaimed without
   manual /admin/sweep. On exit, run_loop waits up to
   drain_timeout (CLI --drain-timeout, default 60s, inside
   systemd's 90s stop window) for running tasks before killing
   leftovers and marking interrupted.
3. Retry: POST /tasks/{id}/retry (maintainer+, 404/403/409):
   failed/interrupted/merge-failed go back to queued with
   result/error/model cleared, prompt/model/deps kept, CAS
   update, audit task_retried.
Tests: 5 concurrency tests (barrier overlap, serial capacity=1,
max_tasks, wait_for_running, CAS final write), 3 retry tests;
full suites pass.
This commit is contained in:
host committed 2026-10-26 06:46:20 +08:00
1 parent 341f4a83e1
commit d51c06a900
7 files changed
+793 -116

No files matched your search

+71 -25
View File
@@ -76,6 +76,41 @@ class CostStore:
)
self._conn.commit()
def _new_conn(self):
"""每次写/读开一个新连接,用完即关。
旧实现在 __init__ 里建一条连接并终身共用,单线程没问题;
worker 并发后多个线程共用同一条 sqlite 连接会直接报
ProgrammingError(连接是线程绑定的)。其它 store(Worker
的 _set_status、_audit 等)本来就是每次开新连接,这里
向它们看齐。_conn 仍保留(建表 + 旧测试直接用它读),
但 record/_report 不再走它。
例外:db_path 为 :memory: 时新连接是另一个空库,必须
复用 _conn(:memory: 只用于单线程单测,并发 worker
从不用它)。
"""
if self.db_path == ":memory:":
return self._conn
conn = _db_connect(self.db_path)
conn.execute(
"""CREATE TABLE IF NOT EXISTS cost_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
project_id TEXT,
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
usd_micros INTEGER,
source TEXT,
fallback_count INTEGER DEFAULT 0,
day TEXT,
created_at REAL
)"""
)
conn.commit()
return conn
def record(self, task_id, project_id, model, input_tokens=0,
output_tokens=0, usd_micros=None, source="actual",
fallback_count=0):
@@ -93,18 +128,24 @@ class CostStore:
total_tokens = input_tokens + output_tokens
created_at = time.time()
day = datetime.fromtimestamp(created_at, tz=timezone.utc).strftime("%Y-%m-%d")
cursor = self._conn.execute(
"""INSERT INTO cost_records
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, fallback_count, day,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, int(fallback_count or 0),
day, created_at),
)
self._conn.commit()
return cursor.lastrowid
conn = self._new_conn()
try:
cursor = conn.execute(
"""INSERT INTO cost_records
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, fallback_count, day,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, project_id, model, input_tokens, output_tokens,
total_tokens, usd_micros, source, int(fallback_count or 0),
day, created_at),
)
conn.commit()
return cursor.lastrowid
finally:
# :memory: 时 conn 就是共享的 _conn,绝不能关掉它。
if conn is not self._conn:
conn.close()
def record_estimated(self, task_id, project_id, model, prompt_text,
output_text):
@@ -123,19 +164,24 @@ class CostStore:
)
def _report(self, where, params):
row = self._conn.execute(
"""SELECT
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(usd_micros), 0),
COUNT(*),
COALESCE(SUM(fallback_count), 0),
COALESCE(SUM(CASE WHEN source = 'actual'
THEN total_tokens ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN source = 'estimated'
THEN total_tokens ELSE 0 END), 0)
FROM cost_records WHERE %s""" % where,
params,
).fetchone()
conn = self._new_conn()
try:
row = conn.execute(
"""SELECT
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(usd_micros), 0),
COUNT(*),
COALESCE(SUM(fallback_count), 0),
COALESCE(SUM(CASE WHEN source = 'actual'
THEN total_tokens ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN source = 'estimated'
THEN total_tokens ELSE 0 END), 0)
FROM cost_records WHERE %s""" % where,
params,
).fetchone()
finally:
if conn is not self._conn:
conn.close()
total_tokens, usd_micros, turns, fallback_count, actual, estimated = row
return {
"total_tokens": int(total_tokens),
+17 -10
View File
@@ -420,10 +420,17 @@ class DockerExecutor(Executor):
return cmd
# -- runner 调用 / 清理 --------------------------------------------
def _call_runner(self, cmd):
"""调 runner:优先传 timeout,假 runner 只接受 cmd 时回退。"""
def _call_runner(self, cmd, timeout=None):
"""调 runner:优先传 timeout,假 runner 只接受 cmd 时回退。
timeout 参数只影响本次调用(并发下两个任务各用各的
超时);不传时用实例的 self.timeout。绝不能在调用方
临时改 self.timeout 再恢复,那在并发下会互相覆盖。
"""
try:
return self.runner(cmd, timeout=self.timeout)
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout)
except TypeError:
return self.runner(cmd)
@@ -493,20 +500,20 @@ class DockerExecutor(Executor):
continue
return ok
def _run_in_container(self, task, inner_cmd):
def _run_in_container(self, task, inner_cmd, timeout=None):
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
说明,returncode 为 None);其它 runner 异常也如实返回
failed,不编造成功。
failed,不编造成功。timeout 只影响本次调用,不改实例。
"""
task_id = task.get("id") if isinstance(task, dict) else None
if task_id is None:
raise ValueError("任务缺少 id")
cmd = self.build_run_command(task, inner_cmd)
try:
result = self._call_runner(cmd)
result = self._call_runner(cmd, timeout=timeout)
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
self.kill_container(task)
@@ -529,7 +536,7 @@ class DockerExecutor(Executor):
parsed["container"] = self.container_name(task)
return parsed
def dispatch_task(self, task) -> dict:
def dispatch_task(self, task, timeout=None) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -541,9 +548,9 @@ class DockerExecutor(Executor):
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
return self._run_in_container(task, inner)
return self._run_in_container(task, inner, timeout=timeout)
def run_task(self, task) -> dict:
def run_task(self, task, timeout=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -552,7 +559,7 @@ class DockerExecutor(Executor):
inner = self._inner_host_command(
"run", "--task", self._host_name(task),
"--prompt", task["prompt"], model=task.get("model"))
return self._run_in_container(task, inner)
return self._run_in_container(task, inner, timeout=timeout)
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。
+63
View File
@@ -624,6 +624,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._merge_task(user_id, rest[: -len("/merge")])
elif rest.endswith("/pr") and method == "POST":
self._task_pr(user_id, rest[: -len("/pr")])
elif rest.endswith("/retry") and method == "POST":
self._retry_task(user_id, rest[: -len("/retry")])
elif rest.endswith("/heartbeat") and method == "POST":
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
else:
@@ -1321,6 +1323,67 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, task["project_id"], "merge_task", task_id)
self._send_json(200, task)
def _retry_task(self, user_id, task_id):
"""把终态失败任务重新排队:只改状态,不直接执行。
规则:任务必须存在(404)、调用方必须是项目 maintainer
以上(approve_merge,403——重跑要再花模型钱,不能让
只有读权限或普通贡献者随便点)、任务必须是 failed /
interrupted / merge-failed(其它状态一律 409:running
本来就在跑,queued 本来就在等,completed/merged 已有
结果,重跑应是新任务而不是覆盖结果)。
成功后状态回 queued,上一轮的 result_text/error_text/
actual_model 清空(否则新一轮的结果会和旧结果混在一起
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
已完成时重跑任务会立刻就绪。写审计 task_retried。
更新用 CAS(WHERE status IN (...)),避免与正在完成的
回合互相覆盖:若 CAS 没改到行,说明状态已变,返回
409 而不是假装成功。
"""
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if task["status"] not in ("failed", "interrupted", "merge-failed"):
self._send_json(409, {
"error": "only failed/interrupted tasks can be retried",
"status": task["status"],
})
return
now = time.time()
conn = self._db()
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')",
(now, task_id),
)
conn.commit()
if cur.rowcount != 1:
self._send_json(409, {
"error": "task status changed, retry aborted",
"status": task["status"],
})
return
task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone()
)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "task_retried", task_id)
self._send_json(200, task)
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
+1 -2
View File
@@ -289,8 +289,7 @@ class WorkerDockerTestCase(unittest.TestCase):
worker = Worker(self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "placeholder-not-a-real-key"}, executor=ex)
# 模拟 dispatch 阻塞期间:worker 已把容器名记下来。
worker._running_task_id = "d3"
worker._running_container = "codex-d3"
worker._running_set("d3", container="codex-d3")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
+136
View File
@@ -0,0 +1,136 @@
"""失败任务重跑端点测试:POST /tasks/{id}/retry。
只测状态机本身,不执行真实任务:失败/中断任务重回 queued,
上一轮结果清空,prompt 与模型原样保留;权限与状态码语义
与 merge 端点对齐(maintainer 以上、404/401/403/409)。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
class RetryTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.contrib_id, self.contrib_key = self.auth.create_user("contrib")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
self.auth.set_member(
self.project["id"], self.owner_id, self.contrib_id, "contributor")
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _failed_task(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x"},
self.owner_key)
self.assertEqual(status, 201)
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'failed',"
" error_text = 'old boom', actual_model = 'm1'"
" WHERE id = ?",
(task["id"],))
conn.commit()
finally:
conn.close()
return task
def test_retry_failed_by_owner_returns_queued_and_clears_result(self):
task = self._failed_task()
status, body = self._req(
"POST", "/tasks/%s/retry" % task["id"], {}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "queued")
self.assertIsNone(body.get("error_text"))
self.assertIsNone(body.get("actual_model"))
conn = sqlite3.connect(self.db_path)
try:
actions = [r[0] for r in conn.execute(
"SELECT action FROM audit_log WHERE project_id = ?",
(self.project["id"],)).fetchall()]
finally:
conn.close()
self.assertIn("task_retried", actions)
def test_retry_non_failed_409(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x"},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/%s/retry" % task["id"], {}, self.owner_key)
self.assertEqual(status, 409)
def test_retry_by_contributor_forbidden_and_missing_404(self):
task = self._failed_task()
status, _ = self._req(
"POST", "/tasks/%s/retry" % task["id"], {}, self.contrib_key)
self.assertEqual(status, 403)
status, _ = self._req(
"POST", "/tasks/nope/retry", {}, self.owner_key)
self.assertEqual(status, 404)
status, _ = self._req(
"POST", "/tasks/%s/retry" % task["id"], {})
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
"""Worker 并发单测。
只用注入的假 runner(假 CLI),不跑真实模型、不联网。
capacity>1 时同一 worker 内多个任务必须真正并行跑,而不是
被单任务字段或共享连接拖回串行;capacity=1 时行为与旧串行
完全一致;最终状态写回必须是 CAS,取消/回收后的任务不能
被后台线程盖回 completed。
"""
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.server import init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.server import init_db
from cloud.worker import Worker
def _insert_task(db_path, task_id, prompt="do work", project_id="p1"):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', 0, 'tester', ?, ?, NULL)",
(task_id, project_id, prompt, now, now),
)
conn.commit()
finally:
conn.close()
def _status(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT status FROM tasks WHERE id = ?",
(task_id,)).fetchone()
finally:
conn.close()
return row[0] if row else None
def _host_json(name, status, text):
return SimpleNamespace(
returncode=0,
stdout=json.dumps({"task": name, "status": status, "text": text}),
stderr="")
class WorkerConcurrencyTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, runner=None, **kwargs):
kwargs.setdefault("env", self.env)
kwargs.setdefault("retry_backoff_seconds", 0)
kwargs.setdefault("max_retries", 0)
return Worker(self.db_path, "/fake/host.py",
runner=runner, **kwargs)
def test_capacity_2_runs_two_tasks_concurrently(self):
# 两个任务各睡 0.5 秒:并行时它们必须在同一时间段内
# 都在跑(用 Barrier 证明重叠,而不是只看总时长,避免
# 慢 CI 上的时间断言抖动)。串行时第一个 runner 会在
# Barrier 上等满 5 秒超时,测试会因此失败——这正是要
# 证明的:capacity=2 不是串行。
barrier = threading.Barrier(2, timeout=5)
def fake(cmd):
barrier.wait()
time.sleep(0.1)
return _host_json("x", "completed", "done")
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
worker = self._worker(runner=fake, capacity=2)
results = worker.run_all()
self.assertEqual(len(results), 2)
self.assertEqual({r["status"] for r in results}, {"completed"})
self.assertEqual(_status(self.db_path, "A"), "completed")
self.assertEqual(_status(self.db_path, "B"), "completed")
def test_capacity_1_stays_serial(self):
# capacity=1 时 run_all 仍是旧串行循环:两个任务都完成,
# 且第二个任务的认领发生在第一个完成之后(靠任务状态
# 变化顺序断言,不靠时间)。
order = []
lock = threading.Lock()
def fake(cmd):
with lock:
order.append("start")
time.sleep(0.2)
with lock:
order.append("end")
return _host_json("x", "completed", "done")
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
worker = self._worker(runner=fake, capacity=1)
results = worker.run_all()
self.assertEqual(len(results), 2)
self.assertEqual(order, ["start", "end", "start", "end"])
def test_pool_max_tasks_respected(self):
# 并行模式下 max_tasks=1 只跑一个任务:提交数受
# max_tasks 约束,不能因为线程池先提交了两个就多跑。
def fake(cmd):
time.sleep(0.1)
return _host_json("x", "completed", "done")
for tid in ("A", "B", "C"):
_insert_task(self.db_path, tid)
worker = self._worker(runner=fake, capacity=2)
results = worker.run_all(max_tasks=1)
self.assertEqual(len(results), 1)
done = [tid for tid in ("A", "B", "C")
if _status(self.db_path, tid) == "completed"]
self.assertEqual(len(done), 1)
def test_wait_for_running(self):
# _wait_for_running:有任务在跑时超时返回 False,
# 任务清掉后返回 True(run_loop 优雅排空靠它)。
worker = self._worker(capacity=2)
worker._running_set("A", container="codex-A")
self.assertFalse(worker._wait_for_running(timeout=0.3))
def clearer():
time.sleep(0.2)
worker._running_clear("A")
threading.Thread(target=clearer, daemon=True).start()
self.assertTrue(worker._wait_for_running(timeout=5))
def test_final_write_does_not_overwrite_interrupted(self):
# 任务在跑时被外部标成 interrupted(取消/回收),随后
# runner 返回 completed,最终写回绝不能把它盖回 completed。
started = threading.Event()
def fake(cmd):
started.set()
time.sleep(0.5)
return _host_json("x", "completed", "done")
_insert_task(self.db_path, "A")
worker = self._worker(runner=fake, capacity=1)
outcome = {}
def run():
outcome["result"] = worker.run_one()
t = threading.Thread(target=run, daemon=True)
t.start()
self.assertTrue(started.wait(timeout=5))
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'interrupted' WHERE id = 'A'")
conn.commit()
finally:
conn.close()
t.join(timeout=10)
self.assertEqual(outcome["result"]["status"], "interrupted")
self.assertEqual(_status(self.db_path, "A"), "interrupted")
if __name__ == "__main__":
unittest.main()
+317 -79
View File
@@ -117,6 +117,7 @@ import signal
import socket
import subprocess
import sys
import threading
import time
from types import SimpleNamespace
@@ -270,13 +271,21 @@ class Worker:
repo=repo)
self._cost_store = cost_store
self._cancel_requested = set()
# 真取消用:正在跑的子进程(Popen 对象)与任务 id。
# process_factory 可注入:单测用它返回一个假长跑子进程,
# 真实执行时走 LocalExecutor.popen_task(进程组启动)。
# 真取消用:process_factory 可注入(单测用它返回一个假长跑
# 子进程,真实执行时走 LocalExecutor.popen_task 进程组启动)。
self.process_factory = process_factory
self._running_proc = None
self._running_task_id = None
self._running_container = None
# 并发运行状态: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()
# DB 写锁:cost 记录共用一条连接(CostStore._conn),
# usage 计数也是共享变量;模型回合本身在子进程/容器里
# 跑不受这把锁影响,锁只覆盖毫秒级的写库操作。
self._db_lock = threading.RLock()
self._loop_stop = False
self.logger = get_logger("cloud.worker")
# 任务级超时 / 失败重试(见模块 docstring 的生产级说明)。
@@ -469,6 +478,27 @@ class Worker:
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,
@@ -597,6 +627,53 @@ class Worker:
if t.get("executor") in (None, "", mine)]
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 _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。
@@ -608,26 +685,24 @@ class Worker:
if task is None or task["status"] in TERMINAL_STATUSES:
return False
self._cancel_requested.add(task_id)
if self._running_task_id == task_id and self._running_proc is not None:
self._kill_proc_group(self._running_proc)
entry = self._running_snapshot().get(task_id) or {}
if entry.get("proc") is not None:
self._kill_proc_group(entry["proc"])
try:
self._running_proc.wait(timeout=3)
entry["proc"].wait(timeout=3)
except Exception:
pass
self._running_proc = None
self._running_task_id = None
# Docker 取消:按任务派生的容器名真杀,不只改数据库状态。
# 同步路径(runner 阻塞在 docker run 里)下 _running_proc
# 为空,此时靠 _running_container 杀容器;kill 尽力而为,
# 失败也不影响后续的状态标记。
if self._running_task_id == task_id and \
self._running_container is not None and \
# 同步路径(runner 阻塞在 docker run 里)下 proc 为空,
# 此时靠容器名杀容器;kill 尽力而为,失败也不影响后续
# 的状态标记。
if entry.get("container") is not None and \
hasattr(self.executor, "kill_container"):
try:
self.executor.kill_container(self._running_container)
self.executor.kill_container(entry["container"])
except Exception:
pass
self._running_container = None
self._running_clear(task_id)
conn = _db_connect(self.db_path)
try:
cur = conn.execute(
@@ -724,8 +799,7 @@ class Worker:
proc = self.process_factory(task)
else:
proc = self.executor.popen_task(task, kind=kind)
self._running_proc = proc
self._running_task_id = task_id
self._running_set(task_id, proc=proc)
try:
self._set_child_pid(task_id, getattr(proc, "pid", None))
except Exception:
@@ -754,8 +828,7 @@ class Worker:
pass
out, err = proc.communicate()
finally:
self._running_proc = None
self._running_task_id = None
self._running_clear(task_id)
if cancelled:
return {"executor": "local", "task_id": task_id,
"status": "cancelled", "stdout": out or "",
@@ -839,28 +912,30 @@ class Worker:
result = None
error = None
# Docker 同步路径:dispatch 阻塞在 docker run 里时,取消
# 线程靠 _running_container 按名杀容器(见 cancel())。
# 线程靠容器名杀容器(见 cancel())。容器名按任务派生,
# 与执行器本身无关,所以这里只记名不碰执行器状态。
if hasattr(self.executor, "container_name"):
try:
self._running_container = \
self.executor.container_name(task)
self._running_task_id = task_id
self._running_set(
task_id,
container=self.executor.container_name(task))
except Exception:
self._running_container = None
pass
# Docker 超时取较小值:两个超时都不应静默延长另一个。
saved_docker_timeout = None
# 注意绝不能改共享的 executor.timeout(并发下两个任务会
# 互相覆盖对方的超时);超时只作为本次调用的参数传下去。
effective_docker_timeout = None
is_docker = isinstance(self.executor, DockerExecutor)
if is_docker and self.task_timeout is not None:
saved_docker_timeout = self.executor.timeout
if saved_docker_timeout is None:
self.executor.timeout = self.task_timeout
if self.executor.timeout is None:
effective_docker_timeout = self.task_timeout
else:
try:
self.executor.timeout = min(
effective_docker_timeout = min(
float(self.task_timeout),
float(saved_docker_timeout))
float(self.executor.timeout))
except (TypeError, ValueError):
self.executor.timeout = self.task_timeout
effective_docker_timeout = self.task_timeout
try:
if self._use_popen():
result = self._execute_via_process(
@@ -874,26 +949,36 @@ class Worker:
task, kind="run",
deadline_seconds=self.task_timeout)
else:
result = self.executor.dispatch_task(task)
if is_docker:
result = self.executor.dispatch_task(
task, timeout=effective_docker_timeout)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
(result.get("stderr", "") or "")
if result["status"] != "completed" and \
_ALREADY_EXISTS_MARKER in combined:
result = self.executor.run_task(task)
if is_docker:
result = self.executor.run_task(
task, timeout=effective_docker_timeout)
else:
result = 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)
finally:
if is_docker:
self.executor.timeout = saved_docker_timeout
# 同步路径执行结束:清掉取消用的容器标记(取消路径里
# 若已清过,这里再清一次无害)。
self._running_container = None
if self._running_task_id == task_id and \
self._running_proc is None:
self._running_task_id = None
# 若已清过,这里再清一次无害)。进程模式的 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)
# Docker 超时标记:执行器已在超时时 kill+rm 容器并返回
# failed(stdout 含 timeout),这里把状态统一成 timeout,
# 调用方据此置 failed 并记 worker_timeout 审计。
@@ -1185,29 +1270,38 @@ class Worker:
fallback_count = 1
self._audit(task["project_id"], "worker_fallback",
"%s -> %s" % (self.model, nxt))
self._set_status(task_id, final)
final_written = self._set_status_if_running(task_id, final)
finished_at = time.time()
self._write_execution_result(
task_id, result_text=stdout, error_text=error,
actual_model=actual_model, started_at=started_at,
finished_at=finished_at)
self.usage_turns += 1
# 成本:host 协议不暴露真实 token 数,用 cost.py 的估算结构
# 记 estimated,绝不标 actual;fallback_count 复用同一记录;
# 模型记最后真正跑的那个(fallback 成功时就是 fallback 模型)。
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)
self._audit(task["project_id"],
"worker_completed" if final == "completed"
else "worker_failed", task_id)
log_event(self.logger,
"worker_completed" if final == "completed"
else "worker_failed",
task_id=task_id, project_id=task["project_id"],
status=final)
with self._db_lock:
self.usage_turns += 1
# 成本:host 协议不暴露真实 token 数,用 cost.py 的估算结构
# 记 estimated,绝不标 actual;fallback_count 复用同一记录;
# 模型记最后真正跑的那个(fallback 成功时就是 fallback 模型)。
# cost 记录共用一条连接,并发下必须持锁写,否则两个线程
# 共用同一 sqlite 连接会直接报错。
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 final_written:
self._audit(task["project_id"],
"worker_completed" if final == "completed"
else "worker_failed", task_id)
log_event(self.logger,
"worker_completed" if final == "completed"
else "worker_failed",
task_id=task_id, project_id=task["project_id"],
status=final)
else:
# 已被取消/回收:DB 状态以 interrupted 为准,不再
# 补一条 worker_completed/failed 审计误导排查。
final = "interrupted"
self._audit(task["project_id"], "worker_interrupted", 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,
@@ -1221,22 +1315,122 @@ class Worker:
每轮都重新读库,所以上一轮刚 completed 的任务会解锁它的
依赖任务;每轮至少推进一个任务到终态,不会空转死循环。
capacity<=1 时走旧串行循环(行为与原来完全一致)。
capacity>1 时用线程池并行跑:同一 worker 内最多
capacity 个任务同时执行(capacity 从此是真实并发上限,
不再只是注册表展示)。认领本身是 CAS,两个线程同时
盯上同一任务时只有一个能拿到它,另一个的 run_one 返回
already_claimed,不计入结果、不死循环。依赖解锁靠
“等一个在跑任务完成后再认领”:pending 非空但暂无就绪
任务时,等一个完成再继续,而不是直接退出。
停机信号(_loop_stop/stop_event)只阻止新认领,不会
丢弃已提交的任务;排空等待由 run_loop 的 finally 负责。
"""
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 + 共享计数实现线程池,避开这个坑。
# 池线程数 = capacity;每个池线程循环认领-执行,认领
# 本身是 CAS,两个线程盯上同一任务时只有一个能拿到。
# 依赖解锁:某线程暂无就绪任务时,若仍有线程在跑就等
# 条件变量(跑完的线程会 notify,可能解锁了依赖),
# 全部线程都空闲且无就绪任务时才退出。
# max_tasks 约束:已派发结果数 + 正在跑的任务数达到
# max_tasks 就不再认领,避免线程池提前提交导致超跑。
# 停机信号:只阻止新认领,不丢弃已在跑的任务;run_all
# 在停机时立刻返回(后台线程继续跑),run_loop 的
# finally 做带超时的排空,超时后才杀残留。
state_lock = threading.Condition()
shared = {"dispatched": 0, "active": 0, "stopped": False}
results = []
while max_tasks is None or len(results) < max_tasks:
result = self.run_one()
if not result["dispatched"]:
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)
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
results.append(result)
return results
def run_loop(self, interval=300, max_seconds=None, stop_event=None):
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 到期。退出时若有正在跑的任务(进程模式下
_running_task_id 非空),先杀进程组、再标 interrupted,
不让任务永远卡在 running。
或 max_seconds 到期。退出时先优雅排空:等正在跑的任务
自然结束(最多 drain_timeout 秒),超时后才杀残留进程组/
容器并标 interrupted——以前是退出时立刻杀,部署重启会
无谓中断一个 16 秒就能跑完的任务。
运行中保障(与并发一起上的两件套):
- 心跳线程:每 30 秒给所有正在跑的任务刷 last_heartbeat,
否则长任务(>300 秒)在跑也会被陈旧回收误杀。
- 自动陈旧回收:每轮结束调一次 sweep_stale_tasks(阈值
sweep_threshold,默认 300 秒),把死 worker 遗留的
running 任务标 interrupted 并审计;活任务因心跳新鲜
绝不会被扫到。回收失败只记日志,不炸循环。
"""
# 常驻启动清理兜底:清掉上一轮残留的 codex- 容器,
# 失败只记日志(见 cleanup_stale_containers),不炸循环。
@@ -1249,6 +1443,18 @@ class 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()
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():
@@ -1265,6 +1471,15 @@ class Worker:
message="worker_loop_error: %s" % exc)
results = []
all_results.extend(results)
# 自动陈旧回收:死 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)
# 每轮结束写心跳:哪怕本轮一个任务都没派,worker 活着
# 这件事本身也要被 server 看到(文件心跳 + 注册表心跳)。
self._write_heartbeat()
@@ -1281,16 +1496,27 @@ class Worker:
break
time.sleep(0.2)
finally:
if self._running_task_id is not None:
task_id = self._running_task_id
if self._running_proc is not None:
self._kill_proc_group(self._running_proc)
hb_stop.set()
# 优雅排空:先等正在跑的任务自然结束,超时后才杀。
# 串行模式下 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:
self._running_proc.wait(timeout=3)
entry["proc"].wait(timeout=3)
except Exception:
pass
self._running_proc = None
self._running_task_id = None
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")
return all_results
@@ -1360,8 +1586,9 @@ def main(argv=None):
help="多 worker 池身份(默认:主机名-进程号;"
"生产部署应配稳定 id,否则重启后旧行变 stale)")
parser.add_argument("--worker-capacity", type=int, default=1,
help="worker 并发容量声明(注册表里展示用,"
"本轮 run_loop 仍是串行一轮一任务)")
help="worker 并发上限:同一 worker 内最多同时跑"
"几个任务(默认 1,即旧串行行为;>1 时用"
"线程池并行,认领仍是 CAS,成本写库持锁)")
parser.add_argument("--docker-image", default="codex-app-server:latest")
parser.add_argument("--docker-cpus", default="2")
parser.add_argument("--docker-memory", default="2g")
@@ -1386,6 +1613,15 @@ def main(argv=None):
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()
@@ -1426,7 +1662,9 @@ def main(argv=None):
try:
if args.loop:
results = worker.run_loop(interval=args.interval,
max_seconds=args.max_seconds)
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: