virtual-insanity
← 리포트 목록

cron 7개 silent fail 감사 + 패치

2026-04-15 cron [phase17, cron, silent-fail, hermes, patch]

cron 7개 silent fail 감사 + 패치

결론

  • 대상 7개 cron을 모두 감사했고, 실제 코드/정의 기준으로 7개 모두 패치 또는 정상화했다.
  • 스크립트 7개: price_history_collector.py, bond_daily_report.py, gmail_credit_monitor.py, fed_liquidity_aggregator.py, blog_monitor.py, gmail_newsletter_collector.py, vault_analyst_feedback.py
  • 추가 래퍼 1개: gmail_newsletter_collector_dual.sh
  • 추가 cron 정의 1개: ~/.hermes/cron/jobs.jsonvault-analyst-feedback prompt-only job을 shell job으로 전환
  • 금지 파일 shared/llm.py, shared/cycle_base.py는 수정하지 않았다.
  • 백업 생성 완료: *.bak-silent-fail-20260415-193234~/.hermes/cron/jobs.json.bak-silent-fail-20260415-193234.
  • 네트워크 정상 환경에서 정상 케이스와 강제 실패 케이스를 모두 실측했다.
  • 정상 케이스: 전부 exit 0
  • 강제 실패 케이스: 전부 exit non-zero
  • 주의: 검증 중 실제 정상 수집으로 아래 산출물이 새로 생겼다. 텔레그램 발송은 막았다.
  • gmail_credit_monitor --fixed-income-only: 260415_황대진_전달-일일-415-수-채권시장-마감정리-DS증권-황대진.md 저장, bond trigger는 env로 skip.
  • gmail_newsletter_collector_dual.sh --no-digest: Linas Newsletter 1건 저장, digest 발송 없음.
  • hermes cron status8 active jobs를 보지만 Gateway is not running — cron jobs will NOT fire를 보고했다. silent-fail 패치는 반영됐지만 자동 firing은 Gateway 복구가 별도 필요하다.

1단계 — 감사 결과

파일 실패 경로 위치/패턴 기존 exit code 위험 위험도 적용 수정
price_history_collector.py 314-320: yfinance+StockAnalysis fallback 모두 실패해도 기존 records 있으면 True 0 가능 high 기존 데이터 유지는 하되 이번 수집은 return False → exit 1
bond_daily_report.py 823-868: 노트 없음/LLM 실패 후 return; 전송 실패도 bool 로그 중심 0 가능 high run() bool 반환 + sys.exit; missing note/LLM/group send 실패 exit 1
gmail_credit_monitor.py sender query 실패 continue, 전체 실패 후 saved=0도 fixed-income-only 0; bond trigger 실패 swallow 0 가능 high query/detail 실패 카운터, 전체 실패/상세 실패만 있고 저장 0이면 exit 1; trigger rc 반영
fed_liquidity_aggregator.py series missing/invalid을 {}로 삼킴; history 0이어도 저장 가능 일부 0 가능 med series 로드 오류 stderr 명시, history 0이면 RuntimeError exit 1
blog_monitor.py RSS 오류는 exit2였지만 LLM extraction 전체 실패 시 raw 저장 후 ok 가능 0 가능 med analysis_success/fail 기록, 신규 저장분 LLM 성공률 <50%면 exit 1
gmail_newsletter_collector.py newsletter query 실패 continue; main 반환값을 process rc에 미반영 0 가능 high query/message 실패 카운터, 전체 query 실패/로드 실패만 있으면 exit 1, sys.exit(main())
gmail_newsletter_collector_dual.sh 두 Gmail token 모두 없으면 loop skip 후 overall_rc=0 0 가능 high ran_any 카운터 추가, 토큰 0개면 exit 1
vault_analyst_feedback.py + Hermes job 스크립트는 입력 latest.json 전부 없어도 스킵 후 ok 가능; Hermes job은 prompt-only라 Codex 401 0 가능/실행 불가 high 입력 latest.json 유효성 가드 + sys.exit(main()); Hermes job을 shell command로 전환

