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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue