Files
codex-app-server/cloud/workers.py
host bd55599859 code-to10 v6 A14 batch1: workers F601 duplicates and check_transplant E741 renames
(cherry picked from commit 4411186681584bf55e065c654fe909a3320d4a22)

Reconcile-conflict: test_skill_transplant_prep artifact count — kept HEAD's finalized 0 refs (2026-11-02 user sign-off deleting §4's 6 references, already in target) with incoming's l→line rename; incoming's 6-ref assertion is pre-finalization and must not overwrite.
2026-11-03 02:49:26 +00:00

849 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Worker 注册表:多 worker 池的注册、心跳与存活判断。
零第三方依赖,只用 Python 标准库。
设计:
- 注册表存在控制面数据库的 workers 表里(worker 与 server 共用
同一个库,所以 worker 直接写库注册,不需要为此再发 HTTP;
HTTP 侧的 GET /workers 与 POST /workers/heartbeat 只是同一张
表的 API 面,给有 API key 的外部 worker 用)。
- worker_id 默认由调用方给(生产 worker 应配稳定的 --worker-id);
未给时由 worker.py 用“主机名-进程号”生成——重启后进程号会变,
旧行会自然变 stale(alive=false),不要用随机 uuid 让注册表
每次重启都多一行无法对应到同一台机器。
- 心跳本身就是注册:heartbeat 时 worker_id 不存在就 INSERT,
存在就 UPDATE,绝不因“未注册”返回 404 报错。
- alive 定义:last_seen 在阈值内(默认 120 秒,可经环境变量
CLOUD_WORKER_ALIVE_SECONDS 调,测试可传参覆盖)。
- 寻址信息(本轮新增):host(worker 所在主机名,worker 的
--host-name,容器内 worker 绝不能用容器名冒充宿主名)、
pid(worker 进程号,owner 死活的本地验证依据)、address
(可选寻址串,如运维侧约定的 host:port,调用方显式给才
有,绝不从环境变量里猜密钥类地址)。老行三列为 NULL,
按“未知”处理,绝不猜成活着或死了。
- 失联识别(本轮新增):stale_workers() 列心跳过期行;
orphaned_containers() 把“容器名 codex-<任务 id>”精确映射
到 tasks.claimed_by_worker(认领时 worker 写),只认领到
失联 worker 且状态仍为 running/interrupted 的任务容器才
算失联容器;名字不匹配任务 id 正则、任务行缺失、无认领
记录、owner 仍存活的一律不算——绝不按 "codex-" 前缀删。
pid_verified 另算:本机 host + 已知 pid 时用 pid_alive
校验(默认 os.kill(pid, 0)),远端 host 或 pid 未知时
为 False(列出来给运维看,不自动杀)。
- 本模块不存、不打任何密钥:注册信息只有 worker_id、executor、
capacity、last_seen,没有任何凭证字段。
- host/pid/address(本轨新增):新库建表直接含三列;老库由
ensure_workers_table 幂等补列,已有行新列为空、不编造值。
两路界线:SQLite 文件库与 PG 生产库共用 ensure_workers_table
这一份迁移代码(PG 下 connect() 返回适配连接,PRAGMA 经
适配层翻译为 information_schema 查询);PG 批量建库
(POSTGRES_SCHEMA / init_postgres_schema)刻意不含 workers
表——workers 是易失心跳数据,见 migrations.py 的
UNMIGRATED_TABLES,把它加进批量 schema 会撞 P2 迁移对齐
(MIGRATION_TABLES 必须等于 schema 表集合,本轨实测 3 项
变红后已回退),不要再加。
心跳刷新 host/pid 时未给保留旧值;address 没有新来源,只存
调用方显式给的值,未给保留旧值。本模块绝不按 worker 名或
前缀枚举/删除容器:失联清理只做判定(见
classify_stale_workers),判定为 kill 的唯一可杀对象是该行
的 pid 本人,且必须是本机 + pid 已死(pid_verified)。
执行路径(本轨新增,见 reap_stale_workers):复用
classify_stale_workers 的判定,不另造判定函数;只对
action == "kill" 的行调 kill_fn,且执行时先用同一 checker
再验一次已死(防判定快照与执行之间 pid 被复用),杀前
写审计(审计写失败则不杀,fail-closed),杀后用同一
checker 复核 pid 已死;异机、无 pid、pid 存活或未知一律
只列不杀。执行路径不枚举容器、不按名/前缀匹配、不碰
基础设施容器、不删 workers 注册行:杀的是 pid 本人,
注册行的去留是另一条决策(删行会改 /workers 列表语义),
本轨不擅自删。亦不新增 HTTP 路由(会改 v1.1 契约):调
用方(管理员/清扫器)在有权限的上下文里直接调本函数。
生产调用方(A5 接线):cloud/worker_sweep.py 的管理员 CLI
(鉴权 + reap 全链路),见该模块 docstring;除它与测试外
不再有其它生产调用方。
"""
import os
import re
import signal
import socket
import time
from cloud.db import connect as _db_connect
from cloud.timeouts import WORKER_ALIVE_SECONDS as _WORKER_ALIVE
# 存活阈值数值收敛到 cloud/timeouts.py(别名保留)。
DEFAULT_ALIVE_SECONDS = _WORKER_ALIVE
# workers 表新增列:(列名, 类型)。pid 用 BIGINT:SQLite 下为
# INTEGER 亲和性,Postgres 下为 8 字节整数,均可容纳进程号;
# host/address 为 TEXT,不存任何凭证。
WORKER_HOSTPID_COLUMNS = (
("host", "TEXT"),
("pid", "BIGINT"),
("address", "TEXT"),
)
def alive_threshold_seconds(override=None):
"""存活阈值:显式参数 > 环境变量 > 默认 120 秒。"""
if override is not None:
try:
return float(override)
except (TypeError, ValueError):
return float(DEFAULT_ALIVE_SECONDS)
try:
return float(os.environ.get(
"CLOUD_WORKER_ALIVE_SECONDS") or DEFAULT_ALIVE_SECONDS)
except (TypeError, ValueError):
return float(DEFAULT_ALIVE_SECONDS)
def _existing_worker_columns(conn):
"""workers 表现有列名集合;查不到时返回 None(调用方跳过补列)。
PG 走 information_schema,SQLite 走 PRAGMA table_info;两种
查询失败都只回滚、不抛异常(老库迁移兜底,绝不因查列炸掉
注册/心跳主路径)。
"""
try:
rows = conn.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'workers'").fetchall()
return {r[0] for r in rows}
except Exception:
try:
conn.rollback()
except Exception:
pass
try:
rows = conn.execute("PRAGMA table_info(workers)").fetchall()
return {r[1] for r in rows}
except Exception:
try:
conn.rollback()
except Exception:
pass
return None
def _ensure_worker_address_columns(conn):
"""老库补 host/pid/address 三列(缺哪个补哪个,幂等)。"""
cols = _existing_worker_columns(conn)
if cols is None:
return
for name, coltype in (("host", "TEXT"), ("pid", "INTEGER"),
("address", "TEXT")):
if name in cols:
continue
try:
conn.execute(
"ALTER TABLE workers ADD COLUMN %s %s" % (name, coltype))
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
def ensure_workers_table(db_path):
"""建 workers 表(不存在时建,已存在时不动,老库补精度)。
注意:last_seen/created_at 必须是 DOUBLE PRECISION,不能用 REAL。
SQLite 的 REAL 是 8 字节双精度,没问题;但 Postgres 的 REAL 是
4 字节 float4,只能存约 7 位有效数字,epoch 秒(10 位)会被
舍入到 128 秒一档,导致存活判断误差可达 ±64 秒、甚至出现负 age
(2026-10-26 主机实测:last_seen 比 NOW() 还大 20 秒)。
新表直接用 DOUBLE PRECISION;老库(已是 REAL 列)用 ALTER
TYPE 升到 DOUBLE PRECISION(float4 -> float8 无损,已存的舍入值
无法恢复,但之后的心跳不再舍入)。SQLite 下 ALTER TYPE 语法
不支持,靠 try/except 跳过——SQLite 的 REAL 本来就是双精度,
不需要升。
"""
conn = _db_connect(str(db_path))
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS workers("
"worker_id TEXT PRIMARY KEY, "
"executor TEXT, "
"capacity INTEGER, "
"running INTEGER, "
"host TEXT, "
"pid BIGINT, "
"address TEXT, "
"last_seen DOUBLE PRECISION, "
"created_at DOUBLE PRECISION)"
)
conn.commit()
_ensure_worker_address_columns(conn)
_ensure_running_column(conn)
_ensure_hostpid_columns(conn)
# 老库升精度只在确实是 Postgres REAL 列时做一次:每次心跳都
# ALTER 会反复拿 ACCESS EXCLUSIVE 锁,没必要。先查
# information_schema(SQLite 下这张表不存在,查询失败即跳过,
# SQLite 的 REAL 本来就是双精度,不需要升)。
for col in ("last_seen", "created_at"):
try:
row = conn.execute(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = 'workers' AND column_name = ?",
(col,)).fetchone()
except Exception:
try:
conn.rollback()
except Exception:
pass
continue
if not row or row[0] != "real":
continue
try:
conn.execute(
"ALTER TABLE workers ALTER COLUMN %s "
"TYPE DOUBLE PRECISION" % col)
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
finally:
conn.close()
def _ensure_running_column(conn):
"""老库补 running 列(新表建表时已含该列)。
running 是 worker 上次心跳上报的当前占用槽数(启动中占位
也算,见 cloud/caps.py);列缺失时 _worker_to_dict 默认 0,
所以补列失败只回滚、不抛,绝不炸注册与心跳。
SQLite 用 PRAGMA 查列,Postgres 用 information_schema。
"""
try:
rows = conn.execute("PRAGMA table_info(workers)").fetchall()
names = set()
for row in rows:
try:
names.add(row[1])
except (IndexError, TypeError):
pass
if rows and "running" not in names:
conn.execute(
"ALTER TABLE workers ADD COLUMN running INTEGER DEFAULT 0")
conn.commit()
return
except Exception:
try:
conn.rollback()
except Exception:
pass
try:
row = conn.execute(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_name = 'workers' AND column_name = 'running'"
).fetchone()
exists = bool(row and row[0])
except Exception:
try:
conn.rollback()
except Exception:
pass
return
if not exists:
try:
conn.execute(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS "
"running INTEGER DEFAULT 0")
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
def _ensure_hostpid_columns(conn):
"""老库补 host/pid/address 列(新表建表时已含三列)。
幂等保证:先查现有列名,只补缺失列,多次跑是 no-op;已有
行新列保持 NULL,不编造 host/pid/address。SQLite 与 PG
共用本函数:SQLite 走 PRAGMA table_info,Postgres 走适配
层翻译后的同款 PRAGMA(见 cloud/db.py 的 PRAGMA 翻译,调用
方取 row[1] 即列名,与 init_postgres_schema 补列同款);
PRAGMA 查不到列时再走 information_schema 逐列 COUNT 兜底
(Postgres 语法 ADD COLUMN IF NOT EXISTS,SQLite 下
PRAGMA 本就可用,不会走到这条兜底)。补列失败只回滚、
不抛,绝不炸注册与心跳。PG 批量建库路径不适用本函数:
POSTGRES_SCHEMA 刻意不含 workers(易失心跳数据,见模块
头注释),PG 生产库的 workers 表同样由本函数懒建/补列。
"""
try:
rows = conn.execute("PRAGMA table_info(workers)").fetchall()
names = set()
for row in rows:
try:
names.add(row[1])
except (IndexError, TypeError):
pass
if rows:
for col, col_type in WORKER_HOSTPID_COLUMNS:
if col not in names:
conn.execute(
"ALTER TABLE workers ADD COLUMN %s %s"
% (col, col_type))
conn.commit()
return
except Exception:
try:
conn.rollback()
except Exception:
pass
for col, col_type in WORKER_HOSTPID_COLUMNS:
try:
row = conn.execute(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_name = 'workers' AND column_name = '%s'"
% col).fetchone()
exists = bool(row and row[0])
except Exception:
try:
conn.rollback()
except Exception:
pass
continue
if not exists:
try:
conn.execute(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS "
"%s %s" % (col, col_type))
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
def _normalize_pid(pid):
"""归一进程号:None -> None;bool 或不可转 int -> ValueError。
pid 列存整数,不存字符串形式的进程号;调用方传错类型时
明确报错,不静默写成 0 或把旧值洗掉。
"""
if pid is None:
return None
if isinstance(pid, bool):
raise ValueError("pid must be an integer")
try:
value = int(pid)
except (TypeError, ValueError):
raise ValueError("pid must be an integer")
if value <= 0:
raise ValueError("pid must be a positive integer")
return value
def pid_is_dead(pid):
"""本机进程是否明确已死:os.kill(pid, 0) 的语义。
ProcessLookupError -> 已死(True);PermissionError -> 进程
存在但无权发信号,不能证明已死(False);其它 OSError /
非法 pid -> 不能证明已死(False)。绝不把“查不到/无权”
当成已死,这是 pid_verified 不误杀的底线。
"""
try:
value = _normalize_pid(pid)
except ValueError:
return False
if value is None:
return False
try:
os.kill(value, 0)
except ProcessLookupError:
return True
except PermissionError:
return False
except OSError:
return False
return False
def classify_stale_workers(db_path, stale_seconds=None, local_host=None,
pid_is_dead_fn=None, now=None):
"""失联 worker 判定(只判定、不杀任何进程或容器)。
返回全部 worker 行,每行含 action 与 reason:
- action == "kill" 当且仅当同时满足:age 严格大于阈值
(恰等于阈值不杀)且 pid_verified(行 host 等于本机 host、
pid 为已知正整数、且 pid_is_dead_fn(pid) 为 True)。
- 其余一律 action == "list_only":未失联、恰等于阈值、
host 不是本机、pid 缺失、pid 还活着或无法证明已死,
都只列不杀。
- 本函数不枚举容器、不按 worker_id 前缀匹配、不碰任何
基础设施容器:kill 项的唯一可杀对象就是该行的 pid 本人,
调用方若要清理也必须按 pid 精确杀,绝不按名/前缀删。
- stale_seconds 未给时复用 alive 阈值语义(显式参数 >
环境变量 > 120 秒);local_host 未给时为本机主机名;
pid_is_dead_fn 未给时为本机 pid_is_dead(测试可注入)。
"""
threshold = alive_threshold_seconds(stale_seconds)
host = local_host if local_host is not None else socket.gethostname()
checker = pid_is_dead_fn if pid_is_dead_fn is not None else pid_is_dead
moment = time.time() if now is None else float(now)
ensure_workers_table(db_path)
conn = _db_connect(str(db_path))
try:
rows = conn.execute(
"SELECT * FROM workers ORDER BY worker_id").fetchall()
finally:
conn.close()
out = []
for row in rows:
item = _worker_to_dict(row)
last = item.get("last_seen")
age = (moment - last) if last is not None else None
stale = bool(age is not None and age > threshold)
verified = False
if stale:
row_pid = item.get("pid")
if (item.get("host") == host and row_pid is not None
and checker(row_pid)):
verified = True
if verified:
action, reason = "kill", "pid_verified_dead"
elif age is None:
action, reason = "list_only", "no_last_seen"
elif age == threshold:
action, reason = "list_only", "age_equals_threshold"
elif age <= threshold:
action, reason = "list_only", "not_stale"
elif item.get("host") != host:
action, reason = "list_only", "host_mismatch"
elif item.get("pid") is None:
action, reason = "list_only", "pid_missing"
else:
action, reason = "list_only", "pid_alive_or_unknown"
item["age_seconds"] = age
item["stale"] = stale
item["pid_verified"] = verified
item["action"] = action
item["reason"] = reason
out.append(item)
return out
def _default_kill(pid):
"""默认真杀:只给该 pid 本人发 SIGKILL,不发进程组。
pid 已死时 os.kill 抛 ProcessLookupError,调用方
(reap_stale_workers)把它当“已死、无需再杀”处理,
不是失败。绝不把信号发给进程组(会误伤同组无关进程),
绝不按名/前缀找进程。
"""
os.kill(pid, signal.SIGKILL)
def _default_audit(db_path, action, detail):
"""默认审计:user_id=system、project_id="",经统一审计口写。
已确认(v5 用户定稿 2026-11-02):#228 直写 × #250 无旁路断言——
定稿选“审计落点迁移”一边:本函数不再自带审计表直写,改调
cloud.auth 的统一审计口(该口写 legacy 审计行 + 防篡改账本链
条目同一对,落点只增链条目、不丢旧行)。另一边(断言放宽、
给 workers 开例外)未选。
写失败抛给调用方:reap 的杀前审计失败时不杀
(fail-closed),杀后审计失败时只记 audit_after=False。
统一审计口在函数内 import:workers 与 auth 同属控制面,
顶层互引无必要,函数内取已加载模块即可。
"""
# 注意:本文件刻意不再出现审计表的直写语句——#250 的
# 无旁路断言扫描 cloud/*.py,任何直写都会让它变红。
from cloud.auth import audit_on_conn
conn = _db_connect(str(db_path))
try:
try:
audit_on_conn(conn, "system", "", action, detail)
conn.commit()
except Exception:
try:
conn.rollback()
except Exception:
pass
raise RuntimeError("audit_log write failed")
finally:
conn.close()
def reap_stale_workers(db_path, stale_seconds=None, local_host=None,
pid_is_dead_fn=None, kill_fn=None, audit_fn=None,
now=None):
"""失联 worker 执行路径:复用判定,只杀 pid_verified。
流程(每行):
- 先调 classify_stale_workers(不另造判定):action 不是
kill 的行一律只列不杀,executed=False、outcome="listed",
绝不调 kill_fn、不写杀审计。
- kill 行执行时先用同一 checker 再验一次 pid 已死:若此时
已不死(判定后 pid 被复用等),跳过不杀,
outcome="skipped_pid_alive_at_execution",不调 kill_fn。
- 杀前写审计:audit_fn 未给时走 _default_audit,经统一
审计口写 audit_log + 账本链(action=worker_pid_kill);
审计写失败则不杀,
outcome="audit_failed"(fail-closed)。
- 杀:kill_fn 未给时走 _default_kill(SIGKILL 只给该 pid
本人);ProcessLookupError 当已死处理,不是失败;其它
OSError 记入 kill_error,仍以后续复核定成败。
- 杀后用同一 checker 复核:已死 -> outcome="killed";
还活着 -> outcome="kill_failed_pid_alive";随后写
杀后审计(action=worker_pid_kill_verified,detail 含
outcome),审计成败只记 audit_after,不改 outcome。
- 本函数不枚举容器、不按名/前缀匹配、不碰基础设施容器、
不删 workers 行;返回每行的判定字段 + executed /
outcome / verified_dead / audit_before / audit_after /
kill_error。verified_dead 仅在杀后复核后才有真值,
未执行时为 None(不是“还活着”的断言)。
"""
rows = classify_stale_workers(
db_path, stale_seconds=stale_seconds, local_host=local_host,
pid_is_dead_fn=pid_is_dead_fn, now=now)
checker = pid_is_dead_fn if pid_is_dead_fn is not None else pid_is_dead
killer = kill_fn if kill_fn is not None else _default_kill
results = []
for row in rows:
result = dict(row)
result["executed"] = False
result["verified_dead"] = None
result["audit_before"] = False
result["audit_after"] = False
result["kill_error"] = None
if row.get("action") != "kill":
result["outcome"] = "listed"
results.append(result)
continue
pid = row.get("pid")
if pid is None or not checker(pid):
result["outcome"] = "skipped_pid_alive_at_execution"
result["verified_dead"] = False
results.append(result)
continue
detail = ("worker_id=%s host=%s pid=%s reason=%s"
% (row.get("worker_id"), row.get("host"), pid,
row.get("reason")))
try:
if audit_fn is not None:
audit_fn("worker_pid_kill", detail)
else:
_default_audit(db_path, "worker_pid_kill", detail)
result["audit_before"] = True
except Exception:
result["outcome"] = "audit_failed"
results.append(result)
continue
result["executed"] = True
try:
killer(pid)
except ProcessLookupError:
pass
except Exception as exc:
result["kill_error"] = str(exc)
verified = bool(checker(pid))
result["verified_dead"] = verified
result["outcome"] = (
"killed" if verified else "kill_failed_pid_alive")
after_detail = detail + " outcome=%s verified_dead=%s" % (
result["outcome"], verified)
try:
if audit_fn is not None:
audit_fn("worker_pid_kill_verified", after_detail)
else:
_default_audit(
db_path, "worker_pid_kill_verified", after_detail)
result["audit_after"] = True
except Exception:
result["audit_after"] = False
results.append(result)
return results
def _coerce_pid(pid):
"""pid 归一化:None/空串 -> None;可转 int 的转 int;否则 None。
绝不把非法 pid 写进库(写进去了后续的本地存活校验会拿
它当进程号查,错 pid 比缺 pid 更危险)。
"""
if pid is None or pid == "":
return None
if isinstance(pid, bool):
return None
try:
return int(pid)
except (TypeError, ValueError):
return None
def register_worker(db_path, worker_id, executor=None, capacity=1,
running=None, host=None, pid=None, address=None):
"""注册(或刷新)一个 worker:不存在 INSERT,存在 UPDATE。
返回注册行 dict。worker_id 为空时抛 ValueError,调用方
(server 的 heartbeat 端点)据此返回 400。新列语义:新行
按给定值写(未给为 NULL);已存在行上给定值覆盖、未给
保留旧值——绝不因一次不带 host/pid 的注册把已有的身份
洗成 NULL,也不编造 host/pid/address。 """
if not worker_id or not isinstance(worker_id, str):
raise ValueError("worker_id required")
pid = _normalize_pid(pid)
ensure_workers_table(db_path)
now = time.time()
conn = _db_connect(str(db_path))
try:
old_row = conn.execute(
"SELECT * FROM workers WHERE worker_id = ?",
(worker_id,)).fetchone()
if old_row is not None:
old = _worker_to_dict(old_row)
conn.execute(
"UPDATE workers SET executor = ?, capacity = ?,"
" running = ?, host = ?, pid = ?, address = ?,"
" last_seen = ? WHERE worker_id = ?",
(executor, capacity,
running if running is not None else 0,
host if host is not None else old["host"],
pid if pid is not None else old["pid"],
address if address is not None else old["address"],
now, worker_id),
)
else:
conn.execute(
"INSERT INTO workers(worker_id, executor, capacity,"
" running, host, pid, address, last_seen, created_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(worker_id, executor, capacity,
running if running is not None else 0,
host, pid, address, now, now), )
conn.commit()
row = conn.execute(
"SELECT * FROM workers WHERE worker_id = ?",
(worker_id,)).fetchone()
finally:
conn.close()
return _worker_to_dict(row)
def heartbeat_worker(db_path, worker_id, executor=None, capacity=None,
running=None, host=None, pid=None, address=None):
"""心跳:就是带 last_seen 刷新的注册。
executor/capacity 未给时保留旧值(已注册的 worker 只刷时间,
不把 executor 洗成 None);未注册时按给定值(或默认)注册。
host/pid/address 同款保留语义:给定则刷新(本机 worker 心跳
应传本机 host 与本进程 pid,见 worker._registry_heartbeat),
未给则保留旧值;address 没有新来源,绝不由本函数推导
(例如不从 worker_id 或 socket 猜地址),只存显式给的值。
显式给 None 与未给在心跳里无法区分(均按未给保留处理)。
"""
if not worker_id or not isinstance(worker_id, str):
raise ValueError("worker_id required")
pid = _normalize_pid(pid)
ensure_workers_table(db_path)
now = time.time()
conn = _db_connect(str(db_path))
try:
row = conn.execute(
"SELECT * FROM workers WHERE worker_id = ?",
(worker_id,)).fetchone()
if row is None:
conn.execute(
"INSERT INTO workers(worker_id, executor, capacity,"
" running, host, pid, address, last_seen, created_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(worker_id, executor, capacity if capacity is not None
else 1, running if running is not None else 0,
host, pid, address, now, now), )
else:
old = _worker_to_dict(row)
conn.execute(
"UPDATE workers SET executor = ?, capacity = ?,"
" running = ?, host = ?, pid = ?, address = ?,"
" last_seen = ? WHERE worker_id = ?",
(executor if executor is not None else old["executor"],
capacity if capacity is not None else old["capacity"],
running if running is not None else old["running"],
host if host is not None else old["host"],
pid if pid is not None else old["pid"],
address if address is not None else old["address"],
now, worker_id), )
conn.commit()
row = conn.execute(
"SELECT * FROM workers WHERE worker_id = ?",
(worker_id,)).fetchone()
finally:
conn.close()
return _worker_to_dict(row)
def list_workers(db_path, alive_seconds=None):
"""列出全部 worker,每行含 alive(last_seen 在阈值内)。"""
threshold = alive_threshold_seconds(alive_seconds)
ensure_workers_table(db_path)
now = time.time()
conn = _db_connect(str(db_path))
try:
rows = conn.execute(
"SELECT * FROM workers ORDER BY worker_id").fetchall()
finally:
conn.close()
out = []
for row in rows:
item = _worker_to_dict(row)
last = item.get("last_seen")
item["alive"] = bool(
last is not None and (now - last) <= threshold)
out.append(item)
return out
def _worker_to_dict(row):
def _get(name):
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
running = _get("running")
return {
"worker_id": _get("worker_id"),
"executor": _get("executor"),
"capacity": _get("capacity"),
"running": running if running is not None else 0,
"host": _get("host"),
"pid": _get("pid"),
"address": _get("address"),
"last_seen": _get("last_seen"),
"created_at": _get("created_at"),
}
def stale_workers(db_path, alive_seconds=None):
"""心跳过期(失联)的 worker 行列表(每行已含 alive=False)。
只按 last_seen 判,不查 pid:pid 校验是 orphaned_containers
里对“能不能自动杀”的加严,不是失联本身的定义(失联的
定义与 sweep_stale_tasks 的任务心跳过期同款语义)。
"""
return [w for w in list_workers(db_path, alive_seconds)
if not w.get("alive")]
def default_pid_alive(pid):
"""本地进程存活检查:os.kill(pid, 0) 成功或无权限都算活着
(PermissionError 说明进程存在,只是归属别人);只有
查无此进程算死了;其它异常算未知(返回 None,不猜)。
pid 非法时返回 None。"""
pid = _coerce_pid(pid)
if pid is None:
return None
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return None
def _is_local_host(host):
"""host 是否本机:空值按“无法确认”处理,不按本机猜。
注意与 pid_verified 的关系:host 为空时即使 pid 给了也
不能断定该 pid 是本机的——跨主机同 pid 号毫无意义,
所以空 host 一律 unverified。
"""
if not host or not isinstance(host, str):
return False
try:
return host == socket.gethostname()
except Exception:
return False
_TASK_CONTAINER_RE = re.compile(r"^codex-([0-9a-f]{32})$")
def orphaned_containers(db_path, container_names,
alive_seconds=None, pid_alive=None):
"""识别失联 owner 的任务容器(纯识别,不杀任何容器)。
返回 list,每项 {container, task_id, worker_id, pid_verified}:
- container:精确的容器名(原样返回,调用方按此名杀,
绝不按前缀展开);
- task_id:从容器名正则里精确抠出的 32 位 hex(不是前缀
切出来的任意串);
- worker_id:tasks.claimed_by_worker(认领时写);
- pid_verified:True = 本机 host + 已知 pid 且 pid_alive
明确报死(可自动杀);False = 未验证(远端 host、pid
未知、pid_alive 未给或返回未知),只列不自动杀。
排除项(任一即排除):容器名不匹配任务正则;tasks 表或
claimed_by_worker 列不存在(无法映射时宁可漏,不猜);
任务行缺失;任务状态不在 running/interrupted;owner 行
缺失或仍存活(alive)。调用方杀之前仍须按返回的精确
容器名逐个杀,绝不把本函数的返回再按前缀展开。
"""
if pid_alive is None:
pid_alive = default_pid_alive
stale = {w["worker_id"]: w for w in stale_workers(
db_path, alive_seconds)}
if not stale:
return []
conn = _db_connect(str(db_path))
try:
try:
rows = conn.execute(
"SELECT id, status, claimed_by_worker FROM tasks"
).fetchall()
except Exception:
try:
conn.rollback()
except Exception:
pass
return []
finally:
conn.close()
tasks = {}
for row in rows:
try:
tasks[row["id"]] = (row["status"], row["claimed_by_worker"])
except (KeyError, IndexError, TypeError):
continue
out = []
for name in container_names or []:
if not isinstance(name, str):
continue
m = _TASK_CONTAINER_RE.match(name)
if not m:
continue
task_id = m.group(1)
info = tasks.get(task_id)
if info is None:
continue
status, claimed = info
if status not in ("running", "interrupted"):
continue
if not claimed or claimed not in stale:
continue
worker = stale[claimed]
verified = False
pid = worker.get("pid")
if _is_local_host(worker.get("host")) and pid is not None:
try:
verified = pid_alive(pid) is False
except Exception:
verified = False
out.append({
"container": name,
"task_id": task_id,
"worker_id": claimed,
"pid_verified": verified,
})
return out