2026-10-17 17:15:16 +00:00
"""训练控制台(本地网页,零第三方依赖,离线可用):一键开始全流程、看进度、可停止。
双击 ml/console.bat( Windows)或运行 bash ml/console.sh( Linux/WSL)→ 自动打开 http://localhost:8100
也可:python -m ml.monitor [--port 8100] [--host 0.0.0.0] [--open]
2026-10-17 18:57:48 +00:00
网页上点「开始训练」→ 选参数 → 后台执行 bash ml/pipeline.sh all(已完成的阶段自动跳过 = 续跑;勾「从头重跑」= FRESH=1),
输出写 ml/runs/console.log,网页实时显示;「停止」结束整个进程树。网页和控制台窗口关掉都不影响训练,重新打开即可继续看。
2026-10-17 17:15:16 +00:00
只读数据来源:
ml/runs/pipeline.json 各阶段开始/结束/返回码(pipeline.sh 自动写)→ 全流程进度
ml/runs/*/log.jsonl 预训练(base / wta / bench_*)与 APG( apg / apg_r*)逐步日志与闭环评估
ml/runs/m5_gate_*.json、mine/r*/report.json + gate.json、policy_bench.json、acceptance.json
ml/data/stats.json / consistency.json / r2.json / samples/index.json、nvidia-smi
安全:默认只监听 127.0.0.1; --host 0.0.0.0 时其他机器只能看,开始/停止只接受本机请求(--remote-control 放开)。
2026-10-17 18:57:48 +00:00
2026-10-18 新增(用户要求:训练全程可视化,并在网页上点按钮一键开始)。
2026-10-18 第五轮(审查第四轮):
N-7 是否在跑看 ml/runs/pipeline.lock(与终端同一把锁),不再靠"日志 120 s 内有更新"去猜;
N-8 开始/停止要求自定义头 X-Console + 同源 Origin/Sec-Fetch-Site,外站网页发不进来;
N-10 启动自检(训练用 Python 能否 import torch、GPU、node、bash),不过就拒绝「开始训练」并在顶栏标红;
N-11 Windows 子进程用 CREATE_NO_WINDOW 自带隐藏控制台:关掉控制台黑窗口训练照跑,重开控制台可接着看、可停止;
N-9 生成阶段的行数按文件增量统计(只读新增字节),其他阶段不扫 shard;
N-12 停止后的状态改写走 ml.pstate(单一写者、原子替换);N-14 阶段名/估时/权重由 ml.pstate.STAGES 下发。"""
2026-10-18 16:15:44 +08:00
import argparse , glob , json , os , platform , re , shutil , signal , subprocess , sys , threading , time , webbrowser
2026-10-17 18:57:48 +00:00
sys . path . insert ( 0 , os . path . dirname ( os . path . dirname ( os . path . abspath ( __file__ ))))
from ml import pstate
2026-10-17 17:15:16 +00:00
from http.server import ThreadingHTTPServer , BaseHTTPRequestHandler
from urllib.parse import urlparse , parse_qs
ROOT = os . path . dirname ( os . path . dirname ( os . path . abspath ( __file__ )))
HTML = os . path . join ( os . path . dirname ( os . path . abspath ( __file__ )), 'monitor.html' )
RUNS , DATA = 'ml/runs' , 'ml/data'
def jload ( p ):
try :
with open ( p , encoding = 'utf-8' ) as f : return json . load ( f )
except Exception : return None
def kind_of ( name ):
if name . startswith ( 'apg' ): return 'apg'
if name . startswith ( 'bench' ): return 'bench'
return 'train'
def list_runs ():
out = []
for f in glob . glob ( os . path . join ( ROOT , RUNS , '*' , 'log.jsonl' )):
d = os . path . basename ( os . path . dirname ( f )); st = os . stat ( f )
out . append ({ 'name' : d , 'kind' : kind_of ( d ), 'size' : st . st_size , 'mtime' : st . st_mtime ,
'has_best' : os . path . exists ( os . path . join ( os . path . dirname ( f ), 'best.pt' )),
'diverged' : os . path . exists ( os . path . join ( os . path . dirname ( f ), 'diverged.pt' ))})
out . sort ( key = lambda r : - r [ 'mtime' ])
return out
def read_log ( name , offset ):
if not re . fullmatch ( r '[\w.\-]+' , name or '' ): return { 'error' : 'bad run' }
f = os . path . join ( ROOT , RUNS , name , 'log.jsonl' )
if not os . path . exists ( f ): return { 'error' : 'no log' }
size = os . path . getsize ( f )
if offset > size : offset = 0 # 日志被新训练覆盖 → 从头读
recs = []
with open ( f , 'rb' ) as fh :
fh . seek ( offset ); buf = fh . read ( min ( size - offset , 64 << 20 ))
end = buf . rfind ( b ' \n ' ) + 1 # 只取完整行,半行留到下次
for line in buf [: end ] . decode ( 'utf-8' , 'replace' ) . splitlines ():
if line . strip ():
try : recs . append ( json . loads ( line ))
except Exception : pass
return { 'records' : recs , 'offset' : offset + end , 'size' : size , 'reset' : offset == 0 , 'mtime' : os . path . getmtime ( f )}
2026-10-18 15:13:37 +08:00
_CPU = { 'v' : {}}
2026-10-18 16:15:44 +08:00
def cpu_name ():
"""CPU 型号:psutil 只有核数,型号得自己读——Windows 在注册表,Linux 在 /proc/cpuinfo。"""
try :
if os . name == 'nt' :
import winreg
with winreg . OpenKey ( winreg . HKEY_LOCAL_MACHINE , r 'HARDWARE\DESCRIPTION\System\CentralProcessor\0' ) as k :
return re . sub ( r '\s+' , ' ' , winreg . QueryValueEx ( k , 'ProcessorNameString' )[ 0 ]) . strip ()
with open ( '/proc/cpuinfo' ) as f :
for ln in f :
if ln . startswith ( 'model name' ):
return ln . split ( ':' , 1 )[ 1 ] . strip ()
except Exception :
pass
return platform . processor () . strip ()
2026-10-18 15:13:37 +08:00
class CpuSampler ( threading . Thread ):
"""后台每秒阻塞采样一次 CPU。cpu_percent 是"距上次调用"的差值:放在请求里算,窗口取决于两次
HTTP 请求的间隔(网页 1 秒轮询 + 手动 curl 会把它压成几毫秒,读数恒为 0)——所以单独一个线程按固定
1 秒窗口取,status 只读最近一次。"""
def __init__ ( self ): super () . __init__ ( daemon = True ); self . start ()
def run ( self ):
try :
import psutil
2026-10-18 16:15:44 +08:00
name = cpu_name () # 型号只读一次,别每秒翻注册表
2026-10-18 15:13:37 +08:00
psutil . cpu_percent ( interval = None , percpu = True ) # 打桩:第一次恒为 0,丢掉
while True :
per = psutil . cpu_percent ( interval = 1.0 , percpu = True )
2026-10-18 16:15:44 +08:00
_CPU [ 'v' ] = { 'total' : round ( sum ( per ) / max ( 1 , len ( per )), 1 ), 'per' : [ round ( x ) for x in per ], 'n' : len ( per ), 'model' : name }
2026-10-18 15:13:37 +08:00
except Exception :
_CPU [ 'v' ] = {}
2026-10-18 13:57:48 +08:00
def cpu ():
2026-10-18 17:31:05 +08:00
return { ** _CPU [ 'v' ], ** mahm ()}
_MAHM = { 't' : 0.0 , 'v' : {}}
def mahm ():
"""借 MSI Afterburner 的共享内存(MAHMSharedMemory)读 CPU 温度 / 封装功耗——Windows 上没有别的免驱动口子:
psutil 在 Windows 没有 sensors_temperatures; ACPI 热区只有个 27.9 °C 的板温;Power Meter 计数器没有实例。
条目布局实测:头 32 字节(签名 MAHM / 版本 / 头大小 / 条目数 / 条目大小 / 时间戳),每条 1324 字节,
名字在前 260 字节、单位在其后 260、数值是偏移 1300 的 float(用 GPU1 power 对 nvidia-smi 校准过)。
2026-10-18 17:59:46 +08:00
读数比 30 秒旧才当没读到:Afterburner 的硬件轮询周期实测 10~13 秒(不是 1 秒),阈值卡 10 秒会
正好落在两次刷新之间、把还在用的读数丢掉(表现为温度和功耗一闪一闪地消失)。"""
2026-10-18 17:31:05 +08:00
now = time . time ()
if now - _MAHM [ 't' ] < 1.0 : return _MAHM [ 'v' ]
_MAHM [ 't' ] = now
out = {}
try :
import ctypes , struct
k = ctypes . windll . kernel32
k . OpenFileMappingW . restype = ctypes . c_void_p
k . OpenFileMappingW . argtypes = [ ctypes . c_uint32 , ctypes . c_int , ctypes . c_wchar_p ]
k . MapViewOfFile . restype = ctypes . c_void_p
k . MapViewOfFile . argtypes = [ ctypes . c_void_p , ctypes . c_uint32 , ctypes . c_uint32 , ctypes . c_uint32 , ctypes . c_size_t ]
h = k . OpenFileMappingW ( 0x0004 , 0 , 'MAHMSharedMemory' )
p = k . MapViewOfFile ( h , 0x0004 , 0 , 0 , 0 ) if h else 0
if p :
hd = ctypes . string_at ( p , 32 )
num , esize , hsize = ( int . from_bytes ( hd [ i * 4 : i * 4 + 4 ], 'little' ) for i in ( 3 , 4 , 2 ))
upd = int . from_bytes ( hd [ 20 : 24 ], 'little' )
2026-10-18 17:59:46 +08:00
if 0 < num < 5000 and 32 < esize < 65536 and now - upd <= 30 :
2026-10-18 17:31:05 +08:00
buf = ctypes . string_at ( p , hsize + num * esize )
core = {}
for i in range ( num ):
e = buf [ hsize + i * esize : hsize + ( i + 1 ) * esize ]
nm = e [: 260 ] . split ( b ' \x00 ' )[ 0 ] . decode ( 'latin1' )
if not nm : continue
val = struct . unpack ( '<f' , e [ 1300 : 1304 ])[ 0 ]
if nm == 'CPU temperature' and 0 < val < 130 : out [ 'temp' ] = round ( val )
elif nm == 'CPU power' and 0 < val < 1000 : out [ 'power' ] = round ( val , 1 )
elif nm . startswith ( 'CPU' ) and nm . endswith ( ' temperature' ):
try :
j = int ( nm [ 3 : - 12 ])
if 0 < val < 130 : core [ j ] = round ( val )
except ValueError : pass
if core : out [ 'coreT' ] = [ core [ k ] for k in sorted ( core )]
except Exception :
out = {}
_MAHM [ 'v' ] = out
return out
2026-10-18 13:57:48 +08:00
2026-10-18 13:48:30 +08:00
_GPU_CACHE = { 't' : 0.0 , 'v' : []}
2026-10-17 17:15:16 +00:00
def gpu ():
2026-10-18 13:48:30 +08:00
# 网页 1 秒轮询:nvidia-smi 每次要 150– 600 ms,缓存 2 秒(GPU 数字不需要更快),避免每轮都起子进程
if time . time () - _GPU_CACHE [ 't' ] < 2.0 : return _GPU_CACHE [ 'v' ]
2026-10-17 17:15:16 +00:00
try :
q = 'name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw'
r = subprocess . run ([ 'nvidia-smi' , f '--query-gpu= { q } ' , '--format=csv,noheader,nounits' ], capture_output = True , text = True , timeout = 3 )
out = []
for line in r . stdout . strip () . splitlines ():
n , u , mu , mt , t , p = [ x . strip () for x in line . split ( ',' )]
num = lambda x : float ( x ) if re . fullmatch ( r '[\d.]+' , x ) else None
out . append ({ 'name' : n , 'util' : num ( u ), 'mem_used' : num ( mu ), 'mem_total' : num ( mt ), 'temp' : num ( t ), 'power' : num ( p )})
except Exception :
2026-10-18 13:48:30 +08:00
out = []
_GPU_CACHE . update ( t = time . time (), v = out )
return out
2026-10-17 17:15:16 +00:00
def last_rec ( name ):
"""读日志最后一条非评估记录(只读文件尾 16 KB)。"""
f = os . path . join ( ROOT , RUNS , name , 'log.jsonl' )
try :
with open ( f , 'rb' ) as fh :
fh . seek ( 0 , 2 ); n = fh . tell (); fh . seek ( max ( 0 , n - 16384 )); tail = fh . read () . decode ( 'utf-8' , 'replace' ) . splitlines ()
for line in reversed ( tail ):
try :
r = json . loads ( line )
if 'eval' not in r : return r
except Exception : pass
except Exception : pass
return None
2026-10-17 18:57:48 +00:00
class LineCount :
"""按文件增量数行:记住每个文件已读到的偏移与行数,只读新增字节(审查 N-9:此前每 5 s 全量重读全部 shard)。"""
def __init__ ( self ): self . c = {}; self . lock = threading . Lock ()
def count ( self , f ):
with self . lock :
try : size = os . path . getsize ( f )
except OSError : return 0
off , n = self . c . get ( f , ( 0 , 0 ))
if size < off : off , n = 0 , 0
if size > off :
with open ( f , 'rb' ) as fh :
fh . seek ( off )
while True :
b = fh . read ( 1 << 22 )
if not b : break
n += b . count ( b ' \n ' ); off += len ( b )
self . c [ f ] = ( off , n )
return n
LC = LineCount ()
2026-10-17 17:15:16 +00:00
def newer ( pattern , t0 ):
return [ f for f in glob . glob ( os . path . join ( ROOT , pattern )) if os . path . getmtime ( f ) >= t0 - 1 ]
def stage_progress ( st , s ):
2026-10-18 17:59:46 +08:00
"""当前阶段的进度:返回 {'frac': 0..1 或 None(无法计量), 'label': 文字, 'eta_s': 按实测速率外推的剩余秒数或 None}。"""
2026-10-17 17:15:16 +00:00
t0 , info = s [ 'start' ], s . get ( 'info' ) or {}
if st == 'gen' :
2026-10-18 13:54:16 +08:00
# 分子算全部(含之前轮次已完成的分片),分母是累计目标:补量时显示 50,008/79,688 而不是 0/79,688。
# 2026-10-18 实测:只统计本轮开始后改过的文件会让"补量"看起来像从零开始。
allf = glob . glob ( os . path . join ( ROOT , DATA , 'shard_*.jsonl' )) + glob . glob ( os . path . join ( ROOT , DATA , 'shard_*.jsonl.part' ))
n = sum ( LC . count ( f ) for f in allf )
2026-10-17 17:15:16 +00:00
tgt = info . get ( 'target_tasks' ) or 0
2026-10-18 13:54:16 +08:00
new = sum ( LC . count ( f ) for f in newer ( f ' { DATA } /shard_*.jsonl' , t0 ) + newer ( f ' { DATA } /shard_*.jsonl.part' , t0 ))
2026-10-18 17:59:46 +08:00
# 剩余时间按"本轮已写条数 ÷ 本轮已用时"外推(条数是实时数出来的,速率就是实测的),不再用静态估时
el = max ( 1.0 , time . time () - t0 )
rate = new / el if new > 0 else 0
eta = (( tgt - n ) / rate if rate > 0 else None ) if tgt > n else ( 0.0 if rate > 0 else None )
lab = f '已生成 { n : , } / { tgt : , } 条任务' + ( f '(本轮补 { new : , } 条 · { rate * 60 : .0f } 条/分)' if 0 < new < n else '' )
return { 'frac' : min ( 1 , n / tgt ) if tgt else None , 'label' : lab , 'eta_s' : eta }
if st == 'gate1' :
# 一致性门禁每 10 个任务往 stderr 打一行 "k/300"( consistency.cjs:41)。进度只读控制台日志最后 64 KB,
# 不跑任何计算、不占 CPU —— 正在生成的节点不受影响。日志里可能还留着上一次门禁的进度行,
# 所以只数最后一个 "consistency.cjs" 命令行之后的部分。
txt = console ( 0 ) . get ( 'text' ) or ''
cut = txt . rfind ( 'consistency.cjs' )
m = re . findall ( r '^(\d+)/(\d+)\s*$' , txt [ cut :] if cut >= 0 else '' , re . M )
n , N = ( int ( m [ - 1 ][ 0 ]), int ( m [ - 1 ][ 1 ])) if m else ( 0 , 300 )
el = time . time () - t0
rate = n / el if n > 0 and el > 2 else 0
return { 'frac' : n / N if N else None ,
'label' : f '门禁 { n } / { N } 个任务' + ( f ' · { rate * 60 : .0f } 个/分' if rate else '' ) if n else '准备中(日志里还没有进度行)' ,
'eta_s' : ( N - n ) / rate if rate > 0 else None }
2026-10-17 17:15:16 +00:00
if st == 'prep' :
nsh = len ( glob . glob ( os . path . join ( ROOT , DATA , 'shard_*.jsonl' )))
2026-10-19 00:33:11 +08:00
fin = newer ( f ' { DATA } /samples/*.X.npy' , t0 )
done = len ( fin )
2026-10-18 17:59:46 +08:00
if os . path . exists ( os . path . join ( ROOT , DATA , 'r2.json' )) and newer ( f ' { DATA } /r2.json' , t0 ): return { 'frac' : 1 , 'label' : '打包完成,R2 检查完成' , 'eta_s' : 0 }
2026-10-19 00:33:11 +08:00
# 2026-10-19: build_dataset 每 10% 打一行 [prep] shard_xxxx a/b;用它把"整片完成"之间的进度和 ETA 算出来
fin_n = { os . path . basename ( p ) . split ( '.' )[ 0 ] for p in fin }
part = {}
for ln in console ( 0 )[ 'text' ] . splitlines ()[ - 800 :]:
m = re . search ( r '\[prep\] (shard_\d+) (\d+)/(\d+)' , ln )
if m : part [ m . group ( 1 )] = int ( m . group ( 2 )) / max ( 1 , int ( m . group ( 3 )))
run = { k : v for k , v in part . items () if k not in fin_n }
frac = ( done + sum ( run . values ())) / nsh if nsh else None
2026-10-18 17:59:46 +08:00
el = time . time () - t0
2026-10-19 00:33:11 +08:00
eta = el * ( 1 - frac ) / frac if frac and 0.02 < frac < 1 and el > 5 else None
lab = f '已打包 { done } / { nsh } 个分片'
if run : lab += f ' · 进行中 { len ( run ) } 个(平均 { 100 * sum ( run . values ()) / len ( run ) : .0f } %) '
elif done == 0 : lab += ' · 首波处理中,第一批完成后显示预估'
elif nsh and done >= nsh : lab += ', R2 检查中'
return { 'frac' : min ( 0.95 , frac ) if frac is not None else None , 'label' : lab , 'eta_s' : eta }
2026-10-17 17:15:16 +00:00
if st == 'train' :
tag , bs = info . get ( 'tag' , 'base' ), info . get ( 'bench_steps' , 2000 )
b , m = last_rec ( f 'bench_ { tag } ' ) if newer ( f ' { RUNS } /bench_ { tag } /log.jsonl' , t0 ) else None , last_rec ( tag ) if newer ( f ' { RUNS } / { tag } /log.jsonl' , t0 ) else None
if m and m . get ( 'total' ):
2026-10-18 17:59:46 +08:00
return { 'frac' : ( bs + m [ 'step' ]) / ( bs + m [ 'total' ]), 'label' : f '正式训练 { m [ "step" ] : , } / { m [ "total" ] : , } 步' , 'run' : tag ,
'eta_s' : m [ 'eta_h' ] * 3600 if m . get ( 'eta_h' ) else None } # 训练脚本自己按实测样本/s 算的
2026-10-17 17:15:16 +00:00
if b : return { 'frac' : b [ 'step' ] / ( bs + 60000 ), 'label' : f '测速 { b [ "step" ] : , } / { bs : , } 步' , 'run' : f 'bench_ { tag } ' }
return { 'frac' : 0 , 'label' : '加载数据中' }
2026-10-18 15:56:39 +00:00
if st == 'dagger' : # 2026-10-18 新增:每轮 = 采起点 → 老师重规划 → 打包 → 微调(微调占大头,进度按 0.1/0.25/0.05/0.6 分)
tag , R = info . get ( 'tag' , 'base' ), int ( info . get ( 'rounds' , 2 ))
k = len ( newer ( f ' { RUNS } / { tag } _dag*/done' , t0 )); k = min ( k , R )
base = f '第 { min ( k + 1 , R ) } / { R } 轮'
if k >= R : return { 'frac' : 1 , 'label' : 'DAgger 完成' }
D , run = f ' { DATA } /dag_ { tag } _r { k + 1 } ' , f ' { tag } _dag { k + 1 } '
m = last_rec ( run ) if newer ( f ' { RUNS } / { run } /log.jsonl' , t0 ) else None
if m and m . get ( 'total' ):
return { 'frac' : ( k + 0.4 + 0.6 * m [ 'step' ] / m [ 'total' ]) / R , 'label' : f ' { base } :微调 { m [ "step" ] : , } / { m [ "total" ] : , } 步' , 'run' : run ,
'eta_s' : m [ 'eta_h' ] * 3600 if m . get ( 'eta_h' ) else None }
if os . path . exists ( os . path . join ( ROOT , D , 'collect.json' )):
nt = sum ( LC . count ( f ) for f in glob . glob ( os . path . join ( ROOT , D , 'tasks_*.jsonl' )))
nd = sum ( LC . count ( f ) for f in glob . glob ( os . path . join ( ROOT , D , 'shard_dag_*.jsonl' )) + glob . glob ( os . path . join ( ROOT , D , 'shard_dag_*.jsonl.part' )))
return { 'frac' : ( k + 0.1 + 0.25 * ( nd / nt if nt else 0 )) / R , 'label' : f ' { base } :老师重新规划 { nd : , } / { nt : , } 个起点' }
return { 'frac' : k / R , 'label' : f ' { base } :闭环采起点' }
2026-10-17 17:15:16 +00:00
if st == 'apg' :
r = last_rec ( 'apg' ) if newer ( f ' { RUNS } /apg/log.jsonl' , t0 ) else None
2026-10-18 17:59:46 +08:00
eta = None
if r and r . get ( 'total' ):
it , mn = r . get ( 'it' ) or 0 , r . get ( 'min' ) or 0
eta = ( r [ 'total' ] - it ) / ( it / mn ) * 60 if it > 0 and mn > 0.1 else None # 步/分实测外推
return { 'frac' : r [ 'it' ] / r [ 'total' ] if r and r . get ( 'total' ) else 0 , 'label' : f 'APG { r [ "it" ] : , } / { r [ "total" ] : , } 步' if r else '准备中' , 'run' : 'apg' , 'eta_s' : eta }
2026-10-17 17:15:16 +00:00
if st == 'mine' :
R = info . get ( 'rounds' , 6 )
k = len ( newer ( f ' { RUNS } /mine/r*/gate.json' , t0 ))
cur = sorted ( newer ( f ' { RUNS } /apg_r*/log.jsonl' , t0 ), key = os . path . getmtime )
2026-10-18 17:59:46 +08:00
lab = f '第 { k + 1 } 轮:挖错出题'
if cur :
name = os . path . basename ( os . path . dirname ( cur [ - 1 ])) # ml/runs/apg_rN
rn = re . sub ( r '\D' , '' , name ) or '1'
gate = os . path . join ( ROOT , RUNS , 'mine' , f 'r { rn } ' , 'gate.json' )
r = last_rec ( name )
if r and r . get ( 'total' ) and not os . path . exists ( gate ): # 这一轮还在跑(还没出 gate)
frac_in = 0.3 + 0.6 * r [ 'it' ] / r [ 'total' ]
lab = f '第 { k + 1 } 轮:APG { r [ "it" ] : , } / { r [ "total" ] : , } 步'
it , mn = r . get ( 'it' ) or 0 , r . get ( 'min' ) or 0
eta = ( r [ 'total' ] - it ) / ( it / mn ) * 60 if it > 0 and mn > 0.1 else None
if eta is not None and k >= 1 : # 剩余轮次按已完成轮次的平均时长外推
eta += max ( 0 , R - k - 1 ) * ( time . time () - t0 ) / k
return { 'frac' : min ( 1 , ( k + frac_in ) / R ), 'label' : f ' { lab } (共最多 { R } 轮,提升不足会提前停)' , 'run' : name , 'eta_s' : eta }
2026-10-17 17:15:16 +00:00
return { 'frac' : min ( 1 , k / R ), 'label' : f ' { lab } (共最多 { R } 轮,提升不足会提前停)' }
if st == 'm5' :
tag = info . get ( 'tag' , 'base' )
n = len ( newer ( f ' { RUNS } /m5_ { tag } _policy.json' , t0 )) + len ( newer ( f ' { RUNS } /m5_ { tag } _safety.json' , t0 ))
2026-10-18 17:59:46 +08:00
el = time . time () - t0
eta = ( 2 - n ) * ( el / n ) if n == 1 else ( 0.0 if n >= 2 else None ) # 两次评估(纯策略 / 加安全层)按已完成的平均耗时外推
return { 'frac' : n / 2 , 'label' : [ '纯策略评估中' , '加安全层评估中' , '汇总' ][ min ( n , 2 )], 'eta_s' : eta }
2026-10-17 17:15:16 +00:00
return { 'frac' : None , 'label' : '进行中' }
2026-10-18 16:59:21 +08:00
def stage_elapsed ( pl , now = None ):
"""每个阶段的累计用时(秒):pipeline.json 的 time{阶段: 秒} 是不含当前段的累计,运行中的阶段
再把"这一段"实时加上。老记录(没有 time 字段)用 start/end 的差值兜底。
网页的「总用时」=各阶段之和,「本步用时」=当前阶段这条 —— 确定/续跑都不归零。"""
now = now or time . time ()
acc = pl . get ( 'time' ) or {}
cur = pl . get ( 'current' )
out = {}
for k , r in ( pl . get ( 'stages' ) or {}) . items ():
t = acc . get ( k )
if t is None and r . get ( 'start' ) and r . get ( 'end' ): t = r [ 'end' ] - r [ 'start' ]
t = t or 0
if k == cur and r . get ( 'start' ) and r . get ( 'end' ) is None : t += max ( 0.0 , now - r [ 'start' ])
out [ k ] = round ( t , 1 )
return out
2026-10-17 17:15:16 +00:00
def pipeline ():
d = jload ( os . path . join ( ROOT , RUNS , 'pipeline.json' ))
if not d : return None
2026-10-18 16:59:21 +08:00
d [ 'elapsed' ] = stage_elapsed ( d )
2026-10-17 17:15:16 +00:00
cur = d . get ( 'current' )
if cur and cur in d [ 'stages' ]:
try : d [ 'progress' ] = stage_progress ( cur , d [ 'stages' ][ cur ])
except Exception as e : d [ 'progress' ] = { 'frac' : None , 'label' : f '进度读取失败: { e } ' }
return d
def status ():
P = lambda * a : os . path . join ( ROOT , * a )
mine = []
for d in sorted ( glob . glob ( P ( RUNS , 'mine' , 'r*' )), key = lambda x : int ( re . sub ( r '\D' , '' , os . path . basename ( x )) or 0 )):
rep , gate = jload ( os . path . join ( d , 'report.json' )), jload ( os . path . join ( d , 'gate.json' ))
if rep or gate :
mine . append ({ 'round' : os . path . basename ( d ), 'report' : rep and { k : rep . get ( k ) for k in ( 'n' , 'E1' , 'E2' , 'E3' , 'E4' , 'E5' , 'ok' , 'pool' )},
'gate' : gate and { k : gate . get ( k ) for k in ( 'gain_pp' , 'A_drop_pp' , 'fixed_kept' , 'R7_left_share_shift_pp' , 'R7_travel_median_ratio' , 'accept' )}})
stats = jload ( P ( DATA , 'stats.json' ))
if stats : stats = { k : stats . get ( k ) for k in ( 'tasks' , 'keep' , 'gapDrop' , 'minGapFilterMm' , 'keepRate' , 'samplesEvery3' , 'samplesPerTask' , 'projectedSamplesFor50k' , 'consensusDrop' , 'vertexMinGapMm' , 'execS' , 'travelDeg' )}
shards = glob . glob ( P ( DATA , 'shard_*.jsonl' ))
2026-10-17 18:57:48 +00:00
pl = pipeline ()
if pl and pl . get ( 'current' ) == 'gen' : # 只在生成阶段数行(增量);其他阶段用 stats.json 的 tasks
gen_lines = sum ( LC . count ( f ) for f in shards + glob . glob ( P ( DATA , 'shard_*.jsonl.part' )))
else : gen_lines = ( stats or {}) . get ( 'tasks' ) or 0
2026-10-17 17:15:16 +00:00
pb = jload ( P ( RUNS , 'policy_bench.json' ))
if pb : pb = { k : { kk : v . get ( kk ) for kk in ( 'n' , 'ok' , 'reached' , 'collisionFrames' , 'brakeRate' , 'recoveryRate' , 'livelock' , 'inferMsP99' , 'safetyMsP99' )}
for k , v in pb . items () if isinstance ( v , dict ) and 'n' in v }
2026-10-18 13:57:48 +08:00
return { 'now' : time . time (), 'gpu' : gpu (), 'cpu' : cpu (),
2026-10-18 17:23:57 +08:00
'vram_reserve_gb' : float ( os . environ . get ( 'VRAM_RESERVE_GIB' , 1 )), # 给系统留的余量(GB):训练进程上限 = 整卡 − 已用 − 这个余量(浮动,见 ml/common/vram.py)
'vram_budget_gb' : float ( os . environ [ 'VRAM_BUDGET_GB' ]) if os . environ . get ( 'VRAM_BUDGET_GB' ) else None , # 只有显式指定才覆盖上面的口径
2026-10-17 22:12:53 +00:00
'pipeline' : pl , 'check' : CHECK . get (), 'lock' : lock_info (), # 2026-10-18 第八轮修:上一版这三项被行尾注释吞掉,网页步骤条/锁状态拿不到
2026-10-17 18:57:48 +00:00
'stage_meta' : [{ 'id' : k , 'name' : n , 'est_h' : h , 'timeout_h' : pstate . TIMEOUT_H [ k ]} for k , n , h in pstate . STAGES ],
'r4' : jload ( P ( RUNS , 'r4.json' )),
2026-10-17 17:15:16 +00:00
'data' : { 'consistency' : ( jload ( P ( DATA , 'consistency.json' )) or {}) . get ( 'consistentByGateRule' ),
'gen_tasks_written' : gen_lines , 'shards' : len ( shards ), 'stats' : stats ,
'index' : jload ( P ( DATA , 'samples' , 'index.json' )), 'r2' : jload ( P ( DATA , 'r2.json' ))},
'm5' : [ x for x in ( jload ( f ) for f in sorted ( glob . glob ( P ( RUNS , 'm5_gate_*.json' )))) if x ],
'selected' : open ( P ( RUNS , 'selected_ckpt' )) . read () . strip () if os . path . exists ( P ( RUNS , 'selected_ckpt' )) else None ,
'mine' : mine , 'bench' : pb , 'acceptance' : jload ( P ( RUNS , 'acceptance.json' ))}
2026-10-17 18:57:48 +00:00
STAGES = pstate . ORDER
ENV_KEYS = { 'N_TASKS' : r '\d+' , 'NPROC' : r '\d+' , 'TARGET_SAMPLES' : r '\d+' , 'CFG' : r 'ml/configs/(base|wta|small)\.json' ,
'CONSENSUS' : r '[01]?' , 'ROUNDS' : r '\d+' , 'APG_STEPS' : r '\d+' , 'MINE_N' : r '\d+' , 'MIN_GAP_MM' : r '\d+(\.\d+)?' ,
2026-10-18 16:01:09 +00:00
'FRESH' : r '[01]' , 'FRESH_FROM' : r '(setup|kin|r4|gate1|gen|prep|train|dagger|m5|apg|mine|export|bench|accept)' , 'R2_STRICT' : r '[01]' , 'AUTO_WTA' : r '[01]' }
2026-10-17 17:15:16 +00:00
CONSOLE = os . path . join ( ROOT , RUNS , 'console.log' )
def find_bash ():
"""Windows 优先 Git Bash( System32 \\ bash.exe 是 WSL,不能用);其他系统用 PATH 里的 bash。"""
if os . name == 'nt' :
for p in [ os . environ . get ( 'BASH' , '' ), r 'C:\Program Files\Git\bin\bash.exe' , r 'C:\Program Files (x86)\Git\bin\bash.exe' ,
os . path . expandvars ( r '%LOCALAPPDATA%\Programs\Git\bin\bash.exe' )]:
if p and os . path . exists ( p ): return p
g = shutil . which ( 'git' )
if g :
2026-10-18 06:34:21 +08:00
root = os . path . dirname ( os . path . dirname ( g )) # 便携版 git 常放在 <根>\mingw64\bin\git.exe,要再上一层才找到 bash
for base in dict . fromkeys ([ root , os . path . dirname ( root )]):
for sub in ( 'bin' , os . path . join ( 'usr' , 'bin' )):
p = os . path . join ( base , sub , 'bash.exe' )
if os . path . exists ( p ): return p
b = shutil . which ( 'bash' )
if b and 'windows \\ system32' not in b . lower (): return b
2026-10-17 17:15:16 +00:00
return None
return shutil . which ( 'bash' )
2026-10-17 18:57:48 +00:00
def train_python ():
"""流水线用的 Python:尊重 PYTHON 环境变量(与 console.sh / console.bat 一致),否则用控制台自己的解释器。"""
return os . environ . get ( 'PYTHON' ) or sys . executable
class SelfCheck :
"""启动自检(审查 N-10):在后台线程跑一次,结果缓存;「开始训练」前不过就拒绝,网页顶栏显示。"""
def __init__ ( self ): self . r = { 'pending' : True }; threading . Thread ( target = self . run , daemon = True ) . start ()
def run ( self ):
py = train_python (); r = { 'python' : py , 'bash' : find_bash (), 'node' : shutil . which ( 'node' )}
try :
o = subprocess . run ([ py , '-c' , 'import json,torch;c=torch.cuda.is_available();print(json.dumps({"torch":torch.__version__,"cuda":c,"gpu":torch.cuda.get_device_name(0) if c else None}))' ],
capture_output = True , text = True , timeout = 120 )
r . update ( json . loads ( o . stdout . strip () . splitlines ()[ - 1 ]) if o . returncode == 0 else { 'torch' : None , 'torch_error' : ( o . stderr or o . stdout ) . strip ()[ - 300 :]})
except Exception as e : r . update ( torch = None , torch_error = str ( e ))
probs = []
if not r . get ( 'torch' ): probs . append ( f '这个 Python 没有 torch: { py } (先激活训练环境再启动控制台,或设 PYTHON=...) ' )
if not r [ 'bash' ]: probs . append ( '找不到 bash: Windows 请安装 Git for Windows(自带 Git Bash) ' )
if not r [ 'node' ]: probs . append ( '找不到 node' )
if r . get ( 'torch' ) and not r . get ( 'cuda' ): r [ 'warn' ] = 'torch 看不到 GPU,训练会极慢'
r [ 'problems' ] = probs ; r [ 'ok' ] = not probs
self . r = r
def get ( self ): return self . r
def lock_info ():
lk = pstate . lock_read ()
if not lk : return None
return dict ( lk , alive = pstate . lock_alive ( lk ), age_s = time . time () - lk . get ( 'beat' , 0 ))
2026-10-18 23:15:53 +08:00
NT_NAMES = "'node.exe','xargs.exe','bash.exe','sh.exe','timeout.exe'"
NT_SWEEP = ( r "pipeline\.sh|run_prep\.sh|run_parallel\.sh|build_dataset\.cjs|"
r "gen_dataset\.cjs|teacher\.cjs|policy_bench\.cjs" )
def sweep_nt ( keep_pid ):
"""taskkill /T 在 Git Bash 下只杀到 bash, xargs/node 孙进程会逃(10-18 实测:停止后还有 28 个
gen_dataset 在跑;当晚二次踩到:两批 build_dataset 抢同一批 shard)。按命令行补扫整条流水线,
keep_pid 的祖先链(= 控制台自己拉起的 monitor bash)不动。先杀包装再杀 node,否则 node 一死
xargs 会立刻补下一个 shard。返回仍存活个数,-1 = pwsh 没跑成。"""
q = ( "$anc=@(); $c= %d ; while ($c -gt 0 -and ($p=Get-CimInstance Win32_Process -Filter \" ProcessId=$c \" "
"-ErrorAction SilentlyContinue)) { $anc+=$p.ProcessId; $c=$p.ParentProcessId }; "
"$m={ $_.Name -in %s -and $_.CommandLine -match ' %s ' -and $anc -notcontains $_.ProcessId }; "
"foreach ($x in @(Get-CimInstance Win32_Process | Where-Object $m | "
"Sort-Object { if ($_.Name -eq 'node.exe') { 1 } else { 0 } })) "
"{ Stop-Process -Id $x.ProcessId -Force -ErrorAction SilentlyContinue }; "
"Start-Sleep -Seconds 2; "
"@(Get-CimInstance Win32_Process | Where-Object $m).Count" % ( int ( keep_pid ), NT_NAMES , NT_SWEEP ))
r = subprocess . run ([ 'pwsh' , '-NoProfile' , '-Command' , q ], capture_output = True , text = True , timeout = 90 )
lines = [ s for s in ( r . stdout or '' ) . split () if s . isdigit ()]
return int ( lines [ - 1 ]) if lines else - 1
2026-10-17 18:57:48 +00:00
def kill_session ( sid ):
"""POSIX:结束会话 sid 里的所有进程(含 timeout 另起的进程组)。"""
pids = []
for d in os . listdir ( '/proc' ) if os . path . isdir ( '/proc' ) else []:
if d . isdigit ():
try :
if os . getsid ( int ( d )) == sid : pids . append ( int ( d ))
except Exception : pass
if not pids :
try : os . killpg ( sid , signal . SIGTERM )
except Exception : pass
return
for p in pids :
try : os . kill ( p , signal . SIGTERM )
except Exception : pass
2026-10-17 17:15:16 +00:00
class Job :
def __init__ ( self ):
self . p = None ; self . info = {}; self . lock = threading . Lock ()
def state ( self ):
with self . lock :
run = self . p is not None and self . p . poll () is None
if self . p is not None and not run and self . info . get ( 'rc' ) is None :
self . info [ 'rc' ] = self . p . returncode ; self . info [ 'ended' ] = time . time ()
2026-10-17 18:57:48 +00:00
lk = lock_info ()
ext = bool ( lk and lk [ 'alive' ]) and not run # 终端或上一个控制台窗口起的流水线
return { 'running' : run or ext , 'external' : ext , 'lock' : lk , ** self . info , 'bash' : find_bash () is not None }
2026-10-17 17:15:16 +00:00
def start ( self , frm , to , env ):
with self . lock :
if self . p is not None and self . p . poll () is None : return { 'error' : '已经在运行' }
2026-10-17 18:57:48 +00:00
lk = lock_info ()
if lk and lk [ 'alive' ]:
return { 'error' : f "已有一条流水线在运行( { lk . get ( 'owner' ) } , pid { lk . get ( 'pid' ) } , { lk [ 'age_s' ] : .0f } s 前心跳);先停止它" }
ck = CHECK . get ()
if ck . get ( 'pending' ): return { 'error' : '环境自检还没完成,稍等几秒再点' }
if not ck . get ( 'ok' ): return { 'error' : '环境自检未通过:' + '; ' . join ( ck [ 'problems' ])}
2026-10-17 17:15:16 +00:00
if frm not in STAGES or to not in STAGES or STAGES . index ( frm ) > STAGES . index ( to ): return { 'error' : '起止阶段无效' }
bash = find_bash ()
2026-10-17 20:28:00 +00:00
e = dict ( os . environ , FROM = frm , TO = to , MONITOR = '0' , PYTHONUNBUFFERED = '1' , PYTHONIOENCODING = 'utf-8' , PYTHONUTF8 = '1' , PIPE_OWNER = '网页' )
2026-10-17 18:57:48 +00:00
py = train_python ()
if ' ' not in py : e [ 'PYTHON' ] = py . replace ( ' \\ ' , '/' )
2026-10-17 17:15:16 +00:00
for k , v in ( env or {}) . items ():
if k in ENV_KEYS and v not in ( None , '' ) and re . fullmatch ( ENV_KEYS [ k ], str ( v )): e [ k ] = str ( v )
os . makedirs ( os . path . dirname ( CONSOLE ), exist_ok = True )
2026-10-17 18:57:48 +00:00
shown = ' ' . join ( f ' { k } = { e [ k ] } ' for k in ( 'FRESH' , 'FROM' , 'TO' ) if k in e )
with open ( CONSOLE , 'w' , encoding = 'utf-8' ) as f : f . write ( f '$ { shown } bash ml/pipeline.sh all [ { time . strftime ( "%Y-%m- %d %H:%M:%S" ) } ] \n ' )
2026-10-17 17:15:16 +00:00
log = open ( CONSOLE , 'a' , encoding = 'utf-8' ) # 追加模式:网页写入的"已停止"提示不会被子进程覆盖
2026-10-17 18:57:48 +00:00
# Windows: CREATE_NO_WINDOW 给子进程树一个隐藏的独立控制台 → 关掉控制台黑窗口不会连带杀训练(审查 N-11)
kw = { 'creationflags' : subprocess . CREATE_NEW_PROCESS_GROUP | 0x08000000 } if os . name == 'nt' else { 'start_new_session' : True }
2026-10-17 17:15:16 +00:00
self . p = subprocess . Popen ([ bash , 'ml/pipeline.sh' , 'all' ], cwd = ROOT , env = e , stdout = log , stderr = subprocess . STDOUT , stdin = subprocess . DEVNULL , ** kw )
self . info = { 'pid' : self . p . pid , 'from' : frm , 'to' : to , 'started' : time . time (), 'rc' : None , 'ended' : None ,
'env' : { k : e [ k ] for k in ENV_KEYS if k in e }}
return { 'ok' : True , 'pid' : self . p . pid }
def stop ( self ):
2026-10-17 18:57:48 +00:00
# 2026-10-18 体面停止:训练类阶段先写 ml/runs/STOP,等训练进程自己存 last.pt 并退出(最多 90 s),再硬杀剩下的。
# Windows 的 taskkill /F 收不到信号,不这样做会丢掉上次存档之后的进度。生成/打包本身按 shard/条断点续,直接杀。
with self . lock :
ours = self . p is not None and self . p . poll () is None
lk = lock_info ()
if not ours and not ( lk and lk [ 'alive' ]): return { 'error' : '没有在运行' }
pid = self . p . pid if ours else int ( lk . get ( 'pid' ) or 0 )
if not pid : return { 'error' : '锁里没有 pid,无法停止' }
cur = ( pstate . load () . get ( 'current' ) or '' )
2026-10-18 15:56:39 +00:00
if cur in ( 'train' , 'dagger' , 'm5' , 'apg' , 'mine' ):
2026-10-17 18:57:48 +00:00
open ( STOPF , 'w' ) . write ( time . strftime ( '%H:%M:%S' ))
with open ( CONSOLE , 'a' , encoding = 'utf-8' ) as f : f . write ( f ' \n [停止请求已发出,等待 { cur } 存档 last.pt…] \n ' )
for _ in range ( 180 ):
time . sleep ( 0.5 )
l2 = pstate . lock_read ()
if ( self . p is not None and self . p . poll () is not None ) if ours else not ( l2 and pstate . lock_alive ( l2 )): break
2026-10-17 17:15:16 +00:00
with self . lock :
if os . name == 'nt' :
2026-10-17 18:57:48 +00:00
subprocess . run ([ 'taskkill' , '/T' , '/F' , '/PID' , str ( pid )], capture_output = True )
2026-10-18 23:15:53 +08:00
# 补扫漏网的子孙;有残留就写进网页日志,别让用户以为停干净了
2026-10-18 13:54:16 +08:00
try :
2026-10-18 23:15:53 +08:00
left = sweep_nt ( os . getpid ())
except Exception :
left = - 1
if left != 0 :
with open ( CONSOLE , 'a' , encoding = 'utf-8' ) as f :
f . write ( f ' \n [停止补扫:仍有 { left } 个流水线子进程存活(-1=补扫没跑成),把这条发我] \n ' )
2026-10-17 17:15:16 +00:00
else :
2026-10-17 18:57:48 +00:00
if ours :
kill_session ( pid )
try : self . p . wait ( 15 )
except subprocess . TimeoutExpired : kill_session ( pid ); self . p . kill ()
else : # 只动它自己的会话 / 进程组,绝不按会话杀到用户的终端
try :
if os . getsid ( pid ) == pid : kill_session ( pid )
elif os . getpgid ( pid ) == pid : os . killpg ( pid , signal . SIGTERM )
else : os . kill ( pid , signal . SIGTERM )
except ProcessLookupError : pass
2026-10-17 17:15:16 +00:00
self . info [ 'stopped' ] = True
with open ( CONSOLE , 'a' , encoding = 'utf-8' ) as f : f . write ( f ' \n [已在网页上停止 { time . strftime ( "%H:%M:%S" ) } ] \n ' )
2026-10-17 18:57:48 +00:00
time . sleep ( 0.5 )
pstate . mark_stopped ( - 15 ) # 当前阶段记为"已停止"、释放锁(单一写者)
try : os . remove ( STOPF )
except FileNotFoundError : pass
2026-10-17 17:15:16 +00:00
return { 'ok' : True }
2026-10-17 18:57:48 +00:00
STOPF = os . path . join ( ROOT , 'ml' , 'runs' , 'STOP' ) # 与 ml/common/ckpt.py 的 STOP 同一文件
2026-10-17 17:15:16 +00:00
JOB = Job ()
2026-10-17 18:57:48 +00:00
CHECK = None # main() 里创建(自检要等 find_bash 定义完)
2026-10-17 17:15:16 +00:00
def console ( offset ):
if not os . path . exists ( CONSOLE ): return { 'text' : '' , 'offset' : 0 , 'reset' : True }
size = os . path . getsize ( CONSOLE ); reset = offset > size or offset == 0
if reset : offset = max ( 0 , size - 65536 ) # 首次只给最后 64 KB
with open ( CONSOLE , 'rb' ) as f : f . seek ( offset ); b = f . read ( min ( size - offset , 1 << 20 ))
return { 'text' : b . decode ( 'utf-8' , 'replace' ) . replace ( ' \r ' , '' ), 'offset' : offset + len ( b ), 'reset' : reset }
class H ( BaseHTTPRequestHandler ):
def log_message ( self , * a ): pass
def send ( self , code , body , ctype ):
b = body if isinstance ( body , bytes ) else body . encode ( 'utf-8' )
self . send_response ( code ); self . send_header ( 'Content-Type' , ctype ); self . send_header ( 'Content-Length' , str ( len ( b )))
self . send_header ( 'Cache-Control' , 'no-store' ); self . end_headers (); self . wfile . write ( b )
def do_GET ( self ):
u = urlparse ( self . path ); q = parse_qs ( u . query )
try :
if u . path in ( '/' , '/index.html' ):
with open ( HTML , 'rb' ) as f : return self . send ( 200 , f . read (), 'text/html; charset=utf-8' )
if u . path == '/api/runs' : return self . send ( 200 , json . dumps ( list_runs ()), 'application/json' )
if u . path == '/api/log' :
return self . send ( 200 , json . dumps ( read_log ( q . get ( 'run' , [ '' ])[ 0 ], int ( q . get ( 'offset' , [ '0' ])[ 0 ]))), 'application/json' )
if u . path == '/api/status' : return self . send ( 200 , json . dumps ( status ()), 'application/json' )
if u . path == '/api/job' : return self . send ( 200 , json . dumps ( JOB . state ()), 'application/json' )
if u . path == '/api/console' : return self . send ( 200 , json . dumps ( console ( int ( q . get ( 'offset' , [ '0' ])[ 0 ]))), 'application/json' )
self . send ( 404 , 'not found' , 'text/plain' )
except Exception as e :
self . send ( 500 , json . dumps ({ 'error' : str ( e )}), 'application/json' )
def do_POST ( self ):
u = urlparse ( self . path )
if not ( self . server . remote_control or self . client_address [ 0 ] in ( '127.0.0.1' , '::1' )):
return self . send ( 403 , json . dumps ({ 'error' : '开始/停止只接受本机操作' }), 'application/json' )
2026-10-17 18:57:48 +00:00
# 审查 N-8:本机任意网页都能向 localhost 发 POST(源地址同为 127.0.0.1)。要求自定义头(跨站请求会触发 CORS 预检,
# 本服务不应答 OPTIONS → 浏览器不发),并且 Origin / Sec-Fetch-Site 若存在必须同源。
org , sfs , host = self . headers . get ( 'Origin' ), self . headers . get ( 'Sec-Fetch-Site' ), self . headers . get ( 'Host' , '' )
if self . headers . get ( 'X-Console' ) != '1' or ( sfs and sfs not in ( 'same-origin' , 'none' )) or \
( org and urlparse ( org ) . netloc not in ( host , f 'localhost: { self . server . server_port } ' , f '127.0.0.1: { self . server . server_port } ' )):
return self . send ( 403 , json . dumps ({ 'error' : '拒绝:非本控制台页面发起的请求' }), 'application/json' )
2026-10-17 17:15:16 +00:00
try :
n = int ( self . headers . get ( 'Content-Length' ) or 0 ); body = json . loads ( self . rfile . read ( n ) or b ' {} ' )
2026-10-17 18:57:48 +00:00
if u . path == '/api/start' :
env = dict ( body . get ( 'env' ) or {})
if body . get ( 'fresh' ): env [ 'FRESH' ] = '1'
r = JOB . start ( body . get ( 'from' , 'kin' ), body . get ( 'to' , 'accept' ), env )
2026-10-17 17:15:16 +00:00
elif u . path == '/api/stop' : r = JOB . stop ()
else : return self . send ( 404 , 'not found' , 'text/plain' )
self . send ( 200 if 'ok' in r else 409 , json . dumps ( r , ensure_ascii = False ), 'application/json' )
except Exception as e :
self . send ( 500 , json . dumps ({ 'error' : str ( e )}), 'application/json' )
def main ():
ap = argparse . ArgumentParser ()
ap . add_argument ( '--port' , type = int , default = 8100 ); ap . add_argument ( '--host' , default = '127.0.0.1' )
ap . add_argument ( '--open' , action = 'store_true' , help = '启动后自动打开浏览器' )
ap . add_argument ( '--remote-control' , action = 'store_true' , help = '允许其他机器点开始/停止(默认只能看)' )
a = ap . parse_args ()
2026-10-17 18:57:48 +00:00
global CHECK
CHECK = SelfCheck ()
2026-10-18 15:13:37 +08:00
CPUS = CpuSampler () # 每秒采一次 CPU(线程常驻,status 只读最近一次)
2026-10-17 17:15:16 +00:00
srv = ThreadingHTTPServer (( a . host , a . port ), H ); srv . remote_control = a . remote_control
url = f 'http://localhost: { a . port } /'
2026-10-17 18:57:48 +00:00
print ( f '训练控制台: { url } (关闭本窗口只关控制台,已开始的训练继续跑;重新打开控制台即可接着看、可停止)' , flush = True )
2026-10-17 17:15:16 +00:00
if a . open : threading . Timer ( 0.8 , lambda : webbrowser . open ( url )) . start ()
try : srv . serve_forever ()
except KeyboardInterrupt : pass
if __name__ == '__main__' :
main ()