Files

237 lines
11 KiB
PowerShell
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.
# FaceLoRA v3 training monitor (scheduled task, fully autonomous)
# ============================================================
# 用途:云端训练全自动监控。Windows 计划任务每 5 分钟调用一次。
# 功能:
# 1. 增量下载 checkpointHTTP HEAD 探测 + aria2c)——任何时刻断线已下载的都是安全的
# 2. 完成判定:e120 + final 都下载 -> 删 pod 停计费
# 3. 崩溃检测:最后 checkpoint 45min 无更新 -> 抢救 + 删 pod
# 4. 超时止损:超过预算小时数 -> 删 pod
# 5. 状态持久化到 monitor_state.txt(纯文本,PS5.1 下 .json 写入不可靠)
# 输出(全在 ASCII 目录 C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\):
# monitor_v3.log - 详细运行日志
# train_events.log - 关键事件时间线(下载/完成/止损/删pod)
# monitor_state.txt - 持久化状态(pod_start/downloaded/done/started
# checkpoints_v3/ - 已下载的 checkpoint
#
# !! PS 5.1 铁律(血泪教训,2026-08-09):
# - 变量名不区分大小写!$STATE(路径) 会被 $state(hashtable) 覆盖 → 路径变量必须用独特名($STATEFILE)
# - 中文路径下 Add-Content 写新 .json 文件不可靠 → 输出目录用纯 ASCII,状态用纯文本
# - train_env.json 读取失败必须退出,绝不用空值执行删 pod(曾因此差点误删)
# ============================================================
$ErrorActionPreference = "Continue"
# ---- 项目路径(注意:脚本必须存 GBK 编码,PowerShell 5.1 用 ANSI 读)----
$PROJ = "D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
# 输出目录用纯 ASCII(避开中文路径 + PS5.1 编码 bug
$OUTDIR = "C:\Users\hmo\AppData\Local\Temp\opencode\face_lora"
# ---- 读取训练环境配置(train_env.jsonpod_id/dl_url/ssh/max_hours/total_steps----
$CFG = Join-Path $PROJ "temp\train_env.json"
if (-not (Test-Path $CFG)) {
Write-Host "missing train_env.json"
exit 1
}
$env_cfg = Get-Content $CFG -Raw | ConvertFrom-Json
$PODID = $env_cfg.pod_id
$DLURL = $env_cfg.dl_url
$SSHHOST = $env_cfg.ssh_host
$SSHPORT = $env_cfg.ssh_port
$MAX_HOURS = [double]$env_cfg.max_hours
$TOTAL_STEPS = [int]$env_cfg.total_steps
# 安全阀:任何关键字段为空 → 立即退出,绝不用空值执行删 pod 等破坏性操作
if (-not $PODID -or -not $DLURL -or $MAX_HOURS -le 0) {
Write-Host ("train_env invalid: pod=" + $PODID + " max_hours=" + $MAX_HOURS)
exit 1
}
$MLOG = Join-Path $OUTDIR "monitor_v3.log"
$EVENT = Join-Path $OUTDIR "train_events.log"
$CDIR = Join-Path $OUTDIR "checkpoints_v3"
$STATEFILE = Join-Path $OUTDIR "monitor_state.txt"
$RPKEY = (Get-Content (Join-Path $PROJ ".runpod_api_key") -Raw).Trim()
New-Item -ItemType Directory -Force $CDIR, (Split-Path $MLOG) | Out-Null
# ---- 单例守卫(防计划任务重复触发)----
$LOCK = Join-Path $OUTDIR "monitor_v3.lock"
if (Test-Path $LOCK) {
$oldPid = [int](Get-Content $LOCK)
if (Get-Process -Id $oldPid -ErrorAction SilentlyContinue) {
exit 0
}
}
Set-Content $LOCK $PID
function Log($msg) {
$ts = Get-Date -Format "MM-dd HH:mm:ss"
Add-Content -Path $MLOG -Value ("[" + $ts + "] " + $msg) -Encoding UTF8
}
function Event($msg) {
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $EVENT -Value ("[" + $ts + "] " + $msg) -Encoding UTF8
}
try {
# ---- 读持久化状态(纯文本)----
$state = @{ downloaded = @(); pod_start = ""; done = $false; started = $false }
if (Test-Path $STATEFILE) {
try {
foreach ($ln in Get-Content $STATEFILE) {
if ($ln -like "downloaded=*") { $state.downloaded = @($ln.Substring(11).Split(";") | Where-Object { $_ }) }
elseif ($ln -like "pod_start=*") { $state.pod_start = $ln.Substring(10) }
elseif ($ln -eq "done=true") { $state.done = $true }
elseif ($ln -eq "started=true") { $state.started = $true }
}
} catch {}
}
if (-not $state.pod_start) {
$state.pod_start = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Event ("monitor start: pod=" + $PODID + " dl_url=" + $DLURL + " max_hours=" + $MAX_HOURS)
}
if ($state.done) {
Log "done, skip"
exit 0
}
# ---- 0. 探测训练是否已开始(train.log 有 steps----
if (-not $state.started) {
try {
$tail = & ssh -i "$env:USERPROFILE\.ssh\id_rsa" -p $SSHPORT -o BatchMode=yes -o ConnectTimeout=10 $SSHHOST "tail -c 2000 /workspace/train.log 2>/dev/null | tr '\r' '\n' | tail -2" 2>$null
if ($tail -match ("(\d+)/" + $TOTAL_STEPS)) {
$state.started = $true
Event ("training started: step " + $Matches[1] + "/" + $TOTAL_STEPS)
}
} catch {}
}
# ---- 1. 探测云端 checkpointHTTP HEAD----
$epochs = @(10,20,30,40,50,60,70,80,90,100,110,120)
$newFiles = @()
foreach ($e in $epochs) {
$fn = "myface_lora-{0:D6}.safetensors" -f $e
if ($fn -in $state.downloaded) { continue }
try {
$resp = Invoke-WebRequest -Uri ($DLURL + "/" + $fn) -Method Head -TimeoutSec 15 -UseBasicParsing
if ($resp.StatusCode -eq 200) { $newFiles += $fn }
} catch {}
}
try {
$resp = Invoke-WebRequest -Uri ($DLURL + "/myface_lora.safetensors") -Method Head -TimeoutSec 15 -UseBasicParsing
if ($resp.StatusCode -eq 200) { $newFiles += "myface_lora.safetensors" }
} catch {}
# ---- 2. 增量下载 ----
foreach ($f in $newFiles) {
$fp = Join-Path $CDIR $f
if (Test-Path $fp) {
$sz = [math]::Round((Get-Item $fp).Length/1MB,0)
if ($sz -gt 100) {
Log ("already have: " + $f + " (" + $sz + " MB), skip")
$state.downloaded += $f
continue
}
Remove-Item $fp -Force -ErrorAction SilentlyContinue
}
Log ("new checkpoint: " + $f + " downloading")
& aria2c -x16 -s16 -k1M --continue -d "$CDIR" -o $f ($DLURL + "/" + $f) 2>&1 | Out-Null
if (Test-Path $fp) {
$sz = [math]::Round((Get-Item $fp).Length/1MB,0)
if ($sz -gt 100) {
Log ("download OK: " + $f + " (" + $sz + " MB)")
Event ("CHECKPOINT DOWNLOADED: " + $f + " (" + $sz + " MB)")
$state.downloaded += $f
} else {
Remove-Item $fp -Force -ErrorAction SilentlyContinue
Log ("incomplete (" + $sz + " MB), retry next round: " + $f)
}
} else {
Log ("download FAIL: " + $f)
}
break
}
# ---- 3. 完成判定 ----
$hasFinal = "myface_lora.safetensors" -in $state.downloaded
if ($hasFinal) {
Log "=== TRAINING COMPLETE: final checkpoint downloaded ==="
Event ("=== TRAINING COMPLETE: all checkpoints in checkpoints_v3\ ===")
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (complete)")
Event ("POD DELETED: " + $PODID + " (complete, billing stopped)")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
}
# ---- 4. 崩溃检测 ----
if ($state.downloaded.Count -gt 0 -and -not $state.done) {
$lastFile = $state.downloaded[-1]
$lastPath = Join-Path $CDIR $lastFile
if (Test-Path $lastPath) {
$ageMin = [int]((Get-Date) - (Get-Item $lastPath).LastWriteTime).TotalMinutes
if ($ageMin -gt 45) {
Log ("CRASH: last ckpt (" + $lastFile + ") " + $ageMin + "min stale -> rescue + delete pod")
Event ("CRASH: last ckpt " + $lastFile + " " + $ageMin + "min stale -> rescue + delete pod")
foreach ($e in $epochs) {
$fn = "myface_lora-{0:D6}.safetensors" -f $e
if ($fn -in $state.downloaded) { continue }
try {
$r = Invoke-WebRequest -Uri ($DLURL + "/" + $fn) -Method Head -TimeoutSec 15 -UseBasicParsing
if ($r.StatusCode -eq 200) {
& aria2c -x16 -s16 -k1M -d "$CDIR" -o $fn ($DLURL + "/" + $fn) 2>&1 | Out-Null
if (Test-Path (Join-Path $CDIR $fn)) { $state.downloaded += $fn; Event ("RESCUED: " + $fn) }
}
} catch {}
}
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (crash)")
Event ("POD DELETED: " + $PODID + " (crash stop)")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
}
}
}
# ---- 5. 超时止损 ----
if (-not $state.done) {
try {
$start = [datetime]::ParseExact($state.pod_start, "yyyy-MM-dd HH:mm:ss", $null)
$runHours = ((Get-Date) - $start).TotalHours
if ($runHours -gt $MAX_HOURS) {
Log ("TIMEOUT: " + [math]::Round($runHours,1) + "h > budget " + $MAX_HOURS + "h")
Event ("TIMEOUT: " + [math]::Round($runHours,1) + "h > budget " + $MAX_HOURS + "h -> delete pod")
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (timeout, downloaded " + $state.downloaded.Count + ")")
Event ("POD DELETED: " + $PODID + " (timeout, downloaded " + $state.downloaded.Count + ")")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
} else {
Log ("running: " + [math]::Round($runHours,1) + "/" + $MAX_HOURS + "h, downloaded " + $state.downloaded.Count)
}
} catch {
Log ("time parse fail: " + $_.Exception.Message)
}
}
# ---- 持久化状态(纯文本行,PS5.1 可靠)----
try {
$lines = @()
$lines += "pod_start=" + $state.pod_start
$lines += "downloaded=" + ($state.downloaded -join ";")
$lines += ("done=" + $state.done)
$lines += ("started=" + $state.started)
if (Test-Path $STATEFILE) { Remove-Item $STATEFILE -Force }
Add-Content -Path $STATEFILE -Value $lines -Encoding UTF8
} catch {
Log ("state write fail: " + $_.Exception.Message)
}
$state.downloaded | ForEach-Object { Event ("state: downloaded " + $_) }
if ($state.done) { Event ("=== MONITOR DONE: " + $state.downloaded.Count + " checkpoints ===") }
else { Event ("state: running, downloaded=" + $state.downloaded.Count) }
if ($state.done) { Log "=== monitor done ===" }
} finally {
Remove-Item $LOCK -Force -ErrorAction SilentlyContinue
}