فهرست منبع

运维控制台: 修"任务把自己的父进程(网关)杀掉"的自伤 + 心跳 + 启动改二次启动

上一条提交做了控制台页面; 这次是**实测暴露的三个必须修的问题**(其中一个后果是"把栈停在半路"):

1. **自伤级**: 从控制台点"执行重算" → 重算走到第 6 步"重启服务" → `guanlan.py stop` 用
   `taskkill /PID <网关> /T /F`; `/T` 沿父子树连子孙一起杀, 而**重算任务正是网关的子进程** ——
   于是它把自己杀了: 日志停在 `[4/12] ⑥ 重启服务 (stop)` 那行, 之后什么都没有, 服务停在半路。
   两处修:
     · 新增 `scripts/_ops_launch.py`: 网关 → 启动器 → 执行器 的**二次启动**, 启动器 Popen 完就退出,
       执行器的父进程成了"已退出的进程", 不再是网关的子孙, taskkill /T 杀不到它;
     · `rebuild_all.py` 第 6 步改成**只重启组件服务**(`_ops_stop_keep_gateway.py` + `guanlan.py serve`),
       网关留着(chain_ingest 要的是 detail 重新加载产物; 网关不缓存产物)。
   实测: 重算 12 步全跑完 rc=0(117.5 s); "完整重启(含网关)"25 s 后全栈自己回来、各页 200、rc=0。

2. **状态永远"执行中"**: 任务被强杀后, `job_running()` 只看 pid 在不在 —— 而 pid 会被复用, 于是
   页面一直显示"执行中"且所有按钮全灰(等于卡死)。修法: `_ops_run.py` 起一个**心跳线程**每 15 s 更新
   job 文件 mtime; `job_running()` 超过 150 s 没心跳就判为"被强杀, 未拿到退出码"并收尾。

3. **日志尾巴长时间不动**: 输出重定向到文件时 Python 默认块缓冲(8 KB), 实测重算跑到第 2 分钟日志尾
   还停在第 3 行。修法: 动作子进程统一带 `PYTHONUNBUFFERED=1`(启动器与执行器都设)。

另: 验证方式补了 `node --check`(页面内联 JS 无语法错)与"六个按钮 id + 2 s 轮询都在"的自检。
zhouyang.xie 4 هفته پیش
والد
کامیت
1041ec04db
5فایلهای تغییر یافته به همراه130 افزوده شده و 17 حذف شده
  1. 26 1
      docs/重算操作手册_v0.1.md
  2. 53 0
      scripts/_ops_launch.py
  3. 14 0
      scripts/_ops_run.py
  4. 30 12
      scripts/guanlan_ops.py
  5. 7 4
      scripts/rebuild_all.py

+ 26 - 1
docs/重算操作手册_v0.1.md

@@ -99,7 +99,32 @@ cd /d <安装目录>
    再也没法"启动服务"。要连网关一起停, 用"完整重启"。
    再也没法"启动服务"。要连网关一起停, 用"完整重启"。
 
 
 实现: 页面与 API 在 `scripts/guanlan_ops.py`(网关只做转发); 动作由 `scripts/_ops_run.py` 执行
 实现: 页面与 API 在 `scripts/guanlan_ops.py`(网关只做转发); 动作由 `scripts/_ops_run.py` 执行
-(把**真实退出码**写回 `run/ops_job.json`, 页面据此显示"完成(退出码 0)"或失败); 日志在 `logs/ops_<动作>_<时间>.log`。
+(把**真实退出码**写回 `run/ops_job.json`, 页面据此显示"完成(退出码 0)"或失败);
+启动经 `scripts/_ops_launch.py` **二次启动** —— 这一层是必须的, 见下面第 4 条。
+
+**为什么要有"二次启动"这一层**(2026-09-12 实逮, 后果是"把栈停在半路"):
+
+    从控制台点"执行重算" → 重算跑到第 6 步"重启服务" → `guanlan.py stop` 用 `taskkill /PID <网关> /T /F`
+    → `/T` 沿父子树把子孙一起杀 → **正在执行的重算(网关的子进程)被自己杀掉**, 日志停在 stop 那行,
+    服务也停在半路(实测就是这样)。修法两条:
+      ① 动作经 `_ops_launch.py` 再启动一次: 执行器的父进程变成"已退出的启动器", 不再是网关的子孙;
+      ② `rebuild_all.py` 的第 6 步改成**只重启组件服务**(`_ops_stop_keep_gateway.py` + `guanlan.py serve`),
+         网关留着 —— chain_ingest 要的是 detail(18033) 重新加载产物, 网关不缓存产物。
+    另外任务加了**心跳**(`_ops_run.py` 每 15 s 更新 job 文件): 被强杀时页面不再永远显示"执行中",
+    而是标成"心跳停了 N 秒 —— 任务多半被强杀, 未拿到退出码"。
+
+**实测**(逐个按钮, 都用真实请求打):
+
+| 动作 | 结果 |
+|---|---|
+| 停组件服务 | 5 个组件 down、网关 up、**页面仍 200**; 按钮翻成 `stop=false start=true`; job `rc=0` ✅ |
+| 启动服务 | 全部 up(`rc=0`, 4.6 s), 日志 `webbrowser.open → True`(**门户已自动打开**) ✅ |
+| 完整重启(含网关) | `restart_all`, 页面断开约 15 s 后**自己回来**, 全栈 up、各页 200、`rc=0` ✅(修好自伤之后) |
+| 执行重算 | 12 步全跑完 `rc=0`(117.5 s), 期间五个按钮全灰、并发点"清除"→ **409** ✅ |
+| 清除产物 | 592 件 → **0 件**, 按钮翻成 `products_off=false products_on=true`; 重复清除 → **409** ✅ |
+| 恢复产物 | 回到 **592 件**, 锚点 39211/5876/19494/9702 全在 ✅ |
+| 语义前置 | 组件都在跑点"启动"→ **409**; 组件都停点"停止"→ **409** ✅ |
+| 页面本身 | `node --check` 通过(内联 JS 无语法错); 六个按钮 id 与 2 s 轮询都在 ✅ |
 
 
 ---
 ---
 
 

+ 53 - 0
scripts/_ops_launch.py

@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""动作启动器 (2026-09-12) —— 只做一件事: 把真正的执行器 **甩开** 与网关的父子关系, 然后立刻退出。
+
+## 为什么需要这层
+
+网关用 `subprocess.Popen` 起的任务, 在 Windows 上是网关的**子进程**; 而 `guanlan.py stop` 用的是
+`taskkill /PID <网关> /T /F` —— `/T` 会**沿父子树把子孙一起杀**。于是:
+
+    在控制台点"执行重算" → 重算跑到第 6 步"重启服务" → stop 杀掉网关 → **把正在执行的重算也杀了**
+    (实测: 日志停在 `[4/12] ⑥ 重启服务 (stop)` 那一行, 之后什么都没有, 服务也停在半路)
+
+现在由本启动器 `Popen` 真正的执行器后**立即退出**: 执行器的父进程变成一个已退出进程, 于是它不再是
+网关的子孙, `taskkill /T` 不会再连带杀掉它。启动器把执行器的 pid 打到 stdout 最后一行, 供父进程记账。
+
+用法: python scripts/_ops_launch.py --tag rebuild --log <日志文件> -- <命令...>
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import pathlib
+import subprocess
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--tag', required=True)
+    ap.add_argument('--log', required=True)
+    ap.add_argument('cmd', nargs=argparse.REMAINDER)
+    a = ap.parse_args()
+    cmd = [c for c in a.cmd if c != '--']
+    if not cmd:
+        print('[X] 没给命令', file=sys.stderr); return 2
+    log = pathlib.Path(a.log)
+    log.parent.mkdir(parents=True, exist_ok=True)
+    env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
+    flags = 0
+    if os.name == 'nt':
+        flags = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, 'DETACHED_PROCESS', 0)
+    with open(log, 'ab') as f:
+        p = subprocess.Popen([sys.executable, 'scripts/_ops_run.py', '--tag', a.tag, '--'] + cmd,
+                             cwd=str(ROOT), env=env, stdout=f, stderr=subprocess.STDOUT,
+                             stdin=subprocess.DEVNULL, creationflags=flags, close_fds=True)
+    print(p.pid)          # ← 最后一行是执行器 pid, 父进程读它
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 14 - 0
scripts/_ops_run.py

