Topic Planner topics.md 누락 재시도: 빈 성공을 실패로 바꾸는 산출물 계약
scripts/run_daily_pipeline.py에서 Planner 호출 뒤 가능한 상태는 네 가지다. 첫 호출에서 파일이 있거나, 첫 누락 뒤 재시도에서 생기거나, 두 번 모두 없거나, 일반 파일은 있지만 파서가 거부한다. Topic Planner topics.md 누락 재시도는 이 가운데 두 번째 상태만 회복시키고 세 번째 상태는 실패로 끝내는 변경이다.
2026년 8월 14일 KST에 Linux 7.0.0-1009-aws와 Python 3.12.3에서 현재 코드의 Stage, planner_retry_stage(), PipelineError를 격리 fake runner로 확인했다. 실제 Codex Planner subprocess와 운영 Cron은 다시 실행하지 않았으며, timeout·비정상 종료·형식 오류는 이 재시도 분기의 대상이 아니다.
20초 핵심 요약
- 무엇: Planner 호출 직후
topics.md가 일반 파일인지 판정하는 산출물 검사다. - 왜: 하위 agent가 실패로 보고되지 않아도 필수 파일이 없으면 파싱이 중단되고 Writer와 Publisher가 입력을 받지 못한다.
- 어떻게:
Path.is_file()이 거짓일 때 한 번만 재호출하고, 다시 거짓이면PipelineError로 종료한다.
현재 코드는 파일 상태에 따라 세 갈래로 움직인다
판정 지점은 첫 run_stage() 직후다. topics_path.is_file()이 참이면 곧바로 parse_topic_plan()으로 간다. 거짓이면 planner_retry_stage()가 누락 사실과 단일 쓰기 대상을 보강한 Stage를 만들고, 그 Stage를 한 번만 실행한다.
첫 Planner 호출
├─ topics.md 일반 파일 있음 → parse_topic_plan()
└─ topics.md 일반 파일 없음 → planner_retry_stage() 1회
├─ 일반 파일 있음 → parse_topic_plan()
└─ 일반 파일 없음 → PipelineError
반복문이 없으므로 최대 호출 수는 2회다. 두 번째 호출이 반환됐다는 사실만으로 진행하지 않고 같은 경로를 다시 검사한다. Python의 Path.is_file() 문서는 경로가 없거나 일반 파일이 아니면 거짓을 반환한다고 설명한다.
이 판정은 내용 검사를 대신하지 않는다. 0바이트 파일이나 필수 필드가 빠진 Markdown도 일반 파일일 수 있다. 따라서 존재 검사를 통과한 뒤 parse_topic_plan()이 TOP10과 TOP2의 포함 관계와 필드 구조를 검사하는 두 번째 판정을 유지해야 한다.
실제 장애는 첫 누락 상태에서 뒤늦게 드러났다
2026년 8월 13일 로그에서 Topic Planner Agent는 118.639초 뒤 failed=false로 기록됐지만, 다음 줄에서 topics.md 부재 때문에 파이프라인이 실패했다. failed=false는 하위 agent 실행이 실패로 보고되지 않았다는 뜻으로 좁혀 읽어야 한다. Stage가 다음 단계에 넘겨야 할 파일까지 만들어졌다는 증거는 아니다.
$ nl -ba logs/2026-08-13.log | sed -n '73834,73842p' | sed -E 's#/home/ubuntu/apps/huntlab-blog/output/runs/[0-9TZ-]+[a-f0-9]+/topics\.md#[운영 경로]/topics.md#g; s#/home/ubuntu/apps/huntlab-blog/scripts/run_daily_pipeline\.py#[프로젝트]/scripts/run_daily_pipeline.py#g'
73834 2026-08-13T20:56:52+0900 INFO topic='-' agent=Topic Planner Agent event=end duration_seconds=118.639 failed=false
73835 2026-08-13T20:56:52+0900 ERROR pipeline event=failed reason=Topic Planner가 [운영 경로]/topics.md를 생성하지 않았습니다.
73836 Traceback (most recent call last):
73837 File "[프로젝트]/scripts/run_daily_pipeline.py", line 1403, in main
73838 plans = parse_topic_plan(topics_path)
73839 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
73840 File "[프로젝트]/scripts/run_daily_pipeline.py", line 330, in parse_topic_plan
73841 raise PipelineError(f"Topic Planner가 {path}를 생성하지 않았습니다.")
73842 PipelineError: Topic Planner가 [운영 경로]/topics.md를 생성하지 않았습니다.
# exit status: 0 (읽기 명령)

