feat: 跨 worker 真 kill(kill_requests 意图表 + 收殓器)与自动化触发入闸

机制 A 移植自旧 PR #101(分支 feat-residual-b,头 895e004),
不合旧 PR:旧基线 6e3eb7b 已 CONFLICTING,本批重做到现 main
6d4f87e(#151 合并后)上。

- kill_requests 意图表:cloud/db.py POSTGRES_SCHEMA + migrations
  清单补表;server.ensure_kill_requests_table / request_kill
  (同一任务只留一条 pending,写失败返回 False);init_db 的
  SQLite 分支补建表(PG 分支由 POSTGRES_SCHEMA 全量建表覆盖)。
- 取消/sweep 写请求 best-effort:_cancel_task 在 commit 后调
  request_kill(失败不挡取消);sweep_stale_tasks 在同一事务
  内为被扫任务写 system 请求。
- worker 收殓器 _reap_kill_requests:owner 活着(心跳停滞未超
  300 秒,COALESCE(last_heartbeat, updated_at))绝不抢杀;只
  有 owner 已死且任务仍 interrupted 时,按任务派生容器名
  (executor.container_name,codex-<安全字符任务 id>)在本机
  真杀,绝不按前缀扫、绝不杀自由传入的名字;本机无容器时
  请求照常终结;任务行失踪或已不在 interrupted 时请求直接
  终结;单条异常只记日志。run loop 每轮调一次,放在 sweep
  块外(旧 PR 只在 sweep 块内,sweep 关掉时请求会积压)。
- 自动化入闸只补两条旁路:worker._coord_sync_children 建子任
  务前过 _quota_exceeded(超限跳过建任务、记审计,同一 worker
  内同一分工只记一次);automations.run_due_automation
  (claim 抢单 -> 配额门 -> 建任务 -> 记产出,超限记
  skipped_quota 不进失败重试)。triage/webhook/续跑/后续/
  分叉未动。
- 单测:旧 PR 三份单测原样移植(Worker/Server 签名与现 main
  兼容,未改用例语义)。
This commit is contained in:
host committed 2026-10-30 01:32:35 +08:00
1 parent b306b56517
commit 3a06205d8b
8 files changed
+938 -12

No files matched your search

+156
View File
@@ -614,6 +614,11 @@ class Worker:
# 配额中断就续跑下去了。受 _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):
"""启动后关键代码是否被换过(部署换目录/手动改代码):
@@ -4016,6 +4021,24 @@ class Worker:
"%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,"
@@ -4124,6 +4147,125 @@ class Worker:
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 并记审计。
@@ -5495,6 +5637,20 @@ class Worker:
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、下一轮认领即断点续跑;用户暂停、