#!/bin/bash
# test_lab_train.sh — lab_train.sh (依頼 49) を, Pod を借りずに確かめる
#
#   ./test_lab_train.sh          全部流す (最後に「OK n / NG m」. NG があれば終了コード 1)
#
# lab_train.sh --dry-run を場合ごとに流す. --dry-run は lab_train_fakes.sh で API・SSH・hf・通知を差し替え,
# 書くのは一時フォルダ (LAB_DRY_ROOT) だけ. 場合は LAB_FAKE_SCENARIO で決める.
# Pod の中で流すコマンド (学習の様子・見張り) は, この Mac の bash で実際に流して書き方を確かめる

set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
OK=0; NG=0
ok() { OK=$((OK + 1)); echo "  ok   $1"; }
ng() { NG=$((NG + 1)); echo "  NG   $1"; }
check() { if eval "$2"; then ok "$1"; else ng "$1"; fi; }
run_to() {                  # run_to 秒 コマンド... (macOS には timeout がない. 時間が来たら TERM で止める → 143)
    local s="$1"; shift
    "$@" & local p=$!
    ( sleep "$s"; kill "$p" 2>/dev/null ) & local w=$!
    wait "$p"; local rc=$?
    kill "$w" 2>/dev/null; wait "$w" 2>/dev/null
    return $rc
}

run() {                     # run 場合 [lab_train.sh の引数...]
    local sc="$1"; shift
    T=$(mktemp -d)
    (cd "$HERE" && LAB_DRY_ROOT="$T" LAB_FAKE_SCENARIO="$sc" ./lab_train.sh --log LogT --dry-run "$@" < /dev/null > "$T/out.txt" 2>&1)
    RC=$?
    TD="$T/data/lab/LogT/train"
    CK="$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1"
}
has() { grep -qF -- "$2" "$T/$1"; }
n_call() { grep -c -- "^$1" "$T/calls.log"; }
line_of() { grep -nF -- "$2" "$T/$1" | head -1 | cut -d: -f1; }
no_secret() { ! grep -rqE 'DRY-RUNPOD-SECRET-KEY|hf_DRYSECRETTOKEN|10\.9\.8\.7|40123' "$T/out.txt" "$T/data/lab" "$T/notify.log" 2>/dev/null; }
common_wrap() {             # どの終わり方でも: ログを持ち帰ってから消し, 消えたのを確かめ, 費用を出し, 通知する
    check "ログを持ち帰ってから DELETE" '[ -f "$TD/train_trim_corr_v3.log" ] && [ -f "$TD/gpu_train.csv" ] && [ $(n_call DELETE) = 1 ] && [ $(line_of scp.log train_trim_corr_v3.log) -ge 1 ]'
    check "消えたのを API で確かめた・費用 (ドルと円)" 'has out.txt "が消えたことを API で確かめた" && grep -qE "約 [0-9.]+ ドル \(約 [0-9]+ 円" "$T/out.txt"'
    check "記録に消した時刻と gone_confirmed" '[ "$(jq -r .gone_confirmed "$TD/lab_pod.json")" = true ] && [ -n "$(jq -r ".terminated_at // empty" "$TD/lab_pod.json")" ]'
    check "通知を1回出す・まとめ result_*.txt" '[ $(wc -l < "$T/notify.log") = 1 ] && ls "$TD"/result_*.txt >/dev/null 2>&1'
    check "鍵・IP・ポートを画面・記録・通知に出さない" 'no_secret'
    check "印 (.lab_train.lock) を外す" '[ ! -e "$TD/.lab_train.lock" ]'
}

