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.json의vault-analyst-feedbackprompt-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 status는 8 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