- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
554 lines
25 KiB
Python
554 lines
25 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
派活监管 · 三线执行状态巡检(**只读**)
|
||
==================================================================
|
||
用途:派活之后**不是结束** —— 本脚本把「各会话到底动没动、动了到哪、有没有卡住、
|
||
该不该跟进」变成一次可复跑的读数。⛔ 不起服务、⛔ 不抢锁、⛔ 不改任何文件。
|
||
|
||
用法:
|
||
"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" \
|
||
"E:/ProgramData/AIProject/ai1net-dsh-server/交付物/派活监管-三线巡检-20260928.py"
|
||
加 `--json` 出机读结果。
|
||
|
||
退出码:0 = 全部已收官 · 1 = 有棒在跑/待跑(正常等) · 2 = **有需跟进的**(未产出/异常/锁未释放)
|
||
|
||
判据源(全部只读):
|
||
① `workbuddy.db` → `automations`(有没有排上、排的时刻)
|
||
② `workbuddy.db` → `automation_runs`(**跑没跑 + 收官结论原文** + result_success)
|
||
③ `workbuddy.db` → `automation_runtime_state`(此刻**是不是在跑**)
|
||
④ `workbuddy.db` → `sessions`(该工作区的会话活动)
|
||
⑤ 全局执行锁 `dsh-server-docs/05-交接单/.exec-lock`(**三线共用一把** ⇒ 谁占着、放没放)
|
||
⑥ 各线「收件目录」里**在派活时刻之后**新增/改动的文件(=有没有产出)
|
||
|
||
⚠️ 本机实测两条硬事实:
|
||
- `workbuddy.db` 是 **WAL 三件套**、app 正在运行 ⇒ 一律以 `mode=ro` 打开,⛔ 不写、⛔ 不 cp。
|
||
- 在受限沙箱里 **Node 不能 spawn 系统命令**;Python 可以 ⇒ 本脚本用 Python。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
|
||
DB = r"E:\ProgramData\.workbuddy\workbuddy.db"
|
||
LOCKROOT = r"D:\github\dsh_shenxian\dsh-server-docs\05-交接单"
|
||
EXECLOCK = os.path.join(LOCKROOT, ".exec-lock")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 派活登记(**派一条就加一条**;这是本脚本唯一的"配置")
|
||
#
|
||
# 🔴 2026-09-28 实测教训(排期):**三条棒挤在 5 分钟内会互相挡死**。
|
||
# 实测:21:19:12 客户端线抢到**全局独占锁**(未声明 `--domains`)⇒
|
||
# 21:20:02 手机接入线 **抢不到 ⇒ 棒 3 未开工**(守规矩停手,但白跑一趟)。
|
||
# ⇒ 排期两条铁律:① **错开 ≥ 一次跑的时长**(实测单棒约 5 分钟 ⇒ 建议 ≥6 分钟);
|
||
# ② 并行棒一律让各线 `--claim-exec … --domains <域键>`(域不重叠才真并行)。
|
||
# ⚠️ 另注意:若别人持的是**旧式全局锁**,域锁也照样抢不到(guard 会明确报这点)。
|
||
# ---------------------------------------------------------------------------
|
||
DISPATCHES = [
|
||
{
|
||
"line": "客户端线",
|
||
"ws": "ai1net-dsh-desktop",
|
||
"auto_id": "b5c82852-04af-4213-936d-60958643029e",
|
||
"scheduled_at": "2026-09-28T21:19",
|
||
"task": "垫片改指 WorkBuddy 前置结算棒(只出 D-1 定论 + 执行交接单)",
|
||
"expect": "docs/ 下新增执行交接单(含 D-1 唯一结论)",
|
||
"watch": ["docs"],
|
||
"precondition": None,
|
||
},
|
||
{
|
||
"line": "手机接入线",
|
||
"ws": "ai1net-dsh-anywhere",
|
||
# 21:20 那次(7d50229a)**抢不到全局锁 ⇒ 未开工**(已消耗);
|
||
# 22:49 重排(da9a0b9e)—— 同一件、加了 --domains 提示。
|
||
"auto_id": "da9a0b9e-5171-47db-b198-1940edb4be2f",
|
||
"scheduled_at": "2026-09-28T22:49",
|
||
"task": "棒 3 开工(Android MVP · 按 sessions 一套实现)· **重排**",
|
||
"expect": "docs/交接单/ 回执 +(可能)dsh-client 壳内改动 / APK",
|
||
"watch": ["docs/交接单", "docs"],
|
||
"precondition": "首次(21:20)被全局锁挡下 ⇒ 只要锁空闲即可开工",
|
||
},
|
||
{
|
||
"line": "ai1net_ui",
|
||
"ws": "ai1net_ui",
|
||
"auto_id": "e5faa1ec-6d12-48d2-94dc-1efd829a2c2d",
|
||
"scheduled_at": "2026-09-28T21:24",
|
||
"task": "新接手外来件(ai1net 并入 M6 设备接入 + 规格修订)",
|
||
"expect": "规格修订成稿 + 归并方案(docs/)",
|
||
"watch": ["docs/交接单", "docs"],
|
||
"precondition": "该线此前未参与本功能 ⇒ 件里已备补课段 §1–§4",
|
||
},
|
||
]
|
||
|
||
WSROOT = r"E:\ProgramData\AIProject"
|
||
NOW_MS = int(time.time() * 1000)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 🔴 2026-09-28 实测教训(本脚本最重要的一个判据)
|
||
#
|
||
# 事故:手机接入线棒 3 在 21:20 **抢不到全局锁 ⇒ 未开工**(合规停手),
|
||
# 但它的运行记录 `result_success = 1`(它确实"跑完了")⇒
|
||
# 旧版判据把它归到「收官·疑未产出」,而旧版**处置规则对这一类只报告、不重排**
|
||
# ⇒ 两条巡检棒(21:45 / 22:18)**连续 2 轮都发现了、都只报告** ⇒
|
||
# **那条活一直躺着死**,直到人手动发现。
|
||
#
|
||
# ⇒ 两条铁律(改这里之前先读):
|
||
# ① **「跑完了」≠「干了活」** —— 结论原文里自述"未开工/未做事"的,判**异常**,不是"疑未产出"。
|
||
# ② **只报告的监管 = 没监管**(甚至更糟:制造"已经在管了"的假象)
|
||
# ⇒ 本脚本对每条线给出**显式 `action`**;巡检棒照 action 办事,
|
||
# ⛔ 不再由巡检棒自己解释状态分类(那正是这次误判的入口)。
|
||
#
|
||
# 🔴 2026-09-28 第二次教训:**第一版判据有假阳性,已收窄**。
|
||
# 原判据含 `未完成` / `阻塞` / `中止` / `放弃` / `被挡` —— 这些词**同时是报告里的字段名**
|
||
# (例:「## ⑥ 未完成项」「- 未完成:…」「阻塞项」)⇒ 会把**正常收官**的线误判成
|
||
# "未实际执行" ⇒ **凭空重排已完成的活**(=让它们白跑一遍)。
|
||
# ⇒ 只保留"**明确没干活/干不下去**"的强信号;⛔ 凡可能是**字段名**的词一律不收。
|
||
# ⚠️ 同一份判据在后台监控 `wb-supervisor-watch.py` 里也有一份,**改一处必须改另一处**。
|
||
PENDING_MARKERS = (
|
||
"未开工", "未做事", "没开工",
|
||
"抢不到执行锁", "抢不到锁", "抢不到",
|
||
"无法继续", "未能继续",
|
||
"停手并报告", "停手并回报", "已停手",
|
||
)
|
||
|
||
# action 四类(巡检棒只认这四个,⛔ 不自创)
|
||
ACTION_RESCHEDULE = "reschedule" # 🔴 活没干成 ⇒ **必须当场新建自动化重排**(⛔ 不许只报告)
|
||
ACTION_REPORT_LOCK = "report-lock" # 锁未释放 ⇒ **只报告用户**(R9:⛔ 不代删、不接管)
|
||
ACTION_REPORT = "report" # 需人判读(如"跑完但产出缺失")⇒ 报告 + 列期望路径
|
||
ACTION_WAIT = "wait" # 正常等待,无事不打扰
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
def out(text):
|
||
"""⛔ 不走 print:文案含 ⛔ 等字符时,裸 print 在非 UTF-8 控制台会 UnicodeEncodeError
|
||
⇒ stdout 空 ⇒ **静默丢结论**(本项目已踩过)。一律写 bytes。"""
|
||
sys.stdout.buffer.write(text.encode("utf-8", "replace"))
|
||
sys.stdout.buffer.write(b"\n")
|
||
|
||
|
||
def ro_conn():
|
||
con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True, timeout=5)
|
||
con.execute("PRAGMA busy_timeout=5000")
|
||
return con
|
||
|
||
|
||
def parse_iso_ms(s):
|
||
try:
|
||
return int(datetime.strptime(s, "%Y-%m-%dT%H:%M").timestamp() * 1000)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def fmt_ms(ms):
|
||
if not ms:
|
||
return "-"
|
||
return datetime.fromtimestamp(ms / 1000).strftime("%H:%M:%S")
|
||
|
||
|
||
def lock_state():
|
||
"""全局执行锁:三线共用一把 ⇒ 监管上这是最强的互斥信号。"""
|
||
if not os.path.isdir(EXECLOCK):
|
||
# 兼容:也有可能是文件式
|
||
if os.path.exists(EXECLOCK):
|
||
try:
|
||
with open(EXECLOCK, "r", encoding="utf-8", errors="replace") as f:
|
||
return {"held": True, "owner": f.read().strip()[:120]}
|
||
except Exception:
|
||
return {"held": True, "owner": "(文件式锁,读不出)"}
|
||
return {"held": False, "owner": None}
|
||
owner_file = os.path.join(EXECLOCK, "OWNER")
|
||
owner = None
|
||
if os.path.isfile(owner_file):
|
||
try:
|
||
with open(owner_file, "r", encoding="utf-8", errors="replace") as f:
|
||
txt = f.read()
|
||
lines = [ln for ln in txt.splitlines() if ln.strip()]
|
||
owner = lines[0][:120] if lines else "(OWNER 存在但为空)"
|
||
except Exception:
|
||
owner = "(OWNER 读不出)"
|
||
else:
|
||
try:
|
||
owner = "(目录式锁,无 OWNER;内容=%s)" % ",".join(os.listdir(EXECLOCK))[:80]
|
||
except Exception:
|
||
owner = "(目录式锁)"
|
||
return {"held": True, "owner": owner or "(未署名)"}
|
||
|
||
|
||
def domain_locks():
|
||
root = os.path.join(LOCKROOT, ".locks")
|
||
if not os.path.isdir(root):
|
||
return []
|
||
got = []
|
||
for name in os.listdir(root):
|
||
if name.startswith("."):
|
||
continue
|
||
got.append(name)
|
||
return got
|
||
|
||
|
||
def new_files(ws, dirs, since_ms, limit=8):
|
||
"""列出收件目录里 mtime > since_ms 的文件(=派活之后新产出的东西)。"""
|
||
hits = []
|
||
for d in dirs:
|
||
base = os.path.join(WSROOT, ws, d.replace("/", os.sep))
|
||
if not os.path.isdir(base):
|
||
continue
|
||
for dirpath, _dirnames, filenames in os.walk(base):
|
||
for fn in filenames:
|
||
fp = os.path.join(dirpath, fn)
|
||
try:
|
||
st = os.stat(fp)
|
||
except Exception:
|
||
continue
|
||
if st.st_mtime * 1000 > since_ms:
|
||
hits.append({
|
||
"path": os.path.relpath(fp, os.path.join(WSROOT, ws)).replace("\\", "/"),
|
||
"mtime": fmt_ms(st.st_mtime * 1000),
|
||
"size": st.st_size,
|
||
})
|
||
hits.sort(key=lambda x: x["mtime"], reverse=True)
|
||
return hits[:limit]
|
||
|
||
|
||
def session_stats(con, ws, since_ms):
|
||
cwd = "%s/%s" % (WSROOT.replace("\\", "/"), ws)
|
||
rows = con.execute(
|
||
"select id,title,status,created_at,last_activity_at,is_background_automation "
|
||
"from sessions where lower(cwd)=lower(?) and coalesce(created_at,0)>? "
|
||
"order by created_at desc", (cwd, since_ms)).fetchall()
|
||
out_rows = []
|
||
for r in rows:
|
||
out_rows.append({
|
||
"id": (r[0] or "")[:8],
|
||
"title": (r[1] or "")[:44],
|
||
"status": r[2],
|
||
"created": fmt_ms(r[3]),
|
||
"last_activity": fmt_ms(r[4]),
|
||
"bg": bool(r[5]),
|
||
})
|
||
return out_rows
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 读「结果钩子」台账 —— 这条链的另一半
|
||
#
|
||
# 设计(用户 2026-09-28 提出):hook 把执行结果交到本地台账 ⇒ 后台任务(本脚本)
|
||
# 读台账 ⇒ 结合登记表**决定下一步**。
|
||
#
|
||
# 🔴 这里必须有的一个自检:**钩子本身可能没挂上/没生效(静默失败)**。
|
||
# ⇒ 台账为空**不等于**"没有会话结束过",只等于"钩子没工作"。
|
||
# ⇒ 所以台账为空时**报出来**(action=report),⛔ 不要当成"一切正常"。
|
||
# ---------------------------------------------------------------------------
|
||
LEDGER = os.path.join(WSROOT, "ai1net-dsh-server", "tmp", "supervise-inbox", "ledger.jsonl")
|
||
HOOK_SCRIPT = os.path.join(WSROOT, "ai1net-dsh-server", ".workbuddy", "tools", "wb-result-hook.py")
|
||
HOOK_LOG = os.path.join(WSROOT, "ai1net-dsh-server", "tmp", "supervise-inbox", "hook.log")
|
||
|
||
# 钩子判定的"没问题"值;其余都值得看一眼
|
||
HOOK_OK = ("ok", "short")
|
||
|
||
|
||
def read_ledger(limit=40):
|
||
"""读台账尾部。⛔ 只读,坏行跳过。"""
|
||
recs = []
|
||
try:
|
||
with open(LEDGER, encoding="utf-8", errors="replace") as f:
|
||
lines = f.read().splitlines()
|
||
except Exception:
|
||
return recs
|
||
for ln in lines[-limit:]:
|
||
ln = ln.strip()
|
||
if not ln:
|
||
continue
|
||
try:
|
||
recs.append(json.loads(ln))
|
||
except Exception:
|
||
continue
|
||
return recs
|
||
|
||
|
||
def hook_health(ledger):
|
||
"""钩子自身是否在工作 —— 这条必须是显式的,否则会静默假绿。"""
|
||
h = {"installed_script": os.path.isfile(HOOK_SCRIPT),
|
||
"ledger_exists": os.path.isfile(LEDGER),
|
||
"records": len(ledger),
|
||
"last_ts": ledger[-1].get("ts") if ledger else None,
|
||
"log_exists": os.path.isfile(HOOK_LOG)}
|
||
h["ok"] = bool(h["installed_script"] and h["records"])
|
||
if not h["installed_script"]:
|
||
h["problem"] = "钩子脚本不在位 ⇒ 台账永远不会写"
|
||
elif not h["ledger_exists"] or not h["records"]:
|
||
h["problem"] = ("台账为空 ⇒ 钩子**从未触发**(可能:还没等到下一个会话结束 / 钩子没挂上 / "
|
||
"Windows 钩子在会话启动时快照 ⇒ 装完后**必须先重启客户端**)")
|
||
else:
|
||
h["problem"] = None
|
||
return h
|
||
|
||
|
||
def ledger_for(ledger, ws, since_ms):
|
||
"""挑出属于某条线的台账记录(按 cwd 尾段匹配)+ 非 ok 的判定。"""
|
||
tail = "/" + ws.lower()
|
||
mine = [r for r in ledger if str(r.get("cwd", "")).lower().endswith(tail)]
|
||
bad = [r for r in mine if r.get("verdict") not in HOOK_OK]
|
||
return mine, bad
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
def check_one(con, d, ledger=None):
|
||
sched_ms = parse_iso_ms(d["scheduled_at"]) or 0
|
||
rec = {"line": d["line"], "ws": d["ws"], "task": d["task"],
|
||
"at": d["scheduled_at"], "expect": d["expect"]}
|
||
|
||
# ① 自动化本体
|
||
# 🔴 幂等待办:`auto_id` 为空 = **这条活还没排上**(不是数据缺失)。
|
||
# 用途(2026-09-28 实测教训):一轮里若"读到一半被打断"(如工具瞬时不可用),
|
||
# 那把「该排的活」**提前写进登记表**(auto_id 留空)⇒ 中断也只丢最后一步,
|
||
# 任何后来的巡检都会看到它并 `reschedule` ⇒ 那条活**不会静默消失**。
|
||
if not d.get("auto_id"):
|
||
rec.update(state="待排(未登记)",
|
||
why="登记表里这条还没排上自动化号 ⇒ 必须新建(幂等待办)",
|
||
need_follow=True, action=ACTION_RESCHEDULE)
|
||
rec["new_files"] = new_files(d["ws"], d["watch"], parse_iso_ms(d["scheduled_at"]) or 0)
|
||
return rec
|
||
|
||
arow = con.execute(
|
||
"select status,scheduled_at,next_run_at,last_run_at from automations where id=?",
|
||
(d["auto_id"],)).fetchone()
|
||
if not arow:
|
||
rec.update(state="异常",
|
||
why="登记里这条自动化**查不到**(被删了?)⇒ 必须新建",
|
||
need_follow=True, action=ACTION_RESCHEDULE)
|
||
return rec
|
||
rec["auto_status"] = arow[0]
|
||
rec["auto_next"] = fmt_ms(arow[2])
|
||
rec["auto_last"] = fmt_ms(arow[3])
|
||
|
||
# ③ 此刻在不在跑
|
||
rrow = con.execute(
|
||
"select running,running_started_at,last_error,running_conversation_id "
|
||
"from automation_runtime_state where automation_id=?", (d["auto_id"],)).fetchone()
|
||
running = bool(rrow[0]) if rrow else False
|
||
running_since = fmt_ms(rrow[1]) if rrow and rrow[1] else None
|
||
last_err = (rrow[2] if rrow else None)
|
||
conv = (rrow[3] if rrow else None)
|
||
rec["running"] = running
|
||
rec["running_since"] = running_since
|
||
if last_err:
|
||
rec["last_error"] = str(last_err)[:120]
|
||
|
||
# ② 跑没跑 + 结论原文
|
||
runs = con.execute(
|
||
"select created_at,status,result_success,thread_title,failure_code,reason_code "
|
||
"from automation_runs where automation_id=? and coalesce(created_at,0)>? "
|
||
"order by created_at desc", (d["auto_id"], sched_ms)).fetchall()
|
||
rec["run_count"] = len(runs)
|
||
if runs:
|
||
r0 = runs[0]
|
||
rec["run_at"] = fmt_ms(r0[0])
|
||
rec["run_status"] = r0[1]
|
||
rec["success"] = (r0[2] == 1)
|
||
rec["conclusion"] = (r0[3] or "")[:300]
|
||
if r0[4] or r0[5]:
|
||
rec["failure"] = "%s/%s" % (r0[4], r0[5])
|
||
if conv:
|
||
rec["conversation"] = str(conv)[:12]
|
||
|
||
# ④ 会话
|
||
rec["sessions"] = session_stats(con, d["ws"], sched_ms)
|
||
|
||
# ⑥ 产出
|
||
rec["new_files"] = new_files(d["ws"], d["watch"], sched_ms)
|
||
|
||
# ⑦ 结果钩子的台账(这条链的"接收"端)
|
||
mine, bad = ledger_for(ledger or [], d["ws"], sched_ms)
|
||
rec["hook"] = {"records": len(mine), "bad": [r.get("verdict") for r in bad],
|
||
"last": (mine[-1].get("ts") if mine else None)}
|
||
|
||
# ---- 判定 ----
|
||
if not runs and not running:
|
||
if NOW_MS < sched_ms:
|
||
rec.update(state="待跑", why="还没到点", need_follow=False, action=ACTION_WAIT)
|
||
else:
|
||
overdue = (NOW_MS - sched_ms) / 60000.0
|
||
if overdue < 3:
|
||
rec.update(state="待跑", why="到点 %.1f 分钟,尚未见运行记录" % overdue,
|
||
need_follow=False, action=ACTION_WAIT)
|
||
else:
|
||
rec.update(state="异常",
|
||
why="已过点 %.0f 分钟仍**无任何运行记录** ⇒ 自动化没触发" % overdue,
|
||
need_follow=True, action=ACTION_RESCHEDULE)
|
||
return rec
|
||
|
||
if running:
|
||
stuck = ""
|
||
if running_since:
|
||
try:
|
||
start = int(datetime.strptime(
|
||
"%s %s" % (datetime.now().strftime("%Y-%m-%d"), running_since),
|
||
"%Y-%m-%d %H:%M:%S").timestamp() * 1000)
|
||
mins = (NOW_MS - start) / 60000.0
|
||
if mins > 60:
|
||
stuck = "(已跑 %.0f 分钟 ⇒ **疑似卡住**)" % mins
|
||
except Exception:
|
||
pass
|
||
rec.update(state="进行中", why="正在跑" + stuck,
|
||
need_follow=bool(stuck),
|
||
action=(ACTION_REPORT if stuck else ACTION_WAIT))
|
||
return rec
|
||
|
||
if runs:
|
||
if rec.get("success"):
|
||
# 🔴 先判「跑完了但**没干活**」—— 这一条必须排在"有没有产出"之前。
|
||
# 判据 = 结论原文自述未执行(机器可读)。判错这里 = 那条活静默死掉(2026-09-28 实测)。
|
||
con_text = rec.get("conclusion") or ""
|
||
hit = [m for m in PENDING_MARKERS if m in con_text]
|
||
if hit:
|
||
rec.update(state="跑完·未实际执行",
|
||
why="运行成功,但结论自述未执行(命中:%s)⇒ **等同异常**,必须重排" % "、".join(hit[:4]),
|
||
need_follow=True, action=ACTION_RESCHEDULE)
|
||
elif rec["new_files"]:
|
||
# 🔴 交叉校验:**自动化说成功 + 有产出,但钩子台账里这条线有"非正常收尾"**
|
||
# ⇒ 两个信号打架 ⇒ 以**证据**为准,交给下一棒看一眼(⛔ 不自动重排,
|
||
# 因为产出文件确实在,重排可能重复做同一件事)。
|
||
if rec["hook"]["bad"]:
|
||
rec.update(state="已收官·但钩子有异常收尾",
|
||
why="有产出,但结果钩子记录到非正常收尾:%s ⇒ 需人判读"
|
||
% "、".join(rec["hook"]["bad"][:4]),
|
||
need_follow=True, action=ACTION_REPORT)
|
||
else:
|
||
rec.update(state="已收官", why="有结论 + 有产出",
|
||
need_follow=False, action=ACTION_WAIT)
|
||
else:
|
||
rec.update(state="收官·疑未产出",
|
||
why="跑完了但收件目录里**没有新文件** ⇒ 核实是否只是「只在对话里答了」",
|
||
need_follow=True, action=ACTION_REPORT)
|
||
else:
|
||
rec.update(state="失败",
|
||
why="运行记录 result_success != 1" + ("(%s)" % rec.get("failure", "") if rec.get("failure") else ""),
|
||
need_follow=True, action=ACTION_RESCHEDULE)
|
||
return rec
|
||
|
||
rec.update(state="未知", why="信号不足", need_follow=True, action=ACTION_REPORT)
|
||
return rec
|
||
|
||
|
||
def main():
|
||
as_json = "--json" in sys.argv
|
||
con = ro_conn()
|
||
|
||
lk = lock_state()
|
||
dl = domain_locks()
|
||
ledger = read_ledger()
|
||
hh = hook_health(ledger)
|
||
results = [check_one(con, d, ledger) for d in DISPATCHES]
|
||
|
||
# 未收官且有在跑的 ⇒ 锁被占属正常;全部停跑但锁还占着 ⇒ 异常(本项目红线:锁只能持有者释放)
|
||
lock_orphan = bool(lk["held"]) and all(
|
||
(not r.get("running")) and r.get("state") in ("已收官", "失败", "异常") for r in results)
|
||
|
||
follow = [r for r in results if r.get("need_follow")]
|
||
rc = 0
|
||
if follow or lock_orphan:
|
||
rc = 2
|
||
elif any(r["state"] in ("待跑", "进行中") for r in results):
|
||
rc = 1
|
||
|
||
if as_json:
|
||
out(json.dumps({"now": fmt_ms(NOW_MS), "lock": lk, "domain_locks": dl,
|
||
"hook_health": hh, "lines": results,
|
||
"lock_orphan": lock_orphan, "rc": rc},
|
||
ensure_ascii=False, indent=2))
|
||
return rc
|
||
|
||
out("═══ 派活监管 · 三线执行巡检 @ %s ═══" % fmt_ms(NOW_MS))
|
||
out("")
|
||
out("【全局执行锁】%s%s" % (
|
||
("🔴 被占:%s" % lk["owner"]) if lk["held"] else "✅ 空闲",
|
||
" + 域锁:%s" % ", ".join(dl) if dl else ""))
|
||
if lock_orphan:
|
||
out(" ⛔ **锁被占但三线都已停跑** ⇒ 疑似未释放(红线:锁只能持有者自己释放,⛔ 不许代为删锁)")
|
||
out("")
|
||
out("【结果钩子】%s 台账记录=%d 最近=%s" % (
|
||
"🟢 在工作" if hh["ok"] else "🔴 **未工作**", hh["records"], hh["last_ts"] or "-"))
|
||
if hh["problem"]:
|
||
out(" ⚠️ %s" % hh["problem"])
|
||
out("")
|
||
|
||
icon = {"已收官": "🟢", "进行中": "🔵", "待跑": "⚪", "失败": "🔴",
|
||
"异常": "🔴", "收官·疑未产出": "🟡", "跑完·未实际执行": "🔴",
|
||
"待排(未登记)": "🔴", "未知": "🟡"}
|
||
for r in results:
|
||
out("%s 【%s】%s (排在 %s)" % (icon.get(r["state"], "·"), r["line"], r["state"], r["at"]))
|
||
out(" 任务:%s" % r["task"])
|
||
if r.get("running"):
|
||
out(" 此刻:**正在跑**" + ("(起于 %s)" % r["running_since"] if r.get("running_since") else ""))
|
||
if r.get("run_count"):
|
||
out(" 运行:%s 次 · 最近 %s · 成功=%s" % (
|
||
r["run_count"], r.get("run_at", "-"), "是" if r.get("success") else "否/未定"))
|
||
con_txt = (r.get("conclusion") or "").replace("\n", " ").strip()
|
||
if con_txt:
|
||
out(" 结论:%s" % con_txt[:160])
|
||
if r.get("last_error") or r.get("failure"):
|
||
out(" 🔴 错误:%s %s" % (r.get("last_error", ""), r.get("failure", "")))
|
||
for s in r["sessions"][:3]:
|
||
out(" 会话:%s status=%s 创建=%s 最后活动=%s" % (
|
||
s["id"], s["status"], s["created"], s["last_activity"]))
|
||
if r["new_files"]:
|
||
out(" 产出(新):")
|
||
for f in r["new_files"][:4]:
|
||
out(" · %s (%s, %s B)" % (f["path"], f["mtime"], f["size"]))
|
||
else:
|
||
out(" 产出(新):**无**")
|
||
hk = r.get("hook") or {}
|
||
if hk.get("records"):
|
||
out(" 钩子台账:%d 条 · 最近 %s%s" % (
|
||
hk["records"], hk.get("last") or "-",
|
||
(" · 🔴 非正常收尾:" + "、".join(hk["bad"])) if hk.get("bad") else ""))
|
||
out(" 判读:%s" % r["why"])
|
||
out(" ➡️ **action = %s**%s" % (r.get("action", "?"),
|
||
ACTIONS_HELP.get(r.get("action"), "")))
|
||
out("")
|
||
|
||
# ---- 汇总:按 action 分组(巡检棒照 action 办事,⛔ 别自己解释状态)----
|
||
by_action = {}
|
||
for r in results:
|
||
by_action.setdefault(r.get("action", "?"), []).append(r)
|
||
if lock_orphan:
|
||
by_action.setdefault(ACTION_REPORT_LOCK, []).append({"line": "(全局)", "why": "锁被占但三线都已停跑 ⇒ 疑似未释放"})
|
||
if not hh["ok"]:
|
||
# 钩子没工作 ⇒ 必须报出来(⛔ 否则"台账为空"会被读成"一切正常")
|
||
by_action.setdefault(ACTION_REPORT, []).append(
|
||
{"line": "(结果钩子)", "why": hh["problem"] or "钩子未工作"})
|
||
|
||
if not by_action or set(by_action) == {ACTION_WAIT}:
|
||
out("⇒ ✅ 无需动作(wait):%s" % (
|
||
"全部收官" if all(r["state"] == "已收官" for r in results) else "正常等待"))
|
||
return rc
|
||
|
||
out("⇒ 本轮动作清单(**照 action 办事**):")
|
||
for act in (ACTION_RESCHEDULE, ACTION_REPORT_LOCK, ACTION_REPORT, ACTION_WAIT):
|
||
items = by_action.get(act)
|
||
if not items:
|
||
continue
|
||
out(" 【%s】%s" % (act, ACTIONS_HELP.get(act, "")))
|
||
for r in items:
|
||
out(" · 【%s】%s" % (r["line"], r["why"]))
|
||
return rc
|
||
|
||
|
||
ACTIONS_HELP = {
|
||
ACTION_RESCHEDULE: "⇒ 🔴 **必须当场新建一条一次性自动化**给该线(⛔ 改时间不触发;⛔ 不许只报告),并把新 id 写回脚本顶部 DISPATCHES",
|
||
ACTION_REPORT_LOCK: "⇒ ⛔ **只报告用户**:不代删锁、不接管(红线)",
|
||
ACTION_REPORT: "⇒ 报告 + 列期望产出路径(须人判读,别自建自动化)",
|
||
ACTION_WAIT: "⇒ 不做任何事(正常等待,无事不打扰)",
|
||
}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
sys.exit(main())
|
||
except Exception as e:
|
||
out("⛔ 巡检脚本自身出错:%s: %s" % (type(e).__name__, e))
|
||
sys.exit(3)
|