echo "== 1. 学習が最後まで終わる"
run success
check "終了コード 0" '[ $RC = 0 ]'
check "A40 で p2m-train-LogT を1つ作る" '[ $(n_call "POST gpu=NVIDIA A40 name=p2m-train-LogT") = 1 ] && [ $(n_call "POST /pods") = 1 ]'
check "Pod 側に見張りを置く (上限+60分の時刻を書く)" 'has ssh.log ": lt=watchdog" && has ssh.log "cat > /workspace/r2r2r/lab_watchdog.sh" && [ "$(jq -r ".watchdog_epoch - .created_epoch" "$TD/lab_pod.json")" = 21600 ]'
check "準備は lab_start.sh 3b〜3f と同じコマンド" 'has ssh.log "hf download PixelstoMotors/pi05-mecharm-trim-lora-v1 --local-dir /workspace/ckpt/3999" && has ssh.log "git checkout -q 215abfb" && has ssh.log "uv sync" && has ssh.log "install_addon.py" && has ssh.log "add_trim_config.py /workspace/openpi"'
check "サーバーは起動しない" '! has ssh.log "serve_policy.py"'
check "pi05/・add_trim_config.py・tar を送る" 'has scp.log "/pi05 root@" && has scp.log "add_trim_config.py root@" && grep -q "_raw_episodes.tar root@.*:/workspace/data/" "$T/scp.log"'
check "pod_prep_trim_corr.sh 1500 /workspace/ckpt/3999/params" 'has ssh.log "bash /workspace/r2r2r/pi05/openpi_addon/pod_prep_trim_corr.sh 1500 /workspace/ckpt/3999/params"'
check "学習は setsid nohup・exp trim_corr_v3・ログ train_trim_corr_v3.log・GPU 30 秒おき" 'has ssh.log "setsid nohup bash /workspace/r2r2r/train_run.sh" && has ssh.log "uv run scripts/train.py pi05_mecharm_trim_corr_lora --exp-name=trim_corr_v3 --overwrite > /workspace/logs/train_trim_corr_v3.log" && has ssh.log "--format=csv -l 30 > /workspace/logs/gpu_train.csv"'
check "進み (step・loss) を出す・寝てよいと出す" 'has out.txt "step 100/1500  loss 0.0412" && has out.txt "ここまで来れば寝てかまいません"'
check "Hugging Face へ上げる (train_state は上げない)" 'has ssh.log "hf_upload_ckpt.py --dir /workspace/openpi/checkpoints/pi05_mecharm_trim_corr_lora/trim_corr_v3/1499 --repo PixelstoMotors/pi05-mecharm-trim-corr-lora-v1"'
check "上げる → ログ → DELETE → KIOXIA の順" '[ $(line_of ssh.log hf_upload_ckpt.py) -lt $(line_of ssh.log ": lt=ls_all") ] && [ $(line_of calls.log "DELETE") -lt $(line_of calls.log "hf download") ]'
check "KIOXIA の checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 に置く (.cache・.gitattributes・一時フォルダは残さない)" '[ -f "$CK/1499/_CHECKPOINT_METADATA" ] && [ -f "$CK/1499/params/manifest.ocdbt" ] && [ ! -e "$CK/1499/.cache" ] && [ ! -e "$CK/1499/.gitattributes" ] && [ -z "$(ls -A "$CK" | grep -v "^1499$")" ] && [ -f "$CK/1499/._params" ]'
check "Pod 側の一覧 (pod_files_1499.tsv・pod_checkpoints.tsv) を持ち帰る" '[ $(wc -l < "$TD/pod_files_1499.tsv") = 5 ] && [ -s "$TD/pod_checkpoints.tsv" ]'
check "通知は「成功」" 'grep -q "成功: 学習が終わりました (step 1499" "$T/notify.log"'
check "lab_start.sh の pod/ には触らない" '[ ! -e "$T/data/lab/LogT/pod" ]'
common_wrap
OUT1="$T"

echo "== 2. 学習が落ちる (終了コード 1)"
run crash
check "終了コード 1" '[ $RC = 1 ]'
check "Hugging Face に上げない・KIOXIA に置かない" '! has ssh.log "hf_upload_ckpt.py" && [ ! -e "$CK" ]'
check "わけを出す" 'has out.txt "終了コード 1" && grep -q "^\[NOTIFY\].*失敗: 学習が終わったが, 終了コード 1" "$T/notify.log"'
common_wrap
rm -rf "$T"

echo "== 3. 学習のプロセスが終わりの印を書かずに消える"
run killed
check "終了コード 1・わけ" '[ $RC = 1 ] && has notify.log "終わりの印を書かずに消えた"'
common_wrap
rm -rf "$T"

