monster-orchestrator 3.3.0 → 3.5.0

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.
@@ -8,11 +8,6 @@
8
8
  - 誰かが list_monsters を叩いた時にしか回らない(pull 型)
9
9
  ため、「死んだこと」も「いつ死んだか」もレジストリからは分からない。
10
10
 
11
- さらに reconcile() は寿命判定を生存判定の内側に置いているため、寿命切れの
12
- monster が「先に session が消えていた」だけで timeout ではなく
13
- "session disappeared" とラベルされる。実測では同一事象(寿命ちょうどの死)が
14
- 片方 timeout・片方 session disappeared になっていた(53cb1d2a / e8df2b33)。
15
-
16
11
  このスクリプトは既存の記録だけから死を判定する。何も書き換えず、何も kill しない。
17
12
  tmux は has-session のみ(attach / send-keys / kill 系は一切使わない)。
18
13
 
@@ -22,16 +17,9 @@ tmux は has-session のみ(attach / send-keys / kill 系は一切使わない
22
17
  = ターン途中で消えた(異常死)。
23
18
  逆に awaiting_input を経てから session_end なら、指示通り止まった(正常完了)。
24
19
 
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
- 「寿命が近く、かつまだ働いている」ものだけを警告対象にする。
20
+ 席の寿命について(箱 1812・主君裁定 2026-09-19「約束を消す」):
21
+ orchestrator は時計で席を止めない(寿命・失効の欄も持たない)。
22
+ ⇒ この道具も「あと N 分で消える」を予告しない。予告は執行されない約束になる。
35
23
 
36
24
  usage:
37
25
  monster-deathcheck 人が読む形式で全 monster を判定(レジストリ外の GHOST を含む)
@@ -47,16 +35,12 @@ exit code:
47
35
  成果が失われるという一点で死亡と同じ扱いにしている
48
36
  2 = 測定不能(state.json や tmux が読めない等)。⚠️ 0 と混同しないこと:
49
37
  「異常なし」と「測れなかった」を同じ値にすると、今日の不検出を再現する
50
- ※ 寿命警告(EXPIRING-*)は exit code を変えない。まだ何も失われていないため、
51
- 「1 が返った=人が見るべきものがある」と混ぜない
52
38
 
53
39
  環境変数:
54
40
  MONSTER_ROOT monster レジストリの場所(既定 ~/.claude/monsters)
55
41
  TMUX_BIN tmux の絶対パス(既定は PATH → /opt/homebrew/bin/tmux の順に探索)
56
42
  ⚠️ 非対話 SSH では PATH に /opt/homebrew/bin が乗らないため必要
57
43
  CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)