Python의 subprocess 문서도 returncode를 자식 프로세스의 종료 상태로 정의한다. 산출물 생성과 내용 유효성은 별도 조건이다. 커밋 c0143d4는 이 차이를 Planner 프롬프트의 단일 쓰기 대상과 호출 직후 파일 검사로 코드에 반영했다.
전체 명령으로 회복과 최종 실패를 재현한다
아래 명령은 프로젝트 루트에서 실행한다. 현재 코드의 세 객체를 import하고, 주제 디렉터리 아래 임시 디렉터리에서 main()의 run_stage → is_file → retry → is_file → PipelineError 분기만 재현한다. 첫 조건은 두 번 모두 파일을 쓰지 않고, 두 번째 조건은 재호출에서만 파일을 쓴다. 임시 디렉터리는 실행 종료와 함께 제거된다.
$ python3 --version && uname -sr
Python 3.12.3
Linux 7.0.0-1009-aws
$ python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
from scripts.run_daily_pipeline import PipelineError, Stage, planner_retry_stage
TOPIC_DIR = Path("output/runs/20260813T204726Z-3454bea56f/topic-808e2763b98b67ac")
def enforce_topics_contract(stage, topics_path, fake_run_stage):
fake_run_stage(stage)
if not topics_path.is_file():
retry = planner_retry_stage(stage, topics_path)
fake_run_stage(retry)
if not topics_path.is_file():
raise PipelineError("missing after retry")
def run_case(name, create_on_call):
with TemporaryDirectory(prefix="research-contract-", dir=TOPIC_DIR) as raw_dir:
topics_path = Path(raw_dir) / "topics.md"
calls = []
stage = Stage(
"Topic Planner Agent",
Path("agents/topic-planner.md"),
"planner prompt",
)
def fake_run_stage(current_stage):
calls.append(current_stage.prompt)
if create_on_call is not None and len(calls) == create_on_call:
topics_path.write_text("# topics\n", encoding="utf-8")
try:
enforce_topics_contract(stage, topics_path, fake_run_stage)
except PipelineError as exc:
print(
f"{name}: status=FAIL calls={len(calls)} "
f"artifact={topics_path.is_file()} error={exc}"
)
return len(calls), topics_path.is_file(), type(exc).__name__
print(
f"{name}: status=PASS calls={len(calls)} "
f"artifact={topics_path.is_file()} "
f"retry_prompt={'재시도입니다.' in calls[-1]}"
)
return len(calls), topics_path.is_file(), None
failed_calls, failed_artifact, failed_error = run_case("missing_twice", None)
recovered_calls, recovered_artifact, recovered_error = run_case(
"created_on_retry", 2
)
assert failed_calls == 2
assert failed_artifact is False
assert failed_error == "PipelineError"
assert recovered_calls == 2
assert recovered_artifact is True
assert recovered_error is None
print(
"bounded_contract: status=PASS max_calls=2 "
"third_call=False exit_contract=PipelineError"
)
PY
missing_twice: status=FAIL calls=2 artifact=False error=missing after retry
created_on_retry: status=PASS calls=2 artifact=True retry_prompt=True
bounded_contract: status=PASS max_calls=2 third_call=False exit_contract=PipelineError
$ printf 'shell_exit=%s\n' "$?"
shell_exit=0