@@ -19,10 +19,12 @@ import os
 import pathlib
 import pathlib
 import subprocess
 import subprocess
 import sys
 import sys
+import threading
 import time
 import time
 
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 JOB = ROOT / 'run' / 'ops_job.json'
 JOB = ROOT / 'run' / 'ops_job.json'
+HEARTBEAT_S = 15
 
 
 
 
 def _write(j: dict):
 def _write(j: dict):
@@ -30,6 +32,15 @@ def _write(j: dict):
     JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
     JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
 
 
 
 
+def _heartbeat(stop_evt: threading.Event):
+    """每 15 s 更新 job 文件的 mtime —— 页面据此区分"还在跑"与"被强杀"(pid 会被复用, 只看 pid 会误判)。"""
+    while not stop_evt.wait(HEARTBEAT_S):
+        try:
+            os.utime(JOB, None)
+        except OSError:
+            pass
+
+
 def main() -> int:
 def main() -> int:
     ap = argparse.ArgumentParser()
     ap = argparse.ArgumentParser()
     ap.add_argument('--tag', required=True)
     ap.add_argument('--tag', required=True)
@@ -49,12 +60,15 @@ def main() -> int:
     _write(job)
     _write(job)
 
 
     print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True)
     print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True)
+    stop_evt = threading.Event()
+    threading.Thread(target=_heartbeat, args=(stop_evt,), daemon=True).start()
     t0 = time.time()
     t0 = time.time()
     try:
     try:
         rc = subprocess.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ)).returncode
         rc = subprocess.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ)).returncode
     except Exception as e:
     except Exception as e:
         print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True)
         print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True)
         rc = 99
         rc = 99
+    stop_evt.set()
     dt = time.time() - t0
     dt = time.time() - t0
     job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
     job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
     _write(job)
     _write(job)

+ 30 - 12
scripts/guanlan_ops.py

@@ -95,6 +95,17 @@ def job_running() -> tuple[bool, dict]:
     if not j or j.get('status') != 'running':
     if not j or j.get('status') != 'running':
         return False, j
         return False, j
     pid = j.get('pid')
     pid = j.get('pid')
+    # 心跳优先: 执行器每 15 s 更新 job 文件; 超过 STALE_S 没动静 = 被强杀(pid 可能被复用, 只看 pid 会误判)
+    STALE_S = 150
+    try:
+        age = time.time() - JOB.stat().st_mtime
+    except OSError:
+        age = 0
+    if age > STALE_S:
+        j.update(status='done', rc=None, finished=time.strftime('%Y-%m-%d %H:%M:%S'),
+                 note=(j.get('note') or '') + f' (心跳停了 {int(age)} s —— 任务多半被强杀, 未拿到退出码)')
+        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+        return False, j
     alive = False
     alive = False
     if pid:
     if pid:
         try:
         try:
@@ -128,19 +139,24 @@ def _log_tail(path, n=40) -> list:
 
 
 
 
 def spawn(args: list, tag: str, detached=False) -> dict:
 def spawn(args: list, tag: str, detached=False) -> dict:
-    """起一个动作: 交给 scripts/_ops_run.py 执行 (它会写回真实退出码), 日志落 logs/ops_<tag>_<时间>.log。"""
+    """起一个动作: 经 `_ops_launch.py` 二次启动 `_ops_run.py`。
+
+    两层是**必须的**(见 _ops_launch.py 注释): 直接 Popen 的话, 任务是网关的子进程, 而
+    `guanlan.py stop` 用 `taskkill /T` —— 停网关时会连带杀掉正在执行的任务(实测把自己杀了)。
+    执行器负责写回真实退出码; 日志落 logs/ops_<tag>_<时间>.log。
+    """
     RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
     RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
     log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
     log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
