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:
1 parent
b306b56517
commit
3a06205d8b
8 files changed
+938
-12
No files matched your search
+156
@@ -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、下一轮认领即断点续跑;用户暂停、
|
||||
|
||||
Reference in new issue
Block a user