echo "== 4. 上限の時間を超える (--limit-hours 0.001 = 3.6 秒)"
run timeout --limit-hours 0.001
check "終了コード 1・上限を超えたと出す" '[ $RC = 1 ] && has notify.log "上限 0.001 時間を超えた"'
check "学習を止めてからログを持ち帰る" 'has ssh.log ": lt=kill_train" && [ $(line_of ssh.log ": lt=kill_train") -lt $(line_of ssh.log ": lt=ls_all") ]'
check "Hugging Face に上げない" '! has ssh.log "hf_upload_ckpt.py"'
common_wrap
rm -rf "$T"

echo "== 5. 学習のログが長く書かれない (止まった)"
run stall
check "終了コード 1・止まったと出す" '[ $RC = 1 ] && has notify.log "分書かれていない (止まった"'
common_wrap
rm -rf "$T"

echo "== 6. Hugging Face に上げられない → Pod から scp で KIOXIA へ取ってから消す"
run hf_fail
check "終了コード 1 (全部はそろっていない)" '[ $RC = 1 ]'
check "KIOXIA に scp で置く (Pod を消す前)" '[ -f "$CK/1499/_CHECKPOINT_METADATA" ] && grep -q "checkpoints/pi05_mecharm_trim_corr_lora/trim_corr_v3/1499/params" "$T/scp.log" && [ $(line_of out.txt "Pod から scp で取ります") -lt $(line_of out.txt "削除 (Terminate) を送った") ]'
check "Mac の hf download はしない" '[ $(n_call "hf download") = 0 ]'
check "通知に「Hugging Face に上げられなかった」「KIOXIA には置いた」" 'has notify.log "Hugging Face に上げられなかった" && has notify.log "KIOXIA には置いた (scp)" && has notify.log "Pod は消しました"'
common_wrap
rm -rf "$T"

echo "== 7. Hugging Face から取ったものが Pod 側と合わない"
run dl_mismatch
check "終了コード 1・1499 は作らず一時フォルダのまま残す" '[ $RC = 1 ] && [ ! -e "$CK/1499" ] && ls -d "$CK"/.incoming_1499_* >/dev/null 2>&1'
check "どのファイルが違うかを出す" 'has out.txt "params/manifest.ocdbt	119" && has notify.log "KIOXIA に置けなかった"'
rm -rf "$T"

echo "== 8. KIOXIA に 1499 がもとからある → 上書きしない"
T=$(mktemp -d); mkdir -p "$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/1499"; echo old > "$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/1499/keep.txt"
(cd "$HERE" && LAB_DRY_ROOT="$T" LAB_FAKE_SCENARIO=success ./lab_train.sh --log LogT --dry-run < /dev/null > "$T/out.txt" 2>&1); RC=$?
CK="$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1"
check "前もって知らせる" 'has out.txt "がもうあります. 学習のあと上書きはせず"'
check "もとの中身はそのまま・終了コード 1・一時フォルダも作らない" '[ "$(cat "$CK/1499/keep.txt")" = old ] && [ $RC = 1 ] && [ $(n_call "hf download") = 0 ] && has notify.log "上書きしなかった"'
rm -rf "$T"

echo "== 9. A40 が借りられない → RTX A6000 で借りる"
run gpu_busy
check "A40 を 5 回試し, RTX A6000 で作る" '[ $(n_call "POST gpu=NVIDIA A40") = 5 ] && [ $(n_call "POST gpu=NVIDIA RTX A6000") = 1 ] && [ "$(jq -r .gpu "$TD/lab_pod.json")" = "NVIDIA RTX A6000" ]'
check "終了コード 0・費用の行に GPU" '[ $RC = 0 ] && has out.txt "GPU=NVIDIA RTX A6000"'
rm -rf "$T"