58
- MONSTER_WARN_MIN 寿命の予告閾値・分(既定 30。ダッシュボード表示)
59
- MONSTER_CRIT_MIN 寿命の最終警告閾値・分(既定 5。鳴らす想定・現在は未発火)
60
44
  """
61
45
 
62
46
  import glob
@@ -66,7 +50,6 @@ import shutil
66
50
  import subprocess
67
51
  import sys
68
52
  import time
69
- from datetime import datetime, timezone
70
53
 
71
54
  # 板 #442 ③: 解決の式を全経路で揃える(server=src/paths.ts は MONSTER_MCP_ROOT を honour)。
72
55
  # MONSTER_ROOT は後方互換の別名として残す(従来この道具だけがこの名前を見ていた)。
@@ -86,31 +69,21 @@ WATCHER_FRESH_SEC = 180
86
69
  # そして STALLED-*(レジストリ内で生きていない=応答できない)。
87
70
  # GHOST の実害は「成果が見えなくなる」、STALLED の実害は「指揮そのものが誤る」
88
71
  #(盤面の稼働数が嘘になり、動けない monster を配車先として数えてしまう)。
89
- # 寿命警告(EXPIRING-*)はここに入れない——まだ何も失われていないため。
90
- # 死亡と警告を同じ値にすると「1 が返った=人が見るべきものがある」という読み方が壊れる。
91
72
  URGENT = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
92
73
  "STALLED-RATE-LIMIT", "STALLED-BACKEND"}
93
74
 
94
75
  # 鳴らす対象(実発火は CTO 裁定後)
95
76
  NOTIFY = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
96
- "STALLED-RATE-LIMIT", "STALLED-BACKEND", "EXPIRING-WORKING"}
77
+ "STALLED-RATE-LIMIT", "STALLED-BACKEND"}
97
78
 
98
79
  VERDICT_ORDER = [
99
80
  "GHOST", "DIED-UNDETECTED", "DIED-MIDTURN",
100
81
  "STALLED-RATE-LIMIT", "STALLED-BACKEND", "STALLED-API-ERROR",
101
- "EXPIRING-WORKING", "EXPIRING-PARKED", "ALIVE-UNVERIFIED",
102
- "TIMEOUT-KILL", "TIMEOUT-MISLABELED", "COMPLETED-MISLABELED",
82
+ "ALIVE-UNVERIFIED",
83
+ "TIMEOUT-KILL", "COMPLETED-MISLABELED",
103
84
  "ALIVE", "COMPLETED", "UNKNOWN",
104
85
  ]
105
86
 
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
87
  class Unmeasurable(Exception):
115
88
  """測定できなかった。異常なしとは違う。"""
116
89
 
@@ -177,40 +150,6 @@ def tmux_alive(tmux, session, retries=3, delay=0.25):
177
150
  return False
178
151
 
179
152
 
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
153
 
215
154
  # ---------------------------------------------------------------------------
216
155
  # ゴースト monster(レジストリ外で生きている monster)
@@ -224,7 +163,6 @@ def classify_expiry(remaining_min, last_event):
224
163
  # レジストリ外なので、
225
164
  # - list_monsters に出ない
226
165
  # - stop_monster で止められない(resolveMonsterId が見つけられない)
227
- # - reconcile が走査しないので expires_at が効かない
228
166
  # ⇒ 止める手段が tmux 直接操作しかない monster になる。
229
167
  #
230
168
  # 🔑 実害は暴走ではなく「成果が誰からも見えなくなること」。
@@ -317,8 +255,7 @@ def find_ghosts(monsters, tmux):
317
255
  "stalled_unverified": not readable,
318
256
  "spawned_at": spawned.get(sess, ""),
319
257
  "detail": ("tmux にセッションが在るが state.json にエントリが無い。"
320
- "list_monsters に出ず、stop_monster で止められず、"
321
- "reconcile が走査しないので寿命も効かない"),
258
+ "list_monsters に出ず、stop_monster で止められない"),
322
259
  })
323
260
  return out
324
261
 
@@ -445,8 +382,7 @@ def classify(mid, rec, events_by_id, tmux):
445
382
  return ("DIED-UNDETECTED",
446
383
  "state.json は running だが tmux セッションが3回とも取れない")
447
384
 
448
- # tmux に居ても API が応答しないことがある。寿命警告より先に見る——
449
- # 応答できない monster に「あと N 分」と言っても意味がないため。
385
+ # tmux に居ても API が応答しないことがある。
450
386
  readable, is_err, status = read_last_api_state(mid)
451
387
  if not readable:
452
388
  # ⚠️ 読めなかったものを ALIVE に丸めない(正常と未確認は別物)
@@ -457,26 +393,12 @@ def classify(mid, rec, events_by_id, tmux):
457
393
  if stalled:
458
394
  return (stalled, stalled_detail(stalled, status))
459
395
 
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} 分")
396
+ return ("ALIVE", "tmux セッション在席を実測(3回確認)")
476
397
 
477
398
  if status == "timeout":
399
+ # 箱 1812 より前の記録だけが持つ status(今の orchestrator は時計で止めない)
478
400
  return ("TIMEOUT-KILL",
479
- "orchestrator の寿命切れ kill。作業途中でも予告なく殺される")
401
+ "(過去の記録)orchestrator が時計で止めた席。今の版はこの status を書かない")
480
402
 
481
403
  # 停止理由は stop_reason ではなく last_action の "stopped: " 接頭辞に入る(実測)。
482
404
  # stop_reason フィールドは全件 null だった。
@@ -488,12 +410,6 @@ def classify(mid, rec, events_by_id, tmux):
488
410
 
489
411
  if last_action == "session disappeared":
490
412
  # ここが本題。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
413
  if ended and not progressed:
498
414
  return ("DIED-MIDTURN",
499
415
  "最後の user_prompt 以降 idle/awaiting_input が一度も出ないまま "
@@ -533,16 +449,12 @@ def collect():
533
449
  results = []
534
450
  for mid, rec in monsters.items():
535
451
  verdict, detail = classify(mid, rec, events_by_id, tmux)
536
- remaining = minutes_until(rec.get("expires_at"))
537
452
  results.append({
538
453
  "short_id": rec.get("short_id", mid[:8]),
539
454
  "verdict": verdict,
540
455
  "detail": detail,
541
456
  "workdir": rec.get("workdir", ""),
542
457
  "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
458
  })
547
459
  ghosts = find_ghosts(monsters, tmux)
548
460
  live, orphan = count_watchers(monsters)
@@ -550,12 +462,10 @@ def collect():
550
462
  "host": os.uname().nodename,
551
463
  "user": os.environ.get("USER", ""),
552
464
  "monster_root": ROOT,
553
- "thresholds": {"warn_min": WARN_MIN, "crit_min": CRIT_MIN},
554
465
  "monsters": results,
555
466
  "watchers": {"live": live, "orphan": orphan},
556
467
  "ghosts": ghosts,
557
468
  "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
469
  }
560
470
 
561
471
 
@@ -577,11 +487,7 @@ def notify_preview(report):
577
487
  print(" 送る対象なし")
578
488
  return
579
489
  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']}")
490
+ print(f" [{r['verdict']}] {r['short_id']} {r['workdir']}")
585
491
  print(f" {r['detail']}")
586
492
 
587
493
 
@@ -649,35 +555,6 @@ def verify_stalled():
649
555
  return ok
650
556
 
651
557
 
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
558
  def verify():
682
559
  """対照実験: 測定器が既知ケースを正しく分類できるか。
