From 61ddd470a195cb38f3a5338bb4575059e349d954 Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 06:00:54 +0000 Subject: [PATCH 1/4] docs: specify bounded automatic question plan runner --- docs/changes/question-plan-runner.md | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/changes/question-plan-runner.md diff --git a/docs/changes/question-plan-runner.md b/docs/changes/question-plan-runner.md new file mode 100644 index 0000000..0e719a8 --- /dev/null +++ b/docs/changes/question-plan-runner.md @@ -0,0 +1,30 @@ +# 질문별 자동 분석 진행과 검증 결과 수집 + +2026-10-26 KST. [순차 작업 접수](question-plan-dispatch.md) 다음 단계다. 한 coroutine에서 기존 DBOS 작업의 완료를 기다리고 같은 요청으로 다음 자료를 접수한다. 모든 자료가 완료되면 현재 권한/원문과 영수증을 재검증해 순서대로 모은다. 브리핑 합성·저장·API/UI/개발 적용은 후속이다. + +## Global Constraints + +- NEW execution files ONLY: apps/worker/src/issuedigest_worker/question_runner.py and apps/worker/tests/test_question_runner.py. All492existing execution files, previous49proof files, provider/model/prompt/workflow/queue names/version/slots, DBschema/migrations, API/UI, config/dependencies/exports/oldfixtures stay unchanged. Docs allowed. No real provider calls, operational server/DB, main merge/default worker/app activation. Local dedicated syntheticPostgreSQL/nativeDBOS/mockSSE only. +- New async run_question_plan(plans:QuestionPlanRepository,jobs:EvidenceJobRepository,*,client:DBOSClient,app_version,token,workspace_id,request_id,question,model,reservation_usd,total_timeout_seconds:int=600)->QuestionEvidenceRun|None. Borrow caller client; no creation/close/register/launch/migrate/import activation in production. Reuse unchanged issuedigest_agents.question_dispatch.advance_question_plan (all existing input/engine/actor/source/price guards) and its deterministic children. Validate timeout exact int1..600 (no bool/float) before any SQL/queue. Entire advance/wait/collect operation uses asyncio.timeout with this total bound; built-in TimeoutError and CancelledError propagate, no remote-job cancellation/refund/hidden continuation. Offloop SQL through existing asyncadvance or asyncio.to_thread; no eventloop blocking. +- Frozen dataclass QuestionEvidenceRun(dispatch:QuestionDispatch,receipts:tuple[GuardedQuestionEvidenceGenerationV2,...]). no extra facts/question/token/actor/source metadata or arbitrary callback result. no_match/blocked => empty receipts, return exact validated progress; None=>None. Evidence-complete receipts stay status unreviewed, not human approval/factual certification or final briefing. Never return partial receipt tuples on timeout/error/revocation. +- First call existing advance. For in_progress, validate returned exact QuestionDispatch/EvidenceDispatch shapes/types: UUID4 IDs, status literals, counts0..5 exactints, tuple choices/acks, unique jobIDs, workflow_id exactly guarded-evidence-{job_id}, completed prefix/len consistent with status, no_match0, evidence_complete allcompleted/queuedFalse, blocked lastfailed/cancelled/indeterminate andcompletedprefix, in_progress lastpending/running or completed acknowledgement race; prefixcompleted/queuedFalse. Reject inconsistent state with fixed sanitized error before arbitrary workflow lookup. Match stable plan_id/total across advancement, monotone completedprefix; no caller source/planDTO input accepted. Reuse helpers narrowly where useful but do not change frozen files. +- Await client.retrieve_workflow_async(last.workflow_id) then handle.get_result(); ignore arbitrary DBOS workflow result content. Queueing still happens only through existing advance/dispatch. On successful wait invoke same advance with identical inputs; it rechecks current session/writer/alloriginalsources and reuses completed jobs before next admission. At most total_sources<=5 waits, <=6 advance calls; if in_progress after wait has no increase in completed prefix (or same unfinished child) fail closed instead of hot loop. Stable identity/count mismatch or excess wait count fail closed. Native await is control-plane observation, never an automatic paid retry/failed-child skip. No sleeps/poll loops/new DBOS parentworkflow. Concurrent same-request callers may wait same workflows but don't duplicate paid execution under existing contracts. +- Existing native workflow may fail with arbitrary exception text. Catch Exception ONLY around native retrieve/get_result and around receipt-collection boundary if needed, raise fixed QuestionRunError(reason Literal workflow/receipt/progress or minimalfixedcodes, optional opaque plan/job IDs only) OUTSIDE except handler with no cause/context/rawtext. Do not catch/swallow unknown exceptions from initial/next advance (existing sanitized auth/budget/source errors) or CancelledError. TimeoutError from the OUTER asyncio.timeout must propagate; an arbitrary native retrieve/get_result exception (including its own TimeoutError) is sanitized within that narrow boundary. No logging raw questions/tokens/source/receipt/provider errors. Persisted child states/reservations remain on caller failure, no reselect/newrequest or automatic refund/reset. Worker failure may surface fixed workflow error; next explicit same-request call can inspect blocked state. +- When evidence_complete, gather receipts OFFLOOP. Reload current writer-owned plan via existing plans.submit and strict raw reconstruction helper question_dispatch._plan; ensure plan ID/order/count match dispatch. For each ordered ack/source, jobs.get with current token/workspace, verify identity/source/question/model/status via existing _job (current self-admitted allowed) and expected sourcehash before read. Reuse unchanged .evidence_worker.read_completed_evidence_job, with an internal authorization callback that calls jobs.get(token,workspace,expected_job_id) and returns True only for matching completed current job; callback denies other IDs. This is authenticated read authorization, not a worker lease or paid-execution ownership grant. Existing helper revalidates canonical receipt/prompt/instructions/usage/current-original source; require nonNone actual GuardedQuestionEvidenceGenerationV2/statusunreviewed for every source. Never trust SQL opaque receipt or DBOS result. Final plans.submit/_plan revalidation after all reads must equal initial plan, ensuring current writer/allsource guard before any tuple returned. Any failed/missing/mutated receipt/currentauth gives no partial output. No provider call/cost/budget mutation during collection/replay. Initial/next advanced completed prefix is not sufficient receipt acceptance. +- Automatic progression exists while caller coroutine/process is alive. On cancellation/timeout/process loss, child jobs stay durable and same explicit request resumes; no persisted sessiontoken/independent durable parentclaim. Return validated receipt packet is a synthesis input, NOT public publication/canonical Canvas or full brief. Actual model meaning, automated parent recovery, synthesis/storage/API/UI/devactivation remain followups. +- TDD: missingmodule RED then scoped GREEN. Pure/native state tests for bounded automatic progress/no_match/blocked/None, completedack race, invalid/raw/model_copy identities/counts/status/crossplan/nonprogress/unknown workflow error sanitized context, exacttimeout beforeSQL, nativewait timeout/cancel/no nextsource, auth/source invalid afterwait/finalcollection, tampered/missing receipts, completedreplay zeroqueue/provider/reservation, same-request two concurrent callers. Real PostgreSQL/DBOSClient/unchangedworker/mockSSE two-source run completes from ONE run_question_plan call, replay yields same validated receipts with no newnativeHTTP; current-source/metadata/receipt/usage/schema tamper rejected not output. New test fixture owns clean question_source_plans/evidence_jobs, restores modules/packageattrs/sys.path, uses unique qpr_ DBOS schema passed to migrate/config/client, closes client/runtime/provider BEFORE dropping ONLY quoted owned schema, asserts schema removed and defaultdbos metadata/queues unchanged. No oldfixtures/conftest modifications. Focused newtest + existing queue tests in same invocation ensures no cross-test collision. Agent scoped Ruff/format/mypy, controller fullQA with failfast/reviews/preservation/privacy/PR/Sites/ownedQAcleanup. No agent subagents. + +## Task 1 + +- [ ] 신규2파일을 TDD로 구현한다. global constraints를 지키고 기존492파일은 수정하지 않는다. 자동 진행·boundedwait/timeout/cancel·현재권한/원문·완료 영수증 검증·native2자료/재접수/동시요청·테스트 격리를 검사한다. scoped검사 후 owned2파일+Task1checkbox만 커밋하고 SDD보고서에 정확RED/GREEN/명령/인터페이스/한계/자체검토를 남긴다. + +## Controller gates + +- [ ] 독립 태스크/전체 검토·전체검사·기존492실행/이전49증거 보존·privacy·문서/Draft PR/Sites·자체QA 정리. +- [ ] 브리핑 합성·저장/API/UI·실제 의미/운영부하·프로세스와 독립된 부모 복구·개발 적용(이번 제외). + +## 판단과 비용 + +1. 기존 자식 workflow를 기다리며 자동으로 진행한다. 토큰을 영속화하지 않지만 서버 프로세스가 중단되면 동일 요청으로 다시 실행해야 한다. 독립 부모 복구를 구현한 것은 아니다. +2. 전체 대기를 최대600초/자료 수 이하로 제한한다. 정체 루프를 막지만 timeout 뒤에도 이미 접수한 작업이 남으므로 상태 재확인과 같은 요청의 재개가 필요하다. +3. 완료된 모든 영수증을 현재 권한·원문으로 다시 검증한 뒤 반환한다. 추가 AI 비용은 없지만 SQL 재검사 비용이 있고 브리핑·사실 인증은 아니다. From dca3341c768029c6e45018454c33000ac0931c0f Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 06:10:33 +0000 Subject: [PATCH 2/4] feat: run question plans through validated evidence collection --- .../src/issuedigest_worker/question_runner.py | 298 +++++++ apps/worker/tests/test_question_runner.py | 843 ++++++++++++++++++ docs/changes/question-plan-runner.md | 2 +- 3 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 apps/worker/src/issuedigest_worker/question_runner.py create mode 100644 apps/worker/tests/test_question_runner.py diff --git a/apps/worker/src/issuedigest_worker/question_runner.py b/apps/worker/src/issuedigest_worker/question_runner.py new file mode 100644 index 0000000..948b809 --- /dev/null +++ b/apps/worker/src/issuedigest_worker/question_runner.py @@ -0,0 +1,298 @@ +"""Bounded caller-lived progression and current-authority evidence collection. + +Children remain durable on timeout/cancellation; the caller must explicitly resume +with the same request. This does not register a parent workflow or certify meaning. +""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from decimal import Decimal +from typing import Literal +from uuid import UUID + +from dbos import DBOSClient, WorkflowHandleAsync +from issuedigest_agents.evidence_dispatch import EvidenceDispatch +from issuedigest_agents.guarded_question_evidence_v2 import GuardedQuestionEvidenceGenerationV2 +from issuedigest_agents.question_dispatch import ( + QuestionDispatch, + _job, + _plan, + advance_question_plan, +) +from issuedigest_domain.question_source_plan import QuestionSourceChoice +from issuedigest_storage.evidence_jobs import EvidenceJobRepository +from issuedigest_storage.question_plans import QuestionPlanRepository +from issuedigest_storage.repository import StorageCorruption +from issuedigest_storage.research import ResearchConflict, ResearchForbidden +from issuedigest_storage.sessions import AuthenticationDenied + +from . import evidence_worker + + +@dataclass(frozen=True) +class QuestionEvidenceRun: + """Validated private transport progress and unreviewed synthesis inputs only.""" + + dispatch: QuestionDispatch + receipts: tuple[GuardedQuestionEvidenceGenerationV2, ...] + + +class QuestionRunError(Exception): + """Fixed safe failure; durable child admission is not rolled back.""" + + def __init__(self, reason: Literal["workflow", "receipt", "progress"]): + super().__init__("Question run " + reason + " unavailable") + self.reason = reason + + +def _uuid(value: object) -> bool: + return type(value) is UUID and value.version == 4 + + +def _dispatch(value: QuestionDispatch) -> None: + """Reject raw dataclass mutation, subclasses, and impossible acknowledgements.""" + if ( + type(value) is not QuestionDispatch + or set(vars(value)) != set(QuestionDispatch.__dataclass_fields__) + or not _uuid(value.plan_id) + or type(value.status) is not str + or value.status not in ("no_match", "in_progress", "blocked", "evidence_complete") + or type(value.completed_count) is not int + or type(value.total_sources) is not int + or not 0 <= value.completed_count <= value.total_sources <= 5 + or type(value.dispatches) is not tuple + ): + raise QuestionRunError("progress") + seen = set() + for ack in value.dispatches: + if ( + type(ack) is not EvidenceDispatch + or set(vars(ack)) != set(EvidenceDispatch.__dataclass_fields__) + or not _uuid(ack.job_id) + or ack.job_id in seen + or type(ack.workflow_id) is not str + or ack.workflow_id != "guarded-evidence-" + str(ack.job_id) + or type(ack.status) is not str + or ack.status + not in ("pending", "running", "completed", "failed", "cancelled", "indeterminate") + or type(ack.queued) is not bool + or (ack.queued and ack.status != "pending") + ): + raise QuestionRunError("progress") + seen.add(ack.job_id) + prefix = value.dispatches[: value.completed_count] + if len(prefix) != value.completed_count or any( + ack.status != "completed" or ack.queued for ack in prefix + ): + raise QuestionRunError("progress") + if value.status == "no_match": + valid = value.total_sources == 0 and value.completed_count == 0 and not value.dispatches + elif value.status == "evidence_complete": + valid = value.total_sources > 0 and value.completed_count == value.total_sources == len( + value.dispatches + ) + else: + valid = ( + value.completed_count < value.total_sources + and len(value.dispatches) == value.completed_count + 1 + and value.dispatches[-1].status + in ( + ("pending", "running", "completed") + if value.status == "in_progress" + else ("failed", "cancelled", "indeterminate") + ) + ) + if not valid: + raise QuestionRunError("progress") + + +def _wait_authorized( + plans: QuestionPlanRepository, + jobs: EvidenceJobRepository, + dispatch: QuestionDispatch, + token: str, + workspace_id: UUID, + request_id: UUID, + question: str, + model: str, +) -> bool: + saved = plans.submit(token, workspace_id, request_id, question) + if saved is None: + return False + plan = _plan(saved, workspace_id, request_id, question) + if plan.plan_id != dispatch.plan_id or len(plan.sources) != dispatch.total_sources: + raise QuestionRunError("progress") + ack = dispatch.dispatches[-1] + current = jobs.get(token, workspace_id, ack.job_id) + if current is None: + return False + if current.job_id != ack.job_id: + raise QuestionRunError("progress") + _job(current, current, plan.sources[dispatch.completed_count], workspace_id, question, model) + return True + + +def _collect( + plans: QuestionPlanRepository, + jobs: EvidenceJobRepository, + dispatch: QuestionDispatch, + token: str, + workspace_id: UUID, + request_id: UUID, + question: str, + model: str, +) -> tuple[GuardedQuestionEvidenceGenerationV2, ...] | None: + saved = plans.submit(token, workspace_id, request_id, question) + if saved is None: + return None + plan = _plan(saved, workspace_id, request_id, question) + if plan.plan_id != dispatch.plan_id or len(plan.sources) != dispatch.total_sources: + raise QuestionRunError("receipt") + + def authorization(expected: UUID, source: QuestionSourceChoice) -> Callable[[UUID], bool]: + def authorized(job_id: UUID) -> bool: + if type(job_id) is not UUID or job_id != expected: + return False + current = jobs.get(token, workspace_id, expected) + if current is None or current.job_id != expected: + return False + _job(current, current, source, workspace_id, question, model) + return current.status == "completed" + + return authorized + + receipts = [] + for source, ack in zip(plan.sources, dispatch.dispatches, strict=True): + authorized = authorization(ack.job_id, source) + if not authorized(ack.job_id): + raise QuestionRunError("receipt") + receipt = evidence_worker.read_completed_evidence_job( + jobs, ack.job_id, check_ownership=authorized + ) + if ( + type(receipt) is not GuardedQuestionEvidenceGenerationV2 + or receipt.status != "unreviewed" + ): + raise QuestionRunError("receipt") + receipts.append(receipt) + final = plans.submit(token, workspace_id, request_id, question) + if final is None: + return None + if _plan(final, workspace_id, request_id, question) != plan: + raise QuestionRunError("receipt") + return tuple(receipts) + + +async def run_question_plan( + plans: QuestionPlanRepository, + jobs: EvidenceJobRepository, + *, + client: DBOSClient, + app_version: str, + token: str, + workspace_id: UUID, + request_id: UUID, + question: str, + model: str, + reservation_usd: Decimal, + total_timeout_seconds: int = 600, +) -> QuestionEvidenceRun | None: + """Advance, observe each existing native child, and collect all current receipts. + + The caller owns the borrowed client and coroutine lifetime. Native workflow + results are ignored; completion is accepted only through current SQL guards. + """ + if type(total_timeout_seconds) is not int or not 1 <= total_timeout_seconds <= 600: + raise ValueError("Invalid question run timeout") + async with asyncio.timeout(total_timeout_seconds): + previous = None + waits = 0 + while True: + current = await advance_question_plan( + plans, + jobs, + client=client, + app_version=app_version, + token=token, + workspace_id=workspace_id, + request_id=request_id, + question=question, + model=model, + reservation_usd=reservation_usd, + ) + if current is None: + return None + _dispatch(current) + if previous is not None and ( + current.plan_id != previous.plan_id + or current.total_sources != previous.total_sources + or current.completed_count < previous.completed_count + or len(current.dispatches) < len(previous.dispatches) + or any( + a.job_id != b.job_id + for a, b in zip(current.dispatches, previous.dispatches, strict=False) + ) + or ( + current.status == "in_progress" + and ( + current.completed_count <= previous.completed_count + or current.dispatches[-1].job_id == previous.dispatches[-1].job_id + ) + ) + ): + raise QuestionRunError("progress") + if current.status in ("no_match", "blocked"): + return QuestionEvidenceRun(current, ()) + if current.status == "evidence_complete": + failed = False + try: + receipts = await asyncio.to_thread( + _collect, + plans, + jobs, + current, + token, + workspace_id, + request_id, + question, + model, + ) + except ( + AuthenticationDenied, + ResearchForbidden, + ResearchConflict, + StorageCorruption, + ): + raise + except Exception: + failed = True + if failed: + raise QuestionRunError("receipt") from None + return None if receipts is None else QuestionEvidenceRun(current, receipts) + if waits >= current.total_sources: + raise QuestionRunError("progress") + if not await asyncio.to_thread( + _wait_authorized, + plans, + jobs, + current, + token, + workspace_id, + request_id, + question, + model, + ): + return None + failed = False + try: + handle: WorkflowHandleAsync[object] = await client.retrieve_workflow_async( + current.dispatches[-1].workflow_id + ) + await handle.get_result() + except Exception: + failed = True + if failed: + raise QuestionRunError("workflow") from None + waits += 1 + previous = current diff --git a/apps/worker/tests/test_question_runner.py b/apps/worker/tests/test_question_runner.py new file mode 100644 index 0000000..d7e254a --- /dev/null +++ b/apps/worker/tests/test_question_runner.py @@ -0,0 +1,843 @@ +"""Automatic private question progression, using only synthetic evidence and native I/O.""" + +import asyncio +import importlib +import sys +import threading +from dataclasses import FrozenInstanceError, replace +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import event, text + + +def module(): + assert importlib.util.find_spec("issuedigest_worker.question_runner"), "Missing question runner" + return importlib.import_module("issuedigest_worker.question_runner") + + +def test_runner_available(): + assert module().run_question_plan + + +@pytest.fixture(scope="module", autouse=True) +def isolated_runner_modules(): + paths = sys.path.copy() + root = Path(__file__).resolve().parents[3] + for path in ("packages/storage/tests", "packages/agents/tests", "apps/worker/tests"): + sys.path.insert(0, str(root / path)) + packages = [importlib.import_module(n) for n in ("issuedigest_agents", "issuedigest_worker")] + before, attrs = set(sys.modules), [vars(p).copy() for p in packages] + yield + for name in set(sys.modules) - before: + if name.startswith(("issuedigest_agents.", "issuedigest_worker.")): + 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_domain import EvidenceBundle + from issuedigest_storage import EvidenceRepository + from issuedigest_storage.evidence_jobs import EvidenceJobRepository + from issuedigest_storage.question_plans import QuestionPlanRepository + from test_evidence_jobs import seed + + from conftest import bundle_data + + with engine.begin() as conn: + conn.execute(text("TRUNCATE question_source_plans, evidence_jobs")) + token, workspace, snapshot = seed(engine) + EvidenceRepository(engine).rebuild_projection(workspace, snapshot) + data = bundle_data() + data["document"]["workspace_id"] = str(workspace) + other = uuid4() + EvidenceRepository(engine).save(other, EvidenceBundle.model_validate(data)) + EvidenceRepository(engine).rebuild_projection(workspace, other) + yield ( + QuestionPlanRepository(engine), + EvidenceJobRepository(engine), + dict( + token=token, + workspace_id=workspace, + request_id=uuid4(), + question="앞", + model="gpt-6.1-sol", + reservation_usd=Decimal(".50"), + ), + ) + with engine.begin() as conn: + conn.execute(text("TRUNCATE question_source_plans, evidence_jobs")) + + +def run(admission, client, **changes): + plans, jobs, inputs = admission + return module().run_question_plan( + plans, jobs, client=client, app_version="question-runner-test-v1", **(inputs | changes) + ) + + +def complete(jobs, job): + from test_evidence_jobs import completion_receipt + + receipt = completion_receipt(jobs, job) + assert jobs.claim( + job.job_id, + prompt_sha256=receipt["prompt_sha256"], + instructions_sha256=receipt["instructions_sha256"], + ) + assert ( + jobs.finish( + job.job_id, + receipt=receipt, + known_usage=receipt["usage"], + usage_complete=True, + stream_closed=True, + ).status + == "completed" + ) + + +def counts(engine): + with engine.connect() as conn: + return ( + conn.scalar(text("SELECT count(*) FROM evidence_jobs")), + conn.scalar( + text("SELECT coalesce(sum(reservation_count),0) FROM research_budget_months") + ), + ) + + +class CompletingClient: + def __init__(self, admission, action=None): + self.admission = admission + self.action = action + self.queued = [] + self.waited = [] + + async def enqueue_async(self, options, *args): + self.queued.append(options["workflow_id"]) + + async def retrieve_workflow_async(self, workflow_id): + self.waited.append(workflow_id) + + async def result(): + _, jobs, inputs = self.admission + job = await asyncio.to_thread( + jobs.get, inputs["token"], inputs["workspace_id"], UUID(workflow_id[17:]) + ) + if job.status != "completed": + await asyncio.to_thread(complete, jobs, job) + if self.action: + await self.action() + return {"untrusted": "workflow output must never become a receipt"} + + return SimpleNamespace(get_result=result) + + +def test_one_call_collects_ordered_receipts_and_replay_is_free(admission, engine): + client = CompletingClient(admission) + result = asyncio.run(run(admission, client)) + assert result.dispatch.status == "evidence_complete" + assert result.dispatch.completed_count == result.dispatch.total_sources == 2 + assert len(result.receipts) == 2 and all(r.status == "unreviewed" for r in result.receipts) + assert counts(engine) == (2, 2) and len(client.queued) == len(client.waited) == 2 + assert set(vars(result)) == {"dispatch", "receipts"} + with pytest.raises(FrozenInstanceError): + result.receipts = () + assert asyncio.run(run(admission, client)) == result + assert counts(engine) == (2, 2) and len(client.queued) == len(client.waited) == 2 + + +def test_no_match_none_and_blocked_return_no_receipts(admission, engine, monkeypatch): + client = CompletingClient(admission) + result = asyncio.run(run(admission, client, question="?!")) + assert result.dispatch.status == "no_match" and result.receipts == () + assert counts(engine) == (0, 0) and client.waited == [] + plans, jobs, inputs = admission + inputs["request_id"] = uuid4() + dispatch = importlib.import_module("issuedigest_agents.question_dispatch") + first = asyncio.run( + dispatch.advance_question_plan(plans, jobs, client=client, app_version="test", **inputs) + ) + jobs.cancel(inputs["token"], inputs["workspace_id"], first.dispatches[0].job_id) + result = asyncio.run(run(admission, client)) + assert result.dispatch.status == "blocked" and result.receipts == () and client.waited == [] + monkeypatch.setattr(plans, "submit", lambda *args: None) + assert asyncio.run(run(admission, client)) is None + + +@pytest.mark.parametrize("value", [True, False, 0, 601, -1, 1.0, "1", None]) +def test_timeout_exact_int_before_sql(admission, engine, value): + def forbidden(*args): + pytest.fail("Invalid timeout issued SQL") + + event.listen(engine, "before_cursor_execute", forbidden) + try: + with pytest.raises(ValueError): + asyncio.run(run(admission, CompletingClient(admission), total_timeout_seconds=value)) + finally: + event.remove(engine, "before_cursor_execute", forbidden) + + +@pytest.mark.parametrize("stage", ["retrieve", "result"]) +@pytest.mark.parametrize("error", [RuntimeError("SECRET question/token"), TimeoutError("SECRET")]) +def test_native_errors_are_sanitized_outside_handler(admission, stage, error): + client = CompletingClient(admission) + + async def fail(*args): + raise error + + async def handle(*args): + return SimpleNamespace(get_result=fail) + + client.retrieve_workflow_async = fail if stage == "retrieve" else handle + with pytest.raises(module().QuestionRunError) as caught: + asyncio.run(run(admission, client)) + assert caught.value.reason == "workflow" + assert caught.value.__context__ is caught.value.__cause__ is None + assert "SECRET" not in str(caught.value) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_wait_timeout_or_cancel_has_no_hidden_next_child(admission, engine, cancel): + client = CompletingClient(admission) + entered = asyncio.Event() + + async def handle(*args): + async def pending(): + entered.set() + await asyncio.Event().wait() + + return SimpleNamespace(get_result=pending) + + client.retrieve_workflow_async = handle + + async def exercise(): + task = asyncio.create_task(run(admission, client, total_timeout_seconds=1)) + await entered.wait() + assert counts(engine) == (1, 1) + if cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError if cancel else TimeoutError): + await task + await asyncio.sleep(0) + + asyncio.run(exercise()) + assert counts(engine) == (1, 1) and len(client.queued) == 1 + + +def ack(status="pending", queued=True, job_id=None): + from issuedigest_agents.evidence_dispatch import EvidenceDispatch + + job_id = job_id or uuid4() + return EvidenceDispatch(job_id, "guarded-evidence-" + str(job_id), status, queued) + + +def progress(status="in_progress", completed=0, total=2, dispatches=None, plan=None): + from issuedigest_agents.question_dispatch import QuestionDispatch + + return QuestionDispatch( + plan or uuid4(), status, completed, total, (ack(),) if dispatches is None else dispatches + ) + + +@pytest.mark.parametrize( + "change", + [ + {"plan_id": "bad"}, + {"plan_id": UUID(int=1)}, + {"total_sources": True}, + {"total_sources": 6}, + {"completed_count": False}, + {"completed_count": -1}, + {"status": "secret"}, + {"dispatches": []}, + {"dispatches": (object(),)}, + {"dispatches": ()}, + {"completed_count": 1}, + {"status": "evidence_complete"}, + {"status": "no_match"}, + {"status": "blocked"}, + ], +) +def test_invalid_progress_rejected_before_workflow_lookup(monkeypatch, change): + runner = module() + bad = replace(progress(), **change) + + async def advance(*args, **kwargs): + return bad + + monkeypatch.setattr(runner, "advance_question_plan", advance) + with pytest.raises(runner.QuestionRunError) as caught: + asyncio.run( + runner.run_question_plan( + None, + None, + client=object(), + app_version="test", + token="synthetic", + workspace_id=uuid4(), + request_id=uuid4(), + question="앞", + model="gpt-6.1-sol", + reservation_usd=Decimal(".50"), + ) + ) + assert caught.value.reason == "progress" and caught.value.__context__ is None + + +@pytest.mark.parametrize( + "change", + [ + {"job_id": "bad"}, + {"workflow_id": "other"}, + {"status": "other"}, + {"queued": 1}, + {"status": "running", "queued": True}, + {"status": "failed"}, + ], +) +def test_invalid_ack_rejected_before_workflow_lookup(monkeypatch, change): + runner = module() + state = progress(dispatches=(replace(ack(), **change),)) + + async def advance(*args, **kwargs): + return state + + monkeypatch.setattr(runner, "advance_question_plan", advance) + with pytest.raises(runner.QuestionRunError, match="progress"): + asyncio.run( + runner.run_question_plan( + None, + None, + client=object(), + app_version="test", + token="synthetic", + workspace_id=uuid4(), + request_id=uuid4(), + question="앞", + model="gpt-6.1-sol", + reservation_usd=Decimal(".50"), + ) + ) + + +@pytest.mark.parametrize("change", ["no_progress", "plan", "total", "same_child", "prefix"]) +def test_progress_must_be_monotone_and_stable(admission, monkeypatch, change): + runner = module() + first_ack = ack("completed", False) + first = progress(completed=1, total=3, dispatches=(first_ack, ack())) + next_ack = ack() + second = progress( + completed=2, + total=3, + dispatches=( + first_ack, + replace(first.dispatches[1], status="completed", queued=False), + next_ack, + ), + plan=first.plan_id, + ) + if change == "no_progress": + second = first + elif change == "plan": + second = replace(second, plan_id=uuid4()) + elif change == "total": + second = replace(second, total_sources=4) + elif change == "same_child": + second = replace(second, dispatches=(first_ack, next_ack, first.dispatches[1])) + else: + second = replace( + second, dispatches=(ack("completed", False), second.dispatches[1], next_ack) + ) + states = iter((first, second)) + + async def advance(*args, **kwargs): + return next(states) + + async def handle(*args): + async def result(): + return None + + return SimpleNamespace(get_result=result) + + monkeypatch.setattr(runner, "advance_question_plan", advance) + monkeypatch.setattr(runner, "_wait_authorized", lambda *a: True) + client = SimpleNamespace(retrieve_workflow_async=handle) + with pytest.raises(runner.QuestionRunError, match="progress"): + asyncio.run(run(admission, client)) + + +@pytest.mark.parametrize("change", ["authority", "source", "metadata", "session"]) +def test_current_guards_rechecked_after_wait(admission, engine, change): + async def mutate(): + with engine.begin() as conn: + conn.execute( + text( + { + "authority": "UPDATE auth_workspace_memberships SET role='viewer'", + "session": "DELETE FROM auth_sessions", + "source": ( + "UPDATE parsed_representations SET " + "payload=jsonb_set(payload,'{text}','\"changed\"')" + ), + "metadata": ( + "UPDATE source_presentations SET " + "payload=jsonb_set(payload,'{title}','\"changed\"')" + ), + }[change] + ) + ) + + client = CompletingClient(admission, mutate) + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.research import ResearchForbidden + from issuedigest_storage.sessions import AuthenticationDenied + + with pytest.raises((StorageCorruption, ResearchForbidden, AuthenticationDenied)): + asyncio.run(run(admission, client)) + assert counts(engine) == (1, 1) and len(client.queued) == 1 + + +@pytest.mark.parametrize("change", ["receipt", "usage", "missing", "status", "source", "extra"]) +def test_completed_replay_refuses_tampered_receipts(admission, engine, monkeypatch, change): + client = CompletingClient(admission) + result = asyncio.run(run(admission, client)) + job_id = result.dispatch.dispatches[0].job_id + if change == "extra": + worker = importlib.import_module("issuedigest_worker.evidence_worker") + original = worker.read_completed_evidence_job + monkeypatch.setattr( + worker, + "read_completed_evidence_job", + lambda *a, **kw: original(*a, **kw).model_copy(update={"status": "approved"}), + ) + else: + with engine.begin() as conn: + conn.execute( + text( + { + "receipt": ( + "UPDATE evidence_jobs SET receipt_sha256=:value WHERE job_id=:job" + ), + "usage": ( + "UPDATE evidence_jobs SET known_usage='{}'::jsonb WHERE job_id=:job" + ), + "missing": "UPDATE evidence_jobs SET receipt=NULL WHERE job_id=:job", + "status": "UPDATE evidence_jobs SET usage_complete=false WHERE job_id=:job", + "source": "UPDATE evidence_jobs SET source_sha256=:value WHERE job_id=:job", + }[change] + ), + {"value": "f" * 64, "job": job_id}, + ) + from issuedigest_storage.repository import StorageCorruption + + with pytest.raises((module().QuestionRunError, StorageCorruption)): + asyncio.run(run(admission, client)) + assert counts(engine) == (2, 2) and len(client.queued) == len(client.waited) == 2 + + +@pytest.mark.parametrize("change", ["source", "authority", "other_id"]) +def test_collection_revalidates_after_last_read_and_denies_other_id( + admission, engine, monkeypatch, change +): + client = CompletingClient(admission) + asyncio.run(run(admission, client)) + worker = importlib.import_module("issuedigest_worker.evidence_worker") + original = worker.read_completed_evidence_job + reads = [] + + def altered(*args, **kwargs): + assert kwargs["check_ownership"](uuid4()) is False + receipt = original(*args, **kwargs) + reads.append(args[1]) + if len(reads) == 2: + if change == "other_id": + assert kwargs["check_ownership"](reads[0]) is False + return None + with engine.begin() as conn: + conn.execute( + text( + "UPDATE auth_workspace_memberships SET role='viewer'" + if change == "authority" + else ( + "UPDATE parsed_representations SET " + "payload=jsonb_set(payload,'{text}','\"late\"')" + ) + ) + ) + return receipt + + monkeypatch.setattr(worker, "read_completed_evidence_job", altered) + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.research import ResearchForbidden + + with pytest.raises((module().QuestionRunError, StorageCorruption, ResearchForbidden)) as caught: + asyncio.run(run(admission, client)) + if change == "other_id": + assert type(caught.value) is module().QuestionRunError + assert caught.value.reason == "receipt" and caught.value.__context__ is None + else: + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.research import ResearchForbidden + + assert isinstance(caught.value, (StorageCorruption, ResearchForbidden)) + assert counts(engine) == (2, 2) and len(client.queued) == 2 + + +def test_unknown_advance_errors_are_not_caught(admission, monkeypatch): + runner = module() + + async def broken(*args, **kwargs): + raise RuntimeError("synthetic existing advance exception") + + monkeypatch.setattr(runner, "advance_question_plan", broken) + with pytest.raises(RuntimeError, match="synthetic existing"): + asyncio.run(run(admission, object())) + + +@pytest.mark.parametrize("change", ["expired", "job_id", "snapshot", "plan", "raw_plan"]) +def test_current_authority_and_fixed_source_checked_before_native_wait( + admission, engine, monkeypatch, change +): + runner = module() + plans, jobs, inputs = admission + original = runner.advance_question_plan + get = jobs.get + + async def altered(*args, **kwargs): + result = await original(*args, **kwargs) + if change == "expired": + with engine.begin() as conn: + conn.execute(text("DELETE FROM auth_sessions")) + elif change in ("job_id", "snapshot"): + monkeypatch.setattr( + jobs, + "get", + lambda *a: replace( + get(*a), **{"job_id" if change == "job_id" else "snapshot_id": uuid4()} + ), + ) + else: + saved = plans.submit( + inputs["token"], inputs["workspace_id"], inputs["request_id"], inputs["question"] + ) + updates = {"plan_id": uuid4()} if change == "plan" else {"sources": list(saved.sources)} + monkeypatch.setattr(plans, "submit", lambda *a: saved.model_copy(update=updates)) + return result + + monkeypatch.setattr(runner, "advance_question_plan", altered) + client = CompletingClient(admission) + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.sessions import AuthenticationDenied + + with pytest.raises((runner.QuestionRunError, StorageCorruption, AuthenticationDenied)): + asyncio.run(run(admission, client)) + assert counts(engine) == (1, 1) and client.waited == [] and len(client.queued) == 1 + + +def test_native_two_source_single_call_concurrent_replay_owned_schema( + admission, engine, database_url, monkeypatch +): + import httpx2 as httpx + from dbos import DBOS, DBOSClient + from openai import AsyncOpenAI + from sqlalchemy.schema import DropSchema + from sqlalchemy.sql import quoted_name + from test_evidence_worker import native_body + + _, jobs, _ = admission + worker = importlib.import_module("issuedigest_worker.evidence_worker") + execute = worker.execute_evidence_job + entered, release = threading.Event(), threading.Event() + requests = [] + schema = "qpr_" + uuid4().hex + + def default_state(): + with engine.connect() as conn: + metadata = conn.execute( + text( + "SELECT c.oid,c.relname,c.relkind FROM pg_class c " + "JOIN pg_namespace n ON n.oid=c.relnamespace " + "WHERE n.nspname='dbos' ORDER BY c.relname" + ) + ).all() + queues = ( + conn.execute(text("SELECT * FROM dbos.queues ORDER BY name")).all() + if conn.scalar(text("SELECT to_regclass('dbos.queues')")) is not None + else None + ) + exists = conn.scalar( + text("SELECT EXISTS (SELECT FROM pg_namespace WHERE nspname='dbos')") + ) + return exists, metadata, queues + + before = default_state() + + async def gated(*args, **kwargs): + entered.set() + assert await asyncio.to_thread(release.wait, 20), "Native gate not released" + return await execute(*args, **kwargs) + + monkeypatch.setattr(worker, "execute_evidence_job", gated) + + async def exercise(): + 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) + client = None + active = [] + try: + DBOS.migrate(database_url, schema=schema, application_role=engine.url.username) + DBOS( + config=dict( + name="question-runner-synthetic", + system_database_url=database_url, + dbos_system_schema=schema, + application_version="question-runner-test-v1", + run_admin_server=False, + ) + ) + client = DBOSClient( + system_database_url=database_url, + application_name="question-runner-synthetic", + dbos_system_schema=schema, + ) + DBOS.launch() + await asyncio.to_thread( + importlib.import_module( + "issuedigest_worker.evidence_queue" + ).register_evidence_queue, + jobs, + client=provider, + check_ownership=lambda _: True, + ) + waits = [] + original = client.retrieve_workflow_async + + async def retrieve(workflow_id): + waits.append(workflow_id) + return await original(workflow_id) + + monkeypatch.setattr(client, "retrieve_workflow_async", retrieve) + active.append(asyncio.create_task(run(admission, client))) + assert await asyncio.to_thread(entered.wait, 10) + active.append(asyncio.create_task(run(admission, client))) + # Both callers observe the same durable first workflow before it can run. + async with asyncio.timeout(10): + while len(waits) < 2: + await asyncio.sleep(0.01) + assert counts(engine) == (1, 1) and requests == [] + assert waits[0] == waits[1] + release.set() + first, second = await asyncio.gather(*active) + assert first == second and first.dispatch.status == "evidence_complete" + assert len(first.receipts) == 2 and all( + r.status == "unreviewed" for r in first.receipts + ) + assert counts(engine) == (2, 2) and len(requests) == 2 + assert len(set(waits)) == 2 and len(waits) <= 4 + + async def forbidden(*args, **kwargs): + pytest.fail("Completed replay attempted native queue/wait") + + monkeypatch.setattr(client, "enqueue_async", forbidden) + monkeypatch.setattr(client, "retrieve_workflow_async", forbidden) + assert await run(admission, client) == first + assert counts(engine) == (2, 2) and len(requests) == 2 + assert not provider.is_closed() + finally: + release.set() + for task in active: + if not task.done(): + task.cancel() + await asyncio.gather(*active, return_exceptions=True) + try: + if client is not None: + client.destroy() + finally: + try: + DBOS.destroy(destroy_registry=True) + finally: + await provider.close() + + try: + asyncio.run(exercise()) + finally: + with engine.begin() as conn: + conn.execute(DropSchema(quoted_name(schema, True), cascade=True, if_exists=True)) + with engine.connect() as conn: + assert not conn.scalar( + text("SELECT EXISTS (SELECT FROM pg_namespace WHERE nspname=:schema)"), + {"schema": schema}, + ) + assert default_state() == before + + +def test_five_sources_bounded_to_five_waits_six_advances_offloop_sql( + admission, engine, monkeypatch +): + from issuedigest_domain import EvidenceBundle + from issuedigest_storage import EvidenceRepository + + from conftest import bundle_data + + plans, _, inputs = admission + for _ in range(3): + data = bundle_data() + data["document"]["workspace_id"] = str(inputs["workspace_id"]) + snapshot = uuid4() + EvidenceRepository(engine).save(snapshot, EvidenceBundle.model_validate(data)) + EvidenceRepository(engine).rebuild_projection(inputs["workspace_id"], snapshot) + client = CompletingClient(admission) + runner = module() + advance = runner.advance_question_plan + calls = [] + main_thread = threading.get_ident() + + async def recorded(*args, **kwargs): + calls.append(kwargs.copy()) + return await advance(*args, **kwargs) + + def offloop(*args): + assert threading.get_ident() != main_thread, "Runner blocked event loop with SQL" + + monkeypatch.setattr(runner, "advance_question_plan", recorded) + event.listen(engine, "before_cursor_execute", offloop) + try: + result = asyncio.run(run(admission, client)) + finally: + event.remove(engine, "before_cursor_execute", offloop) + assert ( + result.dispatch.completed_count + == result.dispatch.total_sources + == len(result.receipts) + == 5 + ) + assert len(calls) == 6 and all(call == calls[0] for call in calls) + assert len(client.waited) == len(client.queued) == 5 and counts(engine) == (5, 5) + assert ( + plans.submit( + inputs["token"], inputs["workspace_id"], inputs["request_id"], inputs["question"] + ).plan_id + == result.dispatch.plan_id + ) + + +def test_completed_acknowledgement_race_advances_without_repeat_job(admission, engine, monkeypatch): + _, jobs, _ = admission + original = jobs.submit + calls = 0 + + def raced(*args, **kwargs): + nonlocal calls + calls += 1 + job = original(*args, **kwargs) + if calls == 2: + complete(jobs, job) + return jobs.get(args[0], args[1], job.job_id) + return job + + monkeypatch.setattr(jobs, "submit", raced) + client = CompletingClient(admission) + result = asyncio.run(run(admission, client)) + assert len(result.receipts) == 2 and counts(engine) == (2, 2) + assert len(client.waited) == 2 and len(client.queued) == 1 + + +@pytest.mark.parametrize( + "change", ["duplicate", "prefix_queue", "prefix_status", "extra", "subclass"] +) +def test_raw_progress_and_prefix_shape_checks(admission, monkeypatch, change): + from issuedigest_agents.question_dispatch import QuestionDispatch + + runner = module() + first = ack("completed", False) + state = progress(completed=1, dispatches=(first, ack())) + if change == "duplicate": + state = replace(state, dispatches=(first, replace(first, status="pending", queued=True))) + elif change == "prefix_queue": + state = replace(state, dispatches=(replace(first, queued=True), state.dispatches[1])) + elif change == "prefix_status": + state = replace(state, dispatches=(replace(first, status="running"), state.dispatches[1])) + elif change == "extra": + object.__setattr__(state, "receipt", {"untrusted": True}) + else: + + class UntrustedDispatch(QuestionDispatch): + pass + + state = UntrustedDispatch(**vars(state)) + + async def advance(*args, **kwargs): + return state + + monkeypatch.setattr(runner, "advance_question_plan", advance) + client = CompletingClient(admission) + with pytest.raises(runner.QuestionRunError, match="progress"): + asyncio.run(run(admission, client)) + assert client.waited == client.queued == [] + + +@pytest.mark.parametrize("change", ["missing_plan", "raw_plan", "unknown", "expired"]) +def test_collection_boundary_preserves_current_access_and_sanitizes_unknown( + admission, engine, monkeypatch, change +): + runner = module() + client = CompletingClient(admission) + asyncio.run(run(admission, client)) + plans, _, inputs = admission + original = runner.advance_question_plan + + async def completed(*args, **kwargs): + result = await original(*args, **kwargs) + if change == "expired": + with engine.begin() as conn: + conn.execute(text("DELETE FROM auth_sessions")) + elif change == "unknown": + + def broken(*args): + raise RuntimeError("SECRET token/receipt") + + monkeypatch.setattr(plans, "submit", broken) + elif change == "missing_plan": + monkeypatch.setattr(plans, "submit", lambda *a: None) + else: + plan = plans.submit( + inputs["token"], inputs["workspace_id"], inputs["request_id"], inputs["question"] + ) + monkeypatch.setattr(plans, "submit", lambda *a: plan.model_copy(update={"receipt": {}})) + return result + + monkeypatch.setattr(runner, "advance_question_plan", completed) + if change == "missing_plan": + assert asyncio.run(run(admission, client)) is None + else: + from issuedigest_storage.repository import StorageCorruption + from issuedigest_storage.sessions import AuthenticationDenied + + expected = { + "raw_plan": StorageCorruption, + "expired": AuthenticationDenied, + "unknown": runner.QuestionRunError, + }[change] + with pytest.raises(expected) as caught: + asyncio.run(run(admission, client)) + if change == "unknown": + assert caught.value.__context__ is caught.value.__cause__ is None + assert caught.value.reason == "receipt" and "SECRET" not in str(caught.value) + assert counts(engine) == (2, 2) and len(client.waited) == 2 diff --git a/docs/changes/question-plan-runner.md b/docs/changes/question-plan-runner.md index 0e719a8..1f4f44b 100644 --- a/docs/changes/question-plan-runner.md +++ b/docs/changes/question-plan-runner.md @@ -16,7 +16,7 @@ ## Task 1 -- [ ] 신규2파일을 TDD로 구현한다. global constraints를 지키고 기존492파일은 수정하지 않는다. 자동 진행·boundedwait/timeout/cancel·현재권한/원문·완료 영수증 검증·native2자료/재접수/동시요청·테스트 격리를 검사한다. scoped검사 후 owned2파일+Task1checkbox만 커밋하고 SDD보고서에 정확RED/GREEN/명령/인터페이스/한계/자체검토를 남긴다. +- [x] 신규2파일을 TDD로 구현한다. global constraints를 지키고 기존492파일은 수정하지 않는다. 자동 진행·boundedwait/timeout/cancel·현재권한/원문·완료 영수증 검증·native2자료/재접수/동시요청·테스트 격리를 검사한다. scoped검사 후 owned2파일+Task1checkbox만 커밋하고 SDD보고서에 정확RED/GREEN/명령/인터페이스/한계/자체검토를 남긴다. ## Controller gates From 31f266db8e56e2f48c9bfb44d2798696245facbf Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 06:26:40 +0000 Subject: [PATCH 3/4] docs: record verified automatic question plan runner --- README.md | 4 +++- docs/README.md | 4 +++- docs/changes/question-plan-runner.md | 2 +- docs/planning/implementation-plan.md | 8 +++++-- docs/planning/progress.md | 4 ++++ docs/planning/tasks.md | 12 +++++++++-- .../question-plan-runner-validation.md | 21 +++++++++++++++++++ 7 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 docs/research/question-plan-runner-validation.md diff --git a/README.md b/README.md index 4fb6141..5a9247f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ +현재 구현: [질문별 자동 분석·결과 수집](docs/changes/question-plan-runner.md). 한 실행에서 자료별 완료를 기다리고 다음 분석으로 이어갑니다. 모든 완료 영수증을 현재 권한·원문으로 다시 검증해 모으며, 브리핑 합성·API/UI·개발 적용은 후속입니다. 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했습니다. + 현재 구현: [질문별 순차 AI 작업 연결](docs/changes/question-plan-dispatch.md). 고정한 자료를 순서대로 기존 영속 작업으로 접수하고, 같은 요청은 완료한 분석을 재사용합니다. 기존 활성 작업1개·합산 예약 예산을 유지하며 브리핑 변환·자동 진행·API/UI·개발 적용은 후속입니다. 집중91/새 전체9,716개·정적/타입/빌드/OpenAPI·독립 최종 수정 검토 C0I0M0를 통과했습니다. -진행 중: [질문별 자료 선택 영속 계획](docs/changes/question-source-plans.md). 질문만으로 저장 색인에서 최대5자료를 선택하고 같은 요청은 원래 선택과 현재 원문을 재검증합니다. 실제 AI/예산 예약/개발 활성화는 없으며 다자료 실행·브리핑 변환/표시는 후속입니다. +현재 구현: [질문별 자료 선택 영속 계획](docs/changes/question-source-plans.md). 질문만으로 저장 색인에서 최대5자료를 선택하고 같은 요청은 원래 선택과 현재 원문을 재검증합니다. 실제 AI/예산 예약/개발 활성화는 없으며 다자료 실행·브리핑 변환/표시는 후속입니다. 현재 구현: [근거 작업 접수·queue 전달](docs/changes/evidence-job-dispatch.md). 신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토/수정 재검토를 통과했습니다. 저장→명시 버전 queue 전달·동일 ID 재접수와 권한/원문 재검사를 연결했습니다. 실제 제공자 호출0·현재 앱 활성화0이며 질문 전용 검색·브리핑 변환/표시는 후속입니다. diff --git a/docs/README.md b/docs/README.md index d8e0824..647b1a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,8 @@ +현재 구현: [질문별 자동 분석·결과 수집](changes/question-plan-runner.md). 한 실행에서 자료별 완료를 기다리고 다음 분석으로 이어갑니다. 모든 완료 영수증을 현재 권한·원문으로 다시 검증해 모으며, 브리핑 합성·API/UI·개발 적용은 후속입니다. 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했습니다. + 현재 구현: [질문별 순차 AI 작업 연결](changes/question-plan-dispatch.md). 질문 검색의 고정 선택과 기존 작업/queue를 연결하며 신규 외부 모델 호출이나 앱 활성화는 이번 검증에 포함하지 않습니다. 집중91/새 전체9,716개·정적/타입/빌드/OpenAPI·독립 최종 수정 검토 C0I0M0를 통과했습니다. -진행 중: [질문별 자료 선택 영속 계획](changes/question-source-plans.md). 질문만으로 저장 색인에서 최대5자료를 선택하고 같은 요청은 원래 선택과 현재 원문을 재검증합니다. 실제 AI/예산 예약/개발 활성화는 없으며 다자료 실행·브리핑 변환/표시는 후속입니다. +현재 구현: [질문별 자료 선택 영속 계획](changes/question-source-plans.md). 질문만으로 저장 색인에서 최대5자료를 선택하고 같은 요청은 원래 선택과 현재 원문을 재검증합니다. 실제 AI/예산 예약/개발 활성화는 없으며 다자료 실행·브리핑 변환/표시는 후속입니다. 현재 구현: [근거 작업 접수·queue 전달](changes/evidence-job-dispatch.md). 신규23/새전체9,595개(Python8,775+JS820)·정적 검사/빌드/OpenAPI·실패/skip/warning0·독립 최종 검토/수정 재검토를 통과했습니다. 저장→명시 버전 queue 전달·동일 ID 재접수와 권한/원문 재검사를 연결했습니다. 실제 제공자 호출0·현재 앱 활성화0이며 질문 전용 검색·브리핑 변환/표시는 후속입니다. diff --git a/docs/changes/question-plan-runner.md b/docs/changes/question-plan-runner.md index 1f4f44b..384f00b 100644 --- a/docs/changes/question-plan-runner.md +++ b/docs/changes/question-plan-runner.md @@ -9,7 +9,7 @@ - Frozen dataclass QuestionEvidenceRun(dispatch:QuestionDispatch,receipts:tuple[GuardedQuestionEvidenceGenerationV2,...]). no extra facts/question/token/actor/source metadata or arbitrary callback result. no_match/blocked => empty receipts, return exact validated progress; None=>None. Evidence-complete receipts stay status unreviewed, not human approval/factual certification or final briefing. Never return partial receipt tuples on timeout/error/revocation. - First call existing advance. For in_progress, validate returned exact QuestionDispatch/EvidenceDispatch shapes/types: UUID4 IDs, status literals, counts0..5 exactints, tuple choices/acks, unique jobIDs, workflow_id exactly guarded-evidence-{job_id}, completed prefix/len consistent with status, no_match0, evidence_complete allcompleted/queuedFalse, blocked lastfailed/cancelled/indeterminate andcompletedprefix, in_progress lastpending/running or completed acknowledgement race; prefixcompleted/queuedFalse. Reject inconsistent state with fixed sanitized error before arbitrary workflow lookup. Match stable plan_id/total across advancement, monotone completedprefix; no caller source/planDTO input accepted. Reuse helpers narrowly where useful but do not change frozen files. - Await client.retrieve_workflow_async(last.workflow_id) then handle.get_result(); ignore arbitrary DBOS workflow result content. Queueing still happens only through existing advance/dispatch. On successful wait invoke same advance with identical inputs; it rechecks current session/writer/alloriginalsources and reuses completed jobs before next admission. At most total_sources<=5 waits, <=6 advance calls; if in_progress after wait has no increase in completed prefix (or same unfinished child) fail closed instead of hot loop. Stable identity/count mismatch or excess wait count fail closed. Native await is control-plane observation, never an automatic paid retry/failed-child skip. No sleeps/poll loops/new DBOS parentworkflow. Concurrent same-request callers may wait same workflows but don't duplicate paid execution under existing contracts. -- Existing native workflow may fail with arbitrary exception text. Catch Exception ONLY around native retrieve/get_result and around receipt-collection boundary if needed, raise fixed QuestionRunError(reason Literal workflow/receipt/progress or minimalfixedcodes, optional opaque plan/job IDs only) OUTSIDE except handler with no cause/context/rawtext. Do not catch/swallow unknown exceptions from initial/next advance (existing sanitized auth/budget/source errors) or CancelledError. TimeoutError from the OUTER asyncio.timeout must propagate; an arbitrary native retrieve/get_result exception (including its own TimeoutError) is sanitized within that narrow boundary. No logging raw questions/tokens/source/receipt/provider errors. Persisted child states/reservations remain on caller failure, no reselect/newrequest or automatic refund/reset. Worker failure may surface fixed workflow error; next explicit same-request call can inspect blocked state. +- Existing native workflow may fail with arbitrary exception text. Catch Exception ONLY around native retrieve/get_result and around receipt-collection boundary if needed, raise fixed QuestionRunError(reason Literal workflow/receipt/progress or minimalfixedcodes, optional opaque plan/job IDs only) OUTSIDE except handler with no cause/context/rawtext. Do not catch/swallow unknown exceptions from initial/next advance (existing sanitized auth/budget/source errors) or CancelledError. TimeoutError from the OUTER asyncio.timeout must propagate; an arbitrary native retrieve/get_result exception (including its own TimeoutError) is sanitized within that narrow boundary. During receipt collection preserve existing sanitized AuthenticationDenied/ResearchForbidden/ResearchConflict/StorageCorruption types; sanitize other unknown receipt/native errors. A missing authorized plan may return None, never a partial packet. No logging raw questions/tokens/source/receipt/provider errors. Persisted child states/reservations remain on caller failure, no reselect/newrequest or automatic refund/reset. Worker failure may surface fixed workflow error; next explicit same-request call can inspect blocked state. - When evidence_complete, gather receipts OFFLOOP. Reload current writer-owned plan via existing plans.submit and strict raw reconstruction helper question_dispatch._plan; ensure plan ID/order/count match dispatch. For each ordered ack/source, jobs.get with current token/workspace, verify identity/source/question/model/status via existing _job (current self-admitted allowed) and expected sourcehash before read. Reuse unchanged .evidence_worker.read_completed_evidence_job, with an internal authorization callback that calls jobs.get(token,workspace,expected_job_id) and returns True only for matching completed current job; callback denies other IDs. This is authenticated read authorization, not a worker lease or paid-execution ownership grant. Existing helper revalidates canonical receipt/prompt/instructions/usage/current-original source; require nonNone actual GuardedQuestionEvidenceGenerationV2/statusunreviewed for every source. Never trust SQL opaque receipt or DBOS result. Final plans.submit/_plan revalidation after all reads must equal initial plan, ensuring current writer/allsource guard before any tuple returned. Any failed/missing/mutated receipt/currentauth gives no partial output. No provider call/cost/budget mutation during collection/replay. Initial/next advanced completed prefix is not sufficient receipt acceptance. - Automatic progression exists while caller coroutine/process is alive. On cancellation/timeout/process loss, child jobs stay durable and same explicit request resumes; no persisted sessiontoken/independent durable parentclaim. Return validated receipt packet is a synthesis input, NOT public publication/canonical Canvas or full brief. Actual model meaning, automated parent recovery, synthesis/storage/API/UI/devactivation remain followups. - TDD: missingmodule RED then scoped GREEN. Pure/native state tests for bounded automatic progress/no_match/blocked/None, completedack race, invalid/raw/model_copy identities/counts/status/crossplan/nonprogress/unknown workflow error sanitized context, exacttimeout beforeSQL, nativewait timeout/cancel/no nextsource, auth/source invalid afterwait/finalcollection, tampered/missing receipts, completedreplay zeroqueue/provider/reservation, same-request two concurrent callers. Real PostgreSQL/DBOSClient/unchangedworker/mockSSE two-source run completes from ONE run_question_plan call, replay yields same validated receipts with no newnativeHTTP; current-source/metadata/receipt/usage/schema tamper rejected not output. New test fixture owns clean question_source_plans/evidence_jobs, restores modules/packageattrs/sys.path, uses unique qpr_ DBOS schema passed to migrate/config/client, closes client/runtime/provider BEFORE dropping ONLY quoted owned schema, asserts schema removed and defaultdbos metadata/queues unchanged. No oldfixtures/conftest modifications. Focused newtest + existing queue tests in same invocation ensures no cross-test collision. Agent scoped Ruff/format/mypy, controller fullQA with failfast/reviews/preservation/privacy/PR/Sites/ownedQAcleanup. No agent subagents. diff --git a/docs/planning/implementation-plan.md b/docs/planning/implementation-plan.md index 5f00509..0f6ab6e 100644 --- a/docs/planning/implementation-plan.md +++ b/docs/planning/implementation-plan.md @@ -1,10 +1,14 @@ +## M3 진행 · 자동 분석·검증 결과 수집 완료 + +[자동 분석 진행 계약](../changes/question-plan-runner.md)은 신규73/집중78·새 전체9,789개·정적/타입/빌드/OpenAPI·독립 검토 C0I0M0를 통과했다. 기존 작업 완료를 기다려 같은 요청으로 다음 자료를 진행한다. 전체 대기와 진행 횟수를 제한하며 timeout/cancel 뒤 기존 작업은 유지한다. 현재 권한/원문과 모든 완료 영수증을 재검증해 브리핑 합성 입력을 만든다. 독립 부모 복구·브리핑/API/UI·실제 의미·개발 적용은 후속이다. 실제 제공자/운영/앱 활성화0이다. + ## M3 진행 · 질문별 순차 접수 검증 완료 [고정 자료→작업 연결](../changes/question-plan-dispatch.md)은 집중91/새 전체9,716개·정적/타입/빌드/OpenAPI·독립 최종 수정 검토 C0I0M0를 통과했다. 테스트 queue 소유권 간섭은 전용 schema 격리 후 새 DB 전체 재검증으로 해결했다. 기존 활성1개/예산 계약을 보존한다. 같은 요청의 완료 작업은 재사용하고 첫 미완료에서 반환한다. 자료 선택 때의 hash를 예약 전에 비교해 중간 원문 변경을 막는다. 자동 진행·브리핑 변환/표시·개발 적용은 후속이며 실제 제공자/운영/앱 활성화0이다. -## M3 진행 · 질문별 자료 선택 영속화 +## M3 검증 완료 · 질문별 자료 선택 영속화 -[질문 전용 검색 계획](../changes/question-source-plans.md)으로 원래 질문·선택 순서·현재 source hash를 저장한다. 같은 request는 재검색하지 않고 원래 자료를 다시 검증한다. no_match를 현실의 근거 부재·의미 판정으로 표시하지 않는다. 기존485실행/이전52증거·앱/기본worker/main을 유지하며 실제 제공자/예산 예약 변경0이다. 다자료 AI 실행·브리핑 변환/표시·부하 인수·개발 적용이 후속이다. +[질문 전용 검색 계획](../changes/question-source-plans.md)으로 원래 질문·선택 순서·현재 source hash를 저장한다. 같은 request는 재검색하지 않고 원래 자료를 다시 검증한다. no_match를 현실의 근거 부재·의미 판정으로 표시하지 않는다. 기존484실행 보존+승인 migration 검사1개 수정/이전52증거·앱/기본worker/main을 유지하며 실제 제공자/예산 예약 변경0이다. 다자료 AI 실행·브리핑 변환/표시·부하 인수·개발 적용이 후속이다. ## M3 검증 완료 · 근거 작업 접수·queue 전달 diff --git a/docs/planning/progress.md b/docs/planning/progress.md index da60c8a..b5aff75 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -1,3 +1,7 @@ +## 검증 완료 · 질문별 자동 분석·검증 결과 수집 · 2026-10-26 + +[계약](../changes/question-plan-runner.md)·[검증](../research/question-plan-runner-validation.md). 한 실행에서 기존 작업 완료를 기다리고 다음 자료를 진행한다. 완료 결과를 현재 세션/원문·canonical 영수증으로 다시 검증하며 부분 결과 없이 unreviewed 묶음으로 반환한다. 실행dca3341·신규73/기존queue5 포함 집중78·새 전체9,789개(Python8,969+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0·태스크/전체 독립 검토 C0I0M0다. 기존492실행/이전49증거 보존, 실제 제공자/운영 변경0·앱/기본worker/main 유지다. 프로세스와 독립된 부모 복구·브리핑 합성/저장/API/UI·실제 의미/부하·개발 적용은 후속이며 PR/Sites·자체QA 정리는 최종 인계를 따른다. + ## 검증 완료 · 질문별 순차 AI 작업 연결 · 2026-10-26 [계약](../changes/question-plan-dispatch.md)·[검증](../research/question-plan-dispatch-validation.md). 고정 자료의 기대 hash를 예약 전 비교하고 결정적인 자식 요청으로 접수한다. 완료한 작업은 재사용하며 첫 미완료/실패에서 반환한다. 실행df8cd6f/테스트 격리 보완6c6f65b·집중91/새 전체9,716개(Python8,896+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0이다. 태스크/전체/최종 수정 검토 C0I0M0다. 최초 전체 실행의 queue 소유권 충돌3건은 새 테스트만 고유 schema로 격리해 해결했고 새 DB에서 전체 재검증했다. 기존488실행/이전43증거 보존·실제 제공자/운영 변경0·앱/기본worker/main 유지다. 자동 진행·브리핑 변환/API/UI·실제 품질/부하·개발 반영은 후속이며 PR/Sites·자체QA 정리는 최종 인계를 따른다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index 8ec90e8..d119e41 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -1,3 +1,11 @@ +## 진행 · 질문별 자동 분석·검증 결과 수집 · 2026-10-26 + +- [x] [boundedwait·현재 권한/원문·완료 영수증 수집 계약](../changes/question-plan-runner.md) 고정. +- [x] 자동 진행·timeout/cancel·완료 수집·native2자료/동시요청/재열람 신규73+기존queue5=78집중·정적 검사. +- [x] 독립 검토·새 전체9,789개/정적/타입/빌드/OpenAPI·기존492실행/이전49증거 보존. +- [ ] Draft PR/Sites·자체QA 정리(최종 인계). +- [ ] 브리핑 합성·저장/API/UI·독립 부모 복구·실제품질/운영부하·개발 적용. + ## 진행 · 질문별 순차 AI 작업 연결 · 2026-10-26 - [x] [기대 원문 hash·순차 접수·자식 요청 ID 계약](../changes/question-plan-dispatch.md) 고정. @@ -6,14 +14,14 @@ - [x] Draft PR113·Sitesv135/이전v134+정확7문서 보존·자체QA3/전용volume3 정리(최종 인계). - [ ] 자동 진행·브리핑 변환/API/UI·실제품질/운영부하·개발 적용. -## 구현·집중 검사 완료 · 질문별 자료 선택 영속화 · 2026-10-26 +## 검증 완료 · 질문별 자료 선택 영속화 · 2026-10-26 - [x] [질문 전용 검색·선택/hash 고정·재접수 계약](../changes/question-source-plans.md) 고정. - [x] strict 계획 DTO·repository/migration·기존 lexical 검색 연결 TDD. - [x] 역할/자료/동시성·변조·재검색0·no_match·예산/run/job 무변경 집중62개+승인된 기존 migration 검사1개(합계63개)·scoped 정적 검사. - [x] 기존 파일484개 변경 없이 보존·승인된 기존 migration 검사1개만 수정·이전 증거52개 보존. - [x] 독립 최종 검토 C0I0M2 비차단·lazy presentation 복구 계약 문서화. -- [ ] 전체QA·PR/Sites·자체QA 정리. +- [x] 새 전체9,657개/정적/타입/빌드/OpenAPI·Draft PR112/Sitesv134·자체QA 정리(기존 최종 인계 확인). - [ ] presentation만 삭제한 saved get/retry 집중 검사(비차단 후속). - [ ] 다자료 AI 실행·브리핑 변환/표시·운영 부하·개발 반영. diff --git a/docs/research/question-plan-runner-validation.md b/docs/research/question-plan-runner-validation.md new file mode 100644 index 0000000..e49c754 --- /dev/null +++ b/docs/research/question-plan-runner-validation.md @@ -0,0 +1,21 @@ +# 질문별 자동 분석·검증 결과 수집 + +2026-10-26 KST. [명세](../changes/question-plan-runner.md). 기존 순차 접수에서 첫 미완료 작업의 native 완료를 기다린 뒤 같은 요청으로 다음 자료를 진행하는 내부 실행기다. 독립 부모 workflow·브리핑 합성/저장/API/UI·개발 적용은 이번 범위가 아니다. 실제 제공자 호출·운영 서버/DB 변경·main/앱/기본 worker 활성화0이다. + +## 구현 계약 + +`run_question_plan`은 기존 `advance_question_plan`과 DBOS handle을 사용한다. 원래 요청과 자식 ID·모델/예약 정책을 그대로 사용하고 완료 상태는 DBOS 응답 본문이 아닌 현재 저장소에서 다시 검증한다. 첫 실패를 건너뛰거나 유료 재시도하지 않는다. 진행 상태/개수/ID가 맞지 않거나 완료 prefix가 늘지 않으면 중단한다. + +전체 대기는 최대600초, 기다릴 작업 수는 선택한 자료 수 이하다. caller 취소/timeout은 이미 접수한 원격 작업을 취소하거나 예약을 환불하지 않는다. coroutine/process가 중단되면 같은 명시 요청으로 재개하며, 토큰을 영속화하거나 독립 부모 복구를 주장하지 않는다. + +모든 자료가 완료된 경우 현재 writer 계획과 원문, 각 완료 job과 canonical 영수증·prompt/instructions/usage를 다시 검증한다. 모든 검증이 성공한 뒤 순서대로 수집하며 중간 실패·권한 상실 때 부분 영수증을 반환하지 않는다. AI status는 unreviewed이며 브리핑·확인된 사실·사람 검토로 승격하지 않는다. 수집 callback은 현재 사용자의 읽기 권한 확인이며 worker lease를 부여하지 않는다. + +## 검증 + +모듈 부재 RED 후 신규73개와 기존 queue5개를 합친 집중78개·Ruff/format/strict mypy가 통과했다. 실제 DBOSClient/기존 worker/모의 SSE로 한 실행의 두 자료 완료·동시 동일 요청·완료 재열람과 예약2개/차단된 native요청2회를 확인했다. 다섯 자료에서는 정확5대기/6진행이며 SQL의 eventloop 밖 실행을 확인했다. 실행dca3341·새 전체9,789개(Python8,969+JS820)·Ruff/format/mypy·타입/빌드/OpenAPI 통과, 실패/skip/warning0이다. 태스크/전체 독립 검토 C0I0M0이며 미해결 지적은 없다. 신선한 전용 DB의 전체 실행에서 테스트 격리/metadata 보존도 통과했고 실행 파일은 시작 이후 바꾸지 않았다. 전용 합성 PostgreSQL·nativeDBOS·모의 SSE만 사용한다. 새 통합 검사는 고유 qpr_ schema를 사용하고 종료 후 정리하며 기본 DBOS metadata/queue를 유지한다. + +## 판단과 비용 + +1. 하나의 coroutine에서 자동 진행한다. 토큰 영속화는 없지만 프로세스 중단 뒤에는 같은 요청으로 재실행해야 한다. +2. 전체600초/자료 수 이하 대기로 제한한다. 정체 루프를 막지만 timeout 뒤 이미 접수한 작업을 다시 확인해야 한다. +3. 모든 완료 영수증을 현재 권한/원문으로 재검증한다. 추가 AI 비용은 없지만 SQL 검사 비용이 있고 브리핑 합성·의미 인수는 별도다. From 4ae092e3f60d709d667d56aebb6fd7c996aae42d Mon Sep 17 00:00:00 2001 From: ijaesinii Date: Mon, 26 Oct 2026 06:30:00 +0000 Subject: [PATCH 4/4] docs: record automatic runner PR and planning site handoff --- docs/changes/question-plan-runner.md | 2 +- docs/planning/progress.md | 2 ++ docs/planning/tasks.md | 2 +- docs/research/question-plan-runner-validation.md | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/changes/question-plan-runner.md b/docs/changes/question-plan-runner.md index 384f00b..39a9620 100644 --- a/docs/changes/question-plan-runner.md +++ b/docs/changes/question-plan-runner.md @@ -20,7 +20,7 @@ ## Controller gates -- [ ] 독립 태스크/전체 검토·전체검사·기존492실행/이전49증거 보존·privacy·문서/Draft PR/Sites·자체QA 정리. +- [x] 독립 태스크/전체 검토 C0I0M0·새 전체9,789개/정적/타입/빌드/OpenAPI·기존492실행/이전49증거 보존·privacy·문서/Draft PR114/Sitesv136·자체QA2/volume2 정리. - [ ] 브리핑 합성·저장/API/UI·실제 의미/운영부하·프로세스와 독립된 부모 복구·개발 적용(이번 제외). ## 판단과 비용 diff --git a/docs/planning/progress.md b/docs/planning/progress.md index b5aff75..ef1b70a 100644 --- a/docs/planning/progress.md +++ b/docs/planning/progress.md @@ -2,6 +2,8 @@ [계약](../changes/question-plan-runner.md)·[검증](../research/question-plan-runner-validation.md). 한 실행에서 기존 작업 완료를 기다리고 다음 자료를 진행한다. 완료 결과를 현재 세션/원문·canonical 영수증으로 다시 검증하며 부분 결과 없이 unreviewed 묶음으로 반환한다. 실행dca3341·신규73/기존queue5 포함 집중78·새 전체9,789개(Python8,969+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0·태스크/전체 독립 검토 C0I0M0다. 기존492실행/이전49증거 보존, 실제 제공자/운영 변경0·앱/기본worker/main 유지다. 프로세스와 독립된 부모 복구·브리핑 합성/저장/API/UI·실제 의미/부하·개발 적용은 후속이며 PR/Sites·자체QA 정리는 최종 인계를 따른다. +최종 인계: [Draft PR114](https://github.com/DigestWorks/IssueDigest/pull/114)·[Sitesv136](https://lawdigest-rebuild-plan.ijaesinii.chatgpt.site/) public/native succeeded. 이전v135와 변경7문서 정확 사본(31f266d)을 보존했다. Site source `0aa39121237430d742d0e568a04d049cab2da4dd`, saved `appgprj_6ac612dd42708191a7a0c8377be64d48~appgver_dfa912d2b4fc819180895c6e9a2bd89f`, deployment `appgdep_6adef32999f48191b84afb0a38d35e80`다. HTML121/로컬대상3,675/공개텍스트375·비공개60+12항목 제외를 확인했다. 자체QA2/전용volume2를 정확 소유권 확인 후 정리하고 포트 닫힘을 확인했다. 사용자 탭 이동/새 브라우저 검사0(기존 레이아웃 텍스트 변경)이다. 실제 제공자/운영 변경·앱/기본worker/main 활성화0이며 독립 부모 복구/브리핑 합성/API/UI 인수는 후속이다. 세 판단과 비용은 명세/검증 기록에 남겼다. 이전 자료 선택 단계의 완료 체크박스/기존 파일 보존 수도 기존 검증 기록에 맞춰 보정했다. PR 채팅 첨부는 도구100개 한도로 실패했지만 링크는 유효하다. 원격CI는 로컬 전체 검사와 구분하며 이후 인계는 저장소 원본을 따른다. + ## 검증 완료 · 질문별 순차 AI 작업 연결 · 2026-10-26 [계약](../changes/question-plan-dispatch.md)·[검증](../research/question-plan-dispatch-validation.md). 고정 자료의 기대 hash를 예약 전 비교하고 결정적인 자식 요청으로 접수한다. 완료한 작업은 재사용하며 첫 미완료/실패에서 반환한다. 실행df8cd6f/테스트 격리 보완6c6f65b·집중91/새 전체9,716개(Python8,896+JS820)·정적/타입/빌드/OpenAPI·실패/skip/warning0이다. 태스크/전체/최종 수정 검토 C0I0M0다. 최초 전체 실행의 queue 소유권 충돌3건은 새 테스트만 고유 schema로 격리해 해결했고 새 DB에서 전체 재검증했다. 기존488실행/이전43증거 보존·실제 제공자/운영 변경0·앱/기본worker/main 유지다. 자동 진행·브리핑 변환/API/UI·실제 품질/부하·개발 반영은 후속이며 PR/Sites·자체QA 정리는 최종 인계를 따른다. diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index d119e41..4fa4b46 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -3,7 +3,7 @@ - [x] [boundedwait·현재 권한/원문·완료 영수증 수집 계약](../changes/question-plan-runner.md) 고정. - [x] 자동 진행·timeout/cancel·완료 수집·native2자료/동시요청/재열람 신규73+기존queue5=78집중·정적 검사. - [x] 독립 검토·새 전체9,789개/정적/타입/빌드/OpenAPI·기존492실행/이전49증거 보존. -- [ ] Draft PR/Sites·자체QA 정리(최종 인계). +- [x] Draft PR114·Sitesv136/이전v135+정확7문서 보존·자체QA2/전용volume2 정리(최종 인계). - [ ] 브리핑 합성·저장/API/UI·독립 부모 복구·실제품질/운영부하·개발 적용. ## 진행 · 질문별 순차 AI 작업 연결 · 2026-10-26 diff --git a/docs/research/question-plan-runner-validation.md b/docs/research/question-plan-runner-validation.md index e49c754..1727756 100644 --- a/docs/research/question-plan-runner-validation.md +++ b/docs/research/question-plan-runner-validation.md @@ -19,3 +19,5 @@ 1. 하나의 coroutine에서 자동 진행한다. 토큰 영속화는 없지만 프로세스 중단 뒤에는 같은 요청으로 재실행해야 한다. 2. 전체600초/자료 수 이하 대기로 제한한다. 정체 루프를 막지만 timeout 뒤 이미 접수한 작업을 다시 확인해야 한다. 3. 모든 완료 영수증을 현재 권한/원문으로 재검증한다. 추가 AI 비용은 없지만 SQL 검사 비용이 있고 브리핑 합성·의미 인수는 별도다. + +최종 인계: [Draft PR114](https://github.com/DigestWorks/IssueDigest/pull/114)·[Sitesv136](https://lawdigest-rebuild-plan.ijaesinii.chatgpt.site/) public/native succeeded. 이전v135와 변경7문서 정확 사본(31f266d)을 보존했다. Site source `0aa39121237430d742d0e568a04d049cab2da4dd`, saved `appgprj_6ac612dd42708191a7a0c8377be64d48~appgver_dfa912d2b4fc819180895c6e9a2bd89f`, deployment `appgdep_6adef32999f48191b84afb0a38d35e80`다. HTML121/로컬대상3,675/공개텍스트375·비공개60+12항목 제외를 확인했다. 자체QA2/전용volume2를 정확 소유권 확인 후 정리하고 포트 닫힘을 확인했다. 사용자 탭 이동/새 브라우저 검사0(기존 레이아웃 텍스트 변경)이다. 실제 제공자/운영 변경·앱/기본worker/main 활성화0이며 독립 부모 복구/브리핑 합성/API/UI 인수는 후속이다. 세 판단과 비용은 명세/검증 기록에 남겼다. 이전 자료 선택 단계의 완료 체크박스/기존 파일 보존 수도 기존 검증 기록에 맞춰 보정했다. PR 채팅 첨부는 도구100개 한도로 실패했지만 링크는 유효하다. 원격CI는 로컬 전체 검사와 구분하며 이후 인계는 저장소 원본을 따른다.