echo "== 10. どの GPU も借りられない → 借りずに通知して終わる"
run all_busy
check "終了コード 1・A40 5 回 + A6000 2 回 + L40S 2 回" '[ $RC = 1 ] && [ $(n_call "POST gpu=NVIDIA A40") = 5 ] && [ $(n_call "POST gpu=NVIDIA RTX A6000") = 2 ] && [ $(n_call "POST gpu=NVIDIA L40S") = 2 ]'
check "DELETE しない・通知に「Pod は借りていません」" '[ $(n_call DELETE) = 0 ] && has notify.log "Pod は借りていません"'
rm -rf "$T"
run all_busy --no-fallback
check "--no-fallback は A40 だけ" '[ $(n_call "POST gpu=NVIDIA A40") = 5 ] && [ $(n_call "POST gpu=NVIDIA RTX A6000") = 0 ]'
rm -rf "$T"

echo "== 11. Pod を消せない → 残っているかもしれないと通知する"
run delete_fail
check "終了コード 1・--stop の案内" '[ $RC = 1 ] && has notify.log "残っているかもしれません" && has notify.log "--stop"'
check "記録は gone_confirmed=false" '[ "$(jq -r .gone_confirmed "$TD/lab_pod.json")" = false ]'
rm -rf "$T"

echo "== 11b. 1 回目の DELETE だけ失敗 → やり直して消す"
run delete_flaky
check "終了コード 0・DELETE 2 回・消えたのを確かめた" '[ $RC = 0 ] && [ $(n_call DELETE) = 2 ] && has out.txt "(1/3) Pod を消せない" && [ "$(jq -r .gone_confirmed "$TD/lab_pod.json")" = true ]'
rm -rf "$T"

echo "== 11c. 上限を超えた直後の確かめで学習が終わっていた → 消さずに持ち帰る (codex R1)"
run late_finish --limit-hours 0.002
check "終了コード 0・成功・上限で止めない" '[ $RC = 0 ] && has notify.log "成功: 学習が終わりました" && ! has out.txt "上限 0.002 時間を超えた" && [ -f "$CK/1499/_CHECKPOINT_METADATA" ]'
rm -rf "$T"

echo "== 11d. Pod 側の見張りが働かない → 続けるが, 画面・まとめで目立たせる"
run wd_missing
check "終了コード 0・警告・寝てよいの案内に注意・まとめに注意" '[ $RC = 0 ] && has out.txt "Pod 側の見張りが働かないかもしれない (WATCHDOG run=1 pod=1 key=0 ctl=0)" && has out.txt "※ Pod 側の見張りがない" && grep -q "Pod 側の見張りは働いていなかった" "$TD"/result_*.txt'
rm -rf "$T"

echo "== 11e. Pod 側のチェックポイントの一覧が取れない → 上げずに, ログを持ち帰って消す"
run list_empty
check "終了コード 1・上げない・DELETE" '[ $RC = 1 ] && ! has ssh.log "hf_upload_ckpt.py" && [ $(n_call DELETE) = 1 ] && has notify.log "一覧を取れない"'
rm -rf "$T"

echo "== 11f. 見守りに入る前に上限を過ぎた (学習は終わっている) → 消さずに持ち帰る (codex R2)"
run late_start --limit-hours 0.002
check "終了コード 0・成功" '[ $RC = 0 ] && has notify.log "成功: 学習が終わりました" && ! has out.txt "時間を超えた"'
rm -rf "$T"

echo "== 11g. Pod 側の一覧が途中で失敗 (一部の行・終了コード 1) → 合ったとせず, 上げない (codex R2)"
run list_partial
check "終了コード 1・上げない・一覧を取れない" '[ $RC = 1 ] && ! has ssh.log "hf_upload_ckpt.py" && has notify.log "一覧を取れない"'
check "一覧のコマンドは pipefail 付き" 'has ssh.log ": lt=list_ckpt; set -o pipefail;"'
rm -rf "$T"