683
560
 
@@ -688,17 +565,12 @@ def verify():
688
565
  ghost_ok = verify_ghost()
689
566
  print("\n--- API 応答不能の判定(純粋関数・実データ非依存)---")
690
567
  stalled_ok = verify_stalled()
691
- print("\n--- 寿命判定(純粋関数・実データ非依存)---")
692
- expiry_ok = verify_expiry()
693
568
  print(f"\n--- 死亡判定(既知ケース・実データ依存)---")
694
569
  cases = {
695
570
  # short_id: 期待する分類(人手で一次資料まで確認済み・2026-08-04)
696
571
  "6e3077c4": "DIED-MIDTURN", # jest 実行中に SIGKILL、transcript も途中で終端
697
572
  "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",
573
+ "53cb1d2a": "TIMEOUT-KILL", # 過去の記録: reconcile の tmuxKillSession(23ms 後に 137)
702
574
  "7b0bb651": "COMPLETED-MISLABELED", # 正常完了なのに disappeared と記録
703
575
  }
704
576
  monsters = load_state()
@@ -720,13 +592,13 @@ def verify():
720
592
 
721
593
  if checked == 0:
722
594
  print(" (この機体には既知ケースが無い。死亡判定の生存は未確認)")
723
- if not (expiry_ok and ghost_ok and stalled_ok):
595
+ if not (ghost_ok and stalled_ok):
724
596
  return 1
725
- print("\n⚠️ 寿命判定は緑だが、死亡判定は検証できていない")
597
+ print("\n⚠️ 純粋関数の判定は緑だが、死亡判定は検証できていない")
726
598
  return 2
