monster-orchestrator 3.7.6 → 3.8.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.
@@ -40,7 +40,8 @@ exit code:
40
40
  MONSTER_ROOT monster レジストリの場所(既定 ~/.claude/monsters)
41
41
  TMUX_BIN tmux の絶対パス(既定は PATH → /opt/homebrew/bin/tmux の順に探索)
42
42
  ⚠️ 非対話 SSH では PATH に /opt/homebrew/bin が乗らないため必要
43
- CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)
43
+ CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)。足跡が無い席(移行前)だけ読む
44
+ BLUELAMP_FOOTPRINTS_ROOT 足跡の場所(既定 ~/.bluelamp/footprints・箱 n=2475 §足跡の形 v1)
44
45
  """
45
46
 
46
47
  import glob
@@ -247,11 +248,11 @@ def find_ghosts(monsters, tmux):
247
248
  # 🚨 GHOST と STALLED は同時に成立しうる(実測: 23b47504)。
248
249
  # 片方が他方を隠さないよう、ghost 側にも API 状態を載せる。
249
250
  gid = next((k for k in monsters if k.startswith(short)), None) or short
250
- readable, is_err, status = read_last_api_state(gid)
251
+ readable, stalled, _cause = read_api_verdict(gid)
251
252
  out.append({
252
253
  "session": sess,
253
254
  "short_id": short,
254
- "stalled": classify_api_error(is_err, status) if readable else None,
255
+ "stalled": stalled if readable else None,
255
256
  "stalled_unverified": not readable,
256
257
  "spawned_at": spawned.get(sess, ""),
257
258
  "detail": ("tmux にセッションが在るが state.json にエントリが無い。"
@@ -330,6 +331,109 @@ def read_last_api_state(monster_id):
330
331
  return (False, None, None)
331
332
 
332
333
 
334
+ # ---------------------------------------------------------------------------
335
+ # 足跡(箱 n=2475 §足跡の形 v1)— 席は会話の記録を書かない(CLAUDE_CODE_SKIP_PROMPT_HISTORY=1)。
336
+ # 起動子のフックが本文なしの 1 行を ~/.bluelamp/footprints/<cwd のスラグ>/<sid>.jsonl に足す:
337
+ # {"v":1,"ts":…,"sid":…,"cwd":…,"ev":"api_error","err":"rate_limit"} 等
338
+ # transcript 版と同じ問い(最後の応答は API エラーか)を、本文を持たない足跡で答える:
339
+ # 最後の ev=api_error の後に、席が応答した印(FOOTPRINT_RESPONDED)が 1 つも無ければ止まっている。
340
+ # 🔑 prompt は応答の印に数えない(transcript 版も user 行の後に assistant 行が来るまで最後のエラーを見続ける)。
341
+ # ⚠️ 足跡が在る席は足跡だけを読む。足跡が無い席(移行前)だけ transcript を読む。
342
+ # ---------------------------------------------------------------------------
343
+
344
+ FOOTPRINTS_ROOT = os.environ.get("BLUELAMP_FOOTPRINTS_ROOT") or os.path.expanduser("~/.bluelamp/footprints")
345
+
346
+ FOOTPRINT_TAIL_LINES = 200
347
+
348
+ # api_error の後に来たら「席が応答した」印になる足跡の種別
349
+ FOOTPRINT_RESPONDED = {"tool", "tool_fail", "stop", "session_start"}
350
+
351
+
352
+ def footprint_dir(monster_id):
353
+ """monster の足跡のディレクトリ。無ければ None(=移行前の席)。"""
354
+ hits = [d for d in glob.glob(os.path.join(FOOTPRINTS_ROOT, f"*monsters-{monster_id}*")) if os.path.isdir(d)]
355
+ return hits[0] if hits else None
356
+
357
+
358
+ def last_footprint_error(lines):
359
+ """足跡の行列から、止めている api_error の err を返す純関数(--verify で単体検証する)。
360
+
361
+ 返り値: None=止まっていない/文字列=最後の api_error の err(その後に応答の印が無い)
362
+ v が 1 でない行・壊れた行は読まない。
363
+ """
364
+ last_err = None
365
+ for line in lines:
366
+ line = line.strip()
367
+ if not line:
368
+ continue
369
+ try:
370
+ e = json.loads(line)
371
+ except json.JSONDecodeError:
372
+ continue
373
+ if not isinstance(e, dict) or e.get("v") != 1:
374
+ continue
375
+ ev = e.get("ev")
376
+ if ev == "api_error":
377
+ err = e.get("err")
378
+ last_err = err if isinstance(err, str) and err else "unknown"
379
+ elif ev in FOOTPRINT_RESPONDED:
380
+ last_err = None
381
+ return last_err
382
+
383
+
384
+ def read_last_footprint_state(monster_id):
385
+ """足跡から API 状態を返す。
386
+
387
+ 返り値: None=足跡が無い(ディレクトリが無い/1 本も無い=移行前の席・transcript へ。
388
+ orchestrator の footprint.ts readSeatFootprintTail と同じ線引き)/(readable, err)
389
+ readable=False は「足跡のファイルは在るが読めない」。⚠️ 正常に丸めない。
390
+ """
391
+ d = footprint_dir(monster_id)
392
+ if d is None:
393
+ return None
394
+ files = glob.glob(os.path.join(d, "*.jsonl"))
395
+ if not files:
396
+ return None
397
+ try:
398
+ with open(max(files, key=os.path.getmtime), errors="replace") as f:
399
+ lines = f.readlines()[-FOOTPRINT_TAIL_LINES:]
400
+ except OSError:
401
+ return (False, None)
402
+ return (True, last_footprint_error(lines))
403
+
404
+
405
+ def classify_footprint_error(err):
406
+ """足跡の err から判定名を返す純関数(--verify で単体検証する)。
407
+
408
+ transcript 版(classify_api_error)と同じ 3 つに畳む:
409
+ rate_limit(transcript では 429)→ STALLED-RATE-LIMIT
410
+ server_error・overloaded(transcript では 5xx・529 Overloaded)→ STALLED-BACKEND
411
+ (err の語は StopFailure の error=箱 n=2475 の W1 実測・Claude Code 2.1.289 の列挙)
412
+ それ以外(authentication_failed 等・transcript では status なし)→ STALLED-API-ERROR
413
+ """
414
+ if not err:
415
+ return None
416
+ if err == "rate_limit":
417
+ return "STALLED-RATE-LIMIT"
418
+ if err in ("server_error", "overloaded"):
419
+ return "STALLED-BACKEND"
420
+ return "STALLED-API-ERROR"
421
+
422
+
423
+ def read_api_verdict(monster_id):
424
+ """席の API 応答不能の判定。足跡が在れば足跡だけ、無ければ transcript(移行の間)。
425
+
426
+ 返り値 (readable, verdict, cause)
427
+ verdict は STALLED-* か None。cause は詳細表示用("HTTP 429" / "err=rate_limit")。
428
+ """
429
+ fp = read_last_footprint_state(monster_id)
430
+ if fp is not None:
431
+ readable, err = fp
432
+ return (readable, classify_footprint_error(err) if readable else None, f"err={err}")
433
+ readable, is_err, status = read_last_api_state(monster_id)
434
+ return (readable, classify_api_error(is_err, status) if readable else None, f"HTTP {status}")
435
+
436
+
333
437
  def classify_api_error(is_api_error, status):
334
438
  """API エラー状態から判定名を返す純関数(--verify で単体検証する)。