echo "== 12. 前の学習の Pod が動いている → 作らずに止まる / --stop で持ち帰って消す"
T=$(mktemp -d); mkdir -p "$T/data/lab/LogT/train" "$T/fake"
jq -n '{id:"dryrunpod0001", kind:"train", gpu:"NVIDIA A40", costPerHr:0.44, created_epoch:1793449178, created_at:"2026-10-31 21:19:38", exp:"trim_corr_v3", train_started_epoch:1793449300}' > "$T/data/lab/LogT/train/lab_pod.json"
echo "NVIDIA A40" > "$T/fake/created"
(cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run < /dev/null > "$T/out.txt" 2>&1); RC=$?
TD="$T/data/lab/LogT/train"
check "終了コード 1・POST しない・--stop の案内" '[ $RC = 1 ] && [ $(n_call POST) = 0 ] && has out.txt "前の学習の Pod (id dryrunpod0001・RUNNING) が残っている" && [ $(n_call DELETE) = 0 ]'
(cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run --stop < /dev/null > "$T/out.txt" 2>&1); RC=$?
check "--stop: 学習を止め, ログを持ち帰って DELETE" '[ $(n_call DELETE) = 1 ] && has ssh.log ": lt=kill_train" && [ -f "$TD/train_trim_corr_v3.log" ] && has out.txt "--stop で止めた"'
check "--stop の出力は lab_train_*_stop.txt に残る" 'ls "$TD"/lab_train_*_stop.txt >/dev/null 2>&1'
(cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run < /dev/null > "$T/out2.txt" 2>&1)
check "消したあとは前の記録を名前を変えて残し, 新しく作る" '[ $(n_call "POST /pods") = 1 ] && ls "$TD"/lab_pod_*.json >/dev/null 2>&1'
rm -rf "$T"

echo "== 13. 見守る途中で止められても Pod を残さない (TERM. Ctrl-C・HUP も同じ扱い)"
T=$(mktemp -d)
(cd "$HERE" && LAB_DRY_ROOT="$T" LAB_FAKE_SCENARIO=timeout LAB_DRY_POLL_SEC=1 ./lab_train.sh --log LogT --dry-run --limit-hours 1 < /dev/null > "$T/out.txt" 2>&1) &
bg=$!
for _ in $(seq 1 40); do grep -q "ここまで来れば寝て" "$T/out.txt" 2>/dev/null && break; sleep 0.5; done
pk=$(pgrep -f "lab_train.sh --log LogT --dry-run --limit-hours 1" | head -1)
kill -TERM "$pk" 2>/dev/null       # 裏で動かした bash は INT を受けない (始まる前から無視) ので TERM で確かめる. INT も同じ on_signal
wait $bg; TD="$T/data/lab/LogT/train"
check "止められたら, ログを持ち帰って DELETE・通知" '[ $(n_call DELETE) = 1 ] && [ -f "$TD/train_trim_corr_v3.log" ] && grep -q "TERM (kill) で止めた" "$T/notify.log"'
rm -rf "$T"

echo "== 14. 引数"
for a in "--limit-hours 0" "--limit-hours x" "--steps 0" "--exp-name a;b" "--init /a b"; do
    T=$(mktemp -d)
    # shellcheck disable=SC2086
    (cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run $a < /dev/null > "$T/out.txt" 2>&1); RC=$?
    check "$a は終了コード 2" '[ $RC = 2 ]'
    rm -rf "$T"
done