-    # PYTHONUNBUFFERED: 输出重定向到文件时 Python 默认块缓冲(8 KB), 页面上的"日志尾巴"会长时间不动 ——
-    # 加了它才能边跑边看到每一行(实测: 重算跑到第二分钟, 日志尾还停在第 3 行)。
     env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
     env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
-    flags = 0
-    if os.name == 'nt':
-        flags = subprocess.CREATE_NEW_PROCESS_GROUP | (subprocess.DETACHED_PROCESS if detached else 0)
-    runner = [PY, 'scripts/_ops_run.py', '--tag', tag, '--'] + list(args)
-    f = open(log, 'ab')
-    p = subprocess.Popen(runner, cwd=str(ROOT), env=env, stdout=f, stderr=subprocess.STDOUT, creationflags=flags)
-    j = dict(kind=tag, cmd=' '.join(args), pid=p.pid, log=str(log), status='running', rc=None,
+    r = subprocess.run([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
+                       cwd=str(ROOT), env=env, capture_output=True, text=True, errors='replace', timeout=60)
+    pid = None
+    for line in reversed((r.stdout or '').strip().splitlines()):
+        if line.strip().isdigit():
+            pid = int(line.strip()); break
+    if pid is None:
+        print('  启动器输出异常:', r.stdout, r.stderr)
+    j = dict(kind=tag, cmd=' '.join(args), pid=pid, log=str(log), status='running', rc=None,
              started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
              started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
     JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
     JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
     return j
     return j
@@ -251,11 +267,13 @@ def act_stop(body: dict) -> tuple[int, dict]:
         # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
         # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
         return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
         return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
     if include_gw:
     if include_gw:
-        # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来
+        # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来。
+        # 现在经 _ops_launch 二次启动 → 这个任务不是网关的子孙, stop 里的 taskkill /T 杀不到它,
+        # 所以"起回来"那半一定执行得到(此前会让整个栈停在半路)。
         j = spawn(['-c', 'import subprocess,sys,time;'
         j = spawn(['-c', 'import subprocess,sys,time;'
                          f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
                          f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
                          'time.sleep(2);'
                          'time.sleep(2);'
-                         f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all', detached=True)
+                         f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all')
         j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
         j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
         JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
         JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
         return 200, dict(ok=True, job=j, note=j['note'])
         return 200, dict(ok=True, job=j, note=j['note'])

+ 7 - 4
scripts/rebuild_all.py

@@ -56,10 +56,13 @@ def build_plan(a) -> list:
     plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py']))
     plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py']))
     plan.append(step_cmd('⑤ 补齐"包内没有生成端"的产物', [PY, 'scripts/products_restore_missing.py']))
     plan.append(step_cmd('⑤ 补齐"包内没有生成端"的产物', [PY, 'scripts/products_restore_missing.py']))
     if not a.no_restart:
     if not a.no_restart:
-        plan.append(step_cmd('⑥ 重启服务 (stop)', [PY, 'guanlan.py', 'stop']))
-        # guanlan.py serve 在"有模块 DOWN"时**故意返回 1**(degraded 是正常状态: 如本机 Ollama 没模型),
-        # 所以这一步单独允许退出码 1, 由 tolerate={...} 标注。
-        plan.append(step_cmd('⑥ 重启服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,)))
+        # 只重启**组件服务**, 网关留着 —— 两个理由:
+        #  ① 控制台页面是网关提供的, 连它一起停的话, 从页面点的重算会把自己的界面(甚至自己)弄没;
+        #  ② rebuild_all 自己可能就是从控制台/网关起的任务, `guanlan.py stop` 用 taskkill /T,
+        #     连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。
+        # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。
+        plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))
+        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,)))
     plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
     plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
     plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
     plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
     plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))
     plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))