335
439
 
@@ -345,15 +449,16 @@ def classify_api_error(is_api_error, status):
345
449
  return "STALLED-API-ERROR"
346
450
 
347
451
 
348
- def stalled_detail(verdict, status):
452
+ def stalled_detail(verdict, cause):
453
+ """cause は詳細表示用の原因(transcript なら "HTTP 503"・足跡なら "err=server_error")。"""
349
454
  if verdict == "STALLED-RATE-LIMIT":
350
- return ("API が上限で応答しない(HTTP 429)。state.json は running・tmux も在席・"
455
+ return (f"API が上限で応答しない({cause})。state.json は running・tmux も在席・"
351
456
  "session.log も更新され続けるが、次の 1 文字も出力できない。"
352
457
  "盤面の稼働数が嘘になるので、配車先として数えないこと")
353
458
  if verdict == "STALLED-BACKEND":
354
- return (f"API がバックエンド障害で応答しない(HTTP {status})。"
355
- "429 と違い、次の瞬間に回復しうる")
356
- return f"API が応答しない(HTTP {status})"
459
+ return (f"API がバックエンド障害で応答しない({cause})。"
460
+ "上限と違い、次の瞬間に回復しうる")
461
+ return f"API が応答しない({cause})"
357
462
 
358
463
  def tail_since_last_prompt(events):
359
464
  """最後の user_prompt / spawned 以降のイベント列を切り出す"""
@@ -383,15 +488,14 @@ def classify(mid, rec, events_by_id, tmux):
383
488
  "state.json は running だが tmux セッションが3回とも取れない")
384
489
 
385
490
  # tmux に居ても API が応答しないことがある。
386
- readable, is_err, status = read_last_api_state(mid)
491
+ readable, stalled, cause = read_api_verdict(mid)
387
492
  if not readable:
388
493
  # ⚠️ 読めなかったものを ALIVE に丸めない(正常と未確認は別物)
389
494
  return ("ALIVE-UNVERIFIED",
390
495
  "tmux セッション在席を実測(3回確認)/"
391
- "⚠️ transcript が読めず、API に応答できるかは未確認")
392
- stalled = classify_api_error(is_err, status)
496
+ "⚠️ 足跡も transcript も読めず、API に応答できるかは未確認")
393
497
  if stalled:
394
- return (stalled, stalled_detail(stalled, status))
498
+ return (stalled, stalled_detail(stalled, cause))
395
499
 