echo "== 15. Pod の中で流すコマンドを, この Mac の bash で流す"
. "$HERE/lab_lib.sh"
d=$(mktemp -d); L="$d/train.log"; E="$d/exit"; C="$d/ck"
st() { bash -c "$(lab_train_status_cmd "$L" "$E" "$C" 1500)" 2>/dev/null; }
check "ログがない: STATUS run=0 exit=- age=-" '[[ "$(st)" == "STATUS run=0 exit=- age=- bar=- logstep=- loss=- ckpt=- cm=0" ]]'
printf 'loading\r  5%%|▌  | 75/1500 [08:00<2:30:00, 6.6s/it]\rStep 0: grad_norm=1.2000, loss=0.0800, param_norm=300.1\n 13%%|█▎ | 200/1500 [22:00<2:23:00, 6.6s/it]\rStep 100: grad_norm=0.5000, loss=0.0412, param_norm=300.2\n\r 14%%|█▍ | 210/1500 [23:00<2:22:00, 6.6s/it]' > "$L"
mkdir -p "$C/1000" "$C/1499" "$C/1499.orbax-checkpoint-tmp-123"; touch "$C/1499/_CHECKPOINT_METADATA"
check "tqdm と Step の行・チェックポイント" '[[ "$(st)" == "STATUS run=0 exit=- age="[0-9]*" bar=210 logstep=100 loss=0.0412 ckpt=1499 cm=1" ]]'
echo 0 > "$E"
check "終わりの印 → exit=0" '[[ "$(st)" == *" exit=0 "* ]]'
lab_train_parse_status "$(st)"
check "読み分け (S_EXIT=0 S_BAR=210 S_LOSS=0.0412 S_CKPT=1499 S_CM=1)" '[ "$S_EXIT" = 0 ] && [ "$S_BAR" = 210 ] && [ "$S_LOSS" = 0.0412 ] && [ "$S_CKPT" = 1499 ] && [ "$S_CM" = 1 ]'
mkdir -p "$d/gnu"; printf '#!/bin/bash\n[ "$1" = -c ] && [ "$2" = %%Y ] && { echo 1000; exit 0; }; exit 1\n' > "$d/gnu/stat"; chmod +x "$d/gnu/stat"
check "Pod (GNU) では stat -c %Y で書いた時刻を読む" '[ $(PATH="$d/gnu:$PATH" st | sed -E "s/.* age=([0-9]+) .*/\1/") -gt 1000000000 ]'
printf '#!/bin/bash\nexit 1\n' > "$d/gnu/stat"; mkdir -p "$d/nodate"; printf '#!/bin/bash\n[ "$1" = -r ] && exit 1; exec /bin/date "$@"\n' > "$d/nodate/date"; chmod +x "$d/gnu/stat" "$d/nodate/date"
check "書いた時刻が読めなければ age=- (止まったとしない)" '[[ "$(PATH="$d/gnu:$d/nodate:$PATH" st)" == *" age=- "* ]]'
check "STATUS の行がなければ読めない" '! lab_train_parse_status "ssh: connect to host"'
touch -t 202601010000 "$L"
check "古いログ → age が大きい" '[ $(st | sed -E "s/.* age=([0-9]+) .*/\1/") -gt 100000 ]'
mkdir -p "$d/x/.cache/huggingface" "$d/x/params" "$d/x/train_state"
printf 'abc' > "$d/x/params/a"; printf '12345' > "$d/x/_CHECKPOINT_METADATA"; touch "$d/x/._a" "$d/x/.gitattributes" "$d/x/.DS_Store" "$d/x/.cache/huggingface/m" "$d/x/train_state/t"
mkdir -p "$d/x/params/locked"; chmod 000 "$d/x/params/locked"
check "Mac の一覧は読めないフォルダがあれば失敗する (黙って飛ばさない)" '! lab_list_files "$d/x" >/dev/null 2>&1'
chmod 755 "$d/x/params/locked"; rmdir "$d/x/params/locked"
check "Mac の一覧は ._・.cache・.gitattributes・train_state を数えない" '[ "$(lab_list_files "$d/x")" = "$(printf "_CHECKPOINT_METADATA\t5\nparams/a\t3")" ]'
# 見張り: 上限の時刻を過ぎたら Pod 用の鍵で DELETE を送る (偽物の curl). 鍵は書き出さない
mkdir -p "$d/bin"; printf '#!/bin/bash\nfor a in "$@"; do case "$a" in @/dev/fd/*|@/proc/*) cat "${a#@}" > "%s/hdr" ;; esac; done\necho "$*" > "%s/curl_args"; printf 204\n' "$d" "$d" > "$d/bin/curl"; chmod +x "$d/bin/curl"
lab_watchdog_script > "$d/wd.sh"
echo $(( $(date +%s) + 2 )) > "$d/deadline"
run_to 20 env PATH="$d/bin:$PATH" LAB_WD_DEADLINE="$d/deadline" LAB_WD_LOG="$d/wd.log" LAB_WD_POLL=1 LAB_WD_API=http://fake \
    RUNPOD_POD_ID=pod123 RUNPOD_API_KEY=podscoped-SECRET bash "$d/wd.sh"; wrc=$?
