From ebe8a2aafe8b9c641f286f791be0d6578a8e271d Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:19:37 +0000 Subject: [PATCH 1/8] docs: specify evidence job admission and queue dispatch --- docs/changes/evidence-job-dispatch.md | 28 +++++++++++++++++++++++++++ docs/planning/progress.md | 4 ++++ docs/planning/tasks.md | 8 ++++++++ 3 files changed, 40 insertions(+) create mode 100644 docs/changes/evidence-job-dispatch.md diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md new file mode 100644 index 0000000..a986713 --- /dev/null +++ b/docs/changes/evidence-job-dispatch.md @@ -0,0 +1,28 @@ +# 영속 근거 작업의 접수·DBOS queue 연결 + +2026-10-26 KST. [저장·복구 worker](durable-evidence-worker.md) 다음 단계다. 지금은 workflow를 명시 시작할 수 있지만 서버의 작업 접수와 queue 전달이 연결되지 않았다. 이 변경은 재사용 가능한 신뢰 서버용 dispatch와 별도 worker queue 등록을 만든다. 브라우저에 snapshot 선택/ID 입력을 요구하는 API를 만들지 않으며 D019 질문 전용 검색·브리핑 변환/표시는 다음 경계다. + +## Global Constraints + +- 신규4파일만 실행 변경: `packages/agents/src/issuedigest_agents/evidence_dispatch.py`, `packages/agents/tests/test_evidence_dispatch.py`, `apps/worker/src/issuedigest_worker/evidence_queue.py`, `apps/worker/tests/test_evidence_queue.py`. 기존481 실행 파일·이전 증거·DB migration/모델/SDK/registry/기본 worker/API/UI/의존성/설정/exports는 그대로 둔다. 문서 변경 허용. 실제 제공자 호출·운영 서버/DB·main 병합/앱 활성화0. +- 신뢰 서버 전용 async `submit_evidence_job(repository, *, client, app_version, token, workspace_id, request_id, snapshot_id, question, model, reservation_usd)`를 agents 모듈에 둔다. required borrowed `DBOSClient`, required exact nonblank app_version(ASCII letters/digits/._-, length1..128), 기존 EvidenceJobRepository를 사용한다. caller가 실제 접근 가능한 검색에서 선택한 snapshot만 전달하며 브라우저 endpoint/직접 source 선택 기능은 추가하지 않는다. 기존 repository의 정확 UUID4/질문/gpt-6.1-sol/Decimal·권한·source·멱등성·공유 예산 계약을 우회하지 않는다. DB 호출은 asyncio.to_thread로 event loop 밖에서 실행한다. +- 작업을 먼저 repository.submit으로 커밋한다. None이면 enqueue 없이 None. 새/기존 pending만 현재 token/workspace/source를 get으로 재확인한 뒤 enqueue한다. 현재 상태가 pending이 아니면 enqueue 없이 상태만 반환한다. 완료 receipt/질문/원문/사용량/자격 증명은 dispatch 반환에 포함하지 않는다. 반환은 frozen dataclass `EvidenceDispatch(job_id, workflow_id, status, queued)`이며 queued는 DBOS ack만 의미하고 AI 완료/실제 송신 보장이 아니다. +- DBOS enqueue_async는 정확 workflow_name `issuedigest_guarded_evidence_job_v1`, queue_name `issuedigest_guarded_evidence_v1`, workflow_id `guarded-evidence-`, required app_version, workflow_id_reuse_policy `return-existing`, args는 canonical job UUID 문자열 하나만 전달한다. 질문/token/source/receipt/키는 queue payload/options/log에 넣지 않는다. client 생성/파괴·환경 접근·sync model bridge·자동 재시도·실제 provider 호출은 없다. 같은 요청의 사용자/서버 재시도는 같은 저장 job/DBOS ID를 사용하며 중복 예약/모델 실행을 만들지 않는다. +- SQL commit 후 enqueue 실패/ack loss는 작업을 failed/free로 바꾸지 않는다. 고정 메시지의 `EvidenceDispatchError`에 job_id/workflow_id만 남기고 원래 예외/비밀 정보를 반환하지 않는다. CancelledError는 전파하며 이미 저장된 예약/작업을 지우거나 재발행하지 않는다. 토큰/권한/source 거부도 sanitize·기존 예외 계약대로 보존한다. enqueue 재시도 loop는0이며 호출자가 같은 request로 재시도할 수 있다. 취소/권한 변경이 재확인 이후 발생해 queue에 들어가도 기존 worker가 현재 권한/상태를 다시 검사해 모델 호출을 막는다. +- 신규 worker의 explicit `register_evidence_queue(repository, *, client, check_ownership)`만 호출할 때 Queue `issuedigest_guarded_evidence_v1`을 global_concurrency=1/worker_concurrency=1로 등록하고 기존 register_evidence_workflow를 재사용한다. 반환 frozen `RegisteredEvidenceQueue(workflow, queue)` (정확 타입은 설치된 DBOS에 맞춤). import는 등록/DB/네트워크/실행을 하지 않는다. DBOS 구성·migration·app_version·launch/client lifecycle은 caller 소유. 기존 queue/workflow는 소비/수정하지 않는다. +- 테스트는 전용 합성 PostgreSQL·실제 DBOSClient/DBOS queue·native AsyncOpenAI MockTransport SSE를 사용한다. 기존 test_evidence_worker/helper는 읽기/재사용하되 수정하지 않는다. new table 정리와 SDK/Pydantic module+package attrs 격리를 자체 fixture로 보존한다. RED missing modules 후 최소 구현; 실제 SQL idempotency와 DBOS enqueue ack loss·재시도/중복 요청→HTTP1·예약1 검증. SDK 모델 완료 receipt는 unreviewed이며 dispatch가 Canvas/사실 인증으로 바꾸지 않는다. fullQA는 controller. + +## Task 1: 접수와 queue의 명시 연결 + +- [ ] 네 파일 TDD. 입력 app_version 검증은 SQL/queue 이전, submit None/권한/예산/source 실패 enqueue0, 저장 먼저/queue payload UUID만, pending재시도/terminal무enqueue, enqueue 실패와 실제 commit-then-raise ack loss 재접수, cancellation, borrowed client 보존/import 무작업. 실제 DBOS queue를 등록/launch하고 두 접수를 같은 workflow ID로 보내 native SSE HTTP1/공통예약1/완료 unreviewed·기존 completed replay 무enqueue를 확인한다. queue 오류에 질문/토큰/예외 원문 노출0. 신규파일 Ruff/format/mypy와 집중 테스트 후 owned files+이 체크박스만 commit, 보고서에 정확 RED/GREEN/인터페이스/한계/스스로 검토 기록. Subagent 추가 금지. + +## Controller gates + +- [ ] 독립 태스크/전체 검토·전체 검사·과거 실행/증거 보존·privacy·문서/PR/Sites·자체QA 정리. +- [ ] 질문 전용 검색→다자료 근거→브리핑 변환/표시와 운영 예약/부하·개발 적용 인수. 이번 활성화 없음. + +## 판단과 영향 + +1. 먼저 신뢰 서버의 저장→queue 경계를 연결한다. 기존 앱 계약을 보존하지만 질문 전용 검색·브리핑 UI는 이번 완료 범위가 아니다. +2. SQL 저장 뒤 queue ack가 불명확하면 같은 ID 재접수만 허용한다. 중복 모델 실행을 막지만 아직 queue에 안 들어간 pending도 예약을 유지한다. +3. app_version 명시와 별도 단일 동시 queue를 사용한다. 예측 가능한 routing을 얻지만 운영자는 같은 version/queue의 worker를 준비해야 하며 처리량 인수가 남는다. diff --git a/docs/planning/progress.md b/docs/planning/progress.md index 686ebe8..212a669 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,7 @@ +## 진행 · 근거 작업 접수·DBOS queue 전달 · 2026-10-26 + +[새 계약](../changes/evidence-job-dispatch.md). 저장된 작업을 명시 버전의 전용 queue로 전달하고 ack 유실 후 같은 ID를 재사용하는 신뢰 서버 경로를 구현한다. 기존481실행/이전46증거를 고정하며 실제 제공자/운영 서버 변경0이다. D019 질문 검색·브리핑 변환/표시는 후속이며 현재 앱/기본worker/main은 유지한다. + ## 검증 완료 · 영속 근거 작업·비동기 worker · 2026-10-26 [새 계약](../changes/durable-evidence-worker.md)·[검증 기록](../research/durable-evidence-worker-validation.md). 실행abfbe26·신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0·미해결0다. 최초 검토의 완료 취소 응답/잠금 역전2건은 회귀7건 RED 재현 후 수정·좁은 재검토로 해소했다. 최초 중단된 전체 검사는 통과로 세지 않고 보존했다. 기존476실행/이전68증거·앱/기본worker/main은 유지하며 제공자 호출0·운영 서버/DB 변경0이다. 서비스 요청·브리핑 변환·예약 정책/부하·개발 반영은 후속이며 PR/Sites/정리는 최종 인계를 따른다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index f591d92..ffd4b87 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -1,3 +1,11 @@ +## 진행 · 근거 작업 접수·queue 전달 · 2026-10-26 + +- [x] [접수/queue 경계·명시 버전·ack 유실 계약](../changes/evidence-job-dispatch.md) 고정. +- [ ] 신뢰 서버 dispatch·전용 queue TDD/실제 DBOSClient 연결. +- [ ] 권한/source/예산·동일 요청/ack 유실·취소·payload/비밀 정보 차단 검사. +- [ ] 독립 검토·전체 검사·기존481실행/이전46증거 보존·PR/Sites·자체QA 정리. +- [ ] 질문 전용 검색·다자료/브리핑 변환·운영 정책/부하·개발 반영. + ## 검증 완료 · 영속 근거 작업·비동기 worker · 2026-10-26 - [x] [새 작업/공유 예산/권한/재생 계약](../changes/durable-evidence-worker.md) 고정. From 79bc6c5a1a481bafc5752aa05b8afb0081d664f2 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:23:26 +0000 Subject: [PATCH 2/8] docs: align queue registration with installed DBOS API --- docs/changes/evidence-job-dispatch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md index a986713..205dc27 100644 --- a/docs/changes/evidence-job-dispatch.md +++ b/docs/changes/evidence-job-dispatch.md @@ -9,7 +9,7 @@ - 작업을 먼저 repository.submit으로 커밋한다. None이면 enqueue 없이 None. 새/기존 pending만 현재 token/workspace/source를 get으로 재확인한 뒤 enqueue한다. 현재 상태가 pending이 아니면 enqueue 없이 상태만 반환한다. 완료 receipt/질문/원문/사용량/자격 증명은 dispatch 반환에 포함하지 않는다. 반환은 frozen dataclass `EvidenceDispatch(job_id, workflow_id, status, queued)`이며 queued는 DBOS ack만 의미하고 AI 완료/실제 송신 보장이 아니다. - DBOS enqueue_async는 정확 workflow_name `issuedigest_guarded_evidence_job_v1`, queue_name `issuedigest_guarded_evidence_v1`, workflow_id `guarded-evidence-`, required app_version, workflow_id_reuse_policy `return-existing`, args는 canonical job UUID 문자열 하나만 전달한다. 질문/token/source/receipt/키는 queue payload/options/log에 넣지 않는다. client 생성/파괴·환경 접근·sync model bridge·자동 재시도·실제 provider 호출은 없다. 같은 요청의 사용자/서버 재시도는 같은 저장 job/DBOS ID를 사용하며 중복 예약/모델 실행을 만들지 않는다. - SQL commit 후 enqueue 실패/ack loss는 작업을 failed/free로 바꾸지 않는다. 고정 메시지의 `EvidenceDispatchError`에 job_id/workflow_id만 남기고 원래 예외/비밀 정보를 반환하지 않는다. CancelledError는 전파하며 이미 저장된 예약/작업을 지우거나 재발행하지 않는다. 토큰/권한/source 거부도 sanitize·기존 예외 계약대로 보존한다. enqueue 재시도 loop는0이며 호출자가 같은 request로 재시도할 수 있다. 취소/권한 변경이 재확인 이후 발생해 queue에 들어가도 기존 worker가 현재 권한/상태를 다시 검사해 모델 호출을 막는다. -- 신규 worker의 explicit `register_evidence_queue(repository, *, client, check_ownership)`만 호출할 때 Queue `issuedigest_guarded_evidence_v1`을 global_concurrency=1/worker_concurrency=1로 등록하고 기존 register_evidence_workflow를 재사용한다. 반환 frozen `RegisteredEvidenceQueue(workflow, queue)` (정확 타입은 설치된 DBOS에 맞춤). import는 등록/DB/네트워크/실행을 하지 않는다. DBOS 구성·migration·app_version·launch/client lifecycle은 caller 소유. 기존 queue/workflow는 소비/수정하지 않는다. +- 신규 worker의 explicit `register_evidence_queue(repository, *, client, check_ownership)`만 호출할 때 설치된 public DBOS.register_queue로 Queue `issuedigest_guarded_evidence_v1`을 global_concurrency=1/worker_concurrency=1로 등록하고 기존 register_evidence_workflow를 재사용한다. 반환된 실효 동시성1/1도 확인하고 다른 설정이면 실패한다. 명시 등록은 해당 전용 queue 설정을 DB에 저장하며 import는 쓰지 않는다. 반환 frozen `RegisteredEvidenceQueue(workflow, queue)` (정확 타입은 설치된 DBOS에 맞춤). import는 등록/DB/네트워크/실행을 하지 않는다. DBOS 구성·migration·app_version·launch/client lifecycle은 caller 소유. 기존 queue/workflow는 소비/수정하지 않는다. - 테스트는 전용 합성 PostgreSQL·실제 DBOSClient/DBOS queue·native AsyncOpenAI MockTransport SSE를 사용한다. 기존 test_evidence_worker/helper는 읽기/재사용하되 수정하지 않는다. new table 정리와 SDK/Pydantic module+package attrs 격리를 자체 fixture로 보존한다. RED missing modules 후 최소 구현; 실제 SQL idempotency와 DBOS enqueue ack loss·재시도/중복 요청→HTTP1·예약1 검증. SDK 모델 완료 receipt는 unreviewed이며 dispatch가 Canvas/사실 인증으로 바꾸지 않는다. fullQA는 controller. ## Task 1: 접수와 queue의 명시 연결 From fb562842c27f97cd92d984f3181ce53124f07157 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:24:50 +0000 Subject: [PATCH 3/8] docs: specify explicit caller launch before queue registration --- docs/changes/evidence-job-dispatch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md index 205dc27..b36f67c 100644 --- a/docs/changes/evidence-job-dispatch.md +++ b/docs/changes/evidence-job-dispatch.md @@ -9,7 +9,7 @@ - 작업을 먼저 repository.submit으로 커밋한다. None이면 enqueue 없이 None. 새/기존 pending만 현재 token/workspace/source를 get으로 재확인한 뒤 enqueue한다. 현재 상태가 pending이 아니면 enqueue 없이 상태만 반환한다. 완료 receipt/질문/원문/사용량/자격 증명은 dispatch 반환에 포함하지 않는다. 반환은 frozen dataclass `EvidenceDispatch(job_id, workflow_id, status, queued)`이며 queued는 DBOS ack만 의미하고 AI 완료/실제 송신 보장이 아니다. - DBOS enqueue_async는 정확 workflow_name `issuedigest_guarded_evidence_job_v1`, queue_name `issuedigest_guarded_evidence_v1`, workflow_id `guarded-evidence-`, required app_version, workflow_id_reuse_policy `return-existing`, args는 canonical job UUID 문자열 하나만 전달한다. 질문/token/source/receipt/키는 queue payload/options/log에 넣지 않는다. client 생성/파괴·환경 접근·sync model bridge·자동 재시도·실제 provider 호출은 없다. 같은 요청의 사용자/서버 재시도는 같은 저장 job/DBOS ID를 사용하며 중복 예약/모델 실행을 만들지 않는다. - SQL commit 후 enqueue 실패/ack loss는 작업을 failed/free로 바꾸지 않는다. 고정 메시지의 `EvidenceDispatchError`에 job_id/workflow_id만 남기고 원래 예외/비밀 정보를 반환하지 않는다. CancelledError는 전파하며 이미 저장된 예약/작업을 지우거나 재발행하지 않는다. 토큰/권한/source 거부도 sanitize·기존 예외 계약대로 보존한다. enqueue 재시도 loop는0이며 호출자가 같은 request로 재시도할 수 있다. 취소/권한 변경이 재확인 이후 발생해 queue에 들어가도 기존 worker가 현재 권한/상태를 다시 검사해 모델 호출을 막는다. -- 신규 worker의 explicit `register_evidence_queue(repository, *, client, check_ownership)`만 호출할 때 설치된 public DBOS.register_queue로 Queue `issuedigest_guarded_evidence_v1`을 global_concurrency=1/worker_concurrency=1로 등록하고 기존 register_evidence_workflow를 재사용한다. 반환된 실효 동시성1/1도 확인하고 다른 설정이면 실패한다. 명시 등록은 해당 전용 queue 설정을 DB에 저장하며 import는 쓰지 않는다. 반환 frozen `RegisteredEvidenceQueue(workflow, queue)` (정확 타입은 설치된 DBOS에 맞춤). import는 등록/DB/네트워크/실행을 하지 않는다. DBOS 구성·migration·app_version·launch/client lifecycle은 caller 소유. 기존 queue/workflow는 소비/수정하지 않는다. +- 신규 worker의 explicit `register_evidence_queue(repository, *, client, check_ownership)`만 호출할 때 설치된 public DBOS.register_queue로 Queue `issuedigest_guarded_evidence_v1`을 global_concurrency=1/worker_concurrency=1로 등록하고 기존 register_evidence_workflow를 재사용한다. 반환된 실효 동시성1/1도 확인하고 다른 설정이면 실패한다. 명시 등록은 해당 전용 queue 설정을 DB에 저장하며 import는 쓰지 않는다. 반환 frozen `RegisteredEvidenceQueue(workflow, queue)` (정확 타입은 설치된 DBOS에 맞춤). import는 등록/DB/네트워크/실행을 하지 않는다. 설치된 SDK는 DBOS가 먼저 launch돼야 register_queue를 지원한다. caller가 구성·migration·명시 app_version·launch를 완료한 뒤 이 동기 factory를 event loop 밖에서 부른다. factory가 엔진을 시작하지 않으며 client lifecycle도 caller 소유. 기존 queue/workflow는 소비/수정하지 않는다. - 테스트는 전용 합성 PostgreSQL·실제 DBOSClient/DBOS queue·native AsyncOpenAI MockTransport SSE를 사용한다. 기존 test_evidence_worker/helper는 읽기/재사용하되 수정하지 않는다. new table 정리와 SDK/Pydantic module+package attrs 격리를 자체 fixture로 보존한다. RED missing modules 후 최소 구현; 실제 SQL idempotency와 DBOS enqueue ack loss·재시도/중복 요청→HTTP1·예약1 검증. SDK 모델 완료 receipt는 unreviewed이며 dispatch가 Canvas/사실 인증으로 바꾸지 않는다. fullQA는 controller. ## Task 1: 접수와 queue의 명시 연결 From afac2d7a8850a0b28784eef5bd6a38639f5cd301 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:27:10 +0000 Subject: [PATCH 4/8] feat: connect evidence job admission to isolated DBOS queue --- .../src/issuedigest_worker/evidence_queue.py | 37 +++ apps/worker/tests/test_evidence_queue.py | 218 +++++++++++++ docs/changes/evidence-job-dispatch.md | 2 +- .../issuedigest_agents/evidence_dispatch.py | 88 ++++++ .../agents/tests/test_evidence_dispatch.py | 289 ++++++++++++++++++ 5 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 apps/worker/src/issuedigest_worker/evidence_queue.py create mode 100644 apps/worker/tests/test_evidence_queue.py create mode 100644 packages/agents/src/issuedigest_agents/evidence_dispatch.py create mode 100644 packages/agents/tests/test_evidence_dispatch.py diff --git a/apps/worker/src/issuedigest_worker/evidence_queue.py b/apps/worker/src/issuedigest_worker/evidence_queue.py new file mode 100644 index 0000000..12fe338 --- /dev/null +++ b/apps/worker/src/issuedigest_worker/evidence_queue.py @@ -0,0 +1,37 @@ +"""Explicit registration for the isolated evidence admission queue.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from dbos import DBOS, Queue +from issuedigest_storage.evidence_jobs import EvidenceJobRepository +from openai import AsyncOpenAI + +from .evidence_worker import Ownership, register_evidence_workflow + + +@dataclass(frozen=True) +class RegisteredEvidenceQueue: + workflow: Callable[..., Any] + queue: Queue + + +def register_evidence_queue( + repository: EvidenceJobRepository, *, client: AsyncOpenAI, check_ownership: Ownership +) -> RegisteredEvidenceQueue: + """Register after caller launch, outside its event loop (or via to_thread). + + The SDK persists this dedicated queue's configuration. A retained conflicting + deployment configuration must fail closed before workflow registration. + Caller owns DBOS configuration, migration, launch and the borrowed client. + """ + queue = DBOS.register_queue( + "issuedigest_guarded_evidence_v1", global_concurrency=1, worker_concurrency=1 + ) + if queue.global_concurrency != 1 or queue.worker_concurrency != 1: + raise ValueError("Evidence queue limits unavailable") + workflow = register_evidence_workflow( + repository, client=client, check_ownership=check_ownership + ) + return RegisteredEvidenceQueue(workflow, queue) diff --git a/apps/worker/tests/test_evidence_queue.py b/apps/worker/tests/test_evidence_queue.py new file mode 100644 index 0000000..1417d2f --- /dev/null +++ b/apps/worker/tests/test_evidence_queue.py @@ -0,0 +1,218 @@ +"""Explicit queue registration and native SSE durable execution.""" + +import asyncio +import importlib +import sys +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +import httpx2 as httpx +import pytest +from openai import AsyncOpenAI +from sqlalchemy import text + + +@pytest.fixture(scope="module", autouse=True) +def isolate_queue_modules(): + paths = sys.path.copy() + root = Path(__file__).resolve().parents[3] + for path in ("packages/agents/tests", "packages/storage/tests"): + sys.path.insert(0, str(root / path)) + packages = [importlib.import_module(n) for n in ("issuedigest_agents", "issuedigest_worker")] + before = set(sys.modules) + attrs = [vars(p).copy() for p in packages] + yield + for name in set(sys.modules) - before: + if name.startswith(("issuedigest_agents.", "issuedigest_worker.evidence_")): + sys.modules.pop(name, None) + sys.path[:] = paths + for package, original in zip(packages, attrs, strict=True): + for key in set(vars(package)) - original.keys(): + delattr(package, key) + for key, value in original.items(): + setattr(package, key, value) + + +@pytest.fixture +def queue_admission(engine): + from issuedigest_storage.evidence_jobs import EvidenceJobRepository + from test_evidence_jobs import seed + + with engine.begin() as conn: + conn.execute(text("TRUNCATE evidence_jobs")) + token, workspace, snapshot = seed(engine) + yield ( + EvidenceJobRepository(engine), + dict( + token=token, + workspace_id=workspace, + snapshot_id=snapshot, + request_id=uuid4(), + question="합성 질문?", + model="gpt-6.1-sol", + reservation_usd=Decimal(".50"), + ), + ) + with engine.begin() as conn: + conn.execute(text("TRUNCATE evidence_jobs")) + + +@pytest.mark.parametrize("ack_loss", [False, True]) +def test_real_queue_duplicate_and_committed_ack_loss( + queue_admission, engine, database_url, monkeypatch, ack_loss +): + from dbos import DBOS, DBOSClient + from test_evidence_worker import native_body + + jobs, inputs = queue_admission + dispatch = importlib.import_module("issuedigest_agents.evidence_dispatch") + requests = [] + + async def run(): + def serve(request): + requests.append(request) + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=native_body() + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(serve)) as http: + provider = AsyncOpenAI(api_key="synthetic", http_client=http, max_retries=0) + DBOS.migrate(database_url, application_role=engine.url.username) + DBOS( + config=dict( + name="evidence-queue-synthetic", + system_database_url=database_url, + application_version="evidence-test-v1", + run_admin_server=False, + ) + ) + client = DBOSClient( + system_database_url=database_url, application_name="evidence-queue-synthetic" + ) + try: + DBOS.launch() + registered = await asyncio.to_thread( + importlib.import_module( + "issuedigest_worker.evidence_queue" + ).register_evidence_queue, + jobs, + client=provider, + check_ownership=lambda _: True, + ) + assert registered.queue.name == "issuedigest_guarded_evidence_v1" + assert registered.queue._worker_concurrency == 1 + assert registered.queue._concurrency == 1 + original = client.enqueue_async + + async def lost(*args, **kwargs): + await original(*args, **kwargs) + raise RuntimeError("SECRET ACK question/token") + + if ack_loss: + monkeypatch.setattr(client, "enqueue_async", lost) + with pytest.raises(dispatch.EvidenceDispatchError) as caught: + await dispatch.submit_evidence_job( + jobs, client=client, app_version="evidence-test-v1", **inputs + ) + assert caught.value.__context__ is None + assert "SECRET" not in str(caught.value) + monkeypatch.setattr(client, "enqueue_async", original) + first = await dispatch.submit_evidence_job( + jobs, client=client, app_version="evidence-test-v1", **inputs + ) + second = await dispatch.submit_evidence_job( + jobs, client=client, app_version="evidence-test-v1", **inputs + ) + assert first == second and first.queued + + handle = await client.retrieve_workflow_async(first.workflow_id) + await asyncio.wait_for(handle.get_result(), timeout=20) + saved = jobs.prepare(first.job_id)[0] + assert saved.status == "completed" and saved.receipt["status"] == "unreviewed" + assert len(requests) == 1 and not provider.is_closed() + + async def forbidden(*args, **kwargs): + pytest.fail("Terminal dispatch must not enqueue") + + monkeypatch.setattr(client, "enqueue_async", forbidden) + replay = await dispatch.submit_evidence_job( + jobs, client=client, app_version="evidence-test-v1", **inputs + ) + assert replay.status == "completed" and not replay.queued + with engine.connect() as conn: + assert ( + conn.scalar(text("SELECT reservation_count FROM research_budget_months")) + == 1 + ) + finally: + client.destroy() + DBOS.destroy(destroy_registry=True) + + asyncio.run(run()) + + +def test_persisted_incompatible_limits_fail_closed( + queue_admission, engine, database_url, monkeypatch +): + from dbos import DBOS + + jobs, _ = queue_admission + DBOS.migrate(database_url, application_role=engine.url.username) + DBOS( + config=dict( + name="evidence-queue-synthetic", + system_database_url=database_url, + application_version="evidence-test-v1", + run_admin_server=False, + ) + ) + provider = AsyncOpenAI(api_key="synthetic", max_retries=0) + try: + DBOS.launch() + original = DBOS.register_queue + original( + "issuedigest_guarded_evidence_v1", + global_concurrency=2, + worker_concurrency=2, + on_conflict="always_update", + ) + + def retained(name, **kwargs): + return original(name, **kwargs, on_conflict="never_update") + + monkeypatch.setattr(DBOS, "register_queue", retained) + with pytest.raises(ValueError, match="Evidence queue limits unavailable"): + importlib.import_module("issuedigest_worker.evidence_queue").register_evidence_queue( + jobs, client=provider, check_ownership=lambda _: True + ) + assert not provider.is_closed() + finally: + asyncio.run(provider.close()) + DBOS.destroy(destroy_registry=True) + + +def test_import_does_not_register_connect_or_read_environment(): + import subprocess + + script = """ +from dbos import DBOS +import socket, os +import issuedigest_worker.evidence_worker +import issuedigest_storage.evidence_jobs + +def forbidden(*a, **k): + raise AssertionError("Import performed external action") +DBOS.register_queue = forbidden +DBOS.launch = forbidden +socket.create_connection = forbidden +os.getenv = forbidden +import issuedigest_agents.evidence_dispatch +import issuedigest_worker.evidence_queue +""" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_queue_module_available(): + assert importlib.util.find_spec("issuedigest_worker.evidence_queue") is not None diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md index b36f67c..5a3c839 100644 --- a/docs/changes/evidence-job-dispatch.md +++ b/docs/changes/evidence-job-dispatch.md @@ -14,7 +14,7 @@ ## Task 1: 접수와 queue의 명시 연결 -- [ ] 네 파일 TDD. 입력 app_version 검증은 SQL/queue 이전, submit None/권한/예산/source 실패 enqueue0, 저장 먼저/queue payload UUID만, pending재시도/terminal무enqueue, enqueue 실패와 실제 commit-then-raise ack loss 재접수, cancellation, borrowed client 보존/import 무작업. 실제 DBOS queue를 등록/launch하고 두 접수를 같은 workflow ID로 보내 native SSE HTTP1/공통예약1/완료 unreviewed·기존 completed replay 무enqueue를 확인한다. queue 오류에 질문/토큰/예외 원문 노출0. 신규파일 Ruff/format/mypy와 집중 테스트 후 owned files+이 체크박스만 commit, 보고서에 정확 RED/GREEN/인터페이스/한계/스스로 검토 기록. Subagent 추가 금지. +- [x] 네 파일 TDD. 입력 app_version 검증은 SQL/queue 이전, submit None/권한/예산/source 실패 enqueue0, 저장 먼저/queue payload UUID만, pending재시도/terminal무enqueue, enqueue 실패와 실제 commit-then-raise ack loss 재접수, cancellation, borrowed client 보존/import 무작업. 실제 DBOS queue를 등록/launch하고 두 접수를 같은 workflow ID로 보내 native SSE HTTP1/공통예약1/완료 unreviewed·기존 completed replay 무enqueue를 확인한다. queue 오류에 질문/토큰/예외 원문 노출0. 신규파일 Ruff/format/mypy와 집중 테스트 후 owned files+이 체크박스만 commit, 보고서에 정확 RED/GREEN/인터페이스/한계/스스로 검토 기록. Subagent 추가 금지. ## Controller gates diff --git a/packages/agents/src/issuedigest_agents/evidence_dispatch.py b/packages/agents/src/issuedigest_agents/evidence_dispatch.py new file mode 100644 index 0000000..ec794fd --- /dev/null +++ b/packages/agents/src/issuedigest_agents/evidence_dispatch.py @@ -0,0 +1,88 @@ +"""Trusted-server admission followed by an explicit durable queue acknowledgement.""" + +import asyncio +import re +from dataclasses import dataclass +from decimal import Decimal +from uuid import UUID + +from dbos import DBOSClient +from issuedigest_storage.evidence_jobs import EvidenceJobRepository + + +@dataclass(frozen=True) +class EvidenceDispatch: + """Queue acknowledgement only; never a generation receipt.""" + + job_id: UUID + workflow_id: str + status: str + queued: bool + + +class EvidenceDispatchError(Exception): + """Admission survived an ambiguous queue outcome; retry the same request.""" + + def __init__(self, job_id: UUID, workflow_id: str): + super().__init__("Evidence queue acknowledgement unavailable") + self.job_id = job_id + self.workflow_id = workflow_id + + +async def submit_evidence_job( + repository: EvidenceJobRepository, + *, + client: DBOSClient, + app_version: str, + token: str, + workspace_id: UUID, + request_id: UUID, + snapshot_id: UUID, + question: str, + model: str, + reservation_usd: Decimal, +) -> EvidenceDispatch | None: + """Commit admission before queueing; caller owns client/version and retries. + + Cancellation can outlive the SQL thread and commit its reservation. No hidden + continuation enqueues that job; retrying the exact request discovers it. + """ + if type(app_version) is not str or re.fullmatch(r"[A-Za-z0-9._-]{1,128}", app_version) is None: + raise ValueError("Invalid evidence application version") + job = await asyncio.to_thread( + repository.submit, + token, + workspace_id, + request_id, + snapshot_id, + question, + model=model, + reservation_usd=reservation_usd, + ) + if job is None: + return None + workflow_id = "guarded-evidence-" + str(job.job_id) + if job.status == "pending": + job = await asyncio.to_thread(repository.get, token, workspace_id, job.job_id) + if job is None: + return None + if job.status != "pending": + return EvidenceDispatch(job.job_id, workflow_id, job.status, False) + failed = False + try: + await client.enqueue_async( + { + "workflow_name": "issuedigest_guarded_evidence_job_v1", + "queue_name": "issuedigest_guarded_evidence_v1", + "workflow_id": workflow_id, + "app_version": app_version, + "workflow_id_reuse_policy": "return-existing", + }, + str(job.job_id), + ) + except Exception: + # Raise outside the handler so even __context__ contains no queue secret. + failed = True + if failed: + raise EvidenceDispatchError(job.job_id, workflow_id) from None + return EvidenceDispatch(job.job_id, workflow_id, job.status, True) diff --git a/packages/agents/tests/test_evidence_dispatch.py b/packages/agents/tests/test_evidence_dispatch.py new file mode 100644 index 0000000..10f3acc --- /dev/null +++ b/packages/agents/tests/test_evidence_dispatch.py @@ -0,0 +1,289 @@ +"""Admission and acknowledgement boundaries, using real synthetic SQL.""" + +import asyncio +import importlib +import sys +import threading +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import text + + +@pytest.fixture(scope="module", autouse=True) +def isolated_modules(): + paths = sys.path.copy() + root = Path(__file__).resolve().parents[3] + sys.path.insert(0, str(root / "packages/storage/tests")) + packages = [importlib.import_module(n) for n in ("issuedigest_agents",)] + before = set(sys.modules) + attrs = [vars(p).copy() for p in packages] + yield + for name in set(sys.modules) - before: + if name.startswith("issuedigest_agents."): + sys.modules.pop(name, None) + sys.path[:] = paths + for package, original in zip(packages, attrs, strict=True): + for key in set(vars(package)) - original.keys(): + delattr(package, key) + for key, value in original.items(): + setattr(package, key, value) + + +@pytest.fixture +def admission(engine): + from issuedigest_storage.evidence_jobs import EvidenceJobRepository + from test_evidence_jobs import seed + + with engine.begin() as conn: + conn.execute(text("TRUNCATE evidence_jobs")) + token, workspace, snapshot = seed(engine) + yield ( + EvidenceJobRepository(engine), + dict( + token=token, + workspace_id=workspace, + snapshot_id=snapshot, + request_id=uuid4(), + question="합성 질문?", + model="gpt-6.1-sol", + reservation_usd=Decimal(".50"), + ), + ) + with engine.begin() as conn: + conn.execute(text("TRUNCATE evidence_jobs")) + + +class QueueClient: + """Only external enqueue is replaced; SQL admission remains real.""" + + def __init__(self, action=None): + self.calls = [] + self.action = action + + async def enqueue_async(self, options, *args): + self.calls.append((options, args)) + if self.action: + self.action() + + +def module(): + assert importlib.util.find_spec("issuedigest_agents.evidence_dispatch") is not None + return importlib.import_module("issuedigest_agents.evidence_dispatch") + + +def send(jobs, inputs, client, **changes): + return module().submit_evidence_job( + jobs, client=client, app_version="evidence-test-v1", **(inputs | changes) + ) + + +def test_pending_retry_payload_and_cancelled_skip(admission): + jobs, inputs = admission + client = QueueClient() + first = asyncio.run(send(jobs, inputs, client)) + second = asyncio.run(send(jobs, inputs, client)) + assert first == second and first.status == "pending" and first.queued + assert set(vars(first)) == {"job_id", "workflow_id", "status", "queued"} + assert ( + client.calls + == [ + ( + dict( + workflow_name="issuedigest_guarded_evidence_job_v1", + queue_name="issuedigest_guarded_evidence_v1", + workflow_id="guarded-evidence-" + str(first.job_id), + app_version="evidence-test-v1", + workflow_id_reuse_policy="return-existing", + ), + (str(first.job_id),), + ) + ] + * 2 + ) + jobs.cancel(inputs["token"], inputs["workspace_id"], first.job_id) + terminal = asyncio.run(send(jobs, inputs, client)) + assert terminal.status == "cancelled" and not terminal.queued + assert len(client.calls) == 2 + + +@pytest.mark.parametrize("version", [None, "", " ", "a b", "한", "a" * 129, "v\n"]) +def test_invalid_version_precedes_storage(admission, version): + jobs, inputs = admission + client = QueueClient() + with pytest.raises(ValueError): + asyncio.run( + module().submit_evidence_job(jobs, client=client, app_version=version, **inputs) + ) + assert client.calls == [] + with jobs.engine.connect() as conn: + assert conn.scalar(text("SELECT count(*) FROM evidence_jobs")) == 0 + + +def test_none_and_source_failure_never_enqueue(admission): + from issuedigest_storage.research import ResearchConflict + + jobs, inputs = admission + client = QueueClient() + from issuedigest_storage.sessions import AuthenticationDenied + + with pytest.raises(AuthenticationDenied): + asyncio.run(send(jobs, inputs, client, token="invalid")) + assert asyncio.run(send(jobs, inputs, client, workspace_id=uuid4())) is None + with pytest.raises(ResearchConflict): + asyncio.run(send(jobs, inputs, client, snapshot_id=uuid4())) + assert client.calls == [] + + +def test_enqueue_error_retains_pending_and_sanitizes(admission): + jobs, inputs = admission + + def fail(): + raise RuntimeError("SECRET question token") + + client = QueueClient(fail) + with pytest.raises(module().EvidenceDispatchError) as caught: + asyncio.run(send(jobs, inputs, client)) + error = caught.value + assert "SECRET" not in str(error) and error.__suppress_context__ + assert jobs.get(inputs["token"], inputs["workspace_id"], error.job_id).status == "pending" + retry = asyncio.run(send(jobs, inputs, QueueClient())) + assert retry.job_id == error.job_id + with jobs.engine.connect() as conn: + assert conn.scalar(text("SELECT reservation_count FROM research_budget_months")) == 1 + + +def test_cancelled_enqueue_propagates_without_reset(admission): + jobs, inputs = admission + + def cancel(): + raise asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + asyncio.run(send(jobs, inputs, QueueClient(cancel))) + retry = asyncio.run(send(jobs, inputs, QueueClient())) + assert retry.status == "pending" + + +def test_cancel_during_thread_commit_has_no_hidden_enqueue(admission, monkeypatch): + jobs, inputs = admission + entered, release, committed = threading.Event(), threading.Event(), threading.Event() + original = jobs.submit + + def delayed(*args, **kwargs): + entered.set() + release.wait(10) + try: + return original(*args, **kwargs) + finally: + committed.set() + + monkeypatch.setattr(jobs, "submit", delayed) + client = QueueClient() + + async def run(): + task = asyncio.create_task(send(jobs, inputs, client)) + assert await asyncio.to_thread(entered.wait, 10) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + assert await asyncio.to_thread(committed.wait, 10) + + asyncio.run(run()) + assert client.calls == [] + retry = asyncio.run(send(jobs, inputs, client)) + assert retry.status == "pending" and len(client.calls) == 1 + + +@pytest.mark.parametrize("mode", ["budget", "authority", "source-change"]) +def test_current_rejections_do_not_enqueue(admission, mode): + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.research import ResearchBudgetExceeded, ResearchForbidden + + jobs, inputs = admission + client = QueueClient() + if mode == "budget": + with jobs.engine.begin() as conn: + conn.execute( + text( + "INSERT INTO research_budget_months " + "(month, reserved_usd, reservation_count) " + "VALUES (to_char(clock_timestamp(),'YYYY-MM'),10,40)" + ) + ) + expected = ResearchBudgetExceeded + else: + asyncio.run(send(jobs, inputs, client)) + client.calls.clear() + with jobs.engine.begin() as conn: + if mode == "authority": + conn.execute(text("UPDATE auth_workspace_memberships SET role='viewer'")) + else: + conn.execute( + text( + "UPDATE parsed_representations SET payload = " + "jsonb_set(payload, '{text}', '\"changed\"'::jsonb)" + ) + ) + expected = ResearchForbidden if mode == "authority" else StorageCorruption + with pytest.raises(expected): + asyncio.run(send(jobs, inputs, client)) + assert client.calls == [] + + +def test_permission_revoked_after_submit_blocks_queue(admission, monkeypatch): + jobs, inputs = admission + original = jobs.submit + + def revoke(*args, **kwargs): + job = original(*args, **kwargs) + with jobs.engine.begin() as conn: + conn.execute(text("UPDATE auth_workspace_memberships SET active=false")) + return job + + monkeypatch.setattr(jobs, "submit", revoke) + client = QueueClient() + assert asyncio.run(send(jobs, inputs, client)) is None + assert client.calls == [] + + +def test_storage_commit_ack_loss_retry_uses_saved_reservation(admission, monkeypatch): + from issuedigest_storage.repository import StorageError + + jobs, inputs = admission + original = jobs.submit + + def lost(*args, **kwargs): + original(*args, **kwargs) + raise StorageError("Synthetic SQL acknowledgement unavailable") + + monkeypatch.setattr(jobs, "submit", lost) + client = QueueClient() + with pytest.raises(StorageError): + asyncio.run(send(jobs, inputs, client)) + assert client.calls == [] + monkeypatch.setattr(jobs, "submit", original) + retry = asyncio.run(send(jobs, inputs, client)) + assert retry.status == "pending" and retry.queued + with jobs.engine.connect() as conn: + assert conn.scalar(text("SELECT count(*) FROM evidence_jobs")) == 1 + assert conn.scalar(text("SELECT reservation_count FROM research_budget_months")) == 1 + + +def test_recheck_observes_cancel_before_enqueue(admission, monkeypatch): + jobs, inputs = admission + original = jobs.submit + + def cancelled(*args, **kwargs): + job = original(*args, **kwargs) + jobs.cancel(inputs["token"], inputs["workspace_id"], job.job_id) + return job + + monkeypatch.setattr(jobs, "submit", cancelled) + client = QueueClient() + dispatch = asyncio.run(send(jobs, inputs, client)) + assert dispatch.status == "cancelled" and not dispatch.queued + assert client.calls == [] From a7cbdb0f4ea938ddef6c5389c909e8d39228e792 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:30:39 +0000 Subject: [PATCH 5/8] docs: record evidence admission integration and validation scope --- README.md | 2 ++ docs/README.md | 2 ++ docs/planning/implementation-plan.md | 4 +++ .../evidence-job-dispatch-validation.md | 31 +++++++++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 docs/research/evidence-job-dispatch-validation.md diff --git a/README.md b/README.md index 96476ca..1bf1235 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +진행 중: [근거 작업 접수·queue 전달](docs/changes/evidence-job-dispatch.md). 저장→명시 버전 queue 전달·동일 ID 재접수를 연결합니다. 실제 모델/운영 서버 변경은 없으며 질문 전용 검색·브리핑 변환/표시는 후속입니다. + 현재 구현: [영속 근거 작업·비동기 worker](docs/changes/durable-evidence-worker.md). 신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0를 통과했습니다. SQL 송신 의도·중단 후 무재호출·공유 예약 예산·현재 권한/원문 검증을 연결했습니다. 이번 실제 AI 호출0회이며 서비스 요청·브리핑 변환·개발 배포는 후속입니다. 현재 구현: [정확한 근거 필드·불확실 상태 지침](docs/changes/guarded-question-reference-rules.md). 신규47/관련532·scoped정적검사·독립 코드 검토를 통과했습니다. 실제1요청에서 모델 완료/명제3개 SDK수용·JSON 재검증을 확인했으며, 필수3명제는 AI 개발판정에서 보존을 확인했으며, 전체9,515개·정적 검사/빌드/OpenAPI도 통과했습니다. 일반 품질·앱 인수와 구분합니다. diff --git a/docs/README.md b/docs/README.md index 9ca2b5b..9d1b66b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,5 @@ +진행 중: [근거 작업 접수·queue 전달](changes/evidence-job-dispatch.md). 저장→명시 버전 queue 전달·동일 ID 재접수를 연결합니다. 실제 모델/운영 서버 변경은 없으며 질문 전용 검색·브리핑 변환/표시는 후속입니다. + 현재 구현: [영속 근거 작업·비동기 worker](changes/durable-evidence-worker.md). 신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0를 통과했습니다. SQL 송신 의도·중단 후 무재호출·공유 예약 예산·현재 권한/원문 검증을 연결했습니다. 이번 실제 AI 호출0회이며 서비스 요청·브리핑 변환·개발 배포는 후속입니다. 현재 구현: [정확한 근거 필드·불확실 상태 지침](changes/guarded-question-reference-rules.md). 신규47/관련532·scoped정적검사·독립 코드 검토를 통과했습니다. 실제1요청에서 모델 완료/명제3개 SDK수용·JSON 재검증을 확인했으며, 필수3명제는 AI 개발판정에서 보존을 확인했으며, 전체9,515개·정적 검사/빌드/OpenAPI도 통과했습니다. 일반 품질·앱 인수와 구분합니다. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index eb1d166..2dc328c 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -1,3 +1,7 @@ +## M3 진행 · 근거 작업 접수·queue 전달 + +[명시 접수·dispatch 계약](../changes/evidence-job-dispatch.md). 실제 DBOSClient의 별도 queue에 저장한 작업 ID만 전달한다. enqueue ack가 유실되더라도 원래 예약과 ID를 보존하며 동일 요청으로 재접수한다. 기존 API/UI/기본worker를 유지하고 D019 질문 검색·브리핑 변환/표시는 다음 연결이다. 실제 제공자 호출0·개발 활성화0이다. + ## M3 검증 완료 · 영속 근거 작업과 복구 [내부 저장·worker 계약](../changes/durable-evidence-worker.md)·[검증 범위](../research/durable-evidence-worker-validation.md). 신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0다. 전용 합성 PostgreSQL/실제 DBOS에서 단일 송신·중단/캐시 재개·취소/권한/원문 변경·사용량 보존을 확인했다. 기존476실행/이전68증거와 앱/기본worker/main은 유지한다. 서비스 enqueue·브리핑 변환·다자료·예약 정책/부하 인수·개발 배포가 다음이다. 실제 제공자 호출0이며 청구/의미/앱 인수를 뜻하지 않는다. diff --git a/docs/research/evidence-job-dispatch-validation.md b/docs/research/evidence-job-dispatch-validation.md new file mode 100644 index 0000000..10a29d0 --- /dev/null +++ b/docs/research/evidence-job-dispatch-validation.md @@ -0,0 +1,31 @@ +# 근거 작업 접수·DBOS queue 연결 검증 + +2026-10-26 KST. [명세](../changes/evidence-job-dispatch.md)의 신뢰 서버용 저장→queue 연결이다. 실제 브라우저/API 활성화나 모델 의미 품질의 인수가 아니다. 실제 제공자 호출·운영 서버/DB 변경은 0회다. + +## 구현 + +`submit_evidence_job`은 기존 repository에 작업을 먼저 커밋한다. pending은 현재 세션·권한·원문을 다시 검사한 뒤 명시한 app_version과 고정 queue/workflow ID로 전달한다. queue payload는 작업 UUID 문자열 하나이며 질문·token·원문·receipt는 포함하지 않는다. 접수 응답에는 작업 ID/상태/queue ack만 포함하며 AI 완료를 뜻하지 않는다. + +같은 요청을 다시 접수하면 저장된 작업과 workflow ID를 재사용한다. SQL 저장 뒤 enqueue 응답을 받지 못해도 원래 예약을 반환하거나 새 요청으로 바꾸지 않는다. 고정 오류에 작업 ID를 남기므로 caller가 같은 request로 재접수할 수 있다. SQL thread가 caller 취소 뒤 커밋해도 몰래 enqueue하지 않는다. 실제 실행 시 권한·source·작업 상태 검증은 기존 worker가 다시 수행한다. + +설치된 DBOS3.2.0은 Queue 직접 생성을 거부한다. caller가 DBOS를 구성·migrate·명시 버전으로 launch한 뒤 동기 `register_evidence_queue`를 event loop 밖에서 호출한다. 이 명시 호출은 전용 queue 설정을 저장하고 반환된 실효 동시성1/1을 확인하며, 설정 불일치는 workflow 등록 전에 실패한다. import는 queue 등록이나 엔진 시작을 하지 않는다. client lifecycle은 caller 소유다. + +## 검증 + +전용 합성 PostgreSQL과 실제 DBOSClient/queue·native AsyncOpenAI/모의 SSE를 사용했다. 모듈 부재 RED13건과 실효 queue 설정 불일치의 미차단 RED를 확인한 뒤 구현했다. 신규 집중 검사23개·Ruff/format4파일·mypy2소스가 통과했다. 전체 검사와 독립 검토 결과는 완료 후 반영한다. + +- app_version 형식 검사는 SQL/queue보다 앞선다. 원래 repository의 UUID/질문/모델/Decimal·권한·source·합산 예산 계약을 유지한다. +- None/권한·source·예산 실패, 현재 pending 재조회 거부, terminal 상태는 enqueue하지 않는다. +- 실제 DBOS enqueue 커밋 후 응답 유실을 재현해 같은 요청을 재접수했다. 모의 HTTP1회·예약1회·동일 workflow ID와 완료 unreviewed receipt를 확인했다. +- enqueue 오류/취소와 SQL thread 중 취소 후 늦은 커밋에서도 예약/작업을 유지하고 숨은 enqueue·자동 재시도는 없다. 오류에 원래 예외·질문·token을 담지 않는다. +- 신규 fixture가 전용 작업 테이블을 정리하고 SDK/Pydantic module과 package 속성을 복원한다. 기존 실행 파일은 바꾸지 않는다. + +## 한계·후속 + +질문 전용 검색에서 자료를 찾는 경로·다자료 실행·브리핑 변환/표시·개발 서버 적용은 아직 연결하지 않았다. 이 함수의 snapshot은 신뢰 서버에서 결정한 값이며 브라우저가 직접 고르는 API를 만들지 않는다. queue ack는 모델 송신/완료·실제 제공자 비용이나 의미 품질을 보장하지 않는다. caller는 queue와 일치하는 엔진/버전을 준비해야 한다. + +## 선택과 영향 + +1. 기존 API/UI를 바꾸기 전에 신뢰 서버 저장→queue 경계를 연결한다. 이전 계약을 보존하지만 질문 전용 검색·브리핑 표시는 후속이다. +2. ack가 불분명하면 같은 ID 재접수만 허용한다. 중복 예약/AI 실행을 막지만 아직 송신되지 않은 pending도 예약을 유지할 수 있다. +3. 명시 버전과 별도 단일 동시 queue를 사용한다. 암묵적 최신 버전 routing을 피하지만 일치하는 worker 준비와 처리량 인수가 필요하다. From b6cbd98d42bfc0f22874d6d4d314a3418a350965 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:35:05 +0000 Subject: [PATCH 6/8] test: hold evidence worker before duplicate queue acknowledgements --- apps/worker/tests/test_evidence_queue.py | 30 ++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/worker/tests/test_evidence_queue.py b/apps/worker/tests/test_evidence_queue.py index 1417d2f..d66c05c 100644 --- a/apps/worker/tests/test_evidence_queue.py +++ b/apps/worker/tests/test_evidence_queue.py @@ -3,6 +3,7 @@ import asyncio import importlib import sys +import threading from decimal import Decimal from pathlib import Path from uuid import uuid4 @@ -68,6 +69,17 @@ def test_real_queue_duplicate_and_committed_ack_loss( jobs, inputs = queue_admission dispatch = importlib.import_module("issuedigest_agents.evidence_dispatch") requests = [] + entered, release = threading.Event(), threading.Event() + worker = importlib.import_module("issuedigest_worker.evidence_worker") + execute = worker.execute_evidence_job + + async def gated_execute(*args, **kwargs): + entered.set() + # Async waiting keeps both the DBOS and caller event loops available. + assert await asyncio.to_thread(release.wait, 20), "Worker gate was not released" + return await execute(*args, **kwargs) + + monkeypatch.setattr(worker, "execute_evidence_job", gated_execute) async def run(): def serve(request): @@ -104,9 +116,17 @@ def serve(request): assert registered.queue._worker_concurrency == 1 assert registered.queue._concurrency == 1 original = client.enqueue_async + acknowledged = [] + + async def enqueue(options, *args, **kwargs): + handle = await original(options, *args, **kwargs) + acknowledged.append(options["workflow_id"]) + return handle + + monkeypatch.setattr(client, "enqueue_async", enqueue) async def lost(*args, **kwargs): - await original(*args, **kwargs) + await enqueue(*args, **kwargs) raise RuntimeError("SECRET ACK question/token") if ack_loss: @@ -117,14 +137,19 @@ async def lost(*args, **kwargs): ) assert caught.value.__context__ is None assert "SECRET" not in str(caught.value) - monkeypatch.setattr(client, "enqueue_async", original) + monkeypatch.setattr(client, "enqueue_async", enqueue) first = await dispatch.submit_evidence_job( jobs, client=client, app_version="evidence-test-v1", **inputs ) + assert await asyncio.to_thread(entered.wait, 10), "Worker did not reach gate" second = await dispatch.submit_evidence_job( jobs, client=client, app_version="evidence-test-v1", **inputs ) assert first == second and first.queued + assert acknowledged == [first.workflow_id] * (3 if ack_loss else 2) + assert jobs.prepare(first.job_id)[0].status == "pending" + assert requests == [] + release.set() handle = await client.retrieve_workflow_async(first.workflow_id) await asyncio.wait_for(handle.get_result(), timeout=20) @@ -146,6 +171,7 @@ async def forbidden(*args, **kwargs): == 1 ) finally: + release.set() client.destroy() DBOS.destroy(destroy_registry=True) From 6d34b9dc10e2f51f854e5a8b79c88655de2e173c Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:47:03 +0000 Subject: [PATCH 7/8] docs: verify evidence queue admission and full regression gates --- README.md | 2 +- docs/README.md | 2 +- docs/changes/evidence-job-dispatch.md | 4 ++++ docs/planning/implementation-plan.md | 4 ++-- docs/planning/progress.md | 4 ++-- docs/planning/tasks.md | 6 +++--- docs/research/evidence-job-dispatch-validation.md | 2 +- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1bf1235..3464d82 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -진행 중: [근거 작업 접수·queue 전달](docs/changes/evidence-job-dispatch.md). 저장→명시 버전 queue 전달·동일 ID 재접수를 연결합니다. 실제 모델/운영 서버 변경은 없으며 질문 전용 검색·브리핑 변환/표시는 후속입니다. +현재 구현: [근거 작업 접수·queue 전달](docs/changes/evidence-job-dispatch.md). 신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토/수정 재검토를 통과했습니다. 저장→명시 버전 queue 전달·동일 ID 재접수와 권한/원문 재검사를 연결했습니다. 실제 제공자 호출0·현재 앱 활성화0이며 질문 전용 검색·브리핑 변환/표시는 후속입니다. 현재 구현: [영속 근거 작업·비동기 worker](docs/changes/durable-evidence-worker.md). 신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0를 통과했습니다. SQL 송신 의도·중단 후 무재호출·공유 예약 예산·현재 권한/원문 검증을 연결했습니다. 이번 실제 AI 호출0회이며 서비스 요청·브리핑 변환·개발 배포는 후속입니다. diff --git a/docs/README.md b/docs/README.md index 9d1b66b..26afb50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -진행 중: [근거 작업 접수·queue 전달](changes/evidence-job-dispatch.md). 저장→명시 버전 queue 전달·동일 ID 재접수를 연결합니다. 실제 모델/운영 서버 변경은 없으며 질문 전용 검색·브리핑 변환/표시는 후속입니다. +현재 구현: [근거 작업 접수·queue 전달](changes/evidence-job-dispatch.md). 신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토/수정 재검토를 통과했습니다. 저장→명시 버전 queue 전달·동일 ID 재접수와 권한/원문 재검사를 연결했습니다. 실제 제공자 호출0·현재 앱 활성화0이며 질문 전용 검색·브리핑 변환/표시는 후속입니다. 현재 구현: [영속 근거 작업·비동기 worker](changes/durable-evidence-worker.md). 신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0를 통과했습니다. SQL 송신 의도·중단 후 무재호출·공유 예약 예산·현재 권한/원문 검증을 연결했습니다. 이번 실제 AI 호출0회이며 서비스 요청·브리핑 변환·개발 배포는 후속입니다. diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md index 5a3c839..298d56a 100644 --- a/docs/changes/evidence-job-dispatch.md +++ b/docs/changes/evidence-job-dispatch.md @@ -26,3 +26,7 @@ 1. 먼저 신뢰 서버의 저장→queue 경계를 연결한다. 기존 앱 계약을 보존하지만 질문 전용 검색·브리핑 UI는 이번 완료 범위가 아니다. 2. SQL 저장 뒤 queue ack가 불명확하면 같은 ID 재접수만 허용한다. 중복 모델 실행을 막지만 아직 queue에 안 들어간 pending도 예약을 유지한다. 3. app_version 명시와 별도 단일 동시 queue를 사용한다. 예측 가능한 routing을 얻지만 운영자는 같은 version/queue의 worker를 준비해야 하며 처리량 인수가 남는다. + +## 검증 결과 + +실행afac2d7/테스트보완b6cbd98·신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·최종 수정 재검토 C0I0M0. 초기 minor1은 실제 완료 선행 RED2건으로 재현하고 결정적 pending gate로 해소했다. [상세 검증·한계](../research/evidence-job-dispatch-validation.md)를 따른다. 실제 제공자/운영 서버/앱 활성화0이다. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index 2dc328c..fc69b7b 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -1,6 +1,6 @@ -## M3 진행 · 근거 작업 접수·queue 전달 +## M3 검증 완료 · 근거 작업 접수·queue 전달 -[명시 접수·dispatch 계약](../changes/evidence-job-dispatch.md). 실제 DBOSClient의 별도 queue에 저장한 작업 ID만 전달한다. enqueue ack가 유실되더라도 원래 예약과 ID를 보존하며 동일 요청으로 재접수한다. 기존 API/UI/기본worker를 유지하고 D019 질문 검색·브리핑 변환/표시는 다음 연결이다. 실제 제공자 호출0·개발 활성화0이다. +[접수·dispatch 계약](../changes/evidence-job-dispatch.md)·[검증 범위](../research/evidence-job-dispatch-validation.md). 신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 수정 재검토 C0I0M0다. 실제 DBOSClient/queue·모의 SSE에서 동일 ID/예약1/HTTP1을 조건별로 확인했다. 실행 순서에 기대던 테스트는 결정적 gate로 보완했다. 기존481실행/이전46증거와 앱/기본worker/main은 유지한다. 질문 전용 검색·다자료/브리핑 변환/표시·운영 정책/부하·개발 적용은 다음이다. ## M3 검증 완료 · 영속 근거 작업과 복구 diff --git a/docs/planning/progress.md b/docs/planning/progress.md index 212a669..5c6975e 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,6 +1,6 @@ -## 진행 · 근거 작업 접수·DBOS queue 전달 · 2026-10-26 +## 검증 완료 · 근거 작업 접수·DBOS queue 전달 · 2026-10-26 -[새 계약](../changes/evidence-job-dispatch.md). 저장된 작업을 명시 버전의 전용 queue로 전달하고 ack 유실 후 같은 ID를 재사용하는 신뢰 서버 경로를 구현한다. 기존481실행/이전46증거를 고정하며 실제 제공자/운영 서버 변경0이다. D019 질문 검색·브리핑 변환/표시는 후속이며 현재 앱/기본worker/main은 유지한다. +[새 계약](../changes/evidence-job-dispatch.md)·[검증 기록](../research/evidence-job-dispatch-validation.md). 실행afac2d7/테스트보완b6cbd98·신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·최종 수정 재검토 C0I0M0·미해결0다. 태스크/전체 검토의 minor1(중복 접수 테스트의 실행 순서 의존)은 실제 완료 선행 RED2건으로 재현하고 결정적 대기/해제로 보완했다. 최초 전체 중단 실행은 통과로 세지 않고 보존했다. 기존481실행/이전46증거·앱/기본worker/main을 유지하며 실제 제공자/운영 서버 변경0이다. 질문 전용 검색·브리핑 변환/표시는 후속이며 PR/Sites/자체QA 정리는 최종 인계를 따른다. ## 검증 완료 · 영속 근거 작업·비동기 worker · 2026-10-26 diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index ffd4b87..c700e59 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -1,8 +1,8 @@ -## 진행 · 근거 작업 접수·queue 전달 · 2026-10-26 +## 검증 완료 · 근거 작업 접수·queue 전달 · 2026-10-26 - [x] [접수/queue 경계·명시 버전·ack 유실 계약](../changes/evidence-job-dispatch.md) 고정. -- [ ] 신뢰 서버 dispatch·전용 queue TDD/실제 DBOSClient 연결. -- [ ] 권한/source/예산·동일 요청/ack 유실·취소·payload/비밀 정보 차단 검사. +- [x] 신뢰 서버 dispatch·전용 queue TDD/실제 DBOSClient 연결. +- [x] 권한/source/예산·동일 요청/ack 유실·취소·payload/비밀 정보 차단 검사. - [ ] 독립 검토·전체 검사·기존481실행/이전46증거 보존·PR/Sites·자체QA 정리. - [ ] 질문 전용 검색·다자료/브리핑 변환·운영 정책/부하·개발 반영. diff --git a/docs/research/evidence-job-dispatch-validation.md b/docs/research/evidence-job-dispatch-validation.md index 10a29d0..9d4d824 100644 --- a/docs/research/evidence-job-dispatch-validation.md +++ b/docs/research/evidence-job-dispatch-validation.md @@ -12,7 +12,7 @@ ## 검증 -전용 합성 PostgreSQL과 실제 DBOSClient/queue·native AsyncOpenAI/모의 SSE를 사용했다. 모듈 부재 RED13건과 실효 queue 설정 불일치의 미차단 RED를 확인한 뒤 구현했다. 신규 집중 검사23개·Ruff/format4파일·mypy2소스가 통과했다. 전체 검사와 독립 검토 결과는 완료 후 반영한다. +전용 합성 PostgreSQL과 실제 DBOSClient/queue·native AsyncOpenAI/모의 SSE를 사용했다. 모듈 부재 RED13건과 실효 queue 설정 불일치의 미차단 RED를 확인한 뒤 구현했다. 신규 집중 검사23개·Ruff/format4파일·mypy2소스가 통과했다. 실행afac2d7/테스트보완b6cbd98·신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·최종 수정 재검토 C0I0M0·미해결0다. 초기 minor1의 중복 접수 상태 단정은 완료 선행 RED2건으로 재현하고 실제 worker의 claim 전 대기/해제를 명시해 해결했다. 최초 중단 전체 실행은 별도 보존하며 통과로 세지 않았다. - app_version 형식 검사는 SQL/queue보다 앞선다. 원래 repository의 UUID/질문/모델/Decimal·권한·source·합산 예산 계약을 유지한다. - None/권한·source·예산 실패, 현재 pending 재조회 거부, terminal 상태는 enqueue하지 않는다. From 4c61549c85f94ce3c833c508163b485f1f8ffa5b Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 03:49:46 +0000 Subject: [PATCH 8/8] docs: record PR111 and planning site v133 handoff --- docs/changes/evidence-job-dispatch.md | 2 +- docs/planning/progress.md | 2 ++ docs/planning/tasks.md | 2 +- docs/research/evidence-job-dispatch-validation.md | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/changes/evidence-job-dispatch.md b/docs/changes/evidence-job-dispatch.md index 298d56a..c14e332 100644 --- a/docs/changes/evidence-job-dispatch.md +++ b/docs/changes/evidence-job-dispatch.md @@ -18,7 +18,7 @@ ## Controller gates -- [ ] 독립 태스크/전체 검토·전체 검사·과거 실행/증거 보존·privacy·문서/PR/Sites·자체QA 정리. +- [x] 독립 태스크/전체 검토·전체 검사·과거 실행/증거 보존·privacy·문서/PR/Sites·자체QA 정리. - [ ] 질문 전용 검색→다자료 근거→브리핑 변환/표시와 운영 예약/부하·개발 적용 인수. 이번 활성화 없음. ## 판단과 영향 diff --git a/docs/planning/progress.md b/docs/planning/progress.md index 5c6975e..31210a7 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -2,6 +2,8 @@ [새 계약](../changes/evidence-job-dispatch.md)·[검증 기록](../research/evidence-job-dispatch-validation.md). 실행afac2d7/테스트보완b6cbd98·신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·최종 수정 재검토 C0I0M0·미해결0다. 태스크/전체 검토의 minor1(중복 접수 테스트의 실행 순서 의존)은 실제 완료 선행 RED2건으로 재현하고 결정적 대기/해제로 보완했다. 최초 전체 중단 실행은 통과로 세지 않고 보존했다. 기존481실행/이전46증거·앱/기본worker/main을 유지하며 실제 제공자/운영 서버 변경0이다. 질문 전용 검색·브리핑 변환/표시는 후속이며 PR/Sites/자체QA 정리는 최종 인계를 따른다. +최종 인계: [Draft PR111](https://github.com/DigestWorks/IssueDigest/pull/111)·Sitesv133 public/native succeeded, 이전v132/변경7문서 정확 사본(6d34b9d)을 보존했다. Site source `f5ac8a5a648141f5c80489c5b1f8ab7df770bd05`, saved `appgprj_6ac612dd42708191a7a0c8377be64d48~appgver_5639c730356881918e26559a86052fc4`, deployment `appgdep_6adecd95977481919b5729248554e88e`다. HTML118/로컬대상3,468/공개텍스트360·비공개60+12항목 제외를 확인했고 사용자 탭 이동/새브라우저QA0이다. 자체QA1/전용volume1과 이번 임시 로그3개를 정리하고 전용 포트 닫힘을 확인했다. 세 판단과 영향은 명세/검증 기록에 남겼다. 이후 인계는 저장소 원본을 따르며 앱/기본worker/main 활성화0이다. 최종 문서 push의 원격CI는 로컬 전체 검증과 구분한다. + ## 검증 완료 · 영속 근거 작업·비동기 worker · 2026-10-26 [새 계약](../changes/durable-evidence-worker.md)·[검증 기록](../research/durable-evidence-worker-validation.md). 실행abfbe26·신규57/새전체9,572개(Python8,752+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토 C0I0M0·미해결0다. 최초 검토의 완료 취소 응답/잠금 역전2건은 회귀7건 RED 재현 후 수정·좁은 재검토로 해소했다. 최초 중단된 전체 검사는 통과로 세지 않고 보존했다. 기존476실행/이전68증거·앱/기본worker/main은 유지하며 제공자 호출0·운영 서버/DB 변경0이다. 서비스 요청·브리핑 변환·예약 정책/부하·개발 반영은 후속이며 PR/Sites/정리는 최종 인계를 따른다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index c700e59..5f8da30 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -3,7 +3,7 @@ - [x] [접수/queue 경계·명시 버전·ack 유실 계약](../changes/evidence-job-dispatch.md) 고정. - [x] 신뢰 서버 dispatch·전용 queue TDD/실제 DBOSClient 연결. - [x] 권한/source/예산·동일 요청/ack 유실·취소·payload/비밀 정보 차단 검사. -- [ ] 독립 검토·전체 검사·기존481실행/이전46증거 보존·PR/Sites·자체QA 정리. +- [x] 독립 검토·전체9,595검사·기존481실행/이전46증거 보존·Draft PR111/Sitesv133·자체QA 정리. - [ ] 질문 전용 검색·다자료/브리핑 변환·운영 정책/부하·개발 반영. ## 검증 완료 · 영속 근거 작업·비동기 worker · 2026-10-26 diff --git a/docs/research/evidence-job-dispatch-validation.md b/docs/research/evidence-job-dispatch-validation.md index 9d4d824..91d10e7 100644 --- a/docs/research/evidence-job-dispatch-validation.md +++ b/docs/research/evidence-job-dispatch-validation.md @@ -29,3 +29,5 @@ 1. 기존 API/UI를 바꾸기 전에 신뢰 서버 저장→queue 경계를 연결한다. 이전 계약을 보존하지만 질문 전용 검색·브리핑 표시는 후속이다. 2. ack가 불분명하면 같은 ID 재접수만 허용한다. 중복 예약/AI 실행을 막지만 아직 송신되지 않은 pending도 예약을 유지할 수 있다. 3. 명시 버전과 별도 단일 동시 queue를 사용한다. 암묵적 최신 버전 routing을 피하지만 일치하는 worker 준비와 처리량 인수가 필요하다. + +최종 인계: [Draft PR111](https://github.com/DigestWorks/IssueDigest/pull/111)·계획판v133/이전v132·변경7문서(6d34b9d) 사본 보존. 자체QA1/전용volume1과 이번 임시 로그3개만 정리했다. 새브라우저QA/사용자탭 이동0·이번 실제 제공자 호출0이며 앱/기본worker/main 활성화는 없다. 게시 이후 인계 기록은 저장소 원본을 따른다.