commit 047e472e7ebbfec537aa3eb5e08c3b1e15571deb Author: Eiichi Shimazu Date: Sat Nov 14 07:43:14 2026 +0900 実機推論をチャンクごとの Enter で止めず, スペースを押したときだけ止める (依頼 178・Log35). run_policy_client.py に --no-step-confirm を足す (始めの確かめは今までどおり・--skip-first-confirm もそのまま. そのあとは入力欄を出さずに次の推論・実行へ進む. 推論中・中継点を送って待つ間・到着の確かめの間に端末でスペース (Enter も可) が押されたら止める予約をし, 今のチャンクを送り終えてから次の推論のあと (送る前) で今までと同じ入力欄を出す. 推論中に押せばその推論は送らない. p・c・b・r・h・q・s・Enter は今までどおりで, Enter で実行するとまた止まらずに進む. s・b・c・p のあとは止めたまま (codex R1). キーは termios の cbreak と select で待たずに読み, 入力欄の前・終わり・Ctrl-C・例外で端末の設定を戻す (戻せてから保存を消す. codex R4). 未到達・関節角を読めない (最後の中継点を送らなかった経路も. codex R3) ときは終了コード 1 にせず入力欄を出す. log.jsonl に type=pause (requested・prompt・resume), 入力欄を出した推論の行に paused と prompt_t, 頭に step_confirm. --auto とは一緒に使えない). lab_start.sh は既定で --no-step-confirm を渡し, --step-confirm で今までどおり. 起動時に [CONFIRM] の行. chunk_timing.py は止めた待ちを確認待ちに入れ (送らなかったチャンクも prompt_t から. 最後のチャンクの終わりにも. codex R5), メモに「止めた(スペース)」・頭に確かめの形. テストを足す (test_no_step_confirm 17・test_chunk_timing 7・lab_start 218. test_rewind_correct は頭の step_confirm を比べる対象から外す) (依頼 178. codex 3 回) Co-Authored-By: Claude Opus 5.5 --- lab_start.sh | 18 +- pi05/chunk_timing.py | 30 +++- pi05/run_policy_client.py | 161 ++++++++++++++++- pi05/test_chunk_timing.py | 33 ++++ pi05/test_no_step_confirm.py | 416 +++++++++++++++++++++++++++++++++++++++++++ pi05/test_rewind_correct.py | 3 +- test_lab_start.sh | 12 +- 7 files changed, 661 insertions(+), 12 deletions(-) diff --git a/lab_start.sh b/lab_start.sh index 4e9371c..9dfafaf 100755 --- a/lab_start.sh +++ b/lab_start.sh @@ -38,6 +38,9 @@ # run_policy_client.py の既定 20. 起動時に [GRIP ] で使う値を出す. 依頼 152) # --flow / --speed N run_policy_client.py にそのまま渡す (中継点を止まらずに流す / チャンクを送る速さ 1〜100. # 付けなければ今までどおり. 依頼 176. 起動時に [FLOW ] で出す) +# --step-confirm 実機推論でチャンクごとに確かめ (Enter) を待つ今までの形に戻す. 付けなければ (既定) run_policy_client.py に +# --no-step-confirm を渡し, 始めの確かめのあとは止まらずに進み, スペース (Enter も可) を押したときだけ次の推論の +# あとで入力欄を出す (依頼 178). 起動時に [CONFIRM] の行でどちらかを出す # つなぎ直しで, 残っている Pod のサーバーが別のモデルで動いていれば, そのことを出して止まる # # 1. Pod を作る (A40×1・Runpod Pytorch 2.8.0・コンテナのディスク 200GB・22/tcp を外へ出す) @@ -61,6 +64,8 @@ # 8. run_policy_client.py (LOG407 と同じ引数). 終わったら, もう1回やるかを聞く (y なら 7 へ) # 2 回目からは --skip-first-confirm を渡し, 始めの確かめ (最初の推論の前の Enter) を待たずに進む (依頼 177. # 7 の HOME・爪を開く・位置合わせは毎回行う. 2 回目からの確かめ・補正・巻き戻し・止める操作は今までどおり) +# 既定で --no-step-confirm を渡す (始めの確かめのあとはチャンクごとに確かめず, スペースを押したときだけ止める. +# --step-confirm で今までどおり. 依頼 178) # 9. Pod は消さない. 片付けは ./lab_stop.sh # # Pod の情報 (id・GPU・1時間の料金・作った時刻・サーバーを起動したモデル) は /pod/lab_pod.json に残す (IP とポートは残さない). @@ -86,6 +91,7 @@ TILT_FIX_ARGS=() # 依頼 144: 既定は傾きをソフトでそろえる. - GRIP_ARGS=() # 依頼 152: --grip-value N のときだけ run_policy_client.py に --grip-value N を渡す GRIP_VALUE_SHOWN="20 (既定. run_policy_client.py の GRIP_VALUE)" FLOW_ARGS=() # 依頼 176: --flow / --speed N のときだけ run_policy_client.py に渡す +STEP_ARGS=(--no-step-confirm) # 依頼 178: 既定はチャンクごとに確かめない. --step-confirm で今までどおり while [ $# -gt 0 ]; do case "$1" in --gpu) lab_need_val A40 "$@"; GPU="$2"; shift 2 ;; @@ -100,13 +106,14 @@ while [ $# -gt 0 ]; do [ ${#2} -le 3 ] && [ "$((10#$2))" -le 100 ] || { echo "[ERROR] --grip-value は 0〜100 の整数: $2" >&2; exit 2; } GRIP_ARGS=(--grip-value "$((10#$2))"); GRIP_VALUE_SHOWN="$((10#$2)) (--grip-value)"; shift 2 ;; --flow) FLOW_ARGS+=(--flow); shift ;; + --step-confirm) STEP_ARGS=(); shift ;; --speed) [ $# -ge 2 ] || { echo "[ERROR] --speed には 1〜100 の整数を付ける" >&2; exit 2; } case "$2" in ''|*[!0-9]*) echo "[ERROR] --speed は 1〜100 の整数: $2" >&2; exit 2 ;; esac [ ${#2} -le 3 ] && [ "$((10#$2))" -ge 1 ] && [ "$((10#$2))" -le 100 ] || { echo "[ERROR] --speed は 1〜100 の整数: $2" >&2; exit 2; } FLOW_ARGS+=(--speed "$((10#$2))"); shift 2 ;; --model) [ $# -ge 2 ] || { echo "[ERROR] --model には ${LAB_MODELS// / か } を付ける" >&2; exit 2; } # 値がないと shift 2 が失敗して回り続ける (codex R1) MODEL="$2"; shift 2 ;; - -h|--help) sed -n '2,58p' "$0"; exit 0 ;; + -h|--help) sed -n '2,61p' "$0"; exit 0 ;; *) echo "[ERROR] 知らない引数: $1 (./lab_start.sh --help)" >&2; exit 2 ;; esac done @@ -156,6 +163,11 @@ else fi echo "[GRIP ] 爪の閉じる値は $GRIP_VALUE_SHOWN. 締め直し r はこの値−3 とこの値を交互に送る (依頼 152)" [ ${#FLOW_ARGS[@]} = 0 ] || echo "[FLOW ] 推論の送り方: ${FLOW_ARGS[*]} (依頼 176)" # 付けなければ今までと同じ出力 +if [ ${#STEP_ARGS[@]} = 0 ]; then + echo "[CONFIRM] チャンクごとに確かめる (--step-confirm. 推論のたびに Enter を待つ)" +else + echo "[CONFIRM] チャンクごとには確かめない (--no-step-confirm. 始めの確かめのあとは止まらずに進む. 止めるときはスペース. 依頼 178)" +fi lab_ssh_opts HF_REPO=$MODEL_REPO # --model で選んだモデル (lab_lib.sh の lab_model_def) @@ -529,14 +541,14 @@ while :; do stage "8. 実機推論 ($round 回目)" python3 pi05/run_policy_client.py --raw-dir "$RAW_RANGE" --steps-per-chunk 10 --via-stride 3 \ --settle-retries 0 --state-from-target --execute --ignore-framing --model "$MODEL_TAG" --framing-raw-dir "$RAW" \ - --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \ + --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${STEP_ARGS[@]+"${STEP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \ ${CONFIRM_ARGS[@]+"${CONFIRM_ARGS[@]}"} rc=$? else stage "8. 推論のクライアント (ドライラン・--dry-keys. アームには触らない)" python3 pi05/run_policy_client.py --raw-dir "$RAW_RANGE" --steps-per-chunk 10 --via-stride 3 \ --settle-retries 0 --state-from-target --dry-keys --ignore-framing --model "$MODEL_TAG" --framing-raw-dir "$RAW" \ - --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \ + --tilt-raw-dir "$RAW_RANGE" ${TILT_FIX_ARGS[@]+"${TILT_FIX_ARGS[@]}"} ${GRIP_ARGS[@]+"${GRIP_ARGS[@]}"} ${STEP_ARGS[@]+"${STEP_ARGS[@]}"} ${FLOW_ARGS[@]+"${FLOW_ARGS[@]}"} \ ${CONFIRM_ARGS[@]+"${CONFIRM_ARGS[@]}"} rc=$? fi diff --git a/pi05/chunk_timing.py b/pi05/chunk_timing.py index 8650f14..0f98f90 100644 --- a/pi05/chunk_timing.py +++ b/pi05/chunk_timing.py @@ -11,6 +11,9 @@ 書くので, その分 (ふつう 0.01 秒ほど) 確認待ちが短く, 推論の始まりが遅めに出る)): 推論 latency_s (π0.5 が答えるまで) 確認待ち 推論が終わってから送り始めるまで (Enter の確認. --auto なら 0 に近い). 送っていないチャンクは - + --no-step-confirm (依頼 178) では, 止める予約 (スペース) で入力欄を出したチャンクの待ちがここに入り + (今までのチャンクごとの確かめと同じ所), 止めなかったチャンクは 0 に近い. メモに「止めた(スペース)」など. + 送らなかったチャンク (s・b・c・p・q・h) も log の prompt_t から入力欄を閉じるまでを確認待ちに入れる 動いた 送り始めてから送り終わるまで (t_exec_start → t_exec_end. 爪の開け閉めを含む) その他 合計 − 推論 − 確認待ち − 動いた (関節角の読み取り・撮影など. 巻き戻し・補正・送らずに次へ のチャンクは 確認待ちもここに入る) @@ -84,7 +87,7 @@ def load_sends(run_dir: Path): def last_time(chunks): """最後のチャンクの記録の終わり (推論の行を書いた時刻か送り終わりの遅い方).""" r = chunks[-1] - ts = [r.get("t"), r.get("t_exec_end")] + ts = [r.get("t"), r.get("t_exec_end"), (r.get("prompt_t") or [None, None])[1]] # prompt_t は依頼 178 codex R5 return max(x for x in ts if x is not None) @@ -101,12 +104,22 @@ def chunk_rows(chunks): moved = r["t_exec_end"] - r["t_exec_start"] else: confirm = moved = None + if r.get("prompt_t"): + # 依頼 178 codex R5: --no-step-confirm で入力欄を出して送らなかったチャンク (s・b・c・p・q・h) も, + # 入力欄を閉じるまでを確認待ちに入れる (補正・巻き戻しの時間はその他) + confirm = max(0.0, r["prompt_t"][1] - r["t"]) other = total - infer - (confirm or 0.0) - (moved or 0.0) note = [] if r.get("grip_cmd") is not None: note.append("爪→" + ("閉" if r["grip_cmd"] == 1 else "開")) if r.get("initial_gripper_open"): note.append("始めに爪を開いた") + if r.get("paused"): + # 依頼 178: --no-step-confirm で止める予約 (スペース・Enter・未到達など) から入力欄を出したチャンク + p = r["paused"] + note.append("止めた(" + ({"space": "スペース", "enter": "Enter"}.get(p.get("key")) + or {"not_arrived": "未到達", "read_failed": "読めない"}.get(p.get("reason"), + str(p.get("reason")))) + ")") for k, t in (("rewind_requested", "巻き戻し"), ("correction_requested", "補正"), ("home_requested", "HOME"), ("unsafe", "安全停止")): if r.get(k): @@ -144,6 +157,12 @@ def via_waits(r, sends): return [(None, marks[k + 1] - marks[k], "") for k in range(len(ts))], "states" +def step_text(model): + """頭の step_confirm (依頼 178) を短く. 記録がなければ依頼 178 より前 = チャンクごとに確かめる.""" + sc = model.get("step_confirm") + return "毎チャンク(記録なし)" if sc is None else ("毎チャンク" if sc else "止めたときだけ") + + def fmt(x, w=7): return f"{'-':>{w}}" if x is None else f"{x:>{w}.2f}" @@ -160,7 +179,8 @@ def report(run_dir: Path): flow = model.get("flow") how = ("記録なし (依頼 176 より前 = --flow なし・速さ 20)" if flow is None else (f"--flow (近づく {model.get('flow_tol')}°)" if flow else "--flow なし")) - print(f" 送り方: {how} 速さ {model.get('speed', 20)} モデル {model.get('model')} 閉じる値 {model.get('grip_value')}") + print(f" 送り方: {how} 速さ {model.get('speed', 20)} モデル {model.get('model')} 閉じる値 {model.get('grip_value')}" + f" 確かめ {step_text(model)}") if not chunks: print(" 推論の行がない") return None @@ -194,7 +214,7 @@ def report(run_dir: Path): print(" 中継点ごと: step:秒+わけ (近=近づいた 時=見込みの時間 着=最後の点が着いた 未=着かなかった 失=送れなかった)") if others: print(" そのほかの行: " + " ".join(f"{k} {v}" for k, v in sorted(others.items()))) - return {"name": run_dir.name, "flow": model.get("flow"), "speed": model.get("speed", 20), "n": len(rows), + return {"name": run_dir.name, "flow": model.get("flow"), "step": step_text(model) if model else "-", "speed": model.get("speed", 20), "n": len(rows), "tot": tot, "mid": mid, "infer1": rows[0]["infer"], "infer2": infer2, "via": med(all_waits)} @@ -215,11 +235,11 @@ def main(argv=None): res.append(r) if len(res) >= 2: print("== 比べる (秒)") - print(f" {'試行':<22} {'送り方':<10} {'速さ':>4} {'チャンク':>6} {'推論計':>7} {'確認待ち計':>7} {'動いた計':>7} " + print(f" {'試行':<22} {'送り方':<10} {'確かめ':<12} {'速さ':>4} {'チャンク':>6} {'推論計':>7} {'確認待ち計':>7} {'動いた計':>7} " f"{'動いた中央':>7} {'中継点中央':>7} {'合計':>7}") for r in res: how = "記録なし" if r["flow"] is None else ("flow" if r["flow"] else "今まで") - print(f" {r['name']:<22} {how:<10} {r['speed']:>4} {r['n']:>6} {fmt(r['tot']['infer'])} " + print(f" {r['name']:<22} {how:<10} {r['step']:<12} {r['speed']:>4} {r['n']:>6} {fmt(r['tot']['infer'])} " f"{fmt(r['tot']['confirm'])} {fmt(r['tot']['moved'])} {fmt(r['mid']['moved'])} {fmt(r['via'])} " f"{fmt(r['tot']['total'])}") return 0 diff --git a/pi05/run_policy_client.py b/pi05/run_policy_client.py index cc6149a..41eff0c 100644 --- a/pi05/run_policy_client.py +++ b/pi05/run_policy_client.py @@ -15,6 +15,15 @@ --skip-first-confirm を付けると始めの確かめ (最初に実行する推論の前) だけ待たずに実行し, 画面に [CONFIRM] の 1 行・ log.jsonl の頭 (type=model) に skip_first_confirm・そのチャンクの行に confirm_skipped を残す. 2 回目からの確かめは 今までどおり (lab_start.sh が 1 回の起動の中の 2 回目からの試行に付ける. 依頼 177) + --no-step-confirm を付けると (依頼 178. lab_start.sh が既定で付ける) 始めの確かめのあとは入力欄を出さずに次の推論・ + 実行へ進む. 推論中・中継点を送って待つ間・到着の確かめの間に端末でスペース (Enter も可) が押されたら止める予約をし, + 今のチャンクを送り終えてから, 次の推論のあと (送る前) で今までと同じ入力欄 (CONFIRM_PROMPT) を出す (推論中に押せば, + その推論は送らずに入力欄). そこからの p・c・b・r・h・q・s・Enter は今までどおりで, Enter で実行すればまた止まらずに + 進む (s・b・c・p のあとは止めたまま次の推論でも入力欄を出す). 動いている途中では止めない. キーは termios の cbreak と + select で待たずに読み, 入力欄の前・終わり (Ctrl-C・例外も) に端末の設定を戻す. 未到達・関節角を読めないときは + 止める予約を入れて入力欄を出す (--auto のように終了コード 1 にはしない). log.jsonl に type=pause の行 + (event=requested: key・reason・chunk・where・after_chunk・t / event=prompt / event=resume), 入力欄を出した推論の行に + paused, 頭 (type=model) に step_confirm. --auto とは一緒に使えない - 学習データで通った関節範囲 ±ENVELOPE_MARGIN_DEG の外へは出さない (クランプ) - 肩 j1 の上限は J1_SAFE_MAX (88°) で更に切る. これを超える指令はアームが黙って捨てる (LOG352) - 送る直前に最終検査. 6 関節のどれかが安全範囲の外か非有限値なら, 送信もグリッパー操作もせず @@ -724,6 +733,70 @@ class KeyReader: return False +class PauseKeys: + """--no-step-confirm (依頼 178): 推論・送っている間に押されたスペース (Enter も可) を, 待たずに読む. + + arm() で端末を termios の cbreak にし (Ctrl-C はそのまま効く), poll() は select で待たずに溜まったバイトを + 全部読んで, スペースなら "space"・Enter なら "enter"・ほかのキーだけなら "other"・何もなければ None を返す. + 入力欄 (input) を出す前・終わるとき (Ctrl-C・例外も) は disarm() で元の設定に必ず戻す. + 端末でないとき (テストでパイプから流すとき) は端末の設定に触らず何も読まない (_read_pending を差し替えて試す). + """ + KEYS = {" ": "space", "\n": "enter", "\r": "enter"} + + def __init__(self, enabled): + self.enabled = bool(enabled) + self.fd = None + self.old = None + + def arm(self): + if not self.enabled or self.old is not None: + return + try: + if not sys.stdin.isatty(): + return + fd = sys.stdin.fileno() + except Exception: + return + import termios + import tty + self.old = termios.tcgetattr(fd) + self.fd = fd + try: + tty.setcbreak(fd) + except Exception: + self.disarm() + raise + + def disarm(self): + if self.old is None: + return + import termios + termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old) + self.old = None # 戻せてから消す (失敗したら finally でもう一度戻せる. codex R4) + + def _read_pending(self): + if self.old is None: + return "" + import os + import select + buf = b"" + while select.select([self.fd], [], [], 0)[0]: + b = os.read(self.fd, 64) + if not b: + break + buf += b + return buf.decode("utf-8", errors="ignore") + + def poll(self): + if not self.enabled: + return None + s = self._read_pending() + for ch in s: + if ch in self.KEYS: + return self.KEYS[ch] + return "other" if s else None + + def plan_correction_step(cur, joint, sign, step, q, lo, hi, max_move): """補正の 1 刻み. cur (今の補正目標) の joint を sign*step 動かし, 推論と同じ apply_limits (実測 q から) と check_send_safe を通す. 戻り値: (目標, notes, unsafe).""" @@ -1410,11 +1483,17 @@ def main(): ap.add_argument("--skip-first-confirm", action="store_true", help="始めの確かめ (最初に実行する推論の前の Enter) だけを待たずに実行する. 2 回目からの確かめと " "補正・巻き戻し・止める操作は今までどおり (lab_start.sh が 2 回目からの試行に付ける. 依頼 177)") + ap.add_argument("--no-step-confirm", action="store_true", + help="2 回目からの推論は確かめ (Enter) を待たずに実行し, 推論中・送っている間にスペース (Enter も可) を " + "押したときだけ, 今のチャンクを送り終えてから次の推論のあとで止めて今までと同じ入力欄を出す. " + "始めの確かめは今までどおり (既定 off = 1 チャンクごとに確かめる. 依頼 178)") ap.add_argument("--speed", type=int, default=SPEED, help=f"推論のチャンクを送る速さ (既定 {SPEED} = 今までと同じ. 補正・巻き戻し・HOME は {SPEED} のまま. 依頼 176)") args = ap.parse_args() if args.dry_keys and (args.execute or args.auto): sys.exit("[ERROR] --dry-keys はドライラン用 (--execute / --auto と一緒には使えない)") + if args.no_step_confirm and args.auto: + sys.exit("[ERROR] --no-step-confirm は --auto と一緒には使えない (--auto はもともと確かめない)") apply_wait_args(args) apply_grip_args(args) apply_flow_args(args) @@ -1441,6 +1520,7 @@ def main(): "tilt_raw_dir": str(tilt_raw_dir(args)), "tilt_fix": not args.no_tilt_fix, "grip_value": GRIP_VALUE, "flow": bool(args.flow), "flow_tol": args.flow_tol, "speed": CHUNK_SPEED, "skip_first_confirm": bool(args.skip_first_confirm), + "step_confirm": not args.no_step_confirm, "host": args.host, "port": args.port, "t": time.time()}, ensure_ascii=False) + "\n") log_f.flush() @@ -1458,6 +1538,38 @@ def main(): n_rewinds = n_corrections = 0 end_reason, outcome = None, None skip_confirm = bool(args.skip_first_confirm) # 依頼 177: 始めの確かめを 1 回だけ飛ばす (使ったら False) + # 依頼 178: --no-step-confirm. 始めの確かめのあとは入力欄を出さずに進み, スペース (Enter) で止める予約をする + step_pause = PauseKeys(args.no_step_confirm) # --auto とは一緒に使えない (引数の確かめで止める) + first_confirm = True # まだ一度も確かめの分かれ目に来ていない (始めの確かめは今までどおり) + pause_req = None # 止める予約 (log.jsonl の type=pause / event=requested の行). Enter で進めば None + hold_confirm = False # 入力欄で s・b・c・p を選んだ. 次の推論でも入力欄を出す (Enter で実行すれば False. codex R1) + last_exec_chunk = None # 最後に送り始めた推論の番号 (止める予約の「どのチャンクのあとか」) + if args.no_step_confirm: + print("[PAUSE ] --no-step-confirm: 始めの確かめのあとは入力欄を出さずに進みます. 止めるときはスペース " + "(Enter も可). 今のチャンクを送り終えてから, 次の推論のあと (送る前) で入力欄を出します") + + def request_pause(key, chunk, where, reason="key"): + """止める予約を 1 回だけ入れる (1 行出して log.jsonl に type=pause / event=requested). 依頼 178.""" + nonlocal pause_req + if not args.no_step_confirm or pause_req is not None: + return + pause_req = {"type": "pause", "event": "requested", "key": key, "reason": reason, "chunk": chunk, + "where": where, "after_chunk": last_exec_chunk, "t": time.time()} + log_f.write(json.dumps(pause_req) + "\n"); log_f.flush() + why = {"space": "スペース", "enter": "Enter"}.get(key) or { + "not_arrived": "未到達", "read_failed": "関節角を読めない"}.get(reason, reason) + if where == "infer": + print(f" [PAUSE ] 止める予約 ({why}). 推論 {chunk} は送らずに入力欄を出します") + else: + print(f" [PAUSE ] 止める予約 ({why}). 推論 {chunk} を送り終えてから, 次の推論のあと (送る前) で止めます") + + def check_pause(chunk, where): + """溜まったキーを待たずに読む. スペース / Enter なら止める予約 (依頼 178).""" + k = step_pause.poll() + if k in ("space", "enter"): + request_pause(k, chunk, where) + elif k == "other" and pause_req is None: + print(" [PAUSE ] 止めるのはスペース (Enter も可) です (ほかのキーは使いません)") try: cams = camera_resolver.resolve_cameras(require_top=True, require_side=False, verbose=False) cap = cv2.VideoCapture(cams["top"]) @@ -1560,6 +1672,7 @@ def main(): end_reason = "max_chunks" for i in range(args.max_chunks): + step_pause.arm() # 依頼 178: 推論・送っている間はスペースを待たずに読む (入力欄の前で戻す) q = arm.read() if args.dry_keys else read_angles() if q is None: print("[ERROR] 関節角を読めない (daemon を確認). 停止"); exit_code = 1; end_reason = "error"; break @@ -1645,16 +1758,32 @@ def main(): last_sent_target = None grasp = None # つかんだ区間を巻き戻すときの答え (GRASP_PLACED / GRASP_DROPPED) skip_now, skip_confirm = skip_confirm and not args.auto, False - if skip_now: + # 依頼 178: --no-step-confirm は始めの確かめと, 止める予約が入ったときだけ入力欄を出す + check_pause(i, "infer") + need_confirm = ((not args.no_step_confirm) or first_confirm or pause_req is not None + or hold_confirm) + first_confirm = False + if skip_now and pause_req is None: # 依頼 177: lab_start.sh の 2 回目からの試行は, 始めの確かめ (Enter) を待たずに実行する. # 2 回目からの確かめ・補正・巻き戻し・止める操作は今までどおり print(" [CONFIRM] 2 回目からの試行なので始めの確かめ (Enter) を待たずに実行します " "(次の推論からは今までどおり確かめます)") rec["confirm_skipped"] = True - elif not args.auto: + elif not args.auto and need_confirm: + step_pause.disarm() # 依頼 178: 入力欄の前に端末の設定を元に戻す + if pause_req is not None: + rec["paused"] = {"key": pause_req["key"], "reason": pause_req["reason"], + "requested_chunk": pause_req["chunk"], "requested_t": pause_req["t"]} + log_f.write(json.dumps({"type": "pause", "event": "prompt", "key": pause_req["key"], + "reason": pause_req["reason"], "chunk": i, + "after_chunk": last_exec_chunk, "t": time.time()}) + "\n") + log_f.flush() + print(f" [PAUSE ] 止めました (推論 {i} はまだ送っていません). " + "Enter で実行すると, また止まらずに進みます") # 入力を待っている間は wait (人が手でブロックを置き直す. 学習データに使わない. 依頼 27) if recorder is not None: recorder.set_mode(MODE_WAIT) + t_prompt0 = time.time() while True: grasp = None # 依頼 40: たまっているキー (位置合わせを閉じたキー・押しっぱなし) で先へ進まない @@ -1731,6 +1860,16 @@ def main(): break if recorder is not None and ans not in ("q", "h"): recorder.set_mode(MODE_MODEL) + if args.no_step_confirm: + # 依頼 178: 入力欄を出していた時刻 (送らなかったチャンクも chunk_timing の確認待ちに入れる. codex R5) + rec["prompt_t"] = [t_prompt0, time.time()] + # Enter で進めば, また止まらずに進む形に戻る. s・b・c・p のあとは止めたまま (codex R1) + hold_confirm = ans != "" + if ans == "" and pause_req is not None: + log_f.write(json.dumps({"type": "pause", "event": "resume", "chunk": i, + "t": time.time()}) + "\n") + log_f.flush() + pause_req = None if ans == "q": end_reason = "q" log_f.write(json.dumps(rec) + "\n"); log_f.flush() @@ -1827,6 +1966,9 @@ def main(): end_reason = "error" break continue + if not unsafe and (args.execute or args.dry_keys): + step_pause.arm() # 依頼 178: 入力欄のあと, 送っている間もスペースを読む + last_exec_chunk = i if not unsafe and args.dry_keys: # --dry-keys の Enter: 送らずに仮の姿勢を目標へ移す (b / c の流れを試すため) t_ex0 = time.time() @@ -1873,6 +2015,9 @@ def main(): exit_code = 1 log_f.write(json.dumps(rec) + "\n") stopped = True + else: + # 依頼 178: --no-step-confirm でも次の推論のあとで入力欄を出して人に任せる + request_pause(None, i, "send", reason="read_failed") break tgt, notes_now = apply_limits(v["raw"], qa, lo, hi, args.max_move_deg) if notes_now != v["notes"]: @@ -1961,6 +2106,9 @@ def main(): stopped = True broke = True break + # 依頼 178: --no-step-confirm でも次の推論のあとで入力欄を出して人に任せる (今のチャンクは続ける) + request_pause(None, i, "send", reason="not_arrived") + check_pause(i, "send") # 依頼 178: 送っている途中でも読む (止めるのはチャンクのあと) if v_last: # 残ったずれを同じ目標の送り直しで詰める (LOG359). 止める条件は増やさない. rec["settle"], rec["settle_retries_used"] = settle_target( @@ -1971,6 +2119,9 @@ def main(): qa = read_angles() rec["arrived"] = bool(qa is not None and float(np.max(np.abs(final_target - qa[:6]))) <= 3.0) + if not rec["arrived"] and not args.auto: + # 依頼 178 codex R3: --no-step-confirm でも次の推論のあとで入力欄を出して人に任せる + request_pause(None, i, "send", reason="read_failed" if qa is None else "not_arrived") rec["settle"], rec["settle_retries_used"] = settle_target( final_target, f"pi05_chunk{i:03d}", args.settle_tol, args.settle_retries) if not broke: @@ -1989,6 +2140,7 @@ def main(): # 残さないと次の b が 1 つ古い区間を巻き戻してしまう) t_exec_end = time.time() rec["t_exec_start"], rec["t_exec_end"] = t_exec_start, t_exec_end + check_pause(i, "send") rec["sent_targets"] = [_fmt6(x) for x in sent_targets] grip_moved = (not broke) and grip_cmd is not None if sent_targets or grip_moved: @@ -2014,6 +2166,7 @@ def main(): # Ctrl-C でこのチャンクの unsafe / envelope が消えないように). log_f.write(json.dumps(rec) + "\n"); log_f.flush() exit_code = 1 + step_pause.disarm() # 依頼 178: 入力欄の前に端末の設定を元に戻す if args.execute and not args.auto: if recorder is not None: recorder.set_mode(MODE_WAIT) # 入力待ちは wait (codex 依頼 27 P3) @@ -2034,6 +2187,10 @@ def main(): print("\n[STOP ] Ctrl-C") end_reason = "ctrl_c" finally: + try: + step_pause.disarm() # 依頼 178: Ctrl-C・例外でも端末の設定を必ず元に戻す + except Exception as e: + print(f"[WARN ] 端末の設定を元に戻せない: {type(e).__name__}: {e}") # finally ではアームを動かさない (HOME 復帰もサーボ解放もしない). 止まった姿勢は # そのまま保つ: 未知の姿勢から勝手に動かす方が危ない. 戻すときは h で明示的に送る. if recorder is not None: diff --git a/pi05/test_chunk_timing.py b/pi05/test_chunk_timing.py index 3e046f7..b9d2a8b 100644 --- a/pi05/test_chunk_timing.py +++ b/pi05/test_chunk_timing.py @@ -69,6 +69,39 @@ class TestChunkTiming(unittest.TestCase): self.assertEqual(sorted(p for p in self.td.rglob("*")), before) # 何も書き出さない return code, out.getvalue() + def test_no_step_confirm_pause_goes_to_confirm(self): + # 依頼 178: 止めなかったチャンクの確認待ちは 0 に近く, 止める予約で入力欄を出したチャンクの待ちは確認待ちに入る + d = self.td / "pi05_20261114_000001" + write_run(d, [{"type": "model", "model": "m", "grip_value": 20, "step_confirm": False, "t": T0}, + chunk(0, T0, 0.5, T0 + 3.0, T0 + 5.0), + chunk(1, T0 + 5.0, 0.5, T0 + 5.51, T0 + 7.0), + {"type": "pause", "event": "requested", "key": "space", "reason": "key", "chunk": 1, + "where": "send", "after_chunk": 1, "t": T0 + 6.0}, + {"type": "pause", "event": "prompt", "key": "space", "chunk": 2, "t": T0 + 7.5}, + chunk(2, T0 + 7.0, 0.5, T0 + 12.5, T0 + 14.0, + paused={"key": "space", "reason": "key", "requested_chunk": 1}), + {"type": "pause", "event": "resume", "chunk": 2, "t": T0 + 12.5}]) + rows = ct.chunk_rows(ct.load_log(d)[1]) + self.assertAlmostEqual(rows[1]["confirm"], 0.01, places=6) + self.assertAlmostEqual(rows[2]["confirm"], 5.0, places=6) + self.assertIn("止めた(スペース)", rows[2]["note"]) + self.assertEqual(rows[1]["note"], "") + # codex R5: 止めて送らなかったチャンク (c など) も入力欄を閉じるまでが確認待ち + rows2 = ct.chunk_rows([chunk(3, T0 + 20.0, 0.5, prompt_t=[T0 + 20.5, T0 + 24.5], correction_requested=True, + paused={"key": "space", "reason": "key"}), + chunk(4, T0 + 40.0, 0.5)]) + self.assertAlmostEqual(rows2[0]["confirm"], 4.0, places=6) + self.assertIsNone(rows2[0]["moved"]) + # 最後のチャンクを止めて送らずに終えたときも, 入力欄を閉じるまでを合計に入れる (その他が負にならない) + last = ct.chunk_rows([chunk(5, T0 + 50.0, 0.5, prompt_t=[T0 + 50.5, T0 + 55.5])])[0] + self.assertAlmostEqual(last["confirm"], 5.0, places=6) + self.assertAlmostEqual(last["total"], 5.5, places=6) + self.assertAlmostEqual(last["other"], 0.0, places=6) + code, out = self.run_main(d) + self.assertEqual(code, 0) + self.assertIn("確かめ 止めたときだけ", out) + self.assertIn("pause 3", out) + def test_plain_run_uses_states_csv(self): d = self.td / "pi05_20261113_000001" write_run(d, [{"type": "model", "model": "m", "grip_value": 25, "t": T0}, diff --git a/pi05/test_no_step_confirm.py b/pi05/test_no_step_confirm.py new file mode 100644 index 0000000..0b115f6 --- /dev/null +++ b/pi05/test_no_step_confirm.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""test_no_step_confirm.py — チャンクごとの確かめをやめ, スペースを押したときだけ止める --no-step-confirm の確かめ +(依頼 178. アームなし). + + python3 pi05/test_no_step_confirm.py # 全部 + python3 pi05/test_no_step_confirm.py -v + +run_policy_client を test_rewind_correct.run_client (偽の daemon / カメラ / モデル) で通し, + - キーなしなら始めの確かめのあと最後まで入力欄が出ない (止めずに進む) + - 送っている途中でスペースが押されたら, そのチャンクを送り終えてから次の推論のあと (送る前) で入力欄が出て, + p・b・q・s が今までどおり動く. Enter で実行するとまた止まらずに進む + - 推論中に押されたら, その推論は送らずに入力欄 + - 未到達・関節角を読めないときは終了コード 1 にせず入力欄を出す + - 入力欄の前には必ず端末の設定を戻し, Ctrl-C でも戻す (PauseKeys を偽物にして数える. 本物は pty で確かめる) + - 付けなければ git HEAD の版と同じ送り方・同じ log (頭の step_confirm だけ増える) +を見る. KIOXIA にもアームにも触らない (一時フォルダだけ). +""" +import importlib.util +import os +import subprocess +import sys +import tempfile +import termios +import time +import unittest +from pathlib import Path +from unittest import mock + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +import run_policy_client as rpc # noqa: E402 +import test_rewind_correct # noqa: E402 +from test_rewind_correct import FakePolicy, run_client, strip # noqa: E402 + +EXEC = ["--execute", "--steps-per-chunk", "10", "--via-stride", "3", "--settle-retries", "0", + "--state-from-target", "--ignore-framing", "--max-chunks", "6"] +NSC = EXEC + ["--no-step-confirm"] +# 1 チャンクを送るときの読み取り (poll) の順: 推論のあと 1 → 中継点 4 点 (via02/05/08/09) の送信ごと 1 → 送り終わり 1 + + +class FakeKeys(rpc.PauseKeys): + """端末の代わり. arm / disarm を数え, press に入れた番号の poll でスペース (などの文字) を返す.""" + instances = [] + press = {} + + def __init__(self, enabled): + super().__init__(enabled) + self.calls = [] + self.n_poll = 0 + FakeKeys.instances.append(self) + + def arm(self): + if self.enabled and self.old is None: + self.old = "armed" + self.calls.append("arm") + + def disarm(self): + if self.old is not None: + self.old = None + self.calls.append("disarm") + + def _read_pending(self): + self.n_poll += 1 + return FakeKeys.press.get(self.n_poll, "") + + +class CheckedInputs: + """input() が呼ばれた瞬間に端末の設定が戻っている (偽の PauseKeys が armed でない) ことを確かめる.""" + def __init__(self, values): + self.values, self.k, self.armed_at_input = list(values), 0, [] + + def __iter__(self): + return self + + def __next__(self): + if self.k >= len(self.values): + raise AssertionError(f"入力欄が {self.k + 1} 回目も出た (用意した入力は {len(self.values)} 個)") + self.armed_at_input.append(any(x.old is not None for x in FakeKeys.instances)) + v = self.values[self.k] + self.k += 1 + return v + + +def chunks(lines): + return [x for x in lines if x.get("type") is None and "chunk" in x] + + +def pauses(lines, ev=None): + return [x for x in lines if x.get("type") == "pause" and (ev is None or x["event"] == ev)] + + +def stages(run): + return [l.split(",")[2] for l in (run / "recording" / "states.csv").read_text().splitlines()[1:] + if l.split(",")[1] == "stage"] + + +class Base(unittest.TestCase): + def setUp(self): + self.td = Path(tempfile.mkdtemp()) + time.sleep(1.1) # run のフォルダ名は秒単位なので重ならないように + FakeKeys.instances = [] + FakeKeys.press = {} + self.p = mock.patch.object(rpc, "PauseKeys", FakeKeys) + self.p.start() + + def tearDown(self): + self.p.stop() + + def run_nsc(self, argv, inputs, press=None, **kw): + FakeKeys.press = press or {} + ci = CheckedInputs(inputs) + res = run_client(rpc, argv, ci, self.td, **kw) + self.assertEqual(ci.k, len(inputs), "用意した入力を使い切っていない") + self.assertFalse(any(ci.armed_at_input), "入力欄のときに端末が cbreak のまま") + keys = FakeKeys.instances[-1] + self.assertIsNone(keys.old, "終わったあとに端末の設定が戻っていない") + if keys.calls: + self.assertEqual(keys.calls[-1], "disarm") + return res + (keys,) + + +class TestNoKeys(Base): + def test_runs_to_end_without_prompt(self): + code, world, run, lines, out, keys = self.run_nsc(NSC, [""]) # 始めの確かめ (Enter) の 1 回だけ + self.assertEqual(code, 0) + self.assertIs(lines[0]["step_confirm"], False) + ch = chunks(lines) + self.assertEqual([x["chunk"] for x in ch], list(range(6))) + self.assertTrue(all(x.get("executed") for x in ch)) + self.assertEqual(pauses(lines), []) + self.assertNotIn("止める予約", out) + self.assertIn("[PAUSE ] --no-step-confirm", out) + self.assertEqual(stages(run)[:3], ["model", "wait", "model"]) + self.assertEqual(stages(run).count("wait"), 1) # 待ったのは始めの確かめだけ + self.assertTrue(any(s[0].startswith("pi05_chunk005") for s in world.sends)) + self.assertIn("arm", keys.calls) + + def test_with_skip_first_confirm_no_input_at_all(self): + code, world, run, lines, out, keys = self.run_nsc(NSC + ["--skip-first-confirm"], []) + self.assertEqual(code, 0) + self.assertTrue(all(x.get("executed") for x in chunks(lines))) + self.assertIs(chunks(lines)[0].get("confirm_skipped"), True) + + def test_dry_keys(self): + code, world, run, lines, out, keys = self.run_nsc( + ["--dry-keys", "--steps-per-chunk", "10", "--via-stride", "3", "--max-chunks", "4", + "--no-step-confirm"], [""]) + self.assertEqual(code, 0) + self.assertEqual(world.sends, []) + self.assertEqual([bool(x.get("simulated")) for x in chunks(lines)], [True] * 4) + + def test_auto_rejected(self): + with self.assertRaises(SystemExit): + run_client(rpc, EXEC + ["--auto", "--no-step-confirm"], [], self.td) + + +class TestSpace(Base): + def test_first_prompt_s_keeps_asking(self): + # codex R1: 始めの入力欄で s → 次の推論でも入力欄 → c (補正) → 次も入力欄 → Enter で実行 → あとは止めずに進む + code, world, run, lines, out, keys = self.run_nsc(NSC, ["s", "c", ""], keys="1+\n") + self.assertEqual(code, 0) + ch = chunks(lines) + self.assertEqual([bool(x.get("executed")) for x in ch], [False, False, True, True, True, True]) + self.assertEqual(pauses(lines), []) # スペースは押していない + self.assertTrue(all(len(x["prompt_t"]) == 2 for x in ch[:3])) + self.assertTrue(all("prompt_t" not in x for x in ch[3:])) + + def test_space_while_sending_then_p_b_enter(self): + # 推論 1 の via05 の送信のあと (poll 9) にスペース → 推論 1 は最後まで送る → 推論 2 のあとで入力欄 → p + # → 推論 3 で b (推論 1 を巻き戻す) → 推論 4 で Enter (実行・止めずに進む形に戻る) → 推論 5 は入力欄なし + calls = [] + + def fake_pad(arm, grip, corr_id, chunk, log_f, recorder, lo, hi): + calls.append(chunk) + return None, grip + with mock.patch.object(rpc, "run_pad_correction", fake_pad): + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "p", "b", ""], press={9: " "}) + self.assertEqual(code, 0) + self.assertEqual(calls, [2]) + ch = chunks(lines) + self.assertEqual([x["chunk"] for x in ch], [0, 1, 2, 3, 4, 5]) + self.assertEqual([bool(x.get("executed")) for x in ch], [True, True, False, False, True, True]) + labels = [s[0] for s in world.sends] + c1 = [lb for lb in labels if lb.startswith("pi05_chunk001")] + self.assertEqual(c1, ["pi05_chunk001_via02", "pi05_chunk001_via05", "pi05_chunk001_via08", + "pi05_chunk001_via09"]) # 押したあとも推論 1 は最後まで + self.assertFalse(any(lb.startswith("pi05_chunk002") or lb.startswith("pi05_chunk003") for lb in labels)) + req = pauses(lines, "requested") + self.assertEqual(len(req), 1) + self.assertEqual((req[0]["key"], req[0]["reason"], req[0]["chunk"], req[0]["where"], req[0]["after_chunk"]), + ("space", "key", 1, "send", 1)) + self.assertEqual([x["chunk"] for x in pauses(lines, "prompt")], [2, 3, 4]) + self.assertEqual([x["chunk"] for x in pauses(lines, "resume")], [4]) + self.assertEqual(ch[2]["paused"]["key"], "space") + self.assertTrue(ch[2].get("correction_requested")) + self.assertTrue(ch[3].get("rewind_requested")) + rw = [x for x in lines if x.get("type") == "rewind"] + self.assertEqual([x["chunk"] for x in rw], [1]) + self.assertTrue(rw[0]["finished"]) + self.assertNotIn("paused", ch[5]) + self.assertIn("[PAUSE ] 止める予約 (スペース). 推論 1 を送り終えてから", out) + self.assertIn("[PAUSE ] 止めました (推論 2 はまだ送っていません)", out) + # 入力欄の間は wait. 推論 5 (入力欄なし) のまわりには wait がない + st = stages(run) + self.assertEqual(st.count("wait"), 4) + self.assertEqual(st[-1], "model") + + def test_space_then_q(self): + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "q", ""], press={12: " "}) + self.assertEqual(code, 0) + ch = chunks(lines) + self.assertEqual([bool(x.get("executed")) for x in ch], [True, True, False]) + self.assertEqual(pauses(lines, "requested")[0]["where"], "send") # 送り終わりの読み取り + self.assertFalse(any(s[0].startswith("pi05_chunk002") for s in world.sends)) + + def test_enter_also_pauses_and_s(self): + # Enter でも止まる. s は送らずに次へ (止めたまま) → Enter で実行 + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "s", ""], press={8: "\n"}) + self.assertEqual(code, 0) + ch = chunks(lines) + self.assertEqual([bool(x.get("executed")) for x in ch], [True, True, False, True, True, True]) + self.assertEqual(pauses(lines, "requested")[0]["key"], "enter") + self.assertEqual([x["chunk"] for x in pauses(lines, "prompt")], [2, 3]) + + def test_space_during_inference_does_not_send(self): + # 推論 1 の直後の読み取り (poll 7) で押されていたら, 推論 1 は送らずに入力欄 → q + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "q", ""], press={7: " "}) + self.assertEqual(code, 0) + ch = chunks(lines) + self.assertEqual([bool(x.get("executed")) for x in ch], [True, False]) + self.assertEqual(pauses(lines, "requested")[0]["where"], "infer") + self.assertFalse(any(s[0].startswith("pi05_chunk001") for s in world.sends)) + self.assertIn("推論 1 は送らずに入力欄を出します", out) + + def test_other_key_does_not_pause(self): + code, world, run, lines, out, keys = self.run_nsc(NSC, [""], press={8: "x"}) + self.assertEqual(code, 0) + self.assertEqual(pauses(lines), []) + self.assertIn("止めるのはスペース", out) + + +class TestFailures(Base): + def test_not_arrived_prompts_instead_of_exit(self): + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "q", ""], stuck_prefix="pi05_chunk002_via05") + self.assertEqual(code, 0) # --auto のように 1 にはしない + req = pauses(lines, "requested") + self.assertEqual((req[0]["key"], req[0]["reason"], req[0]["chunk"]), (None, "not_arrived", 2)) + ch = chunks(lines) + self.assertIs(ch[2]["arrived"], True) # 最後の点は着いた (途中の点の未到達でも続けるのは今までどおり) + self.assertEqual([bool(x.get("executed")) for x in ch], [True, True, True, False]) + self.assertEqual([x["chunk"] for x in pauses(lines, "prompt")], [3]) + + def test_read_failed_prompts(self): + # 推論 1 の via05 のあと 10 回読めない (送ったあとの読み取り 5 回 + via08 を送る直前の 5 回) → via08 から送らない + orig = test_rewind_correct.FakeWorld.send_wait + + def send_wait(world, target, label, tol, *a, **k): + r = orig(world, target, label, tol, *a, **k) + if label == "pi05_chunk001_via05": + world.fail_main_reads = 10 + return r + with mock.patch.object(test_rewind_correct.FakeWorld, "send_wait", send_wait): + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "q", ""]) + self.assertFalse(any(s[0] == "pi05_chunk001_via08" for s in world.sends)) + self.assertEqual(code, 0) + req = pauses(lines, "requested") + self.assertEqual((req[0]["reason"], req[0]["chunk"]), ("read_failed", 1)) + self.assertEqual([x["chunk"] for x in pauses(lines, "prompt")], [2]) + + def test_skipped_final_via_not_arrived_prompts(self): + # codex R3: 最後の中継点が「0.5° 以下で送らない」になり, そのあとの到着の読み取りができない → 入力欄 + class Flat(FakePolicy): + def infer(self, obs): + out = super().infer(obs) + out["actions"][6:] = out["actions"][5] # step 5 のあとは動かない (via08・via09 は送らない) + return out + orig_send, orig_read = test_rewind_correct.FakeWorld.send_wait, rpc.read_angles + st = {"armed": False, "n": 0} + + def send_wait(world, target, label, tol, *a, **k): + r = orig_send(world, target, label, tol, *a, **k) + if label == "pi05_chunk001_via05": + st["armed"] = True + return r + + def read_angles(): + if st["armed"]: + st["n"] += 1 + if st["n"] == 4: # 送ったあと・via08 の前・via09 の前 のあと = 最後の到着の読み取り + st["armed"] = False + return None + return orig_read() + with mock.patch.object(test_rewind_correct.FakeWorld, "send_wait", send_wait), \ + mock.patch.object(rpc, "read_angles", read_angles): + code, world, run, lines, out, keys = self.run_nsc(NSC, ["", "q", ""], policy_cls=Flat) + self.assertEqual(code, 0) + ch = chunks(lines) + self.assertIs(ch[1]["arrived"], False) + self.assertIs(ch[1]["executed"], True) # 途中で止まった (broke) のではない + self.assertFalse(any(x[0] in ("pi05_chunk001_via08", "pi05_chunk001_via09") for x in world.sends)) + req = pauses(lines, "requested") + self.assertEqual((req[0]["reason"], req[0]["chunk"]), ("read_failed", 1)) + self.assertEqual([x["chunk"] for x in pauses(lines, "prompt")], [2]) + + def test_ctrl_c_restores_terminal(self): + class Boom(FakePolicy): + n = 0 + + def infer(self, obs): + Boom.n += 1 + if Boom.n == 3: + raise KeyboardInterrupt + return super().infer(obs) + code, world, run, lines, out, keys = self.run_nsc(NSC, [""], policy_cls=Boom) + self.assertEqual(code, 0) + self.assertIn("Ctrl-C", out) + self.assertEqual(keys.calls[-1], "disarm") + + +class TestRealTerminal(unittest.TestCase): + """本物の PauseKeys を pty で: cbreak になり, スペースを待たずに読み, 元の設定に戻る.""" + @staticmethod + def norm(a): + a = list(a) + a[3] &= ~getattr(termios, "PENDIN", 0) + return a + + def test_pty(self): + master, slave = os.openpty() + f = os.fdopen(slave, "r") + try: + before = termios.tcgetattr(slave) + with mock.patch.object(sys, "stdin", f): + k = rpc.PauseKeys(True) + k.arm() + now = termios.tcgetattr(slave) + self.assertFalse(now[3] & termios.ICANON) + self.assertFalse(now[3] & termios.ECHO) + self.assertTrue(now[3] & termios.ISIG) # Ctrl-C は効く + self.assertIsNone(k.poll()) # 何もなければ待たずに None + os.write(master, b"x") + time.sleep(0.05) + self.assertEqual(k.poll(), "other") + os.write(master, b"ab ") + time.sleep(0.05) + self.assertEqual(k.poll(), "space") + os.write(master, b"\r") + time.sleep(0.05) + self.assertEqual(k.poll(), "enter") + k.disarm() + # PENDIN (読み残しの打ち直し) は端末が自分で立てる印なので比べない + self.assertEqual(self.norm(termios.tcgetattr(slave)), self.norm(before)) + k.disarm() # 2 回呼んでもよい + off = rpc.PauseKeys(False) + off.arm() + self.assertEqual(self.norm(termios.tcgetattr(slave)), self.norm(before)) # 付けなければ触らない + self.assertIsNone(off.poll()) + finally: + f.close() + os.close(master) + + +class TestDisarmRetry(unittest.TestCase): + def test_restore_failure_can_retry(self): + # codex R4: 戻すのに失敗しても保存した設定は消さず, もう一度 disarm で戻せる + k = rpc.PauseKeys(True) + k.fd, k.old = 99, ["saved"] + calls = [] + + def bad(fd, when, attrs): + calls.append(attrs) + if len(calls) == 1: + raise termios.error("busy") + with mock.patch.object(termios, "tcsetattr", bad): + with self.assertRaises(termios.error): + k.disarm() + self.assertEqual(k.old, ["saved"]) + k.disarm() + self.assertIsNone(k.old) + self.assertEqual(calls, [["saved"], ["saved"]]) + + +class TestUnchangedWithoutFlag(unittest.TestCase): + """付けなければ git HEAD の版と同じ送り方・同じ log (頭の step_confirm だけ増える).""" + def test_same_as_head(self): + src = subprocess.run(["git", "show", "HEAD:pi05/run_policy_client.py"], cwd=_HERE, + capture_output=True, text=True) + if src.returncode != 0: + self.skipTest("git HEAD の版が読めない") + td = Path(tempfile.mkdtemp()) + p = td / "run_policy_client_head.py" + p.write_text(src.stdout) + spec = importlib.util.spec_from_file_location("run_policy_client_head", p) + old = importlib.util.module_from_spec(spec) + spec.loader.exec_module(old) + for argv, inputs, kw in ( + (EXEC + ["--skip-first-confirm"], ["", "c", "b", "s", "", "h", "y"], {"keys": "1++\n"}), + (EXEC, ["", "", "", "q", ""], {"stuck_prefix": "pi05_chunk001_via05"}), + (EXEC, ["", "", "q", ""], {"fail_after_label": "pi05_chunk001_via05"})): + time.sleep(1.1) + c_old, w_old, _, l_old, o_old = run_client(old, argv, inputs, td, **kw) + time.sleep(1.1) + c_new, w_new, _, l_new, o_new = run_client(rpc, argv, inputs, td, **kw) + self.assertEqual(c_old, c_new, argv) + self.assertEqual(w_old.sends, w_new.sends, argv) + self.assertEqual(w_old.grips, w_new.grips, argv) + drop = ("t", "latency_s", "reference", "step_confirm", "t_start", "t_end", "t_exec_start", "t_exec_end", + "ended_at", "segment_t") + self.assertEqual(strip(l_old, drop=drop), strip(l_new, drop=drop), argv) + self.assertNotIn("[PAUSE ]", o_new) + self.assertTrue(all("step_confirm" not in x for x in l_new[1:])) + + +if __name__ == "__main__": + unittest.main() diff --git a/pi05/test_rewind_correct.py b/pi05/test_rewind_correct.py index f20e313..1847f12 100644 --- a/pi05/test_rewind_correct.py +++ b/pi05/test_rewind_correct.py @@ -606,7 +606,8 @@ class TestClientFlow(unittest.TestCase): "tilt_raw_dir", "tilt_fix", "measured_on", "rotate_deg", # 依頼 144 "grip_value", # 依頼 152 "flow", "flow_tol", "speed", # 依頼 176 (type=model の頭) - "skip_first_confirm") # 依頼 177 (type=model の頭) + "skip_first_confirm", # 依頼 177 (type=model の頭) + "step_confirm") # 依頼 178 (type=model の頭) # HEAD が依頼 25 より前なら new_keys は新しい方にだけある. 依頼 25 以降なら両方にある (両方から外す) drop = ("t", "latency_s", "reference") + new_keys so, sn = strip(l_old, drop=drop), strip(l_new, drop=drop) diff --git a/test_lab_start.sh b/test_lab_start.sh index 626f1eb..df1279f 100755 --- a/test_lab_start.sh +++ b/test_lab_start.sh @@ -600,7 +600,7 @@ check "--serve-only でも空打ちまで行う (依頼 176)" 'has out.txt "=== check "Pod は消さない (DELETE を呼ばない) で, かかった時間と片付け方を出す" '[ $(n_call DELETE) = 0 ] && has out.txt "=== かかった時間" && has out.txt "Pod は残しています (id $ID_NEW). 片付けは ./lab_stop.sh --log LogT"' check "記録 lab_start_*.txt に残る・始めの行に --serve-only と出る" 'grep -qF "サーバーとトンネルだけ (--serve-only" "$T/data/lab/LogT"/lab_start_*.txt' rm -rf "$T" -check "--help に --serve-only・--grip-value・--flow / --speed・5b の空打ちの説明が入る (2〜58 行)" 'sed -n "2,58p" "$HERE/lab_start.sh" | grep -qF -- "--serve-only Pod を作って" && sed -n "2,58p" "$HERE/lab_start.sh" | grep -qF -- "--no-tilt-fix 傾きを" && sed -n "2,58p" "$HERE/lab_start.sh" | grep -qF -- "--grip-value N 爪の閉じる値" && sed -n "2,58p" "$HERE/lab_start.sh" | grep -qF -- "--flow / --speed N run_policy_client.py に" && sed -n "2,58p" "$HERE/lab_start.sh" | grep -qF -- "# 5b. 空打ち" && grep -qF "sed -n '"'"'2,58p'"'"'" "$HERE/lab_start.sh"' +check "--help に --serve-only・--grip-value・--flow / --speed・5b の空打ちの説明が入る (2〜61 行)" 'sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "--serve-only Pod を作って" && sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "--no-tilt-fix 傾きを" && sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "--grip-value N 爪の閉じる値" && sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "--flow / --speed N run_policy_client.py に" && sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "# 5b. 空打ち" && grep -qF "sed -n '"'"'2,61p'"'"'" "$HERE/lab_start.sh"' echo "== 空打ち (依頼 176): トンネルのあと・アームの前に 1 回. 失敗しても止めずに続ける" setup; state $ID_OLD; pod 200 "$RUNNING"; probe 1 1 1 1 1; run @@ -652,6 +652,16 @@ check "1 回目には付けず, 2 回目・3 回目の終わりに --skip-first- rm -rf "$T" check "実機の 8 も 2 回目からは --skip-first-confirm を渡し, 7 の HOME・爪を開くは毎回流す" '[ "$(grep -c '"'"'${CONFIRM_ARGS\[@\]+"${CONFIRM_ARGS\[@\]}"}'"'"' "$HERE/lab_start.sh")" = 2 ] && grep -qF "[ \$round -ge 2 ] && CONFIRM_ARGS=(--skip-first-confirm)" "$HERE/lab_start.sh" && [ "$(awk "/^while :; do/,/^done/" "$HERE/lab_start.sh" | grep -c "08_home_from_anywhere.py\|daemon_set_gripper(0)")" = 2 ]' +echo "== チャンクごとの確かめ (依頼 178): 既定は --no-step-confirm を渡す. --step-confirm で今までどおり" +setup; state $ID_OLD; pod 200 "$RUNNING"; probe 1 1 1 1 1; run +check "既定ではクライアントに --no-step-confirm を渡し, [CONFIRM] で出す" '[ $RC = 0 ] && [ "$(grep -c -- "pi05/run_policy_client.py .* --no-step-confirm" "$T/py.log")" = 1 ] && has out.txt "[CONFIRM] チャンクごとには確かめない (--no-step-confirm"' +rm -rf "$T" +setup; state $ID_OLD; pod 200 "$RUNNING"; probe 1 1 1 1 1; run --step-confirm --flow +check "--step-confirm なら --no-step-confirm を渡さず (ほかの引数は同じ), [CONFIRM] で出す" '[ $RC = 0 ] && has py.log "pi05/run_policy_client.py" && ! has py.log "--no-step-confirm" && grep -q -- "pi05/run_policy_client.py .* --flow$" "$T/py.log" && has out.txt "[CONFIRM] チャンクごとに確かめる (--step-confirm"' +rm -rf "$T" +check "実機の 8 にも既定で --no-step-confirm を渡す (8 の 2 か所)" '[ "$(grep -c '"'"'${STEP_ARGS\[@\]+"${STEP_ARGS\[@\]}"}'"'"' "$HERE/lab_start.sh")" = 2 ] && grep -qF "STEP_ARGS=(--no-step-confirm)" "$HERE/lab_start.sh" && grep -qF -- "--step-confirm) STEP_ARGS=(); shift ;;" "$HERE/lab_start.sh"' +check "--help に --step-confirm の説明が入る" 'sed -n "2,61p" "$HERE/lab_start.sh" | grep -qF -- "--step-confirm 実機推論で"' + echo echo "OK $OK / NG $NG" [ $NG = 0 ]