|
| 1 | +# Runed-grpc Spec Parity Audit (Task 3) |
| 2 | + |
| 3 | +> Run on `couragehong/feat/runed-grpc` HEAD `309e76d`. |
| 4 | +> Spec: `docs/v04/spec/components/embedder.md`. |
| 5 | +> No Python equivalent — D30 designates runed as a Go-native sibling daemon |
| 6 | +> replacing the in-process Python embedding pipeline. Audit baseline is the |
| 7 | +> spec doc + the runed daemon's actual proto contract |
| 8 | +> (`runed/proto/runed/v1/runed.proto`). |
| 9 | +
|
| 10 | +## Verdict |
| 11 | + |
| 12 | +**Pass on the core RPC surface, two spec-promised behaviors are missing**: |
| 13 | +(1) Info.vector_dim verification on Embed responses, (2) Health-aware |
| 14 | +classification of first-failure (LOADING vs DEGRADED). Both are explicitly |
| 15 | +deferred in the embedder.md spec ("classify first failure via Health" |
| 16 | +described but never marked required for MVP). Documenting + queueing. |
| 17 | + |
| 18 | +## Direct spec parity (✅) |
| 19 | + |
| 20 | +| # | Spec section | Behavior | Where | |
| 21 | +|---|---|---|---| |
| 22 | +| 1 | §RPC 요약 | `Embed(text) → vector` | client.go:96-103 | |
| 23 | +| 2 | §RPC 요약 | `EmbedBatch(texts) → embeddings` | client.go:106-130 | |
| 24 | +| 3 | §RPC 요약 | `Info() → daemon_version, model_identity, vector_dim, max_text_length, max_batch_size` | info_cache.go | |
| 25 | +| 4 | §RPC 요약 | `Health() → status, uptime, total_requests` | client.go:152-164 | |
| 26 | +| 5 | §RPC 요약 | Shutdown intentionally NOT used (rune-mcp does not own daemon lifecycle) | (omitted) | |
| 27 | +| 6 | §Dial | `unix://`+sockPath, insecure creds (UDS = same machine) | client.go:71-86 | |
| 28 | +| 7 | §Retry 정책 (D7) | Backoff `[0, 500ms, 2s]` × 3 attempts | retry.go (#95) + client.go uses | |
| 29 | +| 8 | §Retry 정책 | Retryable: Unavailable / DeadlineExceeded / ResourceExhausted | retry.go:retryable | |
| 30 | +| 9 | §EmbedBatch with split | Split when len > MaxBatchSize, preserve order | client.go:106-130 | |
| 31 | +| 10 | §Info 캐시 | `sync.Once` ensures single Info RPC | info_cache.go:Get | |
| 32 | +| 11 | §Info 캐시 + D30 | `slog.Info "embedder info loaded"` w/ model_identity / vector_dim / max_batch_size | info_cache.go | |
| 33 | +| 12 | §EmbedBatch resp count guard | `len(resp.Embeddings) != len(texts)` → error | client.go:144-147 | |
| 34 | +| 13 | §Health 활용 status mapping | `STATUS_OK / LOADING / DEGRADED / SHUTTING_DOWN / UNSPECIFIED` enum→string | client.go:statusName | |
| 35 | + |
| 36 | +## Acceptable divergences (⚠️) |
| 37 | + |
| 38 | +1. **Hybrid choice — raw stub instead of `runed/client` Plan A wrapper**. |
| 39 | + Spec: "rune-mcp는 [runed/client] 라이브러리를 inner transport로 두고 |
| 40 | + 정책 layer만 자체 작성하는 hybrid 채택 가능." Implementation chose direct |
| 41 | + `runedv1.RunedServiceClient` because the wrapper exposes |
| 42 | + `Connect/Embed/EmbedBatch/Info/Close` but **NOT** `Health` (we need Health |
| 43 | + for the `rune_diagnostics` MCP tool). Documented in PR body. Net effect: a |
| 44 | + slightly larger surface area in our adapter, but no functional gap. |
| 45 | + |
| 46 | +2. **Health is NOT auto-retried**. Spec §Retry 정책 lists Health among the |
| 47 | + retryable surface but D8 ("Boot does NOT poll Health — first embed call |
| 48 | + drives") implies Health is for diagnostic surfaces (vault_status, |
| 49 | + diagnostics) where transient-retry is misleading. We surface the raw error |
| 50 | + instead. Aligns with how `LifecycleService.Diagnostics` consumes it. |
| 51 | + |
| 52 | +3. **`Close` does not call runed `Shutdown` RPC**. Spec §RPC 요약 explicitly |
| 53 | + "rune-mcp does not call Shutdown." Confirmed — we just close the gRPC |
| 54 | + conn. |
| 55 | + |
| 56 | +## Open gaps (⚠️ should follow up) |
| 57 | + |
| 58 | +### 1. **Info.vector_dim mismatch detection** (spec §불변 계약) |
| 59 | + |
| 60 | +Spec: "**dim**: Qwen3-Embedding-0.6B 기준 1024. `Info.vector_dim`으로 확인 후 |
| 61 | +불일치면 에러." Not implemented — `embedBatchOnce` does not verify |
| 62 | +`len(e.Vector) == c.info.Snapshot().VectorDim`. The spec's earlier prose code |
| 63 | +sample (embedder.md §EmbedBatch with split) shows this guard: |
| 64 | + |
| 65 | +```go |
| 66 | +if len(e.Vector) != c.infoCache.Snapshot().VectorDim { |
| 67 | + return nil, fmt.Errorf("embedder: vector dim mismatch at index %d", i) |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +**Resolution**: Add the check inside `embedBatchOnce` (and a similar one |
| 72 | +inside `EmbedSingle`). Low-risk add, ~5 LoC. Track as runed follow-up. |
| 73 | + |
| 74 | +### 2. **Health-aware first-failure classification** (spec §Health 활용) |
| 75 | + |
| 76 | +Spec: "첫 embed 호출 실패 시 `Health` 조회로 분류: |
| 77 | +- LOADING → 잠시 후 재시도 (wait-and-retry 대기) |
| 78 | +- DEGRADED → 경고 로그 + 상위 EmbedderDegradedError 전파 |
| 79 | +- SHUTTING_DOWN → 즉시 실패 + 상위 EmbedderUnavailableError" |
| 80 | + |
| 81 | +Not implemented — we just bubble up the gRPC error. The retry helper does |
| 82 | +N=3 attempts with code-based retryable check, but doesn't probe Health |
| 83 | +between failures. |
| 84 | + |
| 85 | +**Resolution**: This is a separate retry strategy refinement; a healthy MVP |
| 86 | +can ship with code-based retryable alone (LOADING typically surfaces as |
| 87 | +Unavailable + retryable=true → covered). Mark optional. Track as runed |
| 88 | +follow-up. |
| 89 | + |
| 90 | +### 3. **Error mapping to typed adapter errors** (spec §에러 매핑 table) |
| 91 | + |
| 92 | +Spec lists 5 typed error sentinels: |
| 93 | +- `EmbedderInvalidInputError` (gRPC InvalidArgument) |
| 94 | +- `EmbedderBusyError` (ResourceExhausted) |
| 95 | +- `EmbedderUnavailableError` (Unavailable) |
| 96 | +- `EmbedderTimeoutError` (DeadlineExceeded) |
| 97 | +- `EmbedderError(wrap)` (other) |
| 98 | + |
| 99 | +Not implemented — `errors.go` doesn't exist in this adapter. Currently we |
| 100 | +return the raw gRPC status error wrapped with `fmt.Errorf`. Service layer |
| 101 | +relies on `status.FromError` to introspect the code, which works but lacks |
| 102 | +the typed-sentinel pattern that vault uses. |
| 103 | + |
| 104 | +**Resolution**: Add `internal/adapters/embedder/errors.go` with sentinels + |
| 105 | +`MapGRPCError`. Symmetrical to `vault/errors.go`. Low-risk add. Track as |
| 106 | +runed follow-up. |
| 107 | + |
| 108 | +### 4. **Socket path resolution priority is caller's responsibility** |
| 109 | + |
| 110 | +Comment at the top of `client.go` documents: |
| 111 | +> 1. env RUNE_EMBEDDER_SOCKET |
| 112 | +> 2. config.embedder.socket_path |
| 113 | +> 3. default ~/.runed/embedding.sock |
| 114 | +
|
| 115 | +But `New(sockPath string)` takes the resolved path as input — caller must |
| 116 | +implement the priority chain. Not implemented anywhere yet (no callsite |
| 117 | +exists). **Resolution**: Add to `cmd/rune-mcp/main.go` `buildDeps` or in a |
| 118 | +new `internal/adapters/embedder/socket.go` helper, alongside the boot loop |
| 119 | +work. |
| 120 | + |
| 121 | +## Test coverage status |
| 122 | + |
| 123 | +`go test ./internal/adapters/embedder/` reports `[no test files]`. Mock |
| 124 | +`RunedServiceClient` + retry / split / cache tests are queued as Task #7. |
| 125 | + |
| 126 | +## Cross-check: runed daemon contract compatibility |
| 127 | + |
| 128 | +- runed `go.mod` requires `go 1.26.2`; rune `go.mod` is `go 1.25.9`. Local |
| 129 | + `replace ../runed` bypasses the toolchain check. **Open**: bump rune to |
| 130 | + 1.26 before publishing (also needed for runed publish). |
| 131 | +- runed has `model_identity` placeholder behavior — slog breadcrumb captures |
| 132 | + whatever value it emits; we do not validate the format. |
| 133 | +- runed gen/ is `.gitignored` in runed; first-time setup requires |
| 134 | + `cd ../runed && buf generate` (or `make proto`). Document in onboarding. |
0 commit comments