G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -11,12 +11,14 @@ Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴
import asyncio
import json
import os
import secrets
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
@ -58,6 +60,28 @@ SESSION_IDLE_TTL_SECONDS = float(os.environ.get("ENGINE_SESSION_IDLE_TTL_SECONDS
MAX_RESIDENT_SESSIONS = max(1, int(os.environ.get("ENGINE_MAX_RESIDENT_SESSIONS", "24")))
GATEWAY_PROVIDER = "claude_cli"
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
# claude -p 자식의 stderr 보존량. 인증 만료/플래그 오류처럼 CLI가 stdout 한 줄도 못 내고
# 죽는 경우 원인은 stderr에만 남는다. 이걸 버리면 게이트웨이는 "empty engine response"만
# 보고하고 운영자는 프로세스 환경을 재현해야 원인을 안다(2026-08-07 공개 런타임 사고).
STDERR_TAIL_LINES = 8
STDERR_TAIL_CHARS = 400
ENGINE_TOKEN_HEADER = "X-Vignette-Engine-Token"
_SECRET_PLACEHOLDER_PREFIXES = ("change-me", "replace-with", "dummy", "example")
def _load_gateway_shared_secret() -> str:
value = os.environ.get("ENGINE_GATEWAY_SHARED_SECRET", "").strip()
if not value:
return ""
if len(value) < 32 or value.lower().startswith(_SECRET_PLACEHOLDER_PREFIXES):
raise RuntimeError(
"ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value "
"containing at least 32 characters"
)
return value
ENGINE_GATEWAY_SHARED_SECRET = _load_gateway_shared_secret()
@dataclass(frozen=True, slots=True)
@ -83,6 +107,54 @@ BASE_ARGS = [
"--exclude-dynamic-system-prompt-sections",
]
def _usage_int(payload: dict[str, Any], *keys: str) -> int:
for key in keys:
value = payload.get(key)
if value is None:
continue
try:
return max(0, int(value))
except (TypeError, ValueError):
continue
return 0
def _claude_result_tokens(result: dict[str, Any]) -> tuple[int, int]:
"""Claude CLI result의 전체 agent-tree 토큰을 기존 원장 두 컬럼으로 정규화한다.
modelUsage/model_usage가 있으면 서브에이전트를 포함한 모델별 사용량을 합산한다.
입력 토큰은 공식 계약에 맞춰 비캐시 입력 + cache read + cache creation이다.
"""
raw_model_usage = result.get("modelUsage") or result.get("model_usage")
usage_rows = (
[row for row in raw_model_usage.values() if isinstance(row, dict)]
if isinstance(raw_model_usage, dict)
else []
)
if not usage_rows:
usage = result.get("usage")
usage_rows = [usage] if isinstance(usage, dict) else []
tokens_in = 0
tokens_out = 0
for usage in usage_rows:
tokens_in += _usage_int(usage, "inputTokens", "input_tokens")
tokens_in += _usage_int(
usage,
"cacheReadInputTokens",
"cache_read_input_tokens",
)
tokens_in += _usage_int(
usage,
"cacheCreationInputTokens",
"cache_creation_input_tokens",
)
tokens_out += _usage_int(usage, "outputTokens", "output_tokens")
return tokens_in, tokens_out
class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
@ -103,6 +175,55 @@ class EngineSession:
self.cost_usd = 0.0
self.turns = 0
self.last_used_at = time.monotonic()
# stderr는 계속 비워야 한다. PIPE를 열고 아무도 읽지 않으면 자식이 파이프 버퍼가
# 찬 시점에 블록된다. 겸사겸사 마지막 몇 줄을 진단용으로 남긴다.
self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
self._stderr_task: asyncio.Task | None = None
async def _drain_stderr(self) -> None:
proc = self.proc
if proc is None or proc.stderr is None:
return
try:
while True:
line = await proc.stderr.readline()
if not line:
return
text = line.decode("utf-8", "replace").strip()
if text:
self._stderr_tail.append(text)
except asyncio.CancelledError:
raise
except Exception:
return
def stderr_tail(self) -> str:
"""자식 CLI가 마지막으로 남긴 stderr 요약(진단 표면용)."""
joined = " | ".join(self._stderr_tail)
if len(joined) > STDERR_TAIL_CHARS:
return "" + joined[-STDERR_TAIL_CHARS:]
return joined
async def failure_detail(self, fallback: str) -> str:
"""텍스트를 못 받았을 때 원인을 최대한 좁힌 detail을 만든다."""
task = self._stderr_task
if task is not None and not task.done():
# 자식이 즉시 죽은 경우 stdout EOF가 stderr 드레인보다 먼저 도착할 수 있다.
# shield로 감싸 세션이 계속 살아있는 경우의 드레인을 취소하지 않는다.
try:
await asyncio.wait_for(asyncio.shield(task), timeout=0.5)
except Exception:
pass
parts: list[str] = []
code = self.proc.returncode if self.proc is not None else None
if code is not None:
parts.append(f"exit={code}")
tail = self.stderr_tail()
if tail:
parts.append(tail)
if not parts:
return fallback
return f"{fallback} ({' · '.join(parts)})"
async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
@ -123,6 +244,7 @@ class EngineSession:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._stderr_task = asyncio.create_task(self._drain_stderr())
async def turn(self, content: str, timeout: float = 120.0) -> dict:
if self.proc is None or self.proc.returncode is not None:
@ -158,17 +280,28 @@ class EngineSession:
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.last_used_at = time.monotonic()
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
tokens_in, tokens_out = _claude_result_tokens(result)
self.turns += 1
assistant_text = "".join(text_parts)
terminal_result = result.get("result")
terminal_text = terminal_result if isinstance(terminal_result, str) else ""
# Claude CLI stream-json can emit the complete successful answer only
# on the terminal result event. Prefer assistant events when present
# (they preserve streaming semantics), but never discard a terminal-only
# structured response.
response_text = assistant_text or terminal_text
error_detail = (
result.get("error")
or result.get("result")
or (result.get("errors") or [None])[0]
or "".join(text_parts)
or response_text
or None
)
return {
"text": "".join(text_parts),
"text": response_text,
"cost_usd": self.cost_usd,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"turns": self.turns,
"is_error": result.get("is_error", False),
"error": error_detail,
@ -237,7 +370,15 @@ class EngineSession:
elif t == "result":
self.last_used_at = time.monotonic()
self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
tokens_in, tokens_out = _claude_result_tokens(obj)
self.turns += 1
terminal_result = obj.get("result")
terminal_text = (
terminal_result if isinstance(terminal_result, str) else ""
)
if not emitted and terminal_text:
emitted = terminal_text
yield {"type": "delta", "text": terminal_text}
error_detail = (
obj.get("error")
or obj.get("result")
@ -249,6 +390,8 @@ class EngineSession:
"type": "done",
"text": emitted,
"cost_usd": self.cost_usd,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"turns": self.turns,
"is_error": obj.get("is_error", False),
"error": error_detail,
@ -265,6 +408,14 @@ class EngineSession:
await asyncio.wait_for(self.proc.wait(), timeout=5)
except Exception:
self.proc.kill()
task = self._stderr_task
self._stderr_task = None
if task is not None and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
SESSIONS: dict[str, EngineSession] = {}
@ -272,6 +423,21 @@ _SESSION_RESOLVE_LOCK = asyncio.Lock()
_READY_CACHE: dict[tuple[str, str, str], dict[str, Any]] = {}
_READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway")
_AUTH_EXEMPT_PATHS = frozenset({"/health"})
@app.middleware("http")
async def require_gateway_shared_secret(request: Request, call_next):
"""설정된 인스턴스는 순수 liveness 외 모든 HTTP 경로를 fail-closed로 막는다."""
expected = ENGINE_GATEWAY_SHARED_SECRET
if expected and request.url.path not in _AUTH_EXEMPT_PATHS:
provided = request.headers.get(ENGINE_TOKEN_HEADER, "")
if not secrets.compare_digest(provided, expected):
return JSONResponse(
{"detail": "invalid engine gateway credentials"},
status_code=401,
)
return await call_next(request)
class CreateReq(BaseModel):
@ -379,13 +545,19 @@ async def ready(
"Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS
)
if result.get("is_error"):
detail = str(result.get("error") or "engine returned an error")
detail = await probe.failure_detail(
str(result.get("error") or "engine returned an error")
)
else:
text = str(result.get("text") or "").strip()
ok = bool(text)
detail = text or "empty engine response"
detail = text or await probe.failure_detail(
"empty engine response"
)
except Exception as exc:
detail = str(exc)
# TimeoutError 등 str()이 비는 예외가 있어 타입명을 최소 보장한다.
reason = str(exc).strip() or type(exc).__name__
detail = await probe.failure_detail(reason)
finally:
await probe.close()
else:
@ -667,8 +839,8 @@ async def v1_generate(req: GwGenerateReq):
text=text,
model=_response_model_name(s),
provider=GATEWAY_PROVIDER,
tokens_in=0,
tokens_out=0,
tokens_in=result.get("tokens_in", 0),
tokens_out=result.get("tokens_out", 0),
cost_usd=result.get("cost_usd", 0.0),
inference_geo="us",
structured=structured,
@ -747,8 +919,8 @@ async def v1_stream(req: GwGenerateReq):
StreamDoneEvent(
provider=GATEWAY_PROVIDER,
model=_response_model_name(s),
tokens_in=0,
tokens_out=0,
tokens_in=evt.get("tokens_in", 0),
tokens_out=evt.get("tokens_out", 0),
cost_usd=evt.get("cost_usd", 0.0),
turns=evt.get("turns", 0),
),