#!/usr/bin/env python3 """night_runner.py — 夜のあいだ、並べた回を全部回す(依頼 v12 の 4 章) python3 tools/night_runner.py --queue queue.json --parallel 2 --night 20261026 やること ・始める前に、KIOXIA がつながっているか・実行ファイルがあるかを確かめる(無ければ始めない) ・caffeinate で Mac をスリープさせない(終わったら必ず解く) ・N 本まで同時に実行ファイルを起動する。1本終わったら次を起動する ・台帳に completed の行がある回は飛ばす(途中で止めても続きから回せる) ・1回の上限(既定 90 分)を超えたら止め、status=stopped の行を台帳に足す ・落ちた回は status=failed の行を足す ・止まった・落ちた回は、その夜のうちに1度だけやり直す(出力フォルダに _try2) ・途中で KIOXIA が外れたら、新しい回を始めずに待ち、走っている回が終わったら止まる ・時刻では止めない(並べた回が全部終わるまで回す) ・記録は service_sim/unity/night/{night}/runner_log.txt ・全部終わったら judge_night.py を呼ぶ 実行ファイルの呼び方(依頼 v12 の 2 章) {実行ファイル} -batchmode -nographics -logFile {その回のログ} -ssCondition fam_p1_s4_r1_a42 -ssSeed 3 -ssLabel night -ssNight 20261026 -ssParallel N 終了コード:0 = 最後まで回った/2 = 途中で止まった/1 = 失敗 出力フォルダの名前(依頼 v12 の 2 章) {night}_{ラベル}_{cs}_{店}_r{台数}_s{種} 例 20261026_night_cs3_fam_p1_r1_s3 ★ この形は **実行ファイル(Assets 側・依頼 v12 の 1〜3 章)と合わせる決まり**。 いまの Unity 側(ServiceSimStageB.FolderName)は "cs{N}" なので、それにそろえてある。 依頼文の例は "ss12" だったので、変えるなら --cs-token で合わせられる。 標準ライブラリだけで動く。台帳は tools/ledger.py を通してしか触らない。 """ import argparse import datetime as dt import json import os import re import signal import subprocess import sys import time import ledger as L HERE = os.path.dirname(os.path.abspath(__file__)) PROJECT = os.path.dirname(HERE) DEFAULT_VOLUME = '/Volumes/KIOXIA' DEFAULT_UNITY_DIR = '/Volumes/KIOXIA/r2r2r-data/service_sim/unity' DEFAULT_BUILD = os.path.join(PROJECT, 'Builds', 'headless', 'ServiceSim.app', 'Contents', 'MacOS', 'ServiceSim') DEFAULT_LIMIT_MIN = 90.0 COND_RE = re.compile(r'^(?P[a-z0-9_]+?)_s(?P\d+)_r(?P\d+)_a(?P\d+)$') # ---------------- 小さな道具 ---------------- class Log(object): def __init__(self, path): self.path = path d = os.path.dirname(path) if d and not os.path.isdir(d): os.makedirs(d) self._f = open(path, 'a', encoding='utf-8') def __call__(self, msg): line = '%s %s' % (dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), msg) print(line, flush=True) self._f.write(line + '\n') self._f.flush() def close(self): try: self._f.close() except Exception: pass def parse_condition(cid): m = COND_RE.match(cid) if not m: return None return {'store': m.group('store'), 'staff': int(m.group('staff')), 'robots': int(m.group('robots')), 'arrivals_per_h': int(m.group('arr'))} def read_unity_cs(build_path, log): """ 台帳に書く版の字(ServiceSim/cs:N)。 まず実行ファイルのそばの build_info.json(依頼 v12 の 1 章)、 無ければ cm status をプロジェクトで打つ。どちらも駄目なら空。 """ info = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(build_path)))), 'build_info.json') for cand in (os.path.join(os.path.dirname(os.path.abspath(build_path)), 'build_info.json'), info, os.path.join(PROJECT, 'Builds', 'headless', 'build_info.json')): if os.path.exists(cand): try: with open(cand, encoding='utf-8') as f: v = (json.load(f).get('unity_cs') or '').strip() if v: return v, os.path.relpath(cand, PROJECT) except Exception as e: log('build_info.json を読めません(%s): %s' % (cand, e)) for cm in ('/usr/local/bin/cm', '/opt/homebrew/bin/cm', 'cm'): try: out = subprocess.run([cm, 'status', '--machinereadable'], cwd=PROJECT, capture_output=True, text=True, timeout=30).stdout except Exception: continue for line in out.split('\n'): if line.startswith('STATUS '): f = line.strip().split(' ') if len(f) > 2 and 'ServiceSim' in f[2]: return 'ServiceSim/cs:%s' % f[1], 'cm status' return '', 'cm status(別のワークスペース: %s)' % (f[2] if len(f) > 2 else '?') return '', '(分からない)' def cs_token(unity_cs, fmt): """ServiceSim/cs:3 → cs3(出力フォルダの名前に入る字)""" n = '' m = re.search(r'cs:(\d+)', unity_cs or '') if m: n = m.group(1) if not n: n = 'unknown' return fmt.replace('{N}', n) def folder_name(night, label, token, cond, seed, pass_by='off', tryno=1): """ 出力フォルダの名前。 すれ違い「入」の回だけ _passon を付ける(2026-10-26。依頼 v13)。 同じ条件・同じ種で 入 と 切 を両方回すので、付けないと名前がぶつかる。 「切」の回の名前は付ける前と同じまま(基準データと並べるため) """ name = '%s_%s_%s_%s_r%d_s%d' % (night, label, token, cond['store'], cond['robots'], seed) if str(pass_by) == 'on': name += '_passon' return name if tryno <= 1 else '%s_try%d' % (name, tryno) def volume_ok(volume, unity_dir): return os.path.ismount(volume) and os.path.isdir(unity_dir) # ---------------- 実行係 ---------------- class Runner(object): def __init__(self, a, log): self.a = a self.log = log self.unity_dir = a.unity_dir self.night_dir = os.path.join(a.unity_dir, 'night', a.night) self.limit_s = a.limit_min * 60.0 self.unity_cs, self.cs_from = read_unity_cs(a.build, log) self.token = cs_token(self.unity_cs, a.cs_token) self.build_id = '' self.running = [] # [{proc, item, folder, started, tryno, logfile}] self.done = [] # [(folder, status, wall_s, tryno, item)] self.retry = [] # やり直す回 self.stopping = False # KIOXIA が外れたら True(新しい回を始めない) # ---- 前の確かめ ---- def precheck(self, queue): ok = True if not volume_ok(self.a.volume, self.unity_dir): self.log('NG: KIOXIA がつながっていません(%s / %s)' % (self.a.volume, self.unity_dir)) ok = False if not (os.path.isfile(self.a.build) and os.access(self.a.build, os.X_OK)): # から回し(--dry-run)は、実行ファイルを作る前に打つコマンドを見るためのものなので通す msg = '実行ファイルがありません(または実行できません): %s' % self.a.build if self.a.dry_run: self.log('(から回しなので通します)' + msg) else: self.log('NG: ' + msg) ok = False if ok: try: cols = L.read_header(self.unity_dir) self.log('台帳: %s(%d 列)' % (L.ledger_path(self.unity_dir), len(cols))) except L.LedgerError as e: self.log('NG: %s' % e) ok = False if not self.unity_cs: msg = ('版が分かりません(%s)。build_info.json か cm status を直してください' % self.cs_from) if self.a.dry_run: self.log('(から回しなので通します)' + msg) else: self.log('NG: ' + msg) ok = False else: self.log('版: %s(%s)→ 出力フォルダの字 %s' % (self.unity_cs, self.cs_from, self.token)) bi = os.path.join(os.path.dirname(os.path.abspath(self.a.build)), 'build_info.json') for cand in (bi, os.path.join(PROJECT, 'Builds', 'headless', 'build_info.json')): if os.path.exists(cand): try: with open(cand, encoding='utf-8') as f: self.build_id = (json.load(f).get('build_id') or '').strip() except Exception: pass break pm = queue.get('params_model', '') if pm: self.log('並べた回の試算の版: %s / 店の形の版: %s' % (pm, queue.get('layout_generation', ''))) return ok # ---- 1回ぶん ---- def start_one(self, item, tryno): cond = parse_condition(item['condition']) if cond is None: self.log('飛ばします(条件の名前が読めません): %s' % item['condition']) self.done.append((item['condition'], 'skipped', 0.0, tryno, item)) return pass_by = str(item.get('pass_by', 'off')) folder = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'], pass_by, tryno) logfile = os.path.join(self.night_dir, 'logs', folder + '.log') if not self.a.dry_run: d = os.path.dirname(logfile) if not os.path.isdir(d): os.makedirs(d) cmd = [self.a.build, '-batchmode', '-nographics', '-logFile', logfile, '-ssCondition', item['condition'], '-ssSeed', str(item['seed']), '-ssLabel', self.a.label, '-ssNight', self.a.night, '-ssParallel', str(self.a.parallel), # 2026-10-26(依頼 v13):すれ違いの入切を回ごとに渡す '-ssPassBy', pass_by] if tryno > 1: cmd += ['-ssTry', str(tryno)] if self.a.dry_run: self.log('(から回し)%d 回め %s 種%s すれ違い%s → %s' % (len(self.done) + 1, item['condition'], item['seed'], '入' if pass_by == 'on' else '切', folder)) self.log(' %s' % ' '.join(cmd)) self.done.append((folder, 'dry-run', 0.0, tryno, item)) return proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.running.append({'proc': proc, 'item': item, 'folder': folder, 'started': time.time(), 'tryno': tryno, 'logfile': logfile}) self.log('始めました(%d本目・同時 %d): %s 種%s すれ違い%s → %s(pid %d)' % (len(self.done) + len(self.running), len(self.running), item['condition'], item['seed'], '入' if pass_by == 'on' else '切', folder, proc.pid)) def reap(self): """終わった回を片付ける。1つでも片付けたら True""" changed = False for r in list(self.running): code = r['proc'].poll() over = (time.time() - r['started']) > self.limit_s if code is None and not over: continue if code is None and over: self.log('上限 %.1f 分を超えたので止めます: %s(pid %d)' % (self.a.limit_min, r['folder'], r['proc'].pid)) self._kill(r['proc']) code = 2 wall = time.time() - r['started'] status = {0: 'completed', 2: 'stopped'}.get(code, 'failed') self.log('おわり: %s / %s / 終了コード %s / 実時間 %.1f 分' % (r['folder'], status, code, wall / 60.0)) if status != 'completed': self._write_row(r, status, wall) if r['tryno'] == 1 and not self.a.no_retry: self.retry.append(r['item']) self.log('その夜のうちに1度やり直します: %s 種%s' % (r['item']['condition'], r['item']['seed'])) self.running.remove(r) self.done.append((r['folder'], status, wall, r['tryno'], r['item'])) changed = True return changed @staticmethod def _kill(proc): try: proc.send_signal(signal.SIGTERM) except Exception: return for _ in range(100): # 10 秒待つ if proc.poll() is not None: return time.sleep(0.1) try: proc.kill() except Exception: pass def _write_row(self, r, status, wall): """止まった・落ちた回の行を台帳に足す(実行ファイルが書けていないときだけ)""" if not volume_ok(self.a.volume, self.unity_dir): self.log('台帳に書けません(KIOXIA が外れています): %s' % r['folder']) return cond = parse_condition(r['item']['condition']) or {} row = { 'timestamp': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), 'run_folder': r['folder'], 'status': status, 'unity_cs': self.unity_cs, 'store': cond.get('store', ''), 'staff': cond.get('staff', ''), 'robots': cond.get('robots', ''), 'arrivals_per_h': cond.get('arrivals_per_h', ''), 'mode': r['item'].get('mode', ''), 'trays': r['item'].get('trays', ''), 'fragile_by': r['item'].get('fragile_by', ''), 'seed': r['item']['seed'], 'wall_seconds': '%.4f' % wall, } try: L.append_row(self.unity_dir, row, log=self.log) except L.LedgerError as e: self.log('台帳に書けませんでした: %s' % e) # ---- 回す ---- def run(self, queue): skip = L.completed_folders(self.unity_dir) items, skipped = [], 0 for item in queue['runs']: cond = parse_condition(item['condition']) if cond is None: continue f = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'], str(item.get('pass_by', 'off'))) if f in skip: skipped += 1 continue items.append(item) self.log('並べた回 %d / もう completed の回を飛ばす %d / 回す %d' % (len(queue['runs']), skipped, len(items))) queue_items = list(items) while queue_items or self.running or self.retry: # KIOXIA が外れたら、新しい回を始めない if not self.stopping and not volume_ok(self.a.volume, self.unity_dir): self.stopping = True self.log('KIOXIA が外れました。新しい回は始めません。' '走っている %d 本が終わったら止まります' % len(self.running)) while (not self.stopping and len(self.running) < self.a.parallel and (queue_items or self.retry)): if queue_items: self.start_one(queue_items.pop(0), 1) else: self.start_one(self.retry.pop(0), 2) if self.a.stagger > 0 and not self.a.dry_run: time.sleep(self.a.stagger) if not self.running: if self.stopping: break if not queue_items and not self.retry: break continue if not self.reap(): time.sleep(self.a.poll) return not self.stopping # ---- まとめ ---- def summary(self): n = {} for _, status, _, _, _ in self.done: n[status] = n.get(status, 0) + 1 self.log('--- まとめ ---') for k in sorted(n): self.log(' %s: %d 回' % (k, n[k])) walls = [w for _, s, w, _, _ in self.done if s == 'completed' and w > 0] if walls: self.log(' completed の実時間: 平均 %.1f 分 / 最長 %.1f 分' % (sum(walls) / len(walls) / 60.0, max(walls) / 60.0)) return n def call_judge(self): judge = os.path.join(HERE, 'judge_night.py') if not os.path.exists(judge): self.log('judge_night.py がないので判定は呼びません: %s' % judge) return None pattern = '%s_%s_*' % (self.a.night, self.a.label) out = os.path.join('night', self.a.night) cmd = [sys.executable, judge, '--runs', pattern, '--out', out] if self.a.baseline: cmd += ['--baseline', self.a.baseline] self.log('判定を呼びます(%s で): %s' % (self.unity_dir, ' '.join(cmd))) if self.a.dry_run: self.log('(から回しなので呼びません)') return None r = subprocess.run(cmd, cwd=self.unity_dir, capture_output=True, text=True) if r.returncode != 0: self.log('判定が失敗しました(終了コード %d): %s' % (r.returncode, r.stderr.strip())) else: self.log('判定を書きました: %s' % os.path.join(self.unity_dir, out, 'judge.md')) return r.returncode def main(): ap = argparse.ArgumentParser() ap.add_argument('--queue', default=os.path.join(PROJECT, 'queue.json')) ap.add_argument('--parallel', type=int, default=2, help='同時に走らせる本数') ap.add_argument('--night', required=True, help='その夜の名前(例 20261026)') ap.add_argument('--label', default='night', help='出力フォルダに入る名札') ap.add_argument('--build', default=DEFAULT_BUILD, help='画面なしの実行ファイル') ap.add_argument('--volume', default=DEFAULT_VOLUME) ap.add_argument('--unity-dir', default=DEFAULT_UNITY_DIR) ap.add_argument('--limit-min', type=float, default=DEFAULT_LIMIT_MIN, help='1回の上限(分)。超えたら止めて stopped にする') ap.add_argument('--cs-token', default='cs{N}', help='出力フォルダに入る版の字。cs{N} か ss{N}') ap.add_argument('--baseline', default='', help='判定に渡す基準データの glob') ap.add_argument('--stagger', type=float, default=5.0, help='同時に起こすときに少しずらす秒数(焼き直しがぶつからないように)') ap.add_argument('--poll', type=float, default=5.0, help='終わったかを見に行く間隔(秒)') ap.add_argument('--no-retry', action='store_true', help='止まった回をやり直さない') ap.add_argument('--no-caffeinate', action='store_true') ap.add_argument('--dry-run', action='store_true', help='実行ファイルを起こさず、打つコマンドだけ見る') a = ap.parse_args() if a.parallel < 1: sys.exit('--parallel は 1 以上にしてください') if not re.match(r'^\d{8}$', a.night): sys.exit('--night は 20261026 の形にしてください') if not os.path.exists(a.queue): sys.exit('並べた回がありません: %s(先に make_queue.py)' % a.queue) night_dir = os.path.join(a.unity_dir, 'night', a.night) try: if not os.path.isdir(night_dir): os.makedirs(night_dir) except OSError as e: sys.exit('記録の置き場所を作れません: %s\n %s' % (night_dir, e)) log = Log(os.path.join(night_dir, 'runner_log.txt')) with open(a.queue, encoding='utf-8') as f: queue = json.load(f) log('=== 夜の実行係を始めます(night %s・同時 %d 本・上限 %.1f 分)===' % (a.night, a.parallel, a.limit_min)) log('並べた回: %s(%d 回)' % (a.queue, len(queue.get('runs') or []))) r = Runner(a, log) if not r.precheck(queue): log('確かめで引っかかったので始めません') log.close() return 1 caff = None if not a.no_caffeinate and not a.dry_run: try: caff = subprocess.Popen(['/usr/bin/caffeinate', '-dimsu']) log('スリープを止めました(caffeinate pid %d)' % caff.pid) except Exception as e: log('caffeinate を起こせませんでした(手でスリープを切ってください): %s' % e) code = 0 try: finished = r.run(queue) n = r.summary() if not finished: log('KIOXIA が外れたので、途中で止まりました') code = 2 elif n.get('failed') or n.get('stopped'): code = 2 if finished and not a.dry_run: r.call_judge() except KeyboardInterrupt: log('手で止められました。走っている回を止めます') for x in list(r.running): Runner._kill(x['proc']) code = 2 finally: if caff is not None: try: caff.terminate() log('スリープの設定を戻しました') except Exception: pass log('=== 夜の実行係を終わります(終了コード %d)===' % code) log.close() return code if __name__ == '__main__': sys.exit(main())