fix(agent-knowledge): serialize job progress writes, split lock renewal out
PDF 페이지 병렬화(agent_knowledge_pdf_page_concurrency=8) 이후 process_job 의 AsyncSession 을 페이지 워커와 메인 소비 루프가 동시에 만져 잡이 실패했다.
InvalidRequestError: This session is provisioning a new connection; concurrent operations are not permitted
parser 의 progress_lock 은 페이지 워커끼리만 직렬화했고, 같은 세션에 쓰는 메인 루프의 on_chunk_batch 는 그 락 밖에 있었다. 페이지 동시성 1 에서는 두 경로가 겹칠 수 없어 드러나지 않던 회귀다.
- parser: on_page_complete 호출을 페이지 워커에서 페이지를 순서대로 소비하는 메인 루프로 옮긴다. 세션에 쓰는 두 콜백이 한 코루틴에서 직렬 실행된다. progress_lock/completed_pages 는 불필요해져 제거.
- service: 진행률 쓰기를 mark_progress 단일 창구로 모은다(동작 동일, 중복 제거). 진행률 값은 여전히 단조 증가하지 않는다 — 스트리밍이라 parsing(26~59) 보고가 embedding(60) 뒤에 도착한다. 역주행을 막으려 뒤늦은 값을 버려봤더니 275p PDF 기준 진행률 쓰기가 38회→5회로 줄고 indexing(85) 이후 0회가 되어, 진행 중인 잡과 멈춘 잡을 구분할 수 없었다. 표시 방식 개선은 별도 과제로 분리한다.
- service: 폴러 claim 락 갱신을 진행률 콜백에서 떼어내 전용 세션으로 도는 _renew_lock_forever 하트비트(TTL/3 주기)로 분리한다. 갱신이 PDF 진행률에 얹혀 있어 non-PDF 잡은 파싱~임베딩 구간 갱신이 없던 문제도 함께 해소된다. 시도 소요시간만큼 다음 대기를 당겨 느린 실패가 주기를 밀지 않게 하고, extend_lock 이 0행이면(=다른 워커가 재클레임) WARNING 을 남긴다. 잡 종료 시 finally 에서 취소한다. 하트비트는 폴러 전용이라 queue 모드 (개발/검수/운영)에서는 기동하지 않는다.
- repository: extend_lock 이 갱신 행 수를 반환한다. 호출부가 없던 release_lock 제거.
테스트: 두 콜백 겹침을 공유 세션 스텁으로 관측하는 회귀 테스트(수정 전 실패 확인), 하트비트의 전용 세션/주기 당김/락 탈취 경고/실패 내성/실행 중 취소 테스트.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com