The #35 data gate could never fill: a real review call routed cleanly to qwen36 but /pass-rate stayed total:0 under every key. Root cause was three independent defects in the session_log path, each alone fatal: - A: a successful routed call logged final_status "skip", never "pass". /pass-rate computes pass/(pass+fail) and skips count as neither, so the >=0.90 gate was mathematically unreachable. Success now logs "pass". - B: every record was written under skill "_routing", so /pass-rate?skill= review|debug (what #35 measures) always read zero. Now uses the real e.Skill; routing decisions stay groupable via session_id "_routing". - C: the session_log POST to the bearer-gated ingestion /mcp carried no Authorization header → silent 401, swallowed by best-effort logging (the documented mcpclient-empty-token-silent-401 footgun). Logger now takes a token (BRAIN_MCP_TOKEN) and sets the bearer when non-empty. Tests rewritten to assert correct behavior (they had encoded the bugs: "skip" on success, "_routing" skill). New test covers the auth header and the empty-token path. Infra (BRAIN_MCP_TOKEN ExternalSecret + env on the routing deployment) and redeploy follow separately. Refs #73, #35. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
2.9 KiB
Go
93 lines
2.9 KiB
Go
package routing
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// LogEntry describes a single routing decision to log via the brain MCP.
|
|
type LogEntry struct {
|
|
SessionID string
|
|
Skill string // the original skill the call routed (e.g., "review")
|
|
Decision string // "local" or "thinking" or "thinking_fallback"
|
|
Message string // free-form, e.g. "model=qwen35, pass_rate=0.94"
|
|
ProjectRoot string
|
|
DurationMs int64
|
|
Failed bool // true → final_status: "fail"; false → "pass"
|
|
}
|
|
|
|
// Logger posts session_log entries to a brain MCP at BrainURL + /mcp.
|
|
type Logger struct {
|
|
BrainURL string
|
|
Token string // bearer for the (auth-gated) ingestion /mcp; empty = no header
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// NewLogger creates a Logger with a 2-second HTTP timeout. token authenticates
|
|
// to the bearer-gated ingestion /mcp; an empty token sends no Authorization
|
|
// header (and silently 401s against a gated server — see brain
|
|
// mcpclient-empty-token-silent-401-envfrom-missing-key).
|
|
func NewLogger(brainURL, token string) *Logger {
|
|
return &Logger{
|
|
BrainURL: brainURL,
|
|
Token: token,
|
|
HTTP: &http.Client{Timeout: 2 * time.Second},
|
|
}
|
|
}
|
|
|
|
// LogDecision posts a session_log MCP call. Errors are returned but the caller
|
|
// MUST NOT block real work on them — logging is best-effort.
|
|
func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
|
// A completed routed call is a pass (liveness); only an execution error is a
|
|
// fail. There is no "skip" for routing — the prior default-to-"skip" meant a
|
|
// successful call never counted toward pass_rate, so the gate was unreachable.
|
|
status := "pass"
|
|
if e.Failed {
|
|
status = "fail"
|
|
}
|
|
payload := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": map[string]any{
|
|
"name": "session_log",
|
|
"arguments": map[string]any{
|
|
"session_id": e.SessionID,
|
|
// The real skill, so /pass-rate?skill=review|debug sees these
|
|
// records; routing decisions stay groupable via session_id "_routing".
|
|
"skill": e.Skill,
|
|
"phase": "decide",
|
|
"final_status": status,
|
|
"message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message),
|
|
"duration_ms": e.DurationMs,
|
|
"project_root": e.ProjectRoot,
|
|
},
|
|
},
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("log: marshal: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.BrainURL+"/mcp", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("log: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if l.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+l.Token)
|
|
}
|
|
resp, err := l.HTTP.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("log: request: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("log: server returned status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|