missing_twice는 두 번째 검사에서도 파일이 없어 PipelineError('missing after retry')를 냈다. created_on_retry는 호출 2회, 파일 존재, 보강 프롬프트 포함을 모두 확인했다. 마지막 assertion은 세 번째 호출이 없고 누락의 최종 예외 타입이 PipelineError임을 검사한다. 셸 종료 상태 0은 두 조건에 대한 assertion이 모두 통과했다는 뜻이지, 누락 조건을 성공으로 바꿨다는 뜻이 아니다.
이 대조는 실제 Planner의 응답 품질이나 재시도 성공률을 측정하지 않는다. 파일 생성 시점이 호출 수와 다음 상태를 어떻게 바꾸는지만 확인한다.
오류별 첫 진단 지점을 섞지 않는다
같은 Planner 주변 오류라도 처음 볼 위치는 다르다.
| 관측 상태 | 판정 | 첫 진단 지점 |
|---|---|---|
| 첫 호출 뒤 일반 파일 있음 | 재시도 없이 파싱 | parse_topic_plan()의 내용 검사 |
| 첫 호출 뒤 없음, 재호출 뒤 있음 | 회복 후 파싱 | 재시도 경고와 두 번째 파일 생성 |
| 두 번 모두 없음 | 즉시 중단 | 단일 쓰기 경로와 재시도 프롬프트 |
| 일반 파일 있음, 파서 거부 | 내용 계약 실패 | TOP10·TOP2 포함 관계와 필수 필드 |
run_stage()가 timeout·비정상 종료 |
이 분기 밖의 실패 | 호출 예외 처리와 별도 재시도 예산 |
누락과 형식 오류를 같은 메시지로 합치면 재호출해야 할지 파일 내용을 고쳐야 할지 판단할 수 없다. 특히 timeout이나 비정상 종료는 현재 is_file() 분기까지 오지 않으므로 “Planner 실패는 모두 한 번 재시도된다”고 설명하면 틀리다.
재시도 한 번은 보편적인 최적값이 아니라 이 Stage의 제한된 예산이다. Google SRE의 cascading failures 장과 AWS의 재시도 제한 지침은 요청별 재시도를 제한하고 여러 계층의 재시도가 증폭되지 않게 하라고 설명한다. 상위 Cron도 전체 실행을 재시도한다면 두 계층의 최대 호출 수를 함께 계산해야 한다.
개별 Stage보다 넓은 agent 품질·복구 판정은 AI 에이전트 평가 하네스 설계에서 이어서 확인할 수 있다. 현재 글에서는 그 범위를 되풀이하지 않고 topics.md 누락 분기만 다룬다.
쓰기 부작용을 격리할 수 없으면 적용하지 않는다
Planner 재호출은 후보 생성을 다시 수행하는 쓰기 작업이다. 커밋은 변경 대상을 실행별 topics.md 하나로 제한하고 다른 output 실행 디렉터리·코드·테스트를 수정하지 말라고 프롬프트에 명시했다. 이는 안전성을 높이는 장치지만 실제 Planner의 완전한 멱등성을 증명하지는 않는다.
AWS Builders’ Library의 멱등 재시도 설명처럼 재실행 전에 중복 부작용을 통제해야 한다. 외부 발행·결제·삭제를 수행하거나, 같은 파일에 writer가 둘 이상 붙거나, 첫 호출이 아직 쓰는 중일 수 있거나, 원자적 완료를 판정할 수 없다면 이 패턴을 그대로 적용하지 않는다. 예상 밖의 다른 파일 변경, 중복 비용, 지연 급증이 관측되면 자동 재호출을 끄고 첫 누락에서 실패시키는 쪽으로 되돌린다.
이번 검증은 실제 subprocess, 운영 Cron, 동시 실행, 파일 권한 오류, 늦게 나타나는 파일, 부분 쓰기, 심볼릭 링크를 포함하지 않았다. 커밋 당시 timeout 상한 300초와 현재 작업 트리의 미커밋 900초 차이도 이 변경의 효과로 귀속하지 않는다. 크기, checksum, schema, completion marker가 필요한 Stage라면 is_file()보다 강한 완료 조건을 별도로 설계해야 한다.
배포 전 판정은 세 문장으로 끝낼 수 있다. 두 번째에도 파일이 없으면 Writer와 Publisher를 호출하지 않고 중단한다. 일반 파일이 있지만 파서가 실패하면 재시도 횟수를 늘리지 말고 내용 계약을 진단한다. timeout이나 비정상 종료라면 산출물 누락 분기로 우회하지 않고 그 예외의 재시도 예산을 따로 결정한다.