check "見張り: 時刻まで待ってから DELETE (Pod 用の鍵をヘッダーで)・0 で終わる" '[ $wrc = 0 ] && grep -q -- "-X DELETE .*http://fake/pods/pod123" "$d/curl_args" && grep -q "Bearer podscoped-SECRET" "$d/hdr" && grep -q "HTTP 204" "$d/wd.log"'
check "見張り: 鍵をログ・引数に出さない" '! grep -q SECRET "$d/wd.log" "$d/curl_args"'
# 1 回目は 500 → やり直して 204
printf '#!/bin/bash\nn=$(cat "%s/cn" 2>/dev/null || echo 0); echo $((n+1)) > "%s/cn"; [ "$n" = 0 ] && printf 500 || printf 204\n' "$d" "$d" > "$d/bin/curl"
echo 1 > "$d/deadline"; : > "$d/wd.log"
run_to 20 env PATH="$d/bin:$PATH" LAB_WD_DEADLINE="$d/deadline" LAB_WD_LOG="$d/wd.log" LAB_WD_RETRY=0 RUNPOD_POD_ID=p RUNPOD_API_KEY=k bash "$d/wd.sh"; wrc=$?
check "見張り: DELETE が一時だめでもやり直して消す" '[ $wrc = 0 ] && grep -q "(1) DELETE → HTTP 500" "$d/wd.log" && grep -q "(2) DELETE → HTTP 204" "$d/wd.log"'
printf '#!/bin/bash\nprintf 500\n' > "$d/bin/curl"; : > "$d/wd.log"
run_to 20 env PATH="$d/bin:$PATH" LAB_WD_DEADLINE="$d/deadline" LAB_WD_LOG="$d/wd.log" LAB_WD_RETRY=0 LAB_WD_TRIES=3 RUNPOD_POD_ID=p RUNPOD_API_KEY=k bash "$d/wd.sh"; wrc=$?
check "見張り: ずっとだめなら 3 回試して「消せなかった」・終了コード 1" '[ $wrc = 1 ] && grep -q "(3) DELETE" "$d/wd.log" && grep -q "消せなかった" "$d/wd.log"'
# runpodctl が固まっても, 時間で切って次へ進む (Mac には timeout がないので偽物)
printf '#!/bin/bash\ns=$1; shift; "$@" & p=$!; ( sleep "$s"; kill $p 2>/dev/null ) & w=$!; wait $p; r=$?; kill $w 2>/dev/null; exit $r\n' > "$d/bin/timeout"
printf '#!/bin/bash\necho call >> "%s/ctl"; sleep 30\n' "$d" > "$d/bin/runpodctl"; chmod +x "$d/bin/timeout" "$d/bin/runpodctl"; : > "$d/wd.log"
run_to 25 env PATH="$d/bin:$PATH" LAB_WD_DEADLINE="$d/deadline" LAB_WD_LOG="$d/wd.log" LAB_WD_RETRY=0 LAB_WD_TRIES=2 LAB_WD_CTL_TIMEOUT=1 RUNPOD_POD_ID=p RUNPOD_API_KEY=k bash "$d/wd.sh"; wrc=$?
check "見張り: runpodctl が固まっても時間で切ってやり直し, 最後まで行く" '[ $wrc = 1 ] && grep -q "(2) DELETE" "$d/wd.log" && [ $(wc -l < "$d/ctl") = 3 ] && grep -q "消せなかった" "$d/wd.log"'
rm -f "$d/bin/timeout" "$d/bin/runpodctl"
echo $(( $(date +%s) + 3600 )) > "$d/deadline"; : > "$d/wd.log"
run_to 3 env PATH="$d/bin:$PATH" LAB_WD_DEADLINE="$d/deadline" LAB_WD_LOG="$d/wd.log" LAB_WD_POLL=1 RUNPOD_POD_ID=p RUNPOD_API_KEY=k \
    bash "$d/wd.sh"; wrc=$?