727
- print("\n測定器は" + ("生きている(既知ケースを全て再現)" if ok and expiry_ok and ghost_ok and stalled_ok
599
+ print("\n測定器は" + ("生きている(既知ケースを全て再現)" if ok and ghost_ok and stalled_ok
728
600
  else "既知ケースを外した。信用しないこと"))
729
- return 0 if (ok and expiry_ok and ghost_ok and stalled_ok) else 1
601
+ return 0 if (ok and ghost_ok and stalled_ok) else 1
730
602
 
731
603
 
732
604
  def ghost_as_row(g):
@@ -773,9 +645,6 @@ def render(report):
773
645
  print(" " + " ".join(w["orphan"]))
774
646
 
775
647
  print(f"\n>>> 今すぐ人が見るべき monster: {report['urgent']} 体")
776
- if report["expiring"]:
777
- print(f">>> 🚨 寿命が近く、まだ働いている monster: {report['expiring']} 体"
778
- "(この相手に送信すると、その送信が kill を起こす)")
779
648
 
780
649
 
781
650
  def main(argv):
@@ -796,8 +665,6 @@ def main(argv):
796
665
  notify_preview(report)
797
666
  else:
798
667
  render(report)
799
- # ⚠️ 寿命警告では exit code を変えない。死亡と警告を同じ値にすると
800
- # 「1 が返った=誰か死んだ」という読み方が壊れる。
801
668
  return 1 if report["urgent"] else 0
802
669
 
803
670
 
@@ -142,21 +142,31 @@ fi
142
142
  # 指示本文を撒くと機密がそこへ流れる。
143
143
  # 🔑 指紋の対象は **Claude が受け取った本文そのもの**。送り手側は同じ正規化
144
144
  # (tmux.ts の sanitizeSendText)を通した文字列を対象にすること(二重情報源を作らない)。
145
+ # 🔑 受け手は bracketed paste で届いた本文を `<pasted_content id="…">` で**包み**、改行を足して prompt に
146
+ # 載せる(箱 n=1976・2.1.281 で実測)。包みの id は受け手が振るので、送り手は包んだ形の指紋を作れない。
147
+ # ⇒ 包みと空白を落とした形の指紋も `prompt_norm_sha256` に載せる(送り手は同じ正規化で照合する)。
145
148
  # ⚠️ python3 が無い / prompt が無い環境では空になり、meta にキーが増えないだけ。
146
149
  # 送り手側は従来どおり件数差へ安全に縮退する(fail-safe)。
147
150
  PROMPT_SHA=""
151
+ PROMPT_NORM_SHA=""
148
152
  if [ "$EVENT" = "user_prompt" ] && [ -s "$STDIN_TMP" ]; then
149
153
  PROMPT_SHA=$(python3 - "$STDIN_TMP" <<'SHAPY' 2>/dev/null
150
- import json, sys, hashlib
154
+ import json, sys, hashlib, re
151
155
  try:
152
156
  raw = open(sys.argv[1]).read()
153
157
  d = json.loads(raw) if raw.strip() else {}
154
158
  except Exception:
155
159
  d = {}
156
160
  p = d.get('prompt')
157
- print(hashlib.sha256(p.encode('utf-8')).hexdigest()[:16] if isinstance(p, str) else '')
161
+ if isinstance(p, str):
162
+ # 2 つ目は正規化後の指紋(箱 n=1976)。正規化は src/transcript.ts の normalizeForMatch と同じ
163
+ n = re.sub(r'\s+', '', re.sub(r'</?pasted_content\b[^>]*>', '', p))
164
+ print(hashlib.sha256(p.encode('utf-8')).hexdigest()[:16],
165
+ hashlib.sha256(n.encode('utf-8')).hexdigest()[:16])
158
166
  SHAPY
159
167
  )
168
+ PROMPT_NORM_SHA="${PROMPT_SHA#* }"
169
+ PROMPT_SHA="${PROMPT_SHA%% *}"
160
170
  fi
161
171
 
162
172
  # NDJSON 行構築して append(O_APPEND で atomic)
@@ -167,6 +177,9 @@ if [ -n "$REASON" ]; then
167
177
  elif [ -n "$SUBTYPE" ]; then
168
178
  printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","subtype":"%s","message":%s}}\n' \
169
179
  "$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$SUBTYPE" "$MESSAGE_JSON" >> "$EVENTS" 2>/dev/null
180
+ elif [ -n "$PROMPT_SHA" ] && [ -n "$PROMPT_NORM_SHA" ]; then
181
+ printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","prompt_sha256":"%s","prompt_norm_sha256":"%s"}}\n' \
182
+ "$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$PROMPT_SHA" "$PROMPT_NORM_SHA" >> "$EVENTS" 2>/dev/null
170
183
  elif [ -n "$PROMPT_SHA" ]; then
171
184
  printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","prompt_sha256":"%s"}}\n' \
172
185
  "$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$PROMPT_SHA" >> "$EVENTS" 2>/dev/null