monster-orchestrator 3.2.4 → 3.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-obf/.buildinfo +6 -4
- package/dist-obf/index.js +1 -1
- package/dist-obf/manifest.json +1 -0
- package/dist-obf/package.json +13 -2
- package/dist-obf/supervisor-notify-tick.js +1 -1
- package/manifest.json +1 -0
- package/package.json +8 -2
- package/scripts/check-monster-events.sh +200 -0
- package/scripts/monster-deathcheck +805 -0
- package/scripts/monster-stop-hook +178 -0
- package/scripts/supervisor-notify-tick.sh +38 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""monster-deathcheck — 死んだ monster を即座に見つける(読み取り専用)
|
|
3
|
+
|
|
4
|
+
なぜ必要か:
|
|
5
|
+
monster-orchestrator の reconcile() は
|
|
6
|
+
- 死を events.log に書かない(state.json のフィールドを黙って書き換えるだけ)
|
|
7
|
+
- 正常完了も異常死も latest_event = session_end で区別できない
|
|
8
|
+
- 誰かが list_monsters を叩いた時にしか回らない(pull 型)
|
|
9
|
+
ため、「死んだこと」も「いつ死んだか」もレジストリからは分からない。
|
|
10
|
+
|
|
11
|
+
さらに reconcile() は寿命判定を生存判定の内側に置いているため、寿命切れの
|
|
12
|
+
monster が「先に session が消えていた」だけで timeout ではなく
|
|
13
|
+
"session disappeared" とラベルされる。実測では同一事象(寿命ちょうどの死)が
|
|
14
|
+
片方 timeout・片方 session disappeared になっていた(53cb1d2a / e8df2b33)。
|
|
15
|
+
|
|
16
|
+
このスクリプトは既存の記録だけから死を判定する。何も書き換えず、何も kill しない。
|
|
17
|
+
tmux は has-session のみ(attach / send-keys / kill 系は一切使わない)。
|
|
18
|
+
|
|
19
|
+
判定の核(events.log から計算できる、レジストリに無い区別):
|
|
20
|
+
最後の user_prompt 以降に idle / awaiting_input / subagent_stop が
|
|
21
|
+
一度も出ないまま session_end が来ていたら、そのターンは完走していない
|
|
22
|
+
= ターン途中で消えた(異常死)。
|
|
23
|
+
逆に awaiting_input を経てから session_end なら、指示通り止まった(正常完了)。
|
|
24
|
+
|
|
25
|
+
寿命切れの予告(Q9):
|
|
26
|
+
reconcile() は send_to_monster / list_monsters / spawn_monster から呼ばれる
|
|
27
|
+
(src/tools/send.ts:9 ほか)。つまり **寿命切れの monster に話しかけると、
|
|
28
|
+
その send 自体が reconcile を回して tmuxKillSession() を起こす**。
|
|
29
|
+
⇒ 予告が無いと「反応が無いから声をかける → その行為が殺す」経路に入る。
|
|
30
|
+
寿命切れ相手への送信は、無駄なのではなく有害。
|
|
31
|
+
|
|
32
|
+
ただし実測では、寿命で終了した 28 体のうち 23 体は awaiting_input で駐機中
|
|
33
|
+
(=仕掛かりが無く無害)だった。全件に鳴らすとノイズが本物を埋めるため、
|
|
34
|
+
「寿命が近く、かつまだ働いている」ものだけを警告対象にする。
|
|
35
|
+
|
|
36
|
+
usage:
|
|
37
|
+
monster-deathcheck 人が読む形式で全 monster を判定(レジストリ外の GHOST を含む)
|
|
38
|
+
monster-deathcheck --json 機械可読(艦隊コレクタのリモートプローブ用)
|
|
39
|
+
monster-deathcheck --verify 測定器自体を検証(対照実験)
|
|
40
|
+
monster-deathcheck --notify-preview 鳴らすなら何を送るかを表示するだけ
|
|
41
|
+
🚨 送信はしない。LINE への実発火は POLICY 案件で CTO 裁定待ち
|
|
42
|
+
|
|
43
|
+
exit code:
|
|
44
|
+
0 = 人が今すぐ見るべきものは無い
|
|
45
|
+
1 = 人が今すぐ見るべきものを検出した(死亡 DIED-* / レジストリ外 GHOST)
|
|
46
|
+
⚠️ GHOST は死亡ではない。「生きているのに見えない」側であり、放置すると
|
|
47
|
+
成果が失われるという一点で死亡と同じ扱いにしている
|
|
48
|
+
2 = 測定不能(state.json や tmux が読めない等)。⚠️ 0 と混同しないこと:
|
|
49
|
+
「異常なし」と「測れなかった」を同じ値にすると、今日の不検出を再現する
|
|
50
|
+
※ 寿命警告(EXPIRING-*)は exit code を変えない。まだ何も失われていないため、
|
|
51
|
+
「1 が返った=人が見るべきものがある」と混ぜない
|
|
52
|
+
|
|
53
|
+
環境変数:
|
|
54
|
+
MONSTER_ROOT monster レジストリの場所(既定 ~/.claude/monsters)
|
|
55
|
+
TMUX_BIN tmux の絶対パス(既定は PATH → /opt/homebrew/bin/tmux の順に探索)
|
|
56
|
+
⚠️ 非対話 SSH では PATH に /opt/homebrew/bin が乗らないため必要
|
|
57
|
+
CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)
|
|
58
|
+
MONSTER_WARN_MIN 寿命の予告閾値・分(既定 30。ダッシュボード表示)
|
|
59
|
+
MONSTER_CRIT_MIN 寿命の最終警告閾値・分(既定 5。鳴らす想定・現在は未発火)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
import glob
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import shutil
|
|
66
|
+
import subprocess
|
|
67
|
+
import sys
|
|
68
|
+
import time
|
|
69
|
+
from datetime import datetime, timezone
|
|
70
|
+
|
|
71
|
+
# 板 #442 ③: 解決の式を全経路で揃える(server=src/paths.ts は MONSTER_MCP_ROOT を honour)。
|
|
72
|
+
# MONSTER_ROOT は後方互換の別名として残す(従来この道具だけがこの名前を見ていた)。
|
|
73
|
+
ROOT = (os.environ.get("MONSTER_MCP_ROOT")
|
|
74
|
+
or os.environ.get("MONSTER_ROOT")
|
|
75
|
+
or os.path.expanduser("~/.claude/monsters"))
|
|
76
|
+
STATE_PATH = os.path.join(ROOT, "state.json")
|
|
77
|
+
EVENTS_PATH = os.path.join(ROOT, "events.log")
|
|
78
|
+
|
|
79
|
+
# ターンが前に進んだことを示すイベント(これが出ていれば少なくとも1手は完了している)
|
|
80
|
+
PROGRESS = {"idle", "awaiting_input", "subagent_stop"}
|
|
81
|
+
|
|
82
|
+
# watcher が生きているとみなす session.log の更新間隔(watcher は60秒周期)
|
|
83
|
+
WATCHER_FRESH_SEC = 180
|
|
84
|
+
|
|
85
|
+
# 人が今すぐ見るべき分類。死亡(DIED-*)と、レジストリ外で生きている GHOST。
|
|
86
|
+
# そして STALLED-*(レジストリ内で生きていない=応答できない)。
|
|
87
|
+
# GHOST の実害は「成果が見えなくなる」、STALLED の実害は「指揮そのものが誤る」
|
|
88
|
+
#(盤面の稼働数が嘘になり、動けない monster を配車先として数えてしまう)。
|
|
89
|
+
# 寿命警告(EXPIRING-*)はここに入れない——まだ何も失われていないため。
|
|
90
|
+
# 死亡と警告を同じ値にすると「1 が返った=人が見るべきものがある」という読み方が壊れる。
|
|
91
|
+
URGENT = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
|
|
92
|
+
"STALLED-RATE-LIMIT", "STALLED-BACKEND"}
|
|
93
|
+
|
|
94
|
+
# 鳴らす対象(実発火は CTO 裁定後)
|
|
95
|
+
NOTIFY = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
|
|
96
|
+
"STALLED-RATE-LIMIT", "STALLED-BACKEND", "EXPIRING-WORKING"}
|
|
97
|
+
|
|
98
|
+
VERDICT_ORDER = [
|
|
99
|
+
"GHOST", "DIED-UNDETECTED", "DIED-MIDTURN",
|
|
100
|
+
"STALLED-RATE-LIMIT", "STALLED-BACKEND", "STALLED-API-ERROR",
|
|
101
|
+
"EXPIRING-WORKING", "EXPIRING-PARKED", "ALIVE-UNVERIFIED",
|
|
102
|
+
"TIMEOUT-KILL", "TIMEOUT-MISLABELED", "COMPLETED-MISLABELED",
|
|
103
|
+
"ALIVE", "COMPLETED", "UNKNOWN",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# 寿命の閾値(分)
|
|
107
|
+
WARN_MIN = int(os.environ.get("MONSTER_WARN_MIN") or 30)
|
|
108
|
+
CRIT_MIN = int(os.environ.get("MONSTER_CRIT_MIN") or 5)
|
|
109
|
+
|
|
110
|
+
# 駐機中とみなすイベント。これで止まっているなら仕掛かりが無く、寿命切れは無害。
|
|
111
|
+
PARKED_EVENTS = {"awaiting_input"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Unmeasurable(Exception):
|
|
115
|
+
"""測定できなかった。異常なしとは違う。"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_tmux():
|
|
119
|
+
explicit = os.environ.get("TMUX_BIN")
|
|
120
|
+
if explicit:
|
|
121
|
+
return explicit
|
|
122
|
+
# 非対話 SSH では PATH に homebrew が乗らないので実パスも見る
|
|
123
|
+
return shutil.which("tmux") or "/opt/homebrew/bin/tmux"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_state():
|
|
127
|
+
try:
|
|
128
|
+
with open(STATE_PATH) as f:
|
|
129
|
+
state = json.load(f)
|
|
130
|
+
except FileNotFoundError as e:
|
|
131
|
+
raise Unmeasurable(f"state.json が無い: {STATE_PATH}") from e
|
|
132
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
133
|
+
raise Unmeasurable(f"state.json が読めない: {e}") from e
|
|
134
|
+
return state.get("monsters", state)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_events():
|
|
138
|
+
"""monster_id -> [イベント名] を時系列で返す。events.log が無くても致命ではない。"""
|
|
139
|
+
by_id = {}
|
|
140
|
+
try:
|
|
141
|
+
f = open(EVENTS_PATH, errors="replace")
|
|
142
|
+
except OSError:
|
|
143
|
+
return by_id
|
|
144
|
+
with f:
|
|
145
|
+
for line in f:
|
|
146
|
+
line = line.strip()
|
|
147
|
+
if not line:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
e = json.loads(line)
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
continue
|
|
153
|
+
mid = e.get("monster_id")
|
|
154
|
+
if mid:
|
|
155
|
+
by_id.setdefault(mid, []).append(e.get("event", ""))
|
|
156
|
+
return by_id
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def tmux_alive(tmux, session, retries=3, delay=0.25):
|
|
160
|
+
"""has-session を複数回撃つ。1回の false を信じないのが reconcile との違い。
|
|
161
|
+
|
|
162
|
+
reconcile() は単発 false を即 "stopped" 確定する。8/3 の 902f2dec は
|
|
163
|
+
それで生きたまま死亡扱いされた(実際にはその後12分働いて完走した)。
|
|
164
|
+
"""
|
|
165
|
+
if not session:
|
|
166
|
+
return False
|
|
167
|
+
for i in range(retries):
|
|
168
|
+
try:
|
|
169
|
+
r = subprocess.run([tmux, "has-session", "-t", session],
|
|
170
|
+
capture_output=True, timeout=10)
|
|
171
|
+
except (OSError, subprocess.SubprocessError):
|
|
172
|
+
return False
|
|
173
|
+
if r.returncode == 0:
|
|
174
|
+
return True
|
|
175
|
+
if i < retries - 1:
|
|
176
|
+
time.sleep(delay)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def minutes_until(expires_at, now=None):
|
|
181
|
+
"""残り寿命(分)。パースできなければ None。
|
|
182
|
+
|
|
183
|
+
⚠️ None を「余裕あり」に丸めない。読めなかったものを安全側に倒すと、
|
|
184
|
+
測れなかったことが異常なしとして描かれる(本件の失敗そのもの)。
|
|
185
|
+
"""
|
|
186
|
+
if not expires_at:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
exp = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
190
|
+
except (ValueError, AttributeError):
|
|
191
|
+
return None
|
|
192
|
+
if exp.tzinfo is None:
|
|
193
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
194
|
+
now = now or datetime.now(timezone.utc)
|
|
195
|
+
return (exp - now).total_seconds() / 60.0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def classify_expiry(remaining_min, last_event):
|
|
199
|
+
"""寿命が近い running monster を分類する純粋関数(--verify で単体検証する)
|
|
200
|
+
|
|
201
|
+
返り値: (verdict, level)。閾値の外なら (None, None)。
|
|
202
|
+
EXPIRING-PARKED … awaiting_input で駐機中。仕掛かりが無いので寿命切れは無害。
|
|
203
|
+
表示はするが鳴らさない(実測 28 件中 23 件がこれ。
|
|
204
|
+
鳴らせばノイズが本物を埋め、今日と同じ失敗になる)
|
|
205
|
+
EXPIRING-WORKING … まだ働いている。寿命が来れば仕掛かりごと消える。鳴らす対象。
|
|
206
|
+
"""
|
|
207
|
+
if remaining_min is None or remaining_min > WARN_MIN:
|
|
208
|
+
return (None, None)
|
|
209
|
+
if last_event in PARKED_EVENTS:
|
|
210
|
+
return ("EXPIRING-PARKED", None)
|
|
211
|
+
return ("EXPIRING-WORKING", "crit" if remaining_min <= CRIT_MIN else "warn")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# ゴースト monster(レジストリ外で生きている monster)
|
|
217
|
+
#
|
|
218
|
+
# tmux にセッションが在るのに state.json にエントリが無い monster が実在する
|
|
219
|
+
# (2026-08-04 実測: 23b47504。spawn 記録は events.log にあるが state.json に無い)。
|
|
220
|
+
# 原因は spawn 時の書き込み競合と推定される。spawn の 3〜6 秒後に、生まれた monster 自身の
|
|
221
|
+
# orchestrator プロセスが bootstrap し、新エントリを含まない state を読んで後から保存するため。
|
|
222
|
+
# ⇒ spawn は、自分の登録を消しうるプロセスを自分で起動している。
|
|
223
|
+
#
|
|
224
|
+
# レジストリ外なので、
|
|
225
|
+
# - list_monsters に出ない
|
|
226
|
+
# - stop_monster で止められない(resolveMonsterId が見つけられない)
|
|
227
|
+
# - reconcile が走査しないので expires_at が効かない
|
|
228
|
+
# ⇒ 止める手段が tmux 直接操作しかない monster になる。
|
|
229
|
+
#
|
|
230
|
+
# 🔑 実害は暴走ではなく「成果が誰からも見えなくなること」。
|
|
231
|
+
# 実測の 23b47504 は 1 時間 19 分の測定を完走して結果をファイルに残していた。
|
|
232
|
+
# レジストリだけを見ている限り、この 1 体は原理的に検出できない。
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
SESSION_PREFIX = "monster-"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def list_tmux_monster_sessions(tmux):
|
|
239
|
+
"""tmux 上の monster セッション名を列挙する。
|
|
240
|
+
|
|
241
|
+
⚠️ 「列挙できなかった」を「0 件」に丸めない。
|
|
242
|
+
丸めると、tmux が死んでいるときにゴーストが 0 件に見える
|
|
243
|
+
=測定器が「無い」と誤る側に倒れる(本件で最も避けたい失敗)。
|
|
244
|
+
ただし「サーバが起動していない」は正真正銘の 0 件なので、そこは区別する。
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
r = subprocess.run([tmux, "list-sessions", "-F", "#{session_name}"],
|
|
248
|
+
capture_output=True, text=True, timeout=10)
|
|
249
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
250
|
+
raise Unmeasurable(f"tmux を実行できない: {e}") from e
|
|
251
|
+
if r.returncode != 0:
|
|
252
|
+
err = (r.stderr or "").strip()
|
|
253
|
+
if "no server running" in err or "no current session" in err:
|
|
254
|
+
return set()
|
|
255
|
+
raise Unmeasurable(f"tmux list-sessions が失敗した: {err[:120]}")
|
|
256
|
+
return {ln.strip() for ln in r.stdout.splitlines()
|
|
257
|
+
if ln.strip().startswith(SESSION_PREFIX)}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_session_events():
|
|
261
|
+
"""tmux セッション名ごとに (終了を観測したか, spawn 時刻) を返す。
|
|
262
|
+
|
|
263
|
+
state.json はプルーニングされるため、古い停止済み monster の tmux セッションが
|
|
264
|
+
残っていると差集合に出てしまう。events.log 側で終了を観測済みのものは除外する。
|
|
265
|
+
"""
|
|
266
|
+
ended, spawned = set(), {}
|
|
267
|
+
try:
|
|
268
|
+
f = open(EVENTS_PATH, errors="replace")
|
|
269
|
+
except OSError:
|
|
270
|
+
return ended, spawned
|
|
271
|
+
with f:
|
|
272
|
+
for line in f:
|
|
273
|
+
line = line.strip()
|
|
274
|
+
if not line:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
e = json.loads(line)
|
|
278
|
+
except json.JSONDecodeError:
|
|
279
|
+
continue
|
|
280
|
+
sess = e.get("session")
|
|
281
|
+
if not sess:
|
|
282
|
+
continue
|
|
283
|
+
ev = e.get("event", "")
|
|
284
|
+
if ev in ("session_end", "stopped"):
|
|
285
|
+
ended.add(sess)
|
|
286
|
+
elif ev == "spawned":
|
|
287
|
+
spawned[sess] = e.get("ts", "")
|
|
288
|
+
return ended, spawned
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def ghost_sessions(live_sessions, known_sessions, ended_sessions):
|
|
292
|
+
"""レジストリ外セッションを判定する純関数(--verify で単体検証する)。
|
|
293
|
+
|
|
294
|
+
live : tmux に実在するセッション名
|
|
295
|
+
known : state.json が知っているセッション名
|
|
296
|
+
ended : events.log で終了を観測済みのセッション名(=残骸。ゴーストではない)
|
|
297
|
+
"""
|
|
298
|
+
return sorted(s for s in live_sessions
|
|
299
|
+
if s not in known_sessions and s not in ended_sessions)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def find_ghosts(monsters, tmux):
|
|
303
|
+
known = {m.get("tmux_session") for m in monsters.values() if m.get("tmux_session")}
|
|
304
|
+
live = list_tmux_monster_sessions(tmux)
|
|
305
|
+
ended, spawned = load_session_events()
|
|
306
|
+
out = []
|
|
307
|
+
for sess in ghost_sessions(live, known, ended):
|
|
308
|
+
short = sess[len(SESSION_PREFIX):]
|
|
309
|
+
# 🚨 GHOST と STALLED は同時に成立しうる(実測: 23b47504)。
|
|
310
|
+
# 片方が他方を隠さないよう、ghost 側にも API 状態を載せる。
|
|
311
|
+
gid = next((k for k in monsters if k.startswith(short)), None) or short
|
|
312
|
+
readable, is_err, status = read_last_api_state(gid)
|
|
313
|
+
out.append({
|
|
314
|
+
"session": sess,
|
|
315
|
+
"short_id": short,
|
|
316
|
+
"stalled": classify_api_error(is_err, status) if readable else None,
|
|
317
|
+
"stalled_unverified": not readable,
|
|
318
|
+
"spawned_at": spawned.get(sess, ""),
|
|
319
|
+
"detail": ("tmux にセッションが在るが state.json にエントリが無い。"
|
|
320
|
+
"list_monsters に出ず、stop_monster で止められず、"
|
|
321
|
+
"reconcile が走査しないので寿命も効かない"),
|
|
322
|
+
})
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# STALLED — レジストリ内で生きていない monster
|
|
328
|
+
#
|
|
329
|
+
# 週次上限などで API が応答を返さなくなった monster は、
|
|
330
|
+
# - state.json では running
|
|
331
|
+
# - tmux has-session も通る
|
|
332
|
+
# - last_action_at も新しい
|
|
333
|
+
# - session.log も watcher が 60 秒ごとに更新し続ける
|
|
334
|
+
# ⇒ **既存のどの判定でも「生きて働いている」と読める。** 実際には次の 1 文字も出力できない。
|
|
335
|
+
#
|
|
336
|
+
# GHOST が「レジストリ外で生きている」なら、これは「レジストリ内で生きていない」。対になる死角。
|
|
337
|
+
# 実害は成果の消失ではなく **指揮の誤り**——盤面の稼働数が嘘になり、
|
|
338
|
+
# 動けない monster を配車先として数えてしまう(2026-08-04 に実際に発生)。
|
|
339
|
+
#
|
|
340
|
+
# 🚨 判定にペインの文言を使わない。文言は変わる。
|
|
341
|
+
# Claude Code 自身の transcript(~/.claude/projects/<home>/*.jsonl)の
|
|
342
|
+
# 最後の type=assistant エントリに、機械可読なフィールドが載る:
|
|
343
|
+
# isApiErrorMessage: true / apiErrorStatus: 429 / error: "rate_limit"
|
|
344
|
+
# message.model: "<synthetic>"(API が応答せず CLI が合成した応答)
|
|
345
|
+
# 実測(18 体)で偽陽性・偽陰性ともゼロ。
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
PROJECTS_ROOT = os.environ.get("CLAUDE_PROJECTS_ROOT") or os.path.expanduser("~/.claude/projects")
|
|
349
|
+
|
|
350
|
+
# 最後の assistant を探すときに遡る行数。transcript 全体を読むのは高価なので末尾のみ見る。
|
|
351
|
+
TRANSCRIPT_TAIL_LINES = 60
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def transcript_path(monster_id):
|
|
355
|
+
"""monster の transcript を探す。見つからなければ None。
|
|
356
|
+
|
|
357
|
+
ディレクトリ名は cwd をスラグ化したもので、機体やユーザ名で変わる。
|
|
358
|
+
monster_id での前方一致 glob にして、ユーザ名に依存しないようにする。
|
|
359
|
+
"""
|
|
360
|
+
hits = glob.glob(os.path.join(PROJECTS_ROOT, f"*monsters-{monster_id}*", "*.jsonl"))
|
|
361
|
+
if not hits:
|
|
362
|
+
return None
|
|
363
|
+
return max(hits, key=os.path.getmtime)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def read_last_api_state(monster_id):
|
|
367
|
+
"""最後の assistant エントリの API 状態を返す。
|
|
368
|
+
|
|
369
|
+
返り値 (readable, is_api_error, status)
|
|
370
|
+
readable=False は「読めなかった」。⚠️ これを「正常」に丸めない。
|
|
371
|
+
"""
|
|
372
|
+
path = transcript_path(monster_id)
|
|
373
|
+
if path is None:
|
|
374
|
+
return (False, None, None)
|
|
375
|
+
try:
|
|
376
|
+
with open(path, errors="replace") as f:
|
|
377
|
+
lines = f.readlines()[-TRANSCRIPT_TAIL_LINES:]
|
|
378
|
+
except OSError:
|
|
379
|
+
return (False, None, None)
|
|
380
|
+
for line in reversed(lines):
|
|
381
|
+
line = line.strip()
|
|
382
|
+
if not line:
|
|
383
|
+
continue
|
|
384
|
+
try:
|
|
385
|
+
e = json.loads(line)
|
|
386
|
+
except json.JSONDecodeError:
|
|
387
|
+
continue
|
|
388
|
+
if e.get("type") != "assistant":
|
|
389
|
+
continue
|
|
390
|
+
# 🚨 最後の assistant だけを見る。過去に 429 を食って回復したものを
|
|
391
|
+
# 「停止中」と誤検出しないため(一時的な 429 はリトライで回復する)。
|
|
392
|
+
return (True, bool(e.get("isApiErrorMessage")), e.get("apiErrorStatus"))
|
|
393
|
+
return (False, None, None)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def classify_api_error(is_api_error, status):
|
|
397
|
+
"""API エラー状態から判定名を返す純関数(--verify で単体検証する)。
|
|
398
|
+
|
|
399
|
+
429 と 5xx を分ける。同じ「応答できない」でも回復見込みが違うため——
|
|
400
|
+
429 は上限リセットまで戻らない。5xx は次の瞬間に戻るかもしれない。
|
|
401
|
+
"""
|
|
402
|
+
if not is_api_error:
|
|
403
|
+
return None
|
|
404
|
+
if status == 429:
|
|
405
|
+
return "STALLED-RATE-LIMIT"
|
|
406
|
+
if isinstance(status, int) and 500 <= status < 600:
|
|
407
|
+
return "STALLED-BACKEND"
|
|
408
|
+
return "STALLED-API-ERROR"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def stalled_detail(verdict, status):
|
|
412
|
+
if verdict == "STALLED-RATE-LIMIT":
|
|
413
|
+
return ("API が上限で応答しない(HTTP 429)。state.json は running・tmux も在席・"
|
|
414
|
+
"session.log も更新され続けるが、次の 1 文字も出力できない。"
|
|
415
|
+
"盤面の稼働数が嘘になるので、配車先として数えないこと")
|
|
416
|
+
if verdict == "STALLED-BACKEND":
|
|
417
|
+
return (f"API がバックエンド障害で応答しない(HTTP {status})。"
|
|
418
|
+
"429 と違い、次の瞬間に回復しうる")
|
|
419
|
+
return f"API が応答しない(HTTP {status})"
|
|
420
|
+
|
|
421
|
+
def tail_since_last_prompt(events):
|
|
422
|
+
"""最後の user_prompt / spawned 以降のイベント列を切り出す"""
|
|
423
|
+
last = None
|
|
424
|
+
for i, ev in enumerate(events):
|
|
425
|
+
if ev in ("user_prompt", "spawned"):
|
|
426
|
+
last = i
|
|
427
|
+
return events[last:] if last is not None else events
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def classify(mid, rec, events_by_id, tmux):
|
|
431
|
+
"""1体を判定して (verdict, detail) を返す"""
|
|
432
|
+
events = events_by_id.get(mid, [])
|
|
433
|
+
tail = tail_since_last_prompt(events)
|
|
434
|
+
ended = "session_end" in tail
|
|
435
|
+
progressed = bool(set(tail) & PROGRESS)
|
|
436
|
+
status = rec.get("status")
|
|
437
|
+
|
|
438
|
+
# --- running とされているもの: 実在を確認する(偽陽性を避けるためリトライ付き)
|
|
439
|
+
if status == "running":
|
|
440
|
+
if ended:
|
|
441
|
+
return ("DIED-UNDETECTED",
|
|
442
|
+
"state.json は running だが events.log には session_end がある"
|
|
443
|
+
"(誰も list_monsters を叩いていないので気づかれていない)")
|
|
444
|
+
if not tmux_alive(tmux, rec.get("tmux_session", "")):
|
|
445
|
+
return ("DIED-UNDETECTED",
|
|
446
|
+
"state.json は running だが tmux セッションが3回とも取れない")
|
|
447
|
+
|
|
448
|
+
# tmux に居ても API が応答しないことがある。寿命警告より先に見る——
|
|
449
|
+
# 応答できない monster に「あと N 分」と言っても意味がないため。
|
|
450
|
+
readable, is_err, status = read_last_api_state(mid)
|
|
451
|
+
if not readable:
|
|
452
|
+
# ⚠️ 読めなかったものを ALIVE に丸めない(正常と未確認は別物)
|
|
453
|
+
return ("ALIVE-UNVERIFIED",
|
|
454
|
+
"tmux セッション在席を実測(3回確認)/"
|
|
455
|
+
"⚠️ transcript が読めず、API に応答できるかは未確認")
|
|
456
|
+
stalled = classify_api_error(is_err, status)
|
|
457
|
+
if stalled:
|
|
458
|
+
return (stalled, stalled_detail(stalled, status))
|
|
459
|
+
|
|
460
|
+
# 生きている。寿命が近いなら予告する(Q9)
|
|
461
|
+
remaining = minutes_until(rec.get("expires_at"))
|
|
462
|
+
verdict, _level = classify_expiry(remaining, events[-1] if events else "")
|
|
463
|
+
if verdict == "EXPIRING-PARKED":
|
|
464
|
+
return (verdict,
|
|
465
|
+
f"残り {remaining:.0f} 分。awaiting_input で駐機中なので"
|
|
466
|
+
"寿命切れは無害(鳴らさない)")
|
|
467
|
+
if verdict == "EXPIRING-WORKING":
|
|
468
|
+
return (verdict,
|
|
469
|
+
f"🚨 残り {remaining:.0f} 分で作業中。寿命が来れば仕掛かりごと消える。"
|
|
470
|
+
"この状態で send_to_monster を撃つと reconcile が走って"
|
|
471
|
+
"その送信自体が kill を起こす")
|
|
472
|
+
if remaining is None:
|
|
473
|
+
return ("ALIVE", "tmux セッション在席を実測(3回確認)"
|
|
474
|
+
"/⚠️ expires_at が読めず残り寿命は不明")
|
|
475
|
+
return ("ALIVE", f"tmux セッション在席を実測(3回確認)/残り {remaining:.0f} 分")
|
|
476
|
+
|
|
477
|
+
if status == "timeout":
|
|
478
|
+
return ("TIMEOUT-KILL",
|
|
479
|
+
"orchestrator の寿命切れ kill。作業途中でも予告なく殺される")
|
|
480
|
+
|
|
481
|
+
# 停止理由は stop_reason ではなく last_action の "stopped: " 接頭辞に入る(実測)。
|
|
482
|
+
# stop_reason フィールドは全件 null だった。
|
|
483
|
+
last_action = rec.get("last_action") or ""
|
|
484
|
+
if last_action.startswith("stopped:"):
|
|
485
|
+
return ("COMPLETED", f"明示停止: {last_action[8:70].strip()}")
|
|
486
|
+
if rec.get("stop_reason"):
|
|
487
|
+
return ("COMPLETED", "stop_monster による明示停止(理由あり)")
|
|
488
|
+
|
|
489
|
+
if last_action == "session disappeared":
|
|
490
|
+
# ここが本題。session disappeared は死因ではなくラベルにすぎない。
|
|
491
|
+
expires = rec.get("expires_at") or ""
|
|
492
|
+
found_at = rec.get("last_action_at") or ""
|
|
493
|
+
if expires and found_at and found_at > expires:
|
|
494
|
+
return ("TIMEOUT-MISLABELED",
|
|
495
|
+
f"寿命({expires})を過ぎてから発見({found_at})。"
|
|
496
|
+
"本来 timeout だが session が先に消えたため disappeared と誤ラベル")
|
|
497
|
+
if ended and not progressed:
|
|
498
|
+
return ("DIED-MIDTURN",
|
|
499
|
+
"最後の user_prompt 以降 idle/awaiting_input が一度も出ないまま "
|
|
500
|
+
"session_end。ターン途中で消えた")
|
|
501
|
+
if ended and progressed:
|
|
502
|
+
return ("COMPLETED-MISLABELED",
|
|
503
|
+
"awaiting_input を経て session_end。正常完了だが state.json は "
|
|
504
|
+
"disappeared と記録(reconcile が後から拾っただけ)")
|
|
505
|
+
return ("UNKNOWN", "events.log に session_end が無い。判定不能")
|
|
506
|
+
|
|
507
|
+
return ("COMPLETED", f"status={status}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def count_watchers(monsters):
|
|
511
|
+
"""孤児 watcher を数える(#36: stopped 後も session.log を書き続ける)"""
|
|
512
|
+
now = time.time()
|
|
513
|
+
live, orphan = [], []
|
|
514
|
+
for mid, rec in monsters.items():
|
|
515
|
+
p = os.path.join(ROOT, mid, "session.log")
|
|
516
|
+
try:
|
|
517
|
+
fresh = now - os.path.getmtime(p) <= WATCHER_FRESH_SEC
|
|
518
|
+
except OSError:
|
|
519
|
+
continue
|
|
520
|
+
if not fresh:
|
|
521
|
+
continue
|
|
522
|
+
short = rec.get("short_id", mid[:8])
|
|
523
|
+
(live if rec.get("status") == "running" else orphan).append(short)
|
|
524
|
+
return sorted(live), sorted(orphan)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def collect():
|
|
528
|
+
"""判定結果をまとめて返す。--json と人間向け表示の共通の元。"""
|
|
529
|
+
monsters = load_state()
|
|
530
|
+
events_by_id = load_events()
|
|
531
|
+
tmux = resolve_tmux()
|
|
532
|
+
|
|
533
|
+
results = []
|
|
534
|
+
for mid, rec in monsters.items():
|
|
535
|
+
verdict, detail = classify(mid, rec, events_by_id, tmux)
|
|
536
|
+
remaining = minutes_until(rec.get("expires_at"))
|
|
537
|
+
results.append({
|
|
538
|
+
"short_id": rec.get("short_id", mid[:8]),
|
|
539
|
+
"verdict": verdict,
|
|
540
|
+
"detail": detail,
|
|
541
|
+
"workdir": rec.get("workdir", ""),
|
|
542
|
+
"spawned_at": rec.get("spawned_at", ""),
|
|
543
|
+
"expires_at": rec.get("expires_at", ""),
|
|
544
|
+
# ダッシュボードが「あと何分か」を出せるように、running には必ず載せる
|
|
545
|
+
"remaining_min": None if remaining is None else round(remaining),
|
|
546
|
+
})
|
|
547
|
+
ghosts = find_ghosts(monsters, tmux)
|
|
548
|
+
live, orphan = count_watchers(monsters)
|
|
549
|
+
return {
|
|
550
|
+
"host": os.uname().nodename,
|
|
551
|
+
"user": os.environ.get("USER", ""),
|
|
552
|
+
"monster_root": ROOT,
|
|
553
|
+
"thresholds": {"warn_min": WARN_MIN, "crit_min": CRIT_MIN},
|
|
554
|
+
"monsters": results,
|
|
555
|
+
"watchers": {"live": live, "orphan": orphan},
|
|
556
|
+
"ghosts": ghosts,
|
|
557
|
+
"urgent": sum(1 for r in results if r["verdict"] in URGENT) + len(ghosts),
|
|
558
|
+
"expiring": sum(1 for r in results if r["verdict"] == "EXPIRING-WORKING"),
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def notify_preview(report):
|
|
563
|
+
"""鳴らすなら何を送るかを表示するだけ。
|
|
564
|
+
|
|
565
|
+
🚨 送信はしない。LINE mikoto ops への実発火は POLICY 案件で CTO 裁定待ちのため、
|
|
566
|
+
このスクリプトには送信コードを一切持たせていない(フラグの消し忘れで
|
|
567
|
+
誤発火する余地を残さないため、経路そのものを作らない)。
|
|
568
|
+
"""
|
|
569
|
+
targets = [r for r in report["monsters"] if r["verdict"] in NOTIFY]
|
|
570
|
+
ghosts = report.get("ghosts") or []
|
|
571
|
+
print("=== 鳴らす経路のプレビュー(送信はしない)===")
|
|
572
|
+
print(f" 宛先(予定): LINE mikoto ops / 現在の状態: 未発火(CTO 裁定待ち)")
|
|
573
|
+
for g in ghosts:
|
|
574
|
+
print(f" [レジストリ外] {g['short_id']} {g['session']}")
|
|
575
|
+
print(f" {g['detail']}")
|
|
576
|
+
if not targets and not ghosts:
|
|
577
|
+
print(" 送る対象なし")
|
|
578
|
+
return
|
|
579
|
+
for r in sorted(targets, key=lambda x: (x["verdict"], x["short_id"])):
|
|
580
|
+
dead = r["verdict"] in URGENT
|
|
581
|
+
# 死んだ monster に「残り寿命」を出さない(無意味なうえ、読む人が
|
|
582
|
+
# 「まだ生きている」と誤読する)。残り寿命は寿命警告のときだけ意味を持つ。
|
|
583
|
+
rem = "" if (dead or r["remaining_min"] is None) else f"(残り {r['remaining_min']} 分)"
|
|
584
|
+
print(f" [{'死亡' if dead else '寿命間近'}] {r['short_id']} {rem} {r['workdir']}")
|
|
585
|
+
print(f" {r['detail']}")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def verify_ghost():
|
|
589
|
+
"""ゴースト判定の単体検証(純粋関数なので実データに依存しない)。
|
|
590
|
+
|
|
591
|
+
🚨 「ゴースト 1 体」と「ゴースト 0 体」の両方を当てられることを示す。
|
|
592
|
+
片方しか試さないと、常に 0 を返す壊れた測定器でも緑になる。
|
|
593
|
+
"""
|
|
594
|
+
KNOWN = {"monster-aaaa1111", "monster-bbbb2222"}
|
|
595
|
+
cases = [
|
|
596
|
+
# (live, known, ended, 期待)
|
|
597
|
+
("ゴースト 1 体",
|
|
598
|
+
{"monster-aaaa1111", "monster-cccc3333"}, KNOWN, set(), ["monster-cccc3333"]),
|
|
599
|
+
("ゴースト 0 体(全部レジストリにある)",
|
|
600
|
+
{"monster-aaaa1111", "monster-bbbb2222"}, KNOWN, set(), []),
|
|
601
|
+
("ゴースト 0 体(tmux が空)",
|
|
602
|
+
set(), KNOWN, set(), []),
|
|
603
|
+
("終了済みの残骸は除外する",
|
|
604
|
+
{"monster-dddd4444"}, KNOWN, {"monster-dddd4444"}, []),
|
|
605
|
+
("残骸とゴーストが混在",
|
|
606
|
+
{"monster-dddd4444", "monster-eeee5555"}, KNOWN, {"monster-dddd4444"},
|
|
607
|
+
["monster-eeee5555"]),
|
|
608
|
+
("複数ゴーストは並べて返す",
|
|
609
|
+
{"monster-ffff6666", "monster-cccc3333"}, KNOWN, set(),
|
|
610
|
+
["monster-cccc3333", "monster-ffff6666"]),
|
|
611
|
+
]
|
|
612
|
+
ok = True
|
|
613
|
+
for label, live, known, ended, want in cases:
|
|
614
|
+
got = ghost_sessions(live, known, ended)
|
|
615
|
+
good = got == want
|
|
616
|
+
ok = ok and good
|
|
617
|
+
print(f" {'OK ' if good else 'NG '}{label:<34} → {got}")
|
|
618
|
+
return ok
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def verify_stalled():
|
|
622
|
+
"""API エラー判定の単体検証(純粋関数なので実データに依存しない)。
|
|
623
|
+
|
|
624
|
+
🚨 偽陽性の方が危険。働いている monster を STALLED と誤ると「止めてよい」に見える。
|
|
625
|
+
⇒ 「エラーでない」ケースを、検出ケースと同じ数だけ並べる。
|
|
626
|
+
"""
|
|
627
|
+
cases = [
|
|
628
|
+
# (is_api_error, status, 期待)
|
|
629
|
+
(True, 429, "STALLED-RATE-LIMIT"),
|
|
630
|
+
(True, 500, "STALLED-BACKEND"),
|
|
631
|
+
(True, 503, "STALLED-BACKEND"),
|
|
632
|
+
(True, 529, "STALLED-BACKEND"),
|
|
633
|
+
(True, 400, "STALLED-API-ERROR"),
|
|
634
|
+
(True, None, "STALLED-API-ERROR"),
|
|
635
|
+
# --- 以下は「検出してはいけない」側 ---
|
|
636
|
+
(False, None, None),
|
|
637
|
+
(False, 429, None), # 過去に 429 を食ったが最後は正常=回復済み
|
|
638
|
+
(False, 500, None),
|
|
639
|
+
(None, None, None),
|
|
640
|
+
(0, 429, None),
|
|
641
|
+
("", 429, None),
|
|
642
|
+
]
|
|
643
|
+
ok = True
|
|
644
|
+
for is_err, status, want in cases:
|
|
645
|
+
got = classify_api_error(is_err, status)
|
|
646
|
+
good = got == want
|
|
647
|
+
ok = ok and good
|
|
648
|
+
print(f" {'OK ' if good else 'NG '}api_error={str(is_err):<5} status={str(status):<5} → {got}")
|
|
649
|
+
return ok
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def verify_expiry():
|
|
653
|
+
"""寿命判定の単体検証(純粋関数なので実データに依存しない=どの機体でも回る)"""
|
|
654
|
+
W, C = WARN_MIN, CRIT_MIN
|
|
655
|
+
cases = [
|
|
656
|
+
# (残り分, 直近イベント, 期待 verdict, 期待 level)
|
|
657
|
+
(W + 1, "idle", None, None), # 閾値の外は何も出さない
|
|
658
|
+
(W, "idle", "EXPIRING-WORKING", "warn"), # 境界は含む
|
|
659
|
+
(C + 1, "user_prompt", "EXPIRING-WORKING", "warn"),
|
|
660
|
+
(C, "user_prompt", "EXPIRING-WORKING", "crit"), # 境界は含む
|
|
661
|
+
(1, "", "EXPIRING-WORKING", "crit"), # イベント無しは働いている扱い(安全側)
|
|
662
|
+
(0, "idle", "EXPIRING-WORKING", "crit"),
|
|
663
|
+
(-5, "idle", "EXPIRING-WORKING", "crit"), # 既に超過していても出す
|
|
664
|
+
# 駐機中は閾値内でも鳴らさない(23/28 がこれ)
|
|
665
|
+
(W, "awaiting_input", "EXPIRING-PARKED", None),
|
|
666
|
+
(0, "awaiting_input", "EXPIRING-PARKED", None),
|
|
667
|
+
# 読めなかったものを「余裕あり」に丸めない
|
|
668
|
+
(None, "idle", None, None),
|
|
669
|
+
]
|
|
670
|
+
ok = True
|
|
671
|
+
for remaining, last_event, want_v, want_l in cases:
|
|
672
|
+
got_v, got_l = classify_expiry(remaining, last_event)
|
|
673
|
+
good = (got_v == want_v and got_l == want_l)
|
|
674
|
+
ok = ok and good
|
|
675
|
+
rem = "None" if remaining is None else f"{remaining:>4}"
|
|
676
|
+
print(f" {'OK ' if good else 'NG '}残り{rem}分 / {last_event or '(無)':<14} "
|
|
677
|
+
f"→ {got_v}/{got_l}")
|
|
678
|
+
return ok
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def verify():
|
|
682
|
+
"""対照実験: 測定器が既知ケースを正しく分類できるか。
|
|
683
|
+
|
|
684
|
+
「0件」「相関なし」を報告する前に測定器の生存を示す、という品質ゲートのため。
|
|
685
|
+
⚠️ 期待値は ami機の実データに紐づく。他機では該当なしでスキップされる。
|
|
686
|
+
"""
|
|
687
|
+
print("--- ゴースト判定(純粋関数・実データ非依存)---")
|
|
688
|
+
ghost_ok = verify_ghost()
|
|
689
|
+
print("\n--- API 応答不能の判定(純粋関数・実データ非依存)---")
|
|
690
|
+
stalled_ok = verify_stalled()
|
|
691
|
+
print("\n--- 寿命判定(純粋関数・実データ非依存)---")
|
|
692
|
+
expiry_ok = verify_expiry()
|
|
693
|
+
print(f"\n--- 死亡判定(既知ケース・実データ依存)---")
|
|
694
|
+
cases = {
|
|
695
|
+
# short_id: 期待する分類(人手で一次資料まで確認済み・2026-08-04)
|
|
696
|
+
"6e3077c4": "DIED-MIDTURN", # jest 実行中に SIGKILL、transcript も途中で終端
|
|
697
|
+
"799fafd9": "DIED-MIDTURN", # 同上
|
|
698
|
+
"53cb1d2a": "TIMEOUT-KILL", # reconcile の tmuxKillSession(23ms 後に 137)
|
|
699
|
+
"e8df2b33": "TIMEOUT-MISLABELED", # 寿命ちょうど(+15秒で発見)
|
|
700
|
+
"926c8f08": "TIMEOUT-MISLABELED", # 寿命 37 分超過後に発見
|
|
701
|
+
"0893e508": "TIMEOUT-MISLABELED",
|
|
702
|
+
"7b0bb651": "COMPLETED-MISLABELED", # 正常完了なのに disappeared と記録
|
|
703
|
+
}
|
|
704
|
+
monsters = load_state()
|
|
705
|
+
events_by_id = load_events()
|
|
706
|
+
tmux = resolve_tmux()
|
|
707
|
+
by_short = {r.get("short_id"): (mid, r) for mid, r in monsters.items()}
|
|
708
|
+
|
|
709
|
+
ok, checked = True, 0
|
|
710
|
+
for short, want in cases.items():
|
|
711
|
+
entry = by_short.get(short)
|
|
712
|
+
if entry is None:
|
|
713
|
+
print(f" - {short}: この機体には無い(スキップ)")
|
|
714
|
+
continue
|
|
715
|
+
checked += 1
|
|
716
|
+
got, _ = classify(entry[0], entry[1], events_by_id, tmux)
|
|
717
|
+
if got != want:
|
|
718
|
+
ok = False
|
|
719
|
+
print(f" {'OK ' if got == want else 'NG '}{short}: expect={want} got={got}")
|
|
720
|
+
|
|
721
|
+
if checked == 0:
|
|
722
|
+
print(" (この機体には既知ケースが無い。死亡判定の生存は未確認)")
|
|
723
|
+
if not (expiry_ok and ghost_ok and stalled_ok):
|
|
724
|
+
return 1
|
|
725
|
+
print("\n⚠️ 寿命判定は緑だが、死亡判定は検証できていない")
|
|
726
|
+
return 2
|
|
727
|
+
print("\n測定器は" + ("生きている(既知ケースを全て再現)" if ok and expiry_ok and ghost_ok and stalled_ok
|
|
728
|
+
else "既知ケースを外した。信用しないこと"))
|
|
729
|
+
return 0 if (ok and expiry_ok and ghost_ok and stalled_ok) else 1
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def ghost_as_row(g):
|
|
733
|
+
"""ghost を、判定行と同じ形に畳む(表示順を VERDICT_ORDER に載せるため)。"""
|
|
734
|
+
detail = g["detail"]
|
|
735
|
+
if g.get("stalled"):
|
|
736
|
+
detail += f"/+ {g['stalled']}: API が応答しない(2 つの死角に同時該当)"
|
|
737
|
+
elif g.get("stalled_unverified"):
|
|
738
|
+
detail += "/+ transcript が読めず、応答可能かは未確認"
|
|
739
|
+
return {
|
|
740
|
+
"short_id": g["short_id"],
|
|
741
|
+
"spawned_at": g.get("spawned_at") or "(spawn 記録なし)",
|
|
742
|
+
"workdir": g["session"],
|
|
743
|
+
"detail": detail,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def render(report):
|
|
748
|
+
buckets = {}
|
|
749
|
+
for r in report["monsters"]:
|
|
750
|
+
buckets.setdefault(r["verdict"], []).append(r)
|
|
751
|
+
|
|
752
|
+
# 🚨 ghosts は monsters とは別配列で運ばれる(state.json に居ないので monster 行を持てない)。
|
|
753
|
+
# ここで bucket に載せないと VERDICT_ORDER の "GHOST" がどの要素にも一致せず、
|
|
754
|
+
# ループの外=最下位に印字される。COMPLETED が 130 件あると 350 行下になり、
|
|
755
|
+
# 人が読む形式では実質見えない(2026-08-05 に実際に見落としが発生した)。
|
|
756
|
+
# 表示順は VERDICT_ORDER だけが決める、という一箇所に集約しておく。
|
|
757
|
+
for g in report.get("ghosts") or []:
|
|
758
|
+
buckets.setdefault("GHOST", []).append(ghost_as_row(g))
|
|
759
|
+
|
|
760
|
+
for verdict in VERDICT_ORDER:
|
|
761
|
+
items = buckets.get(verdict)
|
|
762
|
+
if not items:
|
|
763
|
+
continue
|
|
764
|
+
print(f"\n■ {verdict} ({len(items)})")
|
|
765
|
+
for r in sorted(items, key=lambda x: x["spawned_at"]):
|
|
766
|
+
print(f" {r['short_id']} {r['spawned_at']} {r['workdir']}")
|
|
767
|
+
print(f" {r['detail']}")
|
|
768
|
+
|
|
769
|
+
w = report["watchers"]
|
|
770
|
+
print(f"\n■ watcher: 稼働 {len(w['live'])} / 孤児 {len(w['orphan'])}"
|
|
771
|
+
"(対象がもう居ないのに60秒ごとに空ペインを書き続けている)")
|
|
772
|
+
if w["orphan"]:
|
|
773
|
+
print(" " + " ".join(w["orphan"]))
|
|
774
|
+
|
|
775
|
+
print(f"\n>>> 今すぐ人が見るべき monster: {report['urgent']} 体")
|
|
776
|
+
if report["expiring"]:
|
|
777
|
+
print(f">>> 🚨 寿命が近く、まだ働いている monster: {report['expiring']} 体"
|
|
778
|
+
"(この相手に送信すると、その送信が kill を起こす)")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def main(argv):
|
|
782
|
+
try:
|
|
783
|
+
if "--verify" in argv:
|
|
784
|
+
return verify()
|
|
785
|
+
report = collect()
|
|
786
|
+
except Unmeasurable as e:
|
|
787
|
+
# ⚠️ 測れなかったことを「異常なし」に丸めない。
|
|
788
|
+
print(f"MEASUREMENT-FAILED: {e}", file=sys.stderr)
|
|
789
|
+
if "--json" in argv:
|
|
790
|
+
print(json.dumps({"error": str(e), "measurable": False}, ensure_ascii=False))
|
|
791
|
+
return 2
|
|
792
|
+
|
|
793
|
+
if "--json" in argv:
|
|
794
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
795
|
+
elif "--notify-preview" in argv:
|
|
796
|
+
notify_preview(report)
|
|
797
|
+
else:
|
|
798
|
+
render(report)
|
|
799
|
+
# ⚠️ 寿命警告では exit code を変えない。死亡と警告を同じ値にすると
|
|
800
|
+
# 「1 が返った=誰か死んだ」という読み方が壊れる。
|
|
801
|
+
return 1 if report["urgent"] else 0
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
if __name__ == "__main__":
|
|
805
|
+
sys.exit(main(sys.argv[1:]))
|