2~3단계 — 패치 요약

  • price_history_collector.py: fallback까지 실패하면 기존 데이터가 있어도 이번 수집 실패로 처리.
  • bond_daily_report.py: run()이 bool을 반환하고, 노트 없음/LLM 실패/그룹 전송 실패 시 process exit 1.
  • gmail_credit_monitor.py: Fixed Income Gmail query 전체 실패 또는 상세 조회 실패만 있고 저장 0건이면 exit 1. bond trigger 실패도 rc에 반영.
  • fed_liquidity_aggregator.py: series 파일 누락/파싱 오류 stderr 표시, history 0건이면 exit 1.
  • blog_monitor.py: RSS 오류는 기존처럼 exit 2, 신규 저장분의 LLM insight 성공률이 50% 미만이면 exit 1.
  • gmail_newsletter_collector.py: 전체 Gmail query 실패/메시지 로드 실패만 있고 저장 0건이면 exit 1, sys.exit(main()) 적용.
  • gmail_newsletter_collector_dual.sh: 두 계정 토큰이 모두 없으면 exit 1.
  • vault_analyst_feedback.py: 입력 analyst latest.json 유효성 가드 추가, sys.exit(main()) 적용.
  • ~/.hermes/cron/jobs.json: vault-analyst-feedback을 Codex prompt job에서 deterministic shell job으로 전환.

전체 diff artifact:

/tmp/cron_silent_fail_audit_20260415-193234/patch_final.diff

패치 diff

--- /Users/ron/.hermes/workspace/scripts/pipeline/price_history_collector.py.bak-silent-fail-20260415-193234    2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/price_history_collector.py    2026-04-15 19:33:16
@@ -314,10 +314,10 @@
             except Exception as fallback_error:
                 if existing_records:
                     print(
-                        f"  [WARN] {ticker} fallback 실패 — 기존 {len(existing_records)}일치 유지: "
+                        f"  [ERROR] {ticker} fallback 실패 — 기존 {len(existing_records)}일치는 유지하지만 이번 수집은 실패 처리: "
                         f"{fallback_error}"
                     )
-                    return True
+                    return False
                 raise

         if not new_records:
--- /Users/ron/.hermes/workspace/scripts/pipeline/bond_daily_report.py.bak-silent-fail-20260415-193234  2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/bond_daily_report.py  2026-04-15 19:34:33
@@ -815,7 +815,7 @@

 # ── 메인 ──────────────────────────────────────────────────────────────────────

-def run(notify: bool = True, dry_run: bool = False) -> None:
+def run(notify: bool = True, dry_run: bool = False) -> bool:
     log("bond_daily_report 시작")

     # Stage 1
@@ -829,7 +829,7 @@
                 "missing_note_alert_ok_or_dedupe" if alert_ok else "missing_note_alert_failed",
                 "missing_hwang_note",
             )
-        return
+        return False

     log(f"노트: {note.name}")
     raw_text = note.read_text(encoding="utf-8")
@@ -865,7 +865,7 @@
                 "llm_failed_alert_ok_or_dedupe" if alert_ok else "llm_failed_alert_failed",
                 err,
             )
-        return
+        return False

     log(f"LLM 완료 (모델: {used_model}, {len(insight)}자)")

@@ -902,7 +902,7 @@
     if dry_run:
         print(report_md)
         print(f"\n[파서 데이터]\n{json.dumps(data, ensure_ascii=False, indent=2, default=str)}")
-        return
+        return True

     # PDF 생성
     pdf_path = _generate_pdf(report_md, date_str)
@@ -933,6 +933,8 @@
                 f"message_id={group_result.get('message_id')} "
                 f"error={group_result.get('error')}"
             )
+            if not group_result.get("ok"):
+                return False
             # 2) PDF DM (개인)
             dm_result = send_document_result(
                 492860021,
@@ -967,8 +969,12 @@
                 f"텔레그램 그룹 텍스트 폴백: {'성공' if ok else '실패'} "
                 f"message_ids={mids} statuses={statuses}"
             )
+            if not ok:
+                return False

+    return True

+
 if __name__ == "__main__":
     parser = argparse.ArgumentParser(description="황대진 채권 브리핑 3페이지 리포트 생성")
     parser.add_argument("--dry-run", action="store_true", help="stdout만 출력, 저장/전송 안 함")
@@ -979,4 +985,4 @@
         # 기본: dry-run
         args.dry_run = True

-    run(notify=args.notify, dry_run=args.dry_run)
+    sys.exit(0 if run(notify=args.notify, dry_run=args.dry_run) else 1)
--- /Users/ron/.hermes/workspace-root-scripts/gmail_credit_monitor.py.bak-silent-fail-20260415-193234   2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace-root-scripts/gmail_credit_monitor.py   2026-04-15 19:34:13
@@ -291,6 +291,9 @@
     processed_ids = _load_processed_ids()
     new_ids = set()
     saved = 0
+    query_attempts = 0
+    query_failures = 0
+    detail_failures = 0

     for sender_cfg in FIXED_INCOME_SENDERS:
         email = sender_cfg["email"]