check "見張り: 時刻の前は何もしない (延ばした時刻を読む)" '[ $wrc = 143 ] && [ ! -s "$d/wd.log" ]'
bash -n <(lab_watchdog_start_cmd 123) && ok "見張りを起動するコマンドの書き方" || ng "見張りを起動するコマンドの書き方"
check "見張りを起動するコマンドは鍵の中身を出さない (有無だけ)" '! lab_watchdog_start_cmd 1 | grep -qE "echo .*\\\$RUNPOD_API_KEY|printenv RUNPOD_API_KEY\$"'
rm -rf "$d"

echo "== 15b. pgrep / pkill の形が, そのコマンドを流す bash -c 自身に当たらない (依頼 47 の codex R1)"
. "$HERE/lab_lib.sh"
all_cmds="$(lab_train_start_cmd c e /w/l /w/g /w/r /w/x)
$(lab_watchdog_start_cmd 1)
$(lab_train_kill_cmd)
$(lab_train_status_cmd /w/l /w/x /w/c 1500)"
self_hit() {                # self_hit 形 → コマンドの中身を引数に持つ bash -c の中から pgrep し, 自分に当たれば 0
    bash -c "true <<'X_EOF'
$all_cmds
X_EOF
pgrep -a -f '$1' >/dev/null; r=\$?; exit \$r" 2>/dev/null
    # Mac の pgrep は自分の親を数えない (-a で数える). Pod (Linux) の pgrep は数えるので, -a で Pod と同じにする.
    # pgrep を最後に置くと bash が exec で pgrep に置き換わるので, 後ろに 1 つ置く
}
for pat in "$LAB_RUN_PAT" "$LAB_WD_PAT" "$LAB_GPU_PAT"; do
    check "「${pat}」は自分に当たらない" '! self_hit "$pat"'
done
check "(確かめの確かめ) ^ のない形なら自分に当たる" 'self_hit "[n]vidia-smi --query-gpu=timestamp"'
check "学習の Pod のコマンドに ^ のない train_run・lab_watchdog・nvidia-smi の pgrep/pkill がない" '! grep -E "(pgrep|pkill)( -9)? -f .\[?(t\]?rain_run|l\]?ab_watchdog|n\]?vidia-smi)" <<<"$all_cmds" | grep -q .'
mkdir -p /tmp/lt_$$/r2r2r && LC_ALL=C sed -e 's#/workspace/r2r2r/train_run.sh#/tmp/lt_'$$'/r2r2r/train_run.sh#g' <<<"$(lab_train_start_cmd c e /w/l /w/g /w/r /w/x)" | LC_ALL=C sed -n "/^cat > /,/^RUN_EOF\$/p" | bash
bash -n "/tmp/lt_$$/r2r2r/train_run.sh" && ok "Pod に置く train_run.sh の書き方" || ng "Pod に置く train_run.sh の書き方"
check "train_run.sh は終了コードを終わりの印に書く・\$HOME を Pod 側で読む" 'grep -q "^echo \$rc > /w/x$" /tmp/lt_$$/r2r2r/train_run.sh && grep -q "source \$HOME/.local/bin/env" /tmp/lt_$$/r2r2r/train_run.sh'
rm -rf /tmp/lt_$$

echo "== 16. lab_start.sh が lab_lib.sh の準備のコマンドで同じものを流す"
. "$HERE/lab_lib.sh"
check "3c は lab_start.sh の時と同じ中身" '[ "$(lab_cmd_ckpt PixelstoMotors/pi05-mecharm-trim-lora-v1 /workspace/ckpt/3999)" = "rm -f /workspace/ckpt/3999/.lab_download_ok && hf download PixelstoMotors/pi05-mecharm-trim-lora-v1 --local-dir /workspace/ckpt/3999 && touch /workspace/ckpt/3999/.lab_download_ok && du -sh /workspace/ckpt/3999" ]'
check "3e は \$HOME を Pod 側で読む" '[[ "$(lab_cmd_uv_sync)" == *"source \$HOME/.local/bin/env"* ]]'

rm -rf "$OUT1"
echo
echo "OK $OK / NG $NG"
[ $NG = 0 ]