396
500
  return ("ALIVE", "tmux セッション在席を実測(3回確認)")
397
501
 
@@ -555,6 +659,52 @@ def verify_stalled():
555
659
  return ok
556
660
 
557
661
 
662
+ def verify_footprint():
663
+ """足跡の判定の単体検証と、付け替え前後(transcript/足跡)が同じ判定を返すかの表(箱 n=2475)。
664
+
665
+ 🚨 偽陽性の方が危険(働いている席を STALLED と誤ると「止めてよい」に見える)。
666
+ """
667
+ def fp(ev, **kw):
668
+ return json.dumps({"v": 1, "ts": "2026-10-04T08:00:00Z", "sid": "s", "cwd": "/x", "ev": ev, **kw})
669
+
670
+ # (場面, transcript の (is_api_error, status), 足跡の行列)
671
+ scenes = [
672
+ ("rate_limit(上限)", (True, 429), [fp("prompt"), fp("tool", tool="Bash", ok=True), fp("api_error", err="rate_limit")]),
673
+ ("authentication_failed(失効)", (True, None), [fp("prompt"), fp("api_error", err="authentication_failed")]),
674
+ ("server_error(バックエンド)", (True, 500), [fp("prompt"), fp("api_error", err="server_error")]),
675
+ ("overloaded(バックエンド)", (True, 529), [fp("prompt"), fp("api_error", err="overloaded")]),
676
+ ("billing_error(その他)", (True, 400), [fp("prompt"), fp("api_error", err="billing_error")]),
677
+ ("正常", (False, None), [fp("prompt"), fp("tool", tool="Bash", ok=True), fp("stop")]),
678
+ ("上限の後に回復", (False, None), [fp("api_error", err="rate_limit"), fp("prompt"), fp("tool", tool="Bash", ok=True)]),
679
+ ("上限の後に続きを投げたが未応答", (True, 429), [fp("api_error", err="rate_limit"), fp("prompt")]),
680
+ ("配下の席の終わり・pane の終わりは応答でない", (True, 429), [fp("api_error", err="rate_limit"), fp("subagent_stop"), fp("session_end")]),
681
+ ]
682
+ ok = True
683
+ seen = set()
684
+ for label, (is_err, status), lines in scenes:
685
+ before = classify_api_error(is_err, status)
686
+ after = classify_footprint_error(last_footprint_error(lines))
687
+ seen.add(after)
688
+ good = before == after
689
+ ok = ok and good
690
+ print(f" {'OK ' if good else 'NG '}{label:<28} transcript={before} 足跡={after}")
691
+ # 壊れた行・v が違う行は読まない
692
+ for label, lines, want in [
693
+ ("壊れた行", ["{壊れた", ""], None),
694
+ ("v が 1 でない", [json.dumps({"v": 2, "ev": "api_error", "err": "rate_limit"})], None),
695
+ ("空", [], None),
696
+ ]:
697
+ got = classify_footprint_error(last_footprint_error(lines))
698
+ good = got == want
699
+ ok = ok and good
700
+ print(f" {'OK ' if good else 'NG '}{label:<28} → {got}")
701
+ # 🔬 陽性対照: 表の判定が全部 None に潰れていない
702
+ good = {"STALLED-RATE-LIMIT", "STALLED-BACKEND", "STALLED-API-ERROR", None} <= seen
703
+ ok = ok and good
704
+ print(f" {'OK ' if good else 'NG '}陽性対照: 場面で判定が変わる → {sorted(str(x) for x in seen)}")
705
+ return ok
706
+
707
+
558
708
  def verify():
559
709
  """対照実験: 測定器が既知ケースを正しく分類できるか。
560
710
 
@@ -565,6 +715,8 @@ def verify():
565
715
  ghost_ok = verify_ghost()
566
716
  print("\n--- API 応答不能の判定(純粋関数・実データ非依存)---")
567
717
  stalled_ok = verify_stalled()
718
+ print("\n--- 足跡の判定と付け替え前後の一致(純粋関数・実データ非依存・箱 n=2475)---")
719
+ stalled_ok = verify_footprint() and stalled_ok
568
720
  print(f"\n--- 死亡判定(既知ケース・実データ依存)---")
569
721
  cases = {
570
722
  # short_id: 期待する分類(人手で一次資料まで確認済み・2026-08-04)
@@ -607,7 +759,7 @@ def ghost_as_row(g):
607
759
  if g.get("stalled"):
608
760
  detail += f"/+ {g['stalled']}: API が応答しない(2 つの死角に同時該当)"
609
761
  elif g.get("stalled_unverified"):
610
- detail += "/+ transcript が読めず、応答可能かは未確認"
762
+ detail += "/+ 足跡も transcript も読めず、応答可能かは未確認"
611
763
  return {
612
764
  "short_id": g["short_id"],
613
765
  "spawned_at": g.get("spawned_at") or "(spawn 記録なし)",