@@ -306,6 +309,7 @@
             base = f'subject:"{subject_query}"' if subject_query else f"from:{email}"
             query = f"{base} after:{lookback}"

+        query_attempts += 1
         try:
             results = service.users().messages().list(
                 userId='me',
@@ -314,6 +318,7 @@
             ).execute()
         except Exception as e:
             print(f"[warn] {sender_name} 쿼리 실패: {e}", file=sys.stderr)
+            query_failures += 1
             continue

         messages = results.get('messages', [])
@@ -329,6 +334,7 @@
                 msg_detail = service.users().messages().get(userId='me', id=msg_id).execute()
             except Exception as e:
                 print(f"[warn] 메시지 상세 조회 실패 {msg_id}: {e}", file=sys.stderr)
+                detail_failures += 1
                 continue

             headers = msg_detail['payload']['headers']
@@ -349,6 +355,15 @@

     if new_ids:
         _save_processed_ids(processed_ids.union(new_ids))
+
+    print(
+        f"[fixed-income] query_attempts={query_attempts} "
+        f"query_failures={query_failures} detail_failures={detail_failures}"
+    )
+    if query_attempts > 0 and query_failures == query_attempts:
+        raise RuntimeError(f"Fixed Income Gmail queries all failed ({query_failures}/{query_attempts})")
+    if detail_failures > 0 and saved == 0:
+        raise RuntimeError(f"Fixed Income message detail failures with no saved notes ({detail_failures})")

     return saved

@@ -574,26 +589,34 @@
     today_prefix = datetime.now().strftime("%y%m%d")
     credit_dir = os.path.expanduser("~/knowledge/100 수신함/119 크레딧메일")
     if not os.path.isdir(credit_dir):
-        return
+        return None
     today_hwang = [f for f in os.listdir(credit_dir) if f.startswith(today_prefix) and "황대진" in f]
     if not today_hwang:
-        return
+        return None
     # 이미 오늘 브리핑이 생성되었는지 확인 (중복 방지)
     briefing_dir = os.path.expanduser("~/.hermes/workspace/memory/bond-briefing")
     today_iso = datetime.now().strftime("%Y-%m-%d")
     if os.path.exists(os.path.join(briefing_dir, f"{today_iso}.json")):
         print(f"[bond-trigger] 오늘({today_iso}) 브리핑 이미 존재 — 스킵")
-        return
+        return None
+    if os.getenv("GMAIL_CREDIT_SKIP_BOND_TRIGGER") == "1":
+        print("[bond-trigger] GMAIL_CREDIT_SKIP_BOND_TRIGGER=1 — 스킵")
+        return None
     print(f"[bond-trigger] 황대진 메일 감지({len(today_hwang)}건) → bond_daily_report 실행")
     bond_script = os.path.expanduser("~/.hermes/workspace/scripts/pipeline/bond_daily_report.py")
     try:
-        subprocess.run(
+        result = subprocess.run(
             [sys.executable, bond_script, "--notify"],
             timeout=300,
             cwd=os.path.expanduser("~/.hermes/workspace"),
         )
+        if result.returncode != 0:
+            print(f"[bond-trigger] bond_daily_report 실패 rc={result.returncode}", file=sys.stderr)
+            return False
+        return True
     except Exception as e:
         print(f"[bond-trigger] 실행 실패: {e}", file=sys.stderr)
+        return False


 def main():
@@ -614,12 +637,18 @@
         return 1

     # Fixed Income 수집 (볼트 저장)
-    fi_saved = collect_fixed_income(service, backfill=args.backfill)
+    try:
+        fi_saved = collect_fixed_income(service, backfill=args.backfill)
+    except Exception as e:
+        print(f"[fixed-income] 실패: {e}", file=sys.stderr)
+        return 1
     print(f"[fixed-income] 볼트 저장 {fi_saved}건")

     # 황대진 메일이 새로 저장되면 bond_daily_report 자동 트리거
     if fi_saved > 0:
-        _trigger_bond_report_if_new()
+        trigger_ok = _trigger_bond_report_if_new()
+        if trigger_ok is False:
+            return 1

     if args.fixed_income_only:
         return 0
--- /Users/ron/.hermes/workspace/scripts/pipeline/fed_liquidity_aggregator.py.bak-silent-fail-20260415-193234   2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/fed_liquidity_aggregator.py   2026-04-15 19:33:24
@@ -26,6 +26,7 @@
     """series_id.json → {date: value(백만달러)} 딕셔너리 반환."""
     path = MACRO_DIR / f"{series_id}.json"
     if not path.exists():
+        print(f"[fed_liquidity_aggregator] ERROR: missing series file: {path}", file=sys.stderr)
         return {}
     try:
         d = json.loads(path.read_text(encoding="utf-8"))
@@ -35,8 +36,11 @@
                 result[row["date"]] = float(row["value"])
             except (TypeError, ValueError):
                 pass
+        if not result:
+            print(f"[fed_liquidity_aggregator] ERROR: no valid rows in {path}", file=sys.stderr)
         return result
-    except Exception:
+    except Exception as e:
+        print(f"[fed_liquidity_aggregator] ERROR: failed to load {path}: {e}", file=sys.stderr)
         return {}


@@ -268,6 +272,8 @@

     data = compute(target)
     history = build_history(52)
+    if not history:
+        raise RuntimeError("history build failed: 0 records")
     _save(data, history, dry_run=args.dry_run)

     print(f"[fed_liquidity_aggregator] 완료")
--- /Users/ron/.hermes/workspace/scripts/pipeline/blog_monitor.py.bak-silent-fail-20260415-193234   2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/blog_monitor.py   2026-04-15 19:33:32
@@ -481,6 +481,8 @@

     saved = 0
     notified = 0
+    analysis_success = 0
+    analysis_fail = 0
     for guid, entry in new_entries:
         title = getattr(entry, "title", "Untitled")
         summary = getattr(entry, "summary", "")
@@ -493,6 +495,10 @@

         # LLM 인사이트 추출
         extracted = extract_insight(title, analysis_text, categories)
+        if extracted:
+            analysis_success += 1
+        else:
+            analysis_fail += 1

         # 지정학 워치리스트 업데이트
         if extracted and extracted.get("regions"):
@@ -515,8 +521,19 @@
     result = {"source": "blog_monitor", "blog": BLOG_ID,
               "collected_at": datetime.now().isoformat(),
               "status": "ok", "new_posts": saved,
-              "notified": notified, "total_processed": len(processed)}
+              "notified": notified, "total_processed": len(processed),
+              "analysis_success": analysis_success,
+              "analysis_fail": analysis_fail}
     print(json.dumps(result, ensure_ascii=False))
+    if saved > 0 and analysis_success == 0:
+        log("All new entries saved without LLM insight extraction", level="ERROR")
+        raise SystemExit(1)
+    if saved > 0 and analysis_success / saved < 0.5:
+        log(
+            f"LLM insight success ratio below 50%: {analysis_success}/{saved}",
+            level="ERROR",
+        )
+        raise SystemExit(1)
     return result


--- /Users/ron/.hermes/workspace/scripts/pipeline/gmail_newsletter_collector.py.bak-silent-fail-20260415-193234 2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/gmail_newsletter_collector.py 2026-04-15 19:33:47
@@ -284,6 +284,9 @@
     results = []
     only_key = only.lower().strip() if only else ""
     total_saved = 0
+    query_attempts = 0
+    query_failures = 0
+    message_load_failures = 0

     after_date = "" if backfill else (
         datetime.now(KST) - timedelta(days=days)
@@ -296,12 +299,14 @@
         if after_date:
             query += f" after:{after_date}"

+        query_attempts += 1
         try:
             resp = service.users().messages().list(
                 userId="me", q=query, maxResults=20
             ).execute()
         except Exception as e:
             print(f"[error] {nl['name']} 검색 실패: {e}")
+            query_failures += 1
             continue

         messages = resp.get("messages", [])
@@ -321,6 +326,7 @@
                 ).execute()
             except Exception as e:
                 print(f"[error] 메시지 {msg_id} 로드 실패: {e}")
+                message_load_failures += 1
                 continue

             headers = {h["name"]: h["value"]
@@ -401,7 +407,15 @@
     state["processed_ids"] = list(processed_set)
     state["last_run"] = datetime.now(KST).isoformat()
     state["last_count"] = len(results)
+    state["last_query_attempts"] = query_attempts
+    state["last_query_failures"] = query_failures
+    state["last_message_load_failures"] = message_load_failures
     _save_state(state)
+
+    if query_attempts > 0 and query_failures == query_attempts:
+        raise RuntimeError(f"all Gmail newsletter queries failed ({query_failures}/{query_attempts})")
+    if message_load_failures > 0 and not results:
+        raise RuntimeError(f"message loads failed and no newsletter saved ({message_load_failures} failures)")

     return results

@@ -452,13 +466,17 @@
     args = parser.parse_args()

     print(f"[start] 뉴스레터 수집 시작 (days={args.days}, backfill={args.backfill}, only={args.only}, limit={args.limit})")
-    results = collect(days=args.days, backfill=args.backfill, only=args.only, limit=args.limit)
+    try:
+        results = collect(days=args.days, backfill=args.backfill, only=args.only, limit=args.limit)
+    except Exception as e:
+        print(f"[fatal] 뉴스레터 수집 실패: {e}", file=sys.stderr)
+        return 1
     print(f"[done] {len(results)}건 수집 완료")

     if not args.no_digest:
         _send_digest(results)
-    return len(results)
+    return 0


 if __name__ == "__main__":
-    main()
+    sys.exit(main())
--- /Users/ron/.hermes/workspace/scripts/pipeline/gmail_newsletter_collector_dual.sh.bak-silent-fail-20260415-193234    2026-04-15 19:32:34
+++ /Users/ron/.hermes/workspace/scripts/pipeline/gmail_newsletter_collector_dual.sh    2026-04-15 19:33:51
@@ -17,11 +17,13 @@
 )

 overall_rc=0
+ran_any=0
 for token in "${tokens[@]}"; do
   if [ ! -f "$token" ]; then
     echo "[dual-collector] skip missing token: $token"
     continue
   fi
+  ran_any=1
   label="$(basename "$token")"
   echo "=== [dual-collector] run with $label ==="
   if GMAIL_TOKEN_PATH="$token" python3 "$COLLECTOR" "$@"; then
@@ -33,4 +35,9 @@
   fi
 done

+if [ "$ran_any" -eq 0 ]; then
+  echo "[dual-collector] FAILED: no Gmail token files found" >&2
+  exit 1
+fi
+
 exit "$overall_rc"
--- /Users/ron/.hermes/workspace/scripts/pipeline/vault_analyst_feedback.py.bak-silent-fail-20260415-193234 2026-04-15 19:41:31
+++ /Users/ron/.hermes/workspace/scripts/pipeline/vault_analyst_feedback.py 2026-04-15 19:41:42
@@ -572,7 +572,35 @@
 # main
 # ────────────────────────────────────────────────────────────────────────────

-def main() -> None:
+def _selected_source_paths(args) -> list[Path]:
+    """이번 실행 대상 경로의 입력 latest.json 목록."""
+    paths: list[Path] = []
+    if not args.macro_only and not args.technical_only:
+        paths.append(WORKSPACE / "memory" / "analyst-fundamental" / "latest.json")
+    if not args.fundamental_only and not args.technical_only:
+        paths.append(WORKSPACE / "memory" / "analyst-macro" / "latest.json")
+    if not args.fundamental_only and not args.macro_only:
+        paths.append(WORKSPACE / "memory" / "analyst-technical" / "latest.json")
+    return paths
+
+
+def _has_valid_source(path: Path) -> bool:
+    """입력 latest.json이 존재하고 JSON object/list로 파싱되는지 확인."""
+    if not path.exists():
+        log.error(f"입력 파일 없음: {path}")
+        return False
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except Exception as e:
+        log.error(f"입력 파일 파싱 실패: {path}: {e}")
+        return False
+    if not data:
+        log.error(f"입력 파일 비어 있음: {path}")
+        return False
+    return isinstance(data, (dict, list))
+
+
+def main() -> int:
     parser = argparse.ArgumentParser(description="분석 에이전트 → 볼트 환류")
     parser.add_argument("--dry-run", action="store_true", help="파일 미수정 시뮬레이션")
     parser.add_argument("--fundamental-only", action="store_true", help="경로 1만 실행")
@@ -580,6 +608,16 @@
     parser.add_argument("--technical-only", action="store_true", help="경로 3만 실행")
     args = parser.parse_args()

+    source_paths = _selected_source_paths(args)
+    valid_sources = [p for p in source_paths if _has_valid_source(p)]
+    if not valid_sources:
+        print(json.dumps({
+            "status": "error",
+            "error": "no valid analyst latest.json inputs",
+            "checked": [str(p) for p in source_paths],
+        }, ensure_ascii=False))
+        return 1
+
     stats = {
         "profiles_updated": 0,
         "signals_created": 0,
@@ -633,6 +671,8 @@
     except Exception:
         pass

+    return 0

+
 if __name__ == "__main__":
-    main()
+    sys.exit(main())
--- /Users/ron/.hermes/cron/jobs.json.bak-silent-fail-20260415-193234   2026-04-15 19:41:31
+++ /Users/ron/.hermes/cron/jobs.json   2026-04-15 19:41:54
@@ -3,7 +3,7 @@
     {
       "id": "41c2736f0527",
       "name": "vault-analyst-feedback",
-      "prompt": "\ubcfc\ud2b8 \ubd84\uc11d \ud658\ub958 \uc791\uc5c5\uc744 \uc2e4\ud589\ud574\uc918.\n\n1) ~/knowledge/500 \uc2dc\uadf8\ub110/ \uc5d0\uc11c \uc624\ub298 \ub0a0\uc9dc\uc758 \ub9e4\ud06c\ub85c/\ud380\ub354\uba58\ud0c8/\ud14c\ud06c\ub2c8\uceec \uc2dc\uadf8\ub110 \ud30c\uc77c\uc744 \uc77d\uc5b4\n2) \ud575\uc2ec \uc778\uc0ac\uc774\ud2b8\ub97c \ucd94\ucd9c\ud574\uc11c ~/knowledge-agent/200-atomic/ \uc5d0 \uc6d0\uc790 \ub178\ud2b8\ub85c \uc800\uc7a5\ud574\n3) \ud30c\uc77c\uba85 \ud615\uc2dd: YYMMDD_analyst-feedback-{topic}.md\n4) frontmatter \ud3ec\ud568: title, date, tags, maturity: seedling\n5) \uc131\uacf5\uc774\uba74 [SILENT]\ub85c \uc2dc\uc791\ud574\uc11c \uc751\ub2f5\ud574. \uc2e4\ud328\ud558\uba74 \uc5d0\ub7ec \ubcf4\uace0\ud574.",
+      "prompt": "",
       "skills": [],
       "skill": null,
       "model": null,
@@ -29,7 +29,19 @@
       "last_status": "error",
       "last_error": "RuntimeError: Codex token refresh failed with status 401.",
       "deliver": "local",
-      "origin": null
+      "origin": null,
+      "type": "shell",
+      "job_type": "shell",
+      "kind": "shell",
+      "command": "/usr/bin/python3 /Users/ron/.hermes/workspace/scripts/pipeline/vault_analyst_feedback.py",
+      "cwd": "/Users/ron/.hermes/workspace",
+      "timeout_seconds": 300,
+      "silent": false,
+      "updated_at": "2026-04-15T19:41:54+09:00",
+      "migration": {
+        "silent_fail_patch": "2026-04-15",
+        "reason": "prompt-only job failed with Codex token 401; route to deterministic Hermes shell script with exit codes"
+      }
     },
     {
       "id": "ocPH-SPY-price-history-refresh",
@@ -137,7 +149,7 @@
     {
       "id": "ocAO-AO003-bond-morning-command",
       "name": "bond-morning-poll-real-gmail-collector",
-      "description": "Phase17 restored deterministic shell runner for \ud669\ub300\uc9c4 fixed-income Gmail collector.",
+      "description": "Phase17 restored deterministic shell runner for 황대진 fixed-income Gmail collector.",
       "enabled": true,
       "state": "scheduled",
       "type": "shell",
@@ -154,7 +166,7 @@
         "completed": 1
       },
       "cwd": "/Users/ron/.hermes/workspace",
-      "command": "/usr/bin/python3 /Users/ron/.hermes/workspace-root-scripts/gmail_credit_monitor.py --fixed-income-only && echo \uc644\ub8cc",
+      "command": "/usr/bin/python3 /Users/ron/.hermes/workspace-root-scripts/gmail_credit_monitor.py --fixed-income-only && echo 완료",
       "timeout_seconds": 1800,
       "prompt": "",
       "skills": [],
@@ -182,7 +194,7 @@
     {
       "id": "macro-series-collector",
       "name": "macro-series-collector FRED macro timeseries collector",
-      "description": "FRED 46\uac1c \uac70\uc2dc\uacbd\uc81c \uc2dc\uacc4\uc5f4 \uc218\uc9d1. RRPONTSYD/WTREGEN/WRESBAL \ud3ec\ud568.",
+      "description": "FRED 46개 거시경제 시계열 수집. RRPONTSYD/WTREGEN/WRESBAL 포함.",
       "enabled": true,
       "state": "scheduled",
       "type": "shell",
@@ -236,7 +248,7 @@
     {
       "id": "fed-liquidity-aggregator",
       "name": "fed-liquidity-aggregator Fed liquidity aggregator",
-      "description": "RRP/TGA/\uc9c0\uae09\uc900\ube44\uae08 \ubc0f WALCL \uae30\ubc18 Fed \uc21c\uc720\ub3d9\uc131 \uc9d1\uacc4.",
+      "description": "RRP/TGA/지급준비금 및 WALCL 기반 Fed 순유동성 집계.",
       "enabled": true,
       "state": "scheduled",
       "type": "shell",

4단계 — 검증 결과

구문 검증

python3 -m py_compile price_history_collector.py                  OK
python3 -m py_compile bond_daily_report.py                        OK
python3 -m py_compile gmail_credit_monitor.py                     OK
python3 -m py_compile fed_liquidity_aggregator.py                 OK
python3 -m py_compile blog_monitor.py                             OK
python3 -m py_compile gmail_newsletter_collector.py               OK
python3 -m py_compile vault_analyst_feedback.py                   OK
bash -n gmail_newsletter_collector_dual.sh                        OK

네트워크 정상 환경 실측 + 강제 실패 실측

case 구분 rc sec 판정 증거 tail
price_normal_spy 정상 0 1.3 PASS === 가격 이력 수집 시작 [증분] — 1개 티커 ===
저장 경로: /Users/ron/.hermes/workspace/memory/price-history

[SPY] 증분 수집 (마지막: 2026-04-14)...
[SPY] OK — 51일치 저장 (신규 2개)

=== 완료 — 성공 1/1, 실패 0 ===
price_forced_proxy_fail_spy 강제 실패 1 0.4 PASS === 가격 이력 수집 시작 [증분] — 1개 티커 ===
저장 경로: /Users/ron/.hermes/workspace/memory/price-history

[SPY] 증분 수집 (마지막: 2026-04-14)...
[WARN] SPY yfinance 수집 실패: 'NoneType' object is not subscriptable
[SPY] StockAnalysis fallback 시도...
[ERROR] SPY fallback 실패 — 기존 51일치는 유지하지만 이번 수집은
fed_normal_dry_run 정상 0 0.1 PASS "rrp_wow_t": 0.0001,
"tga_wow_t": 0.0,
"walcl_wow_t": 0.0,
"resv_wow_t": 0.0,
"phase": "유동성 축소 — 흡수 국면",
"phase_color": "red",
"interpretation": "TGA 증가 또는 Fed 자산 감소로 시장에서 자금이 회수되고 있음.",
"investment_signal": "단기 위험자산 비중 축소. 연준 정책 전환 시점 주시."
}
[fed_liquidity_aggregat
fed_forced_bad_date 강제 실패 1 0.1 PASS [fed_liquidity_aggregator] 시작: 1900-01-01
blog_normal_dry_run 정상 0 0.4 PASS [2026-04-15 19:42:32] Fetching RSS from https://rss.blog.naver.com/ranto28
[2026-04-15 19:42:32] RSS fetched: 50 entries
[2026-04-15 19:42:32] Category filter: 50 → 44 entries
[2026-04-15 19:42:32] New entries: 34
[2026-04-15 19:42:32] [DRY-RUN] 이스라엘이 달라진 이유 2 [경제/주식/국제정세/사회]
{
blog_forced_proxy_fail 강제 실패 2 0.3 PASS [2026-04-15 19:42:32] Fetching RSS from https://rss.blog.naver.com/ranto28
[2026-04-15 19:42:32] [ERROR] RSS fetch error: [Errno 61] Connection refused
[2026-04-15 19:42:32] No entries found
{"source": "blog_monitor", "blog": "ranto28", "collected_at": "2026-04-15T19:42:32.707228
bond_normal_dry_run 정상 0 214.7 PASS {
"title": "호텔신라 , 회사채 수요예측서 1 兆 주문받아",
"url": "https://news.einfomax.co.kr/news/articleView.html?idxno=4409495"
}
],
"issuance_summary": {
"여전채": 6700,
"회사채": 4500
}
}
bond_forced_missing_note 강제 실패 1 0.3 PASS [2026-04-15 19:51:34] bond_daily_report 시작
[2026-04-15 19:51:34] [ERROR] 황대진 노트를 찾을 수 없습니다: /tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/empty_home/knowledge/100 수신함/119 크레딧메일
gmail_credit_normal_fixed_income_only 정상 0 2.6 PASS ✅ Gmail 토큰 자동 갱신 완료
[정대호] 0건 발견
[이향기] 0건 발견
[황대진] 9건 발견
[vault] 저장: 260415_황대진_전달-일일-415-수-채권시장-마감정리-DS증권-황대진.md
[fixed-income] query_attempts=3 query_failures=0 detail_failures=0
[fixed-income] 볼트 저장 1건
[bond-trigger] GMAIL_CREDIT_SKIP_BOND_TRIGGER=1 — 스킵
gmail_credit_forced_bad_creds 강제 실패 1 0.2 PASS /Users/ron/Library/Python/3.9/lib/python/site-packages/urllib3/init.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
/Users/ron
newsletter_normal_no_digest 정상 0 2.5 PASS [start] 뉴스레터 수집 시작 (days=0, backfill=False, only=None, limit=1)
[done] 0건 수집 완료
newsletter_forced_bad_creds 강제 실패 1 0.2 PASS [start] 뉴스레터 수집 시작 (days=0, backfill=False, only=None, limit=1)
newsletter_dual_normal_no_digest 정상 0 56.7 PASS [start] 뉴스레터 수집 시작 (days=0, backfill=False, only=None, limit=1)
[done] 0건 수집 완료
[dual-collector] gmail_token.json ok
=== [dual-collector] run with gmail_token_mangdeng2.json ===
[start] 뉴스레터 수집 시작 (days=0, backfill=False, only=None, limit=1)
[auth] 토큰 자동 갱신 완료
[vault] 저장: 260415_
newsletter_dual_forced_no_tokens 강제 실패 1 0.0 PASS [dual-collector] skip missing token: /tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/empty_home/.credentials/gmail_token.json
[dual-collector] skip missing token: /tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/empty_home/.credentials/gma
vault_feedback_normal_dry_run 정상 0 0.2 PASS [2026-04-15 19:52:36] [DEBUG] [경로2] kr_credit_stress — 이상 없음 (z=1.34, v=0.67)
[2026-04-15 19:52:36] [DRY] [경로2] 신규 시그널 노트 생성 예정: macro-signal-fed_cycle_end.md
[2026-04-15 19:52:36] [DRY] [경로2] 신규 시그널 노트 생성 예정: macro-signal-gpr_oil_divergence.md
[2026-04-15 19:52:36] [DEBUG] [경로2]
vault_feedback_forced_empty_home 강제 실패 1 0.1 PASS [2026-04-15 19:52:36] [ERROR] 입력 파일 없음: /tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/empty_home/.hermes/workspace/memory/analyst-fundamental/latest.json
[2026-04-15 19:52:36] [ERROR] 입력 파일 없음: /tmp/cron_silent_fail_audit_20260415-193234/verify_network_rema

5단계 — Hermes cron 반영 여부

파일 Hermes job id 현재 command 반영 판정
price_history_collector.py ocPH-SPY-price-history-refresh python3 .../price_history_collector.py --ticker SPY 반영됨
bond_daily_report.py ocAK-AK000-bond-daily-dry-run /usr/bin/python3 .../bond_daily_report.py --notify 반영됨
gmail_credit_monitor.py ocAO-AO003-bond-morning-command /usr/bin/python3 .../workspace-root-scripts/gmail_credit_monitor.py --fixed-income-only && echo 완료 반영됨; rc 0일 때만 echo
fed_liquidity_aggregator.py fed-liquidity-aggregator /usr/bin/python3 .../fed_liquidity_aggregator.py 반영됨
blog_monitor.py ocM-M019-blog-monitor python3 .../blog_monitor.py --notify 반영됨
gmail_newsletter_collector.py + dual.sh ocAQ-AQ004-gmail-newsletter-command bash .../gmail_newsletter_collector_dual.sh --days 2 반영됨
vault_analyst_feedback.py 41c2736f0527 /usr/bin/python3 .../vault_analyst_feedback.py prompt-only → shell 전환 완료

hermes cron status 확인:

✗ Gateway is not running — cron jobs will NOT fire
8 active job(s)
Next run: 2026-04-15T22:46:46.116361+09:00

즉, jobs.json 반영은 완료됐지만 Gateway 재기동/복구는 별도 작업이다.

증거 파일

/tmp/cron_silent_fail_audit_20260415-193234/patch_final.diff
/tmp/cron_silent_fail_audit_20260415-193234/verify_network/results.jsonl
/tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/results.json
각 stdout/stderr: /tmp/cron_silent_fail_audit_20260415-193234/verify_network/*.log, /tmp/cron_silent_fail_audit_20260415-193234/verify_network_remaining/*.log

자체평가

  • 정확성: 4.7/5 — 7개 cron 전부 silent-fail 경로를 exit code로 연결했고, vault prompt job까지 shell로 정상화했다.
  • 완성도: 4.6/5 — 백업, 패치 diff, 정상/강제 실패 실측, cron 매핑 확인 완료.
  • 검증: 4.7/5 — 정상 8케이스 rc=0, 강제 실패 8케이스 rc!=0, 구문 검사 모두 통과.
  • 최소 변경: 4.5/5 — exit code/로깅/잡 정의만 수정. 단, vault-analyst-feedback은 실행 불가 prompt job이라 jobs.json 변경이 필요했다.

종합: 4.63/5