Compare commits

...
13 Commits
Author SHA1 Message Date
seon fdd776c9aa fix(harness): retry a failed revert step three times
2026-10-18 m2 배치에서 X-M2 복원 SSH 가 connection reset 으로 한 번 실패해 레거시
인스턴스에 netem loss 50% qdisc 가 남았고, 이후 14런의 V-G1·X-M2 주입이 막혔다.
복원 실패는 장애를 환경에 남기므로 15초 간격으로 최대 3회 다시 시도하고, 시도마다
이벤트를 남긴다. 3회 모두 실패하면 장애가 남아 있을 수 있다는 note 를 기록한다.
2026-10-19 01:10:28 +09:00
seon 2a2f91a32c fix(ai): hand an unanswered external turn out again after 3 minutes
참고 측정 1세트 첫 진단에서 클라이언트가 /external/next 로 가져간 2번째 턴의 출력을
잃어 답이 오지 않았고, 턴은 awaiting_reply 에 남아 진단이 데드라인까지 멈췄다. 가져간
뒤 RequeueAfter(기본 3분) 동안 답이 없는 턴은 새 reply_token 으로 큐 앞에 되돌려
다음 폴링이 다시 받게 한다(requeued 횟수 기록, 이전 토큰은 거부). 출력 유실은
재시도 한 번의 비용으로 끝나고 진단은 이어진다.
2026-10-16 22:22:33 +09:00
seon b0ff118841 fix(ai): external responder on its own listener with random turn ids and reply tokens
보안 리뷰 반영. 외부 응답은 곧 진단이고 자동 승인 아래서는 실행 조치가 되므로,
/external/next·/external/reply 를 에이전트 포트에서 떼어 EXTERNAL_ADDR 리스너(기본 꺼짐,
VM compose 는 127.0.0.1:8090 에만 매핑해 IAP SSH 터널로만 도달)로 옮기고, 턴 id 를 난수로
만들며, 응답마다 그 턴을 가져간 클라이언트만 아는 reply_token 을 요구해 상수 시간으로
비교한다. /selfcheck 는 리스너가 꺼져 있으면 external_responder: "off" 를 보인다.
2026-10-16 09:09:22 +09:00
seon ed3d41d399 feat(ai): external responder for external-* model ids (reference runs)
모델 id가 external- 로 시작하면 ai-agent 가 LLM API 를 부르지 않고 각 턴(시스템
프롬프트·대화 이력·도구 정의·강제 제출 여부)을 GET /external/next 로 내어주고
POST /external/reply 로 답을 받는다. 진단 루프·프롬프트·도구·턴 상한은 다른 모델과
동일하므로, API 경로가 없는 모델(Claude Mythos 5.1 을 Claude Code 서브에이전트가
구동)의 참고 측정에 쓴다. 토큰 사용량은 알 수 없어 0 으로 기록한다.

external-* 진단은 EXTERNAL_DIAGNOSE_TIMEOUT_S(기본 1500초), 한 턴은
EXTERNAL_TURN_TIMEOUT_S(기본 1200초)까지 기다리고, 하네스는 같은 모델에 26분을
기다린다. /selfcheck 에 external_responder 상태(대기 턴·마지막 폴링 시각)를 추가.
2026-10-16 09:05:12 +09:00
seon 85d99324a1 fix(harness): atomic claim for auto-approval and rejected proposals stay rejected
보안 리뷰 반영. 자동 승인은 approved_at·executed_at·rejected_at 이 비어 있고 만료 전인
행만 조건부 UPDATE 로 선점한 경우에만 실행하므로, 사람의 승인·실행·거절이 먼저 오면
이중 실행이 없다. proposals 에 rejected_at 열을 추가해 거절 경로가 기록하고,
approve/authorize/자동 승인이 거절된 제안을 거부한다.
2026-10-15 23:41:44 +09:00
seon ca933a646e feat(harness): per-run auto-approval of layer-2 proposals for model measurement
런 옵션 auto_approve_after_s(runs 열·POST /runs)를 추가한다. 값이 양수면 하네스가 제안
등록 뒤 그 지연만큼 기다렸다가 스스로 승인(actor auto)하고 HITL 버튼 경로와 같은 방식으로
조치를 실행(actor ai-approved, MarkProposalExecuted)한다. 사람 없이 모델 비교 캠페인을
반복하기 위한 측정 모드로, MTTA는 지연값으로 고정되고 판정 로직은 그대로다(2층 판정은
ai-approved 조치에서 나온다). 이미 결정·만료된 제안과 기록 전용 조치는 API 경로와 같이
처리한다.
2026-10-15 23:39:39 +09:00
seon f671e2419f fix(ai): replay Claude assistant turns verbatim so thinking blocks keep their signature
Claude Sonnet 5.5 는 응답에 thinking 블록을 포함한다(adaptive thinking, 비활성화 불가).
어댑터가 어시스턴트 턴을 자체 구조체로 재직렬화하면서 thinking 본문과 signature 를
떨어뜨려 두 번째 턴에서 400(thinking.thinking: Field required)이 났다(2026-10-15 C-G1
실측). 응답 content 를 json.RawMessage 로 보관해 그대로 재전송한다. 테스트에 thinking
블록 재전송 검증을 추가.
2026-10-15 22:12:16 +09:00
seon 684e70ba60 feat(ai): authenticate to the Claude API with Workload Identity Federation
정적 API 키 대신 Workload Identity Federation을 쓴다. 에이전트는 AI VM의 Google 서명
ID 토큰(메타데이터 서버, audience https://api.anthropic.com, format=full)을
POST /v1/oauth/token(jwt-bearer)으로 교환해 단기 access token을 받고 Authorization: Bearer로
호출한다. 토큰은 캐시하고 만료 120초 전에 재교환하며, 401이면 한 번 재교환한다.
규칙·조직·서비스 계정·워크스페이스 ID 4개는 비밀이 아니므로 ai.env 환경 변수로 받고,
비어 있으면 기존 키 파일 경로로 동작한다. /selfcheck가 auth 모드와 실제 교환 결과를
보고해 캠페인 없이 설정을 검증할 수 있다. 테스트: 교환 1회 재사용·401 재교환·만료 전
갱신·거부 응답·불완전 ID 무시.
2026-10-15 11:48:26 +09:00
seon 53ff243183 feat: per-run model selection, usage in diagnosis results and a Claude Messages API provider
캠페인마다 진단 모델을 고를 수 있게 한다. 하네스는 runs.model 열을 추가해 POST /runs의
model을 저장하고, 활성 런의 model을 /diagnose 요청에 실어 보낸다. 에이전트는 요청의
model을 우선 사용하고(없으면 OPENAI_MODEL 기본값), 모델 id가 claude-로 시작하면
internal/anthropic(Claude Messages API 어댑터)로 라우팅한다. 어댑터는 Responses API
항목(message/function_call/function_call_output)을 system·tools(input_schema)·tool_use/
tool_result로 변환하고 previous_response_id 체인을 클라이언트 쪽에서 재현하며, Claude 5.5가
강제 tool_choice를 거부하므로 강제 호출은 auto + 지시문으로 바꾼다. 키는
ANTHROPIC_KEY_FILE(/run/secrets/anthropic-api-key)에서 읽고 비어 있으면 명확한 오류를 낸다.
Result에 usage(입력·출력·합계 토큰)를 추가해 하네스 diagnosis 이벤트에 비용 근거를 남긴다.
2026-10-14 23:21:46 +09:00
seon fa9c08c778 fix(hitl): re-read the full HITL row before mirroring an API approval
SyncLoop は store.Pending() の行(diagnosis を持たない)で埋め込みを描き直して
いたため、API 承認を反映した直後のメッセージで推定原因・根拠・判断理由が
<nil> になっていた(第 5 回 C-G2、2026-10-09)。編集前に GetHitl で全列を
読み直す。
2026-10-09 10:12:03 +09:00
seon f8e0aa9122 feat: alert dedupe, proposal state API, HITL mirrors API approvals, Jev recovering reads the alert condition
第 4 回キャンペーン(2026-10-08)の 3 つの穴を塞ぐ。
- ハーネス: 同じアラート名が ALERT_DEDUPE_S(既定 300 秒)以内に再着したら
  /diagnose を送らず note(dedupe) に記録する。Grafana は Pod ごとに 1 件ずつ
  出し 30 秒後にグループを再送するため、1 障害が診断 4 件・提案 4 件になっていた。
  全件 resolved になれば窓をリセットする。
- ハーネス: GET /proposals/{token} で承認・実行状態を返す。
- hitl: 60 秒ごとに未決の提案をハーネスへ照会し、API で承認された件は
  メッセージを「ハーネス API で承認」に書き換えてタイマーを外す。期限切れ処理の
  直前にも同じ照会を行う。
- AI: Jev 選別の now に alert_condition_now / alert_condition_clear /
  recovered_now を追加し、recovering は recovered_now を読む。SLO に触れない
  障害(replay 停止、メモリ圧迫)で 0.98 を返していた。now の p95 と 5xx 比率に
  route フィルタを付け、判定と同じ式にそろえる。
2026-10-09 08:33:41 +09:00
seon 8836b37927 fix(harness): do not hold recovery on layer-1 signature alerts
alerts_ok は第 2 層(signature なし)のアラートだけを見る。第 1 層のシグネチャ
アラートはプラットフォームが復旧を担う情報であり、Grafana の解決が 8 分遅れると
MTTR に乗ってしまうため除外する。
2026-10-08 21:27:34 +09:00
seon cd8fbdddab feat(harness): hold recovery while a Grafana alert is firing, add legacy:busy host resolution
キャンペーン第 2 回のグレーゾーン 3 件を受けた変更。SLO 3 系列に現れない症状
(レプリケーション一時停止、メモリ圧迫)でも、受信中のアラートが解決するまで
recovered を出さないよう、AlertRouter が firing 中のアラートを保持し judge が
alerts_ok を条件に加える。legacy:busy は直近 2 分で最も多くリクエストを受けた
MIG インスタンス(Mimir topk)を指し、run+シナリオ単位でキャッシュして revert が
同じインスタンスに当たるようにする(ILB のハッシュで api の接続が 1 台に偏る)。
2026-10-08 19:20:35 +09:00
25 changed files with 1994 additions and 27 deletions

No files matched your search

+54 -1
View File
@@ -7,11 +7,14 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/agent" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/agent"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/anthropic"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/diag" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/diag"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/external"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/harness" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/harness"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/jev" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/jev"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
@@ -19,6 +22,15 @@ import (
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/tools" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/tools"
) )
func envInt(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
func env(k, d string) string { func env(k, d string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v
@@ -95,6 +107,33 @@ func main() {
HitlURL: env("HITL_URL", "http://hitl:8081"), HitlURL: env("HITL_URL", "http://hitl:8081"),
} }
ag := agent.New(acfg, oai, tr, hc, store, enums, pv) ag := agent.New(acfg, oai, tr, hc, store, enums, pv)
// Claude Messages API for claude-* model ids chosen per campaign; the key may be registered later (empty = diagnoses with
// a claude-* model fail with "anthropic api key is empty", recorded in the harness diagnosis event).
anth := anthropic.New(env("ANTHROPIC_BASE_URL", ""), keyReader(env("ANTHROPIC_KEY_FILE", "/run/secrets/anthropic-api-key")))
// Workload Identity Federation (preferred, no static key): the four ids are plain settings in ai.env; the VM's
// Google identity token is exchanged for a short-lived access token (internal/anthropic/federation.go).
anth.SetFederation(&anthropic.Federation{
RuleID: env("ANTHROPIC_FEDERATION_RULE_ID", ""), OrgID: env("ANTHROPIC_ORGANIZATION_ID", ""),
ServiceAccountID: env("ANTHROPIC_SERVICE_ACCOUNT_ID", ""), WorkspaceID: env("ANTHROPIC_WORKSPACE_ID", ""),
IdentityTokenURL: env("ANTHROPIC_IDENTITY_TOKEN_URL", ""),
})
ag.SetAnthropic(anth)
log.Printf("anthropic auth: %s", anth.AuthMode())
// external-* model ids (reference runs, 2026-10-16): each loop turn is served to an outside client over
// GET /external/next and answered through POST /external/reply (internal/external). The diagnosis deadline is
// EXTERNAL_DIAGNOSE_TIMEOUT_S (the harness waits a little longer for external-* models), one turn at most
// EXTERNAL_TURN_TIMEOUT_S. Off unless EXTERNAL_ADDR is set; the endpoints live on that separate listener, which
// the VM compose file maps to 127.0.0.1 only (reachable through the IAP SSH tunnel, never from the VPC): a reply
// is a diagnosis and, under auto-approval, an executed action.
var ext *external.Responder
if addr := env("EXTERNAL_ADDR", ""); addr != "" {
ext = external.New(time.Duration(envInt("EXTERNAL_TURN_TIMEOUT_S", 1200)) * time.Second)
ag.SetExternal(ext)
esrv := &http.Server{Addr: addr, Handler: logging(ext.Handler()), ReadHeaderTimeout: 10 * time.Second}
go func() { log.Fatal(esrv.ListenAndServe()) }()
log.Printf("external responder: listening on %s", addr)
}
externalDiagnoseTimeout := time.Duration(envInt("EXTERNAL_DIAGNOSE_TIMEOUT_S", 1500)) * time.Second
jevMode := env("JEV_MODE", "off") // off | shadow | enforce (shadow records Jev answers next to the diagnosis; enforce also applies the two review rules) jevMode := env("JEV_MODE", "off") // off | shadow | enforce (shadow records Jev answers next to the diagnosis; enforce also applies the two review rules)
var jc *jev.Client var jc *jev.Client
if jevMode == "shadow" || jevMode == "enforce" { if jevMode == "shadow" || jevMode == "enforce" {
@@ -130,6 +169,16 @@ func main() {
out := map[string]any{"model": acfg.Model, "prompt_version": pv, "labels": enums.Labels, "actions": enums.Actions} out := map[string]any{"model": acfg.Model, "prompt_version": pv, "labels": enums.Labels, "actions": enums.Actions}
key, kerr := keyReader(env("OPENAI_KEY_FILE", "/run/secrets/openai-api-key"))() key, kerr := keyReader(env("OPENAI_KEY_FILE", "/run/secrets/openai-api-key"))()
out["openai_key_present"] = kerr == nil && key != "" out["openai_key_present"] = kerr == nil && key != ""
out["anthropic_key_present"] = anth.KeyPresent()
out["anthropic_auth"] = anth.AuthMode()
if f := anth.Federation(); f != nil {
out["anthropic_federation"] = f.Probe(c, anth.BaseURL) // one real token exchange; the Console history page shows the deny reason on failure
}
if ext != nil {
out["external_responder"] = ext.Status()
} else {
out["external_responder"] = "off"
}
out["jev_mode"] = jevMode out["jev_mode"] = jevMode
if jc != nil { if jc != nil {
out["jev_key_present"] = jc.KeyPresent() out["jev_key_present"] = jc.KeyPresent()
@@ -162,7 +211,11 @@ func main() {
} }
inflight++ inflight++
defer func() { inflight-- }() defer func() { inflight-- }()
c, cancel := context.WithTimeout(context.Background(), 170*time.Second) // harness waits 3 minutes timeout := 170 * time.Second // harness waits 3 minutes
if agent.IsExternal(req.Model) {
timeout = externalDiagnoseTimeout
}
c, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
prefetched := ag.Prefetch(c) prefetched := ag.Prefetch(c)
tri := ag.ShadowTriage(c, req, prefetched) // nil unless JEV_MODE=shadow; recorded only tri := ag.ShadowTriage(c, req, prefetched) // nil unless JEV_MODE=shadow; recorded only
+1
View File
@@ -59,6 +59,7 @@ func main() {
} }
connected = true connected = true
bot.RearmPending(ctx) bot.RearmPending(ctx)
go bot.SyncLoop(ctx, 60*time.Second)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
+51 -4
View File
@@ -35,9 +35,17 @@ type Config struct {
ConfidenceMin float64 // 0.5 (design 6.3) ConfidenceMin float64 // 0.5 (design 6.3)
} }
// Responder is the one call the diagnosis loop needs from an LLM provider (OpenAI Responses API natively,
// the Claude Messages API through internal/anthropic).
type Responder interface {
CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error)
}
type Agent struct { type Agent struct {
cfg Config cfg Config
oai *openai.Client oai *openai.Client
anth Responder // nil = claude-* models unavailable
ext Responder // nil = external-* models unavailable (internal/external: a client outside the VM answers each turn)
tools *tools.Runner tools *tools.Runner
harness *harness.Client harness *harness.Client
store *rag.Store store *rag.Store
@@ -64,6 +72,33 @@ func New(cfg Config, oai *openai.Client, tr *tools.Runner, hc *harness.Client, s
return &Agent{cfg: cfg, oai: oai, tools: tr, harness: hc, store: store, enums: enums, promptV: promptVersion, http: &http.Client{Timeout: 30 * time.Second}} return &Agent{cfg: cfg, oai: oai, tools: tr, harness: hc, store: store, enums: enums, promptV: promptVersion, http: &http.Client{Timeout: 30 * time.Second}}
} }
// SetAnthropic enables claude-* model ids (Claude Messages API adapter).
func (a *Agent) SetAnthropic(c Responder) { a.anth = c }
// SetExternal enables external-* model ids (turns served to an outside client over /external/next, reference runs).
func (a *Agent) SetExternal(c Responder) { a.ext = c }
// IsExternal reports whether the model id is served by the external responder (longer deadlines apply).
func IsExternal(model string) bool { return strings.HasPrefix(model, "external-") }
// provider picks the LLM client for a model id: claude-* -> Anthropic adapter, external-* -> external responder,
// everything else -> OpenAI.
func (a *Agent) provider(model string) (Responder, error) {
if strings.HasPrefix(model, "claude-") {
if a.anth == nil {
return nil, fmt.Errorf("model %s: anthropic provider not configured", model)
}
return a.anth, nil
}
if IsExternal(model) {
if a.ext == nil {
return nil, fmt.Errorf("model %s: external responder not configured", model)
}
return a.ext, nil
}
return a.oai, nil
}
// EnableShadow turns the Jev gates on (JEV_MODE=shadow records only; enforce also applies the review rules). // EnableShadow turns the Jev gates on (JEV_MODE=shadow records only; enforce also applies the review rules).
func (a *Agent) EnableShadow(c *jev.Client, enforce bool) { a.jev, a.jevEnforce = c, enforce } func (a *Agent) EnableShadow(c *jev.Client, enforce bool) { a.jev, a.jevEnforce = c, enforce }
@@ -72,6 +107,7 @@ type Request struct {
Alert map[string]any `json:"alert"` Alert map[string]any `json:"alert"`
RunID *string `json:"run_id"` RunID *string `json:"run_id"`
ScenarioID *string `json:"scenario_id"` ScenarioID *string `json:"scenario_id"`
Model string `json:"model,omitempty"` // campaign-level model selection (harness runs.model); "" = Config.Model
} }
// Result is returned to the harness: the diagnosis fields at top level (the harness stores the whole object as the // Result is returned to the harness: the diagnosis fields at top level (the harness stores the whole object as the
@@ -84,6 +120,7 @@ type Result struct {
Turns int `json:"turns"` Turns int `json:"turns"`
ToolCalls int `json:"tool_calls"` ToolCalls int `json:"tool_calls"`
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
Usage map[string]int `json:"usage,omitempty"` // input_tokens / output_tokens / total_tokens summed over the turns
Disposition string `json:"disposition"` // proposal | escalated | none Disposition string `json:"disposition"` // proposal | escalated | none
ProposalToken string `json:"proposal_token,omitempty"` ProposalToken string `json:"proposal_token,omitempty"`
Jev map[string]any `json:"jev,omitempty"` // shadow gate answers (recorded, never acted on) Jev map[string]any `json:"jev,omitempty"` // shadow gate answers (recorded, never acted on)
@@ -155,13 +192,17 @@ func (a *Agent) Diagnose(ctx context.Context, req Request) (*Result, error) {
func (a *Agent) DiagnoseWith(ctx context.Context, req Request, prefetched string) (*Result, error) { func (a *Agent) DiagnoseWith(ctx context.Context, req Request, prefetched string) (*Result, error) {
start := time.Now() start := time.Now()
sid := "ses-" + uuid.NewString()[:8] sid := "ses-" + uuid.NewString()[:8]
res := &Result{SessionID: sid, Model: a.cfg.Model, PromptVersion: a.promptV} model := a.cfg.Model
if req.Model != "" {
model = req.Model
}
res := &Result{SessionID: sid, Model: model, PromptVersion: a.promptV}
var calls []toolCallLog var calls []toolCallLog
input := []openai.Item{ input := []openai.Item{
{Type: "message", Role: "system", Content: a.cfg.SystemPrompt}, {Type: "message", Role: "system", Content: a.cfg.SystemPrompt},
{Type: "message", Role: "user", Content: a.userMessage(ctx, req, prefetched)}, {Type: "message", Role: "user", Content: a.userMessage(ctx, req, prefetched)},
} }
session := rag.Session{SessionID: sid, RunID: req.RunID, ScenarioID: req.ScenarioID, Model: a.cfg.Model, PromptVersion: a.promptV, Input: map[string]any{"alert": req.Alert, "user_message": input[1].Content}} session := rag.Session{SessionID: sid, RunID: req.RunID, ScenarioID: req.ScenarioID, Model: model, PromptVersion: a.promptV, Input: map[string]any{"alert": req.Alert, "user_message": input[1].Content}}
finish := func(err error) { finish := func(err error) {
session.Output = res session.Output = res
session.ToolCalls = calls session.ToolCalls = calls
@@ -176,6 +217,11 @@ func (a *Agent) DiagnoseWith(ctx context.Context, req Request, prefetched string
} }
} }
llm, err := a.provider(model)
if err != nil {
finish(err)
return nil, err
}
defs := a.toolDefs() defs := a.toolDefs()
var prevID string var prevID string
var diagnosis *diag.Diagnosis var diagnosis *diag.Diagnosis
@@ -185,14 +231,14 @@ func (a *Agent) DiagnoseWith(ctx context.Context, req Request, prefetched string
usage := map[string]int{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "turns": 0} usage := map[string]int{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "turns": 0}
for turn := 1; turn <= a.cfg.MaxTurns && diagnosis == nil; turn++ { for turn := 1; turn <= a.cfg.MaxTurns && diagnosis == nil; turn++ {
res.Turns = turn res.Turns = turn
r := openai.Request{Model: a.cfg.Model, Input: input, Tools: defs, ToolChoice: "auto", MaxOutputTokens: a.cfg.MaxOutputTokens, ParallelToolCalls: &pf} r := openai.Request{Model: model, Input: input, Tools: defs, ToolChoice: "auto", MaxOutputTokens: a.cfg.MaxOutputTokens, ParallelToolCalls: &pf}
if prevID != "" { if prevID != "" {
r.PreviousResponseID = prevID r.PreviousResponseID = prevID
} }
if turn == a.cfg.MaxTurns { // last chance: force the final call if turn == a.cfg.MaxTurns { // last chance: force the final call
r.ToolChoice = map[string]any{"type": "function", "name": "submit_diagnosis"} r.ToolChoice = map[string]any{"type": "function", "name": "submit_diagnosis"}
} }
resp, err := a.oai.CreateResponse(ctx, r) resp, err := llm.CreateResponse(ctx, r)
if err != nil { if err != nil {
finish(err) finish(err)
return nil, err return nil, err
@@ -228,6 +274,7 @@ func (a *Agent) DiagnoseWith(ctx context.Context, req Request, prefetched string
} }
} }
session.Usage = usage session.Usage = usage
res.Usage = usage
res.ToolCalls = len(calls) res.ToolCalls = len(calls)
res.DurationMs = time.Since(start).Milliseconds() res.DurationMs = time.Since(start).Milliseconds()
if diagnosis == nil { if diagnosis == nil {
+68
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/anthropic"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/diag" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/diag"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/harness" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/harness"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai" "github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
@@ -81,3 +82,70 @@ func TestTargetFor(t *testing.T) {
t.Fatal("mig") t.Fatal("mig")
} }
} }
// A claude-* model id routes the same loop through the Anthropic adapter; the request model wins over Config.Model
// and the summed usage is returned in the result.
func TestDiagnoseRoutesClaudeModel(t *testing.T) {
var models []string
anth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
models = append(models, b["model"].(string))
args := `{"root_cause_label":"db_primary_down","confidence":0.8,"evidence":["e"],"proposed_action":"escalate","target":"db-primary","rationale":"r"}`
w.Write([]byte(`{"id":"msg_a","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_a","name":"submit_diagnosis","input":` + args + `}],"usage":{"input_tokens":40,"output_tokens":8}}`))
}))
defer anth.Close()
oaiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/embeddings" {
w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2]}]}`))
return
}
t.Errorf("openai must not be called for a claude-* model: %s", r.URL.Path)
}))
defer oaiSrv.Close()
mimir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`))
}))
defer mimir.Close()
oai := openai.New(oaiSrv.URL, func() (string, error) { return "test-key", nil })
tr := tools.New(tools.Config{MimirURL: mimir.URL, LokiURL: mimir.URL, EmbedModel: "e"}, nil, oai)
enums := diag.Enums{Labels: diag.DefaultLabels, Actions: diag.DefaultActions}
ag := New(Config{Model: "gpt-default", SystemPrompt: "sys", Topology: "topo", SLO: "slo", HitlURL: "http://127.0.0.1:1", MaxTurns: 3}, oai, tr, harness.New("http://127.0.0.1:1"), nil, enums, "pv1")
req := Request{Alert: map[string]any{"alertname": "DbPrimaryDown"}, Model: "claude-sonnet-5-5"}
if _, err := ag.Diagnose(context.Background(), req); err == nil || !strings.Contains(err.Error(), "anthropic provider not configured") {
t.Fatalf("without SetAnthropic a claude-* model must fail clearly, got %v", err)
}
ag.SetAnthropic(anthropic.New(anth.URL, func() (string, error) { return "k", nil }))
res, err := ag.Diagnose(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if res.Model != "claude-sonnet-5-5" || len(models) != 1 || models[0] != "claude-sonnet-5-5" || res.RootCauseLabel != "db_primary_down" {
t.Fatalf("routing: res=%+v models=%v", res, models)
}
if res.Usage["input_tokens"] != 40 || res.Usage["output_tokens"] != 8 || res.Usage["total_tokens"] != 48 || res.Usage["turns"] != 1 {
t.Fatalf("usage: %v", res.Usage)
}
}
// Model routing: external-* goes to the external responder when one is set, and is refused when none is configured.
func TestProviderRouting(t *testing.T) {
a := New(Config{}, nil, nil, nil, nil, diag.Enums{}, "v")
if _, err := a.provider("external-claude-mythos-5-1"); err == nil || !strings.Contains(err.Error(), "external responder not configured") {
t.Fatalf("expected unconfigured error, got %v", err)
}
fake := &fakeResponder{}
a.SetExternal(fake)
if p, err := a.provider("external-claude-mythos-5-1"); err != nil || p != Responder(fake) {
t.Fatalf("external routing: %v %v", p, err)
}
if !IsExternal("external-x") || IsExternal("claude-x") || IsExternal("gpt-6-luna") {
t.Fatal("IsExternal prefix check")
}
}
type fakeResponder struct{}
func (*fakeResponder) CreateResponse(context.Context, openai.Request) (*openai.Response, error) {
return nil, nil
}
+28 -6
View File
@@ -78,15 +78,15 @@ func (a *Agent) ShadowTriage(ctx context.Context, req Request, prefetched string
rec := &ShadowRecord{Gate: "triage"} rec := &ShadowRecord{Gate: "triage"}
rec.state = map[string]any{ rec.state = map[string]any{
"alert": compactAlert(req.Alert), "alert": compactAlert(req.Alert),
"now": a.nowSummary(ctx), "now": a.nowSummary(ctx, req.Alert),
"samples_last_10m": prefetched, "samples_last_10m": prefetched,
"recent_error_logs": a.recentErrorLogs(ctx), "recent_error_logs": a.recentErrorLogs(ctx),
"slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%", "slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%",
} }
rec.questions = map[string]jev.Question{ rec.questions = map[string]jev.Question{
"label": {Type: "choice", Instructions: "Which root cause label best explains `alert`, `now`, `samples_last_10m` and `recent_error_logs`?", Criteria: labels}, "label": {Type: "choice", Instructions: "Which root cause label best explains `alert`, `now`, `samples_last_10m` and `recent_error_logs`?", Criteria: labels},
"recovering": {Type: "noul", Instructions: "Look only at `now`: is `now.slo_now_ok` true, meaning the newest probe, the 1-minute p95 and the 1-minute error ratio are all inside `slo` right now?", "recovering": {Type: "noul", Instructions: "Look only at `now`: is `now.recovered_now` true, meaning the alert's own condition no longer holds (`now.alert_condition_clear`) and the newest probe, the 1-minute p95 and the 1-minute error ratio are all inside `slo` right now?",
Criteria: map[string]string{"true": "now.slo_now_ok is true: the symptom is gone in the newest samples", "false": "now.slo_now_ok is false, or the values are missing"}}, Criteria: map[string]string{"true": "now.recovered_now is true: the alert condition is clear and the SLO values are inside", "false": "now.recovered_now is false, or the values are missing"}},
"needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (more logs, Kubernetes objects, VM status, database replication status)?", "needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (more logs, Kubernetes objects, VM status, database replication status)?",
Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}}, Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}},
"irreversible_needed": {Type: "noul", Instructions: "Would fixing the root cause shown in this state require an irreversible or human-only operation: promoting the database replica, repairing a database schema or data, or starting or recreating a stopped VM?", "irreversible_needed": {Type: "noul", Instructions: "Would fixing the root cause shown in this state require an irreversible or human-only operation: promoting the database replica, repairing a database schema or data, or starting or recreating a stopped VM?",
@@ -98,7 +98,7 @@ func (a *Agent) ShadowTriage(ctx context.Context, req Request, prefetched string
// nowSummary computes the newest values and the SLO verdict in code (Jev reads text literally and should not compare // nowSummary computes the newest values and the SLO verdict in code (Jev reads text literally and should not compare
// numbers or read the tail of a series: jev-1.13 jaggedness notes). // numbers or read the tail of a series: jev-1.13 jaggedness notes).
func (a *Agent) nowSummary(ctx context.Context) map[string]any { func (a *Agent) nowSummary(ctx context.Context, alert map[string]any) map[string]any {
q := func(promql string) any { q := func(promql string) any {
v, err := a.tools.Instant(ctx, promql) v, err := a.tools.Instant(ctx, promql)
if err != nil || v != v { // NaN if err != nil || v != v { // NaN
@@ -110,8 +110,18 @@ func (a *Agent) nowSummary(ctx context.Context) map[string]any {
probeDB := q(`probe_success{target="db-primary"}`) probeDB := q(`probe_success{target="db-primary"}`)
probeMin1m := q(`min_over_time(probe_success{target="alb"}[1m])`) probeMin1m := q(`min_over_time(probe_success{target="alb"}[1m])`)
probeMin3m := q(`min_over_time(probe_success{target="alb"}[3m])`) probeMin3m := q(`min_over_time(probe_success{target="alb"}[3m])`)
p95 := q(`histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket[1m])))`) p95 := q(`histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket{route=~"orders|price"}[1m])))`)
errRatio := q(`sum(rate(demo_http_requests_total{status=~"5.."}[1m])) / sum(rate(demo_http_requests_total[1m]))`) errRatio := q(`sum(rate(demo_http_requests_total{route=~"orders|price",status=~"5.."}[1m])) / sum(rate(demo_http_requests_total{route=~"orders|price"}[1m]))`)
// the alert's own condition (Grafana annotation `condition`, the rule expression in bool form). A fault that does
// not touch the SLO (replay paused, memory pressure) is "recovering" only when this clears (campaign 3/4: 0.98).
var condNow any
condClear := true
if cond := alertCondition(alert); cond != "" {
condNow = q("max(" + cond + ")")
if v, ok := condNow.(float64); ok {
condClear = v == 0
}
}
restarts := q(`sum(increase(kube_pod_container_status_restarts_total{namespace="demo"}[10m]))`) restarts := q(`sum(increase(kube_pod_container_status_restarts_total{namespace="demo"}[10m]))`)
legacyUp := q(`count(up{job="legacy-api"} == 1)`) legacyUp := q(`count(up{job="legacy-api"} == 1)`)
f := func(v any) (float64, bool) { f := func(v any) (float64, bool) {
@@ -127,10 +137,22 @@ func (a *Agent) nowSummary(ctx context.Context) map[string]any {
return map[string]any{ return map[string]any{
"probe_alb_now": probeALB, "probe_db_primary_now": probeDB, "probe_alb_min_1m": probeMin1m, "probe_alb_min_3m": probeMin3m, "probe_alb_now": probeALB, "probe_db_primary_now": probeDB, "probe_alb_min_1m": probeMin1m, "probe_alb_min_3m": probeMin3m,
"api_p95_1m_s": p95, "api_error_ratio_1m": errRatio, "api_restarts_10m": restarts, "legacy_instances_up": legacyUp, "api_p95_1m_s": p95, "api_error_ratio_1m": errRatio, "api_restarts_10m": restarts, "legacy_instances_up": legacyUp,
"alert_condition_now": condNow, "alert_condition_clear": condClear,
"probe_ok_now": probeOK, "latency_ok_now": latOK, "errors_ok_now": errOK, "slo_now_ok": probeOK && latOK && errOK, "probe_ok_now": probeOK, "latency_ok_now": latOK, "errors_ok_now": errOK, "slo_now_ok": probeOK && latOK && errOK,
"recovered_now": probeOK && latOK && errOK && condClear,
} }
} }
// alertCondition returns the rule expression carried in the alert annotations (empty when absent).
func alertCondition(alert map[string]any) string {
ann, _ := alert["annotations"].(map[string]any)
if ann == nil {
return ""
}
c, _ := ann["condition"].(string)
return c
}
// recentErrorLogs returns a short, newest-first excerpt of api error lines (5 minutes, 8 lines) for the triage state. // recentErrorLogs returns a short, newest-first excerpt of api error lines (5 minutes, 8 lines) for the triage state.
func (a *Agent) recentErrorLogs(ctx context.Context) string { func (a *Agent) recentErrorLogs(ctx context.Context) string {
out := a.tools.Call(ctx, "query_logs", `{"logql":"{namespace=\"demo\", app=\"api\"} |~ \"(?i)error|timeout|refused|SQLSTATE\"","range_minutes":5,"limit":8}`) out := a.tools.Call(ctx, "query_logs", `{"logql":"{namespace=\"demo\", app=\"api\"} |~ \"(?i)error|timeout|refused|SQLSTATE\"","range_minutes":5,"limit":8}`)
+321
View File
@@ -0,0 +1,321 @@
// Package anthropic adapts the Claude Messages API to the small Responses-API surface the agent loop uses
// (openai.Request / openai.Response with message, function_call and function_call_output items), so one loop
// serves both providers. The agent picks this client for model ids starting with "claude-".
//
// Wire format (Messages API reference, anthropic-version 2023-06-01): system prompt as the top-level `system`
// field, tools as {name, description, input_schema, strict}, tool calls as `tool_use` content blocks answered by
// `tool_result` blocks in the next user message. The Responses-API `previous_response_id` chain is emulated
// client-side: every response id maps to the full message list up to and including that assistant turn.
//
// Claude Opus 5.5 / Sonnet 5.5 reject forced tool use (tool_choice type "tool"/"any" -> 400), so a forced
// tool_choice from the loop becomes {type:"auto"} plus a one-line instruction in the user turn.
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
const (
apiVersion = "2023-06-01"
maxConvCached = 64 // response ids kept for previous_response_id chaining (one diagnosis uses <= MaxTurns)
defaultTokens = 4000
)
type Client struct {
BaseURL string
HTTP *http.Client
keyFn func() (string, error)
fed *Federation // nil = API key from keyFn; set = Workload Identity Federation (takes precedence)
mu sync.Mutex
conv map[string]conversation // response id -> messages (and system prompt) up to and including that assistant turn
order []string // insertion order for eviction
seq int
}
type conversation struct {
system string
msgs []message
}
func New(baseURL string, keyFn func() (string, error)) *Client {
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: &http.Client{Timeout: 150 * time.Second}, keyFn: keyFn, conv: map[string]conversation{}}
}
// KeyPresent reports whether the key file has a non-empty value (the secret may not be registered yet).
func (c *Client) KeyPresent() bool {
k, err := c.keyFn()
return err == nil && k != ""
}
// SetFederation switches the client to Workload Identity Federation (ignored when the ids are incomplete).
func (c *Client) SetFederation(f *Federation) {
if f.Configured() {
c.fed = f
}
}
// AuthMode is "federation", "api_key" or "none" (reported by /selfcheck).
func (c *Client) AuthMode() string {
if c.fed != nil {
return "federation"
}
if c.KeyPresent() {
return "api_key"
}
return "none"
}
// Federation returns the active federation config (nil when the API key path is used).
func (c *Client) Federation() *Federation { return c.fed }
// authorize sets the credential header: a federated bearer token, or the API key.
func (c *Client) authorize(ctx context.Context, req *http.Request) error {
if c.fed != nil {
tok, err := c.fed.AccessToken(ctx, c.BaseURL)
if err != nil {
return fmt.Errorf("anthropic federation: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
return nil
}
key, err := c.keyFn()
if err != nil {
return fmt.Errorf("anthropic key: %w", err)
}
if key == "" {
return fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
}
req.Header.Set("x-api-key", key)
return nil
}
type message struct {
Role string `json:"role"`
Content any `json:"content"` // string or []block
}
type block struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
ToolUseID string `json:"tool_use_id,omitempty"` // tool_result
Content string `json:"content,omitempty"` // tool_result
}
type tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
Strict bool `json:"strict,omitempty"`
}
type request struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Tools []tool `json:"tools,omitempty"`
ToolChoice map[string]any `json:"tool_choice,omitempty"`
}
type response struct {
ID string `json:"id"`
Type string `json:"type"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Content json.RawMessage `json:"content"` // kept verbatim: thinking blocks (with their signature) must be replayed unchanged
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
} `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
// forcedNudge is appended to the user turn when the loop asked for a forced submit_diagnosis call.
const forcedNudge = "You must call the submit_diagnosis tool now with your final answer; do not call any other tool."
// CreateResponse translates one loop turn to a Messages API call and the answer back to Responses-API items.
func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
if c.fed == nil {
if key, err := c.keyFn(); err != nil {
return nil, fmt.Errorf("anthropic key: %w", err)
} else if key == "" {
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
}
}
var msgs []message
var system []string
if req.PreviousResponseID != "" {
c.mu.Lock()
prev, ok := c.conv[req.PreviousResponseID]
c.mu.Unlock()
if !ok {
return nil, fmt.Errorf("anthropic: unknown previous_response_id %q", req.PreviousResponseID)
}
msgs = append(msgs, prev.msgs...)
if prev.system != "" {
system = append(system, prev.system) // the loop sends the system message only on the first turn
}
}
var userBlocks []block
flush := func() {
if len(userBlocks) == 0 {
return
}
msgs = append(msgs, message{Role: "user", Content: userBlocks})
userBlocks = nil
}
for _, it := range req.Input {
switch it.Type {
case "message":
text, _ := it.Content.(string)
switch it.Role {
case "system":
system = append(system, text)
case "assistant":
flush()
msgs = append(msgs, message{Role: "assistant", Content: []block{{Type: "text", Text: text}}})
default:
userBlocks = append(userBlocks, block{Type: "text", Text: text})
}
case "function_call_output":
userBlocks = append(userBlocks, block{Type: "tool_result", ToolUseID: it.CallID, Content: it.Output})
}
}
forced := false
if m, ok := req.ToolChoice.(map[string]any); ok && m["type"] == "function" {
forced = true
}
if forced {
userBlocks = append(userBlocks, block{Type: "text", Text: forcedNudge})
}
flush()
if len(msgs) == 0 {
return nil, fmt.Errorf("anthropic: no messages to send")
}
body := request{Model: req.Model, MaxTokens: req.MaxOutputTokens, System: strings.Join(system, "\n\n"), Messages: msgs}
if body.MaxTokens <= 0 {
body.MaxTokens = defaultTokens
}
for _, t := range req.Tools {
body.Tools = append(body.Tools, tool{Name: t.Name, Description: t.Description, InputSchema: t.Parameters, Strict: t.Strict})
}
if len(body.Tools) > 0 {
tc := map[string]any{"type": "auto"}
if req.ParallelToolCalls != nil && !*req.ParallelToolCalls {
tc["disable_parallel_tool_use"] = true
}
body.ToolChoice = tc
}
b, _ := json.Marshal(body)
send := func() (*http.Response, error) {
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
if err != nil {
return nil, err
}
if err := c.authorize(ctx, hreq); err != nil {
return nil, err
}
hreq.Header.Set("anthropic-version", apiVersion)
hreq.Header.Set("Content-Type", "application/json")
return c.HTTP.Do(hreq)
}
resp, err := send()
if err != nil {
return nil, err
}
if resp.StatusCode == 401 && c.fed != nil { // token revoked/expired early: exchange once more
resp.Body.Close()
c.fed.Invalidate()
if resp, err = send(); err != nil {
return nil, err
}
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
var r response
_ = json.Unmarshal(rb, &r)
if resp.StatusCode/100 != 2 {
msg := truncate(string(rb), 600)
if r.Error != nil {
msg = r.Error.Type + ": " + r.Error.Message
}
return nil, fmt.Errorf("anthropic /v1/messages: status %d: %s", resp.StatusCode, msg)
}
if r.Error != nil {
return nil, fmt.Errorf("anthropic response error: %s", r.Error.Message)
}
out := &openai.Response{ID: r.ID, Status: "completed", Model: r.Model}
if out.ID == "" {
c.mu.Lock()
c.seq++
out.ID = fmt.Sprintf("msg_local_%d", c.seq)
c.mu.Unlock()
}
var blocks []block
_ = json.Unmarshal(r.Content, &blocks)
var text strings.Builder
for _, bl := range blocks {
switch bl.Type {
case "text":
text.WriteString(bl.Text)
case "tool_use":
args := string(bl.Input)
if args == "" {
args = "{}"
}
out.Output = append(out.Output, openai.Item{Type: "function_call", CallID: bl.ID, Name: bl.Name, Arguments: args, ID: bl.ID, Status: "completed"})
}
}
if text.Len() > 0 {
out.Output = append([]openai.Item{{Type: "message", Role: "assistant", Content: []any{map[string]any{"type": "output_text", "text": text.String()}}}}, out.Output...)
}
// input tokens include cache writes/reads so the sum matches what is billed
out.Usage.InputTokens = r.Usage.InputTokens + r.Usage.CacheCreationInputTokens + r.Usage.CacheReadInputTokens
out.Usage.OutputTokens = r.Usage.OutputTokens
out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens
// remember the conversation for the next turn: the assistant content is replayed as the raw JSON the API returned,
// so tool_use ids and thinking blocks (adaptive thinking is always on for Claude 5.5; a thinking block replayed
// without its text/signature is rejected with "thinking.thinking: Field required") stay intact
full := append(append([]message{}, msgs...), message{Role: "assistant", Content: r.Content})
c.mu.Lock()
c.conv[out.ID] = conversation{system: body.System, msgs: full}
c.order = append(c.order, out.ID)
for len(c.order) > maxConvCached {
delete(c.conv, c.order[0])
c.order = c.order[1:]
}
c.mu.Unlock()
return out, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+122
View File
@@ -0,0 +1,122 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
// Two turns through a fake Messages API: the first asks for a tool, the second (with previous_response_id and a
// forced submit) must carry the tool_result, the assistant tool_use block, the nudge text and an auto tool_choice.
func TestTranslateTurns(t *testing.T) {
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "k1" || r.Header.Get("anthropic-version") != "2023-06-01" {
t.Errorf("bad request line/headers: %s %v", r.URL.Path, r.Header)
}
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
bodies = append(bodies, b)
switch len(bodies) {
case 1:
w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"thinking","thinking":"let me probe","signature":"sig-abc"},{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`))
default:
w.Write([]byte(`{"id":"msg_2","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_2","name":"submit_diagnosis","input":{"root_cause_label":"x","confidence":0.9}}],"usage":{"input_tokens":200,"output_tokens":30}}`))
}
}))
defer srv.Close()
c := New(srv.URL, func() (string, error) { return "k1", nil })
pf := false
tools := []openai.Tool{{Type: "function", Strict: true, Name: "get_probe_status", Description: "probe", Parameters: map[string]any{"type": "object", "properties": map[string]any{}}}}
r1, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-sonnet-5-5", MaxOutputTokens: 1234, ParallelToolCalls: &pf, Tools: tools, ToolChoice: "auto",
Input: []openai.Item{{Type: "message", Role: "system", Content: "SYS"}, {Type: "message", Role: "user", Content: "alert"}}})
if err != nil {
t.Fatal(err)
}
fcs := r1.FunctionCalls()
if r1.ID != "msg_1" || len(fcs) != 1 || fcs[0].CallID != "toolu_1" || fcs[0].Name != "get_probe_status" || fcs[0].Arguments != "{}" {
t.Fatalf("turn 1 translation: %+v", r1)
}
if r1.Usage.InputTokens != 105 || r1.Usage.OutputTokens != 20 || r1.Usage.TotalTokens != 125 {
t.Fatalf("usage: %+v", r1.Usage)
}
b1 := bodies[0]
if b1["system"] != "SYS" || b1["max_tokens"].(float64) != 1234 || b1["model"] != "claude-sonnet-5-5" {
t.Fatalf("body 1: %v", b1)
}
tl := b1["tools"].([]any)[0].(map[string]any)
if tl["name"] != "get_probe_status" || tl["input_schema"] == nil || tl["strict"] != true {
t.Fatalf("tool translation: %v", tl)
}
if tc := b1["tool_choice"].(map[string]any); tc["type"] != "auto" || tc["disable_parallel_tool_use"] != true {
t.Fatalf("tool_choice 1: %v", tc)
}
r2, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-sonnet-5-5", Tools: tools, PreviousResponseID: "msg_1",
ToolChoice: map[string]any{"type": "function", "name": "submit_diagnosis"},
Input: []openai.Item{{Type: "function_call_output", CallID: "toolu_1", Output: `{"alb":1}`}}})
if err != nil {
t.Fatal(err)
}
if got := r2.FunctionCalls(); len(got) != 1 || got[0].Name != "submit_diagnosis" || !strings.Contains(got[0].Arguments, `"root_cause_label":"x"`) {
t.Fatalf("turn 2 translation: %+v", r2.Output)
}
b2 := bodies[1]
msgs := b2["messages"].([]any)
if len(msgs) != 3 { // user(alert) / assistant(text+tool_use) / user(tool_result+nudge)
t.Fatalf("messages: %v", msgs)
}
as := msgs[1].(map[string]any)
if as["role"] != "assistant" || !strings.Contains(mustJSON(as["content"]), `"tool_use"`) {
t.Fatalf("assistant turn not replayed: %v", as)
}
// the thinking block must come back verbatim with its text and signature (Claude 5.5 rejects a stripped one)
if ac := mustJSON(as["content"]); !strings.Contains(ac, `"thinking":"let me probe"`) || !strings.Contains(ac, `"signature":"sig-abc"`) {
t.Fatalf("thinking block not replayed verbatim: %s", ac)
}
last := msgs[2].(map[string]any)["content"].([]any)
if tr := last[0].(map[string]any); tr["type"] != "tool_result" || tr["tool_use_id"] != "toolu_1" || tr["content"] != `{"alb":1}` {
t.Fatalf("tool_result: %v", tr)
}
if nd := last[1].(map[string]any); nd["type"] != "text" || !strings.Contains(nd["text"].(string), "submit_diagnosis") {
t.Fatalf("forced nudge missing: %v", nd)
}
if tc := b2["tool_choice"].(map[string]any); tc["type"] != "auto" {
t.Fatalf("forced tool_choice must become auto for Claude 5.5: %v", tc)
}
if b2["system"] != "SYS" {
t.Fatalf("system prompt must be carried on the chained turn, got %v", b2["system"])
}
}
func TestEmptyKeyAndErrors(t *testing.T) {
c := New("http://127.0.0.1:1", func() (string, error) { return "", nil })
if c.KeyPresent() {
t.Fatal("empty key reported present")
}
_, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
if err == nil || !strings.Contains(err.Error(), "anthropic api key is empty") {
t.Fatalf("expected empty-key error, got %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
w.Write([]byte(`{"type":"error","error":{"type":"invalid_request_error","message":"tool_choice: type \"tool\" and \"any\" are not supported for this model."}}`))
}))
defer srv.Close()
c2 := New(srv.URL, func() (string, error) { return "k", nil })
_, err = c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
if err == nil || !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "invalid_request_error") {
t.Fatalf("expected 400 error, got %v", err)
}
_, err = c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", PreviousResponseID: "nope", Input: []openai.Item{{Type: "function_call_output", CallID: "a", Output: "b"}}})
if err == nil || !strings.Contains(err.Error(), "unknown previous_response_id") {
t.Fatalf("expected unknown id error, got %v", err)
}
}
func mustJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
+177
View File
@@ -0,0 +1,177 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Federation authenticates with Workload Identity Federation instead of an API key (platform.claude.com/docs/en/
// manage-claude/wif-providers/gcp): the VM's Google-signed identity token (metadata server, audience
// https://api.anthropic.com, format=full so the email claim is present) is exchanged at POST /v1/oauth/token
// (RFC 7523 jwt-bearer grant) for a short-lived sk-ant-oat01-... access token bound to an Anthropic service account.
// The four ids are not secrets; they come from ai.env. The token is cached and re-exchanged 120 s before expiry
// (the SDKs' advisory refresh), and a 401 from the API triggers one re-exchange. A fresh identity token is fetched
// for every exchange (identity tokens with a jti claim are single-use on the Anthropic side).
type Federation struct {
RuleID string // fdrl_...
OrgID string // Anthropic organization id (uuid)
ServiceAccountID string // svac_...
WorkspaceID string // wrkspc_... (optional when the rule covers one workspace)
IdentityTokenURL string // metadata server URL (tests override it)
HTTP *http.Client
mu sync.Mutex
token string
expiry time.Time
nex int // exchanges performed (tests)
}
const defaultIdentityTokenURL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full"
const refreshBefore = 120 * time.Second
// Configured reports whether the mandatory ids are set (then federation takes precedence over the key file).
func (f *Federation) Configured() bool {
return f != nil && f.RuleID != "" && f.OrgID != "" && f.ServiceAccountID != ""
}
func (f *Federation) identityToken(ctx context.Context) (string, error) {
u := f.IdentityTokenURL
if u == "" {
u = defaultIdentityTokenURL
}
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return "", err
}
req.Header.Set("Metadata-Flavor", "Google")
resp, err := f.http().Do(req)
if err != nil {
return "", fmt.Errorf("identity token: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
tok := strings.TrimSpace(string(b))
if resp.StatusCode != 200 || strings.Count(tok, ".") != 2 {
return "", fmt.Errorf("identity token: status %d: %s", resp.StatusCode, truncate(tok, 200))
}
return tok, nil
}
func (f *Federation) http() *http.Client {
if f.HTTP != nil {
return f.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
// AccessToken returns a cached access token, exchanging a fresh identity token when none is cached or the cached
// one is within refreshBefore of its expiry.
func (f *Federation) AccessToken(ctx context.Context, baseURL string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.token != "" && time.Until(f.expiry) > refreshBefore {
return f.token, nil
}
tok, exp, err := f.exchange(ctx, baseURL)
if err != nil {
return "", err
}
f.token, f.expiry = tok, exp
f.nex++
return tok, nil
}
// Invalidate drops the cached token (after a 401) so the next call exchanges again.
func (f *Federation) Invalidate() {
f.mu.Lock()
f.token, f.expiry = "", time.Time{}
f.mu.Unlock()
}
// Exchanges counts the token exchanges performed (tests).
func (f *Federation) Exchanges() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.nex
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
ErrorObj *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error_obj,omitempty"`
}
func (f *Federation) exchange(ctx context.Context, baseURL string) (string, time.Time, error) {
jwt, err := f.identityToken(ctx)
if err != nil {
return "", time.Time{}, err
}
body := map[string]string{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": jwt,
"federation_rule_id": f.RuleID,
"organization_id": f.OrgID,
"service_account_id": f.ServiceAccountID,
}
if f.WorkspaceID != "" {
body["workspace_id"] = f.WorkspaceID
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(baseURL, "/")+"/v1/oauth/token", bytes.NewReader(b))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := f.http().Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("token exchange: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var tr tokenResponse
_ = json.Unmarshal(rb, &tr)
if resp.StatusCode/100 != 2 || tr.AccessToken == "" {
// the API answers a failed exchange with an opaque 401 "Authentication failed"; the deny reason is on the
// Console authentication-history page (platform.claude.com/settings/workload-identity-federation?tab=history)
return "", time.Time{}, fmt.Errorf("token exchange: status %d: %s", resp.StatusCode, truncate(strings.TrimSpace(string(rb)), 300))
}
ttl := time.Duration(tr.ExpiresIn) * time.Second
if ttl <= 0 {
ttl = 60 * time.Second
}
return tr.AccessToken, time.Now().Add(ttl), nil
}
// Probe performs one exchange and reports the outcome (selfcheck): the setup can be verified without a diagnosis.
func (f *Federation) Probe(ctx context.Context, baseURL string) map[string]any {
out := map[string]any{"rule_id": f.RuleID, "service_account_id": f.ServiceAccountID}
f.Invalidate()
start := time.Now()
tok, err := f.AccessToken(ctx, baseURL)
out["ms"] = time.Since(start).Milliseconds()
if err != nil {
out["ok"] = false
out["error"] = err.Error()
return out
}
f.mu.Lock()
out["ok"] = true
out["token_prefix"] = truncate(tok, 12)
out["expires_in_s"] = int(time.Until(f.expiry).Seconds())
f.mu.Unlock()
return out
}
+130
View File
@@ -0,0 +1,130 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
// Federation: the identity token from the (fake) metadata server is exchanged once at /v1/oauth/token with the
// jwt-bearer grant and the four ids; /v1/messages gets "Authorization: Bearer" and no x-api-key; the cached token is
// reused; a 401 triggers exactly one re-exchange; an expired cache re-exchanges before the next call.
func TestFederationExchangeAndReuse(t *testing.T) {
var idCalls, exchanges, msgCalls int32
var fail401 atomic.Bool
md := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Metadata-Flavor") != "Google" || !strings.Contains(r.URL.RawQuery, "audience=https://api.anthropic.com") || !strings.Contains(r.URL.RawQuery, "format=full") {
t.Errorf("bad metadata request: %s %v", r.URL.String(), r.Header)
}
atomic.AddInt32(&idCalls, 1)
w.Write([]byte("eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig"))
}))
defer md.Close()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/oauth/token":
var b map[string]string
_ = json.NewDecoder(r.Body).Decode(&b)
if b["grant_type"] != "urn:ietf:params:oauth:grant-type:jwt-bearer" || !strings.HasPrefix(b["assertion"], "eyJ") || b["federation_rule_id"] != "fdrl_1" || b["organization_id"] != "org-1" || b["service_account_id"] != "svac_1" || b["workspace_id"] != "wrkspc_1" {
t.Errorf("bad exchange body: %v", b)
}
n := atomic.AddInt32(&exchanges, 1)
w.Write([]byte(`{"access_token":"sk-ant-oat01-tok` + string(rune('0'+n)) + `","token_type":"Bearer","expires_in":600}`))
case "/v1/messages":
atomic.AddInt32(&msgCalls, 1)
if r.Header.Get("x-api-key") != "" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer sk-ant-oat01-tok") {
t.Errorf("bad auth headers: %v", r.Header)
}
if fail401.Swap(false) {
w.WriteHeader(401)
w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"Authentication failed"}}`))
return
}
w.Write([]byte(`{"id":"msg_f","type":"message","model":"claude-sonnet-5-5","stop_reason":"end_turn","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":3,"output_tokens":1}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer api.Close()
c := New(api.URL, func() (string, error) { return "", nil }) // no key file: federation must carry the call
fed := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", WorkspaceID: "wrkspc_1", IdentityTokenURL: md.URL + "/identity?audience=https://api.anthropic.com&format=full"}
c.SetFederation(fed)
if c.AuthMode() != "federation" {
t.Fatalf("auth mode %s", c.AuthMode())
}
req := openai.Request{Model: "claude-sonnet-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}}
for i := 0; i < 2; i++ {
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
}
if atomic.LoadInt32(&exchanges) != 1 || atomic.LoadInt32(&idCalls) != 1 || atomic.LoadInt32(&msgCalls) != 2 {
t.Fatalf("expected one exchange for two calls, got exchanges=%d id=%d msgs=%d", exchanges, idCalls, msgCalls)
}
// 401 -> one re-exchange and a successful retry
fail401.Store(true)
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
if atomic.LoadInt32(&exchanges) != 2 || atomic.LoadInt32(&msgCalls) != 4 {
t.Fatalf("401 must re-exchange once: exchanges=%d msgs=%d", exchanges, msgCalls)
}
// cached token close to expiry -> advisory refresh before the next call
fed.mu.Lock()
fed.expiry = time.Now().Add(60 * time.Second)
fed.mu.Unlock()
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
if atomic.LoadInt32(&exchanges) != 3 {
t.Fatalf("near-expiry token must be refreshed: exchanges=%d", exchanges)
}
// probe reports a successful exchange
p := fed.Probe(context.Background(), api.URL)
if p["ok"] != true || p["expires_in_s"].(int) < 500 || !strings.HasPrefix(p["token_prefix"].(string), "sk-ant-oat01") {
t.Fatalf("probe: %v", p)
}
}
func TestFederationFailuresAndPrecedence(t *testing.T) {
// incomplete ids are ignored: the key path stays active
c := New("http://127.0.0.1:1", func() (string, error) { return "k", nil })
c.SetFederation(&Federation{RuleID: "fdrl_1"})
if c.AuthMode() != "api_key" || c.Federation() != nil {
t.Fatalf("incomplete federation must not activate: %s", c.AuthMode())
}
// exchange denied (opaque 401) -> clear error, no message call
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/oauth/token" {
w.WriteHeader(401)
w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"Authentication failed"}}`))
return
}
t.Errorf("messages must not be called when the exchange fails")
}))
defer api.Close()
md := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("a.b.c")) }))
defer md.Close()
c2 := New(api.URL, func() (string, error) { return "", nil })
fed := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", IdentityTokenURL: md.URL}
c2.SetFederation(fed)
_, err := c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
if err == nil || !strings.Contains(err.Error(), "token exchange: status 401") {
t.Fatalf("expected exchange failure, got %v", err)
}
if p := fed.Probe(context.Background(), api.URL); p["ok"] != false || !strings.Contains(p["error"].(string), "status 401") {
t.Fatalf("probe must report the denial: %v", p)
}
// metadata server unreachable
fed2 := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", IdentityTokenURL: "http://127.0.0.1:1/identity", HTTP: &http.Client{Timeout: 2 * time.Second}}
if _, err := fed2.AccessToken(context.Background(), api.URL); err == nil || !strings.Contains(err.Error(), "identity token") {
t.Fatalf("expected identity token error, got %v", err)
}
}
+359
View File
@@ -0,0 +1,359 @@
// Package external lets a client outside the AI VM act as the LLM for model ids starting with "external-": the
// diagnosis loop (internal/agent) is unchanged, but every turn is queued as a Turn and served to the client over
// GET /external/next (long poll); the client answers with POST /external/reply. Used for reference measurements with
// a model that has no API path (2026-10-16: Claude Mythos 5.1 driven by a Claude Code subagent). Token usage is not
// known for such turns and is reported as zero; reports mark those runs as reference records.
//
// A reply becomes a diagnosis and, with auto-approval, an executed action, so whoever can answer a turn controls the
// lab: the endpoints are served on their own listener (EXTERNAL_ADDR, off by default; the VM maps it to 127.0.0.1 so
// only the IAP SSH tunnel reaches it), turn ids are random, and every reply must carry the per-turn reply_token that
// only the client who took the turn received.
package external
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
const maxConvCached = 64
// Turn is one request to the external client: the whole conversation so far (the loop sends the system prompt only
// on the first turn, so it is carried along), the tool definitions and whether the final submit_diagnosis is forced.
type Turn struct {
TurnID string `json:"turn_id"`
ReplyToken string `json:"reply_token"` // random, handed out with the turn, required by /external/reply
Requeued int `json:"requeued"` // times this turn was handed out again after an unanswered take
Session string `json:"session"` // first response id of the conversation ("" on the first turn)
Model string `json:"model"`
Turn int `json:"turn"` // 1-based index within the conversation
System string `json:"system"`
Input []openai.Item `json:"input"` // user messages, assistant function_call items, function_call_output items
Tools []openai.Tool `json:"tools"`
ForceSubmit bool `json:"force_submit"` // the loop asked for submit_diagnosis now (last turn)
MaxOutputTokens int `json:"max_output_tokens"`
IssuedAt time.Time `json:"issued_at"`
DeadlineAt time.Time `json:"deadline_at"`
}
// Reply is the client's answer: function calls (name + arguments) and/or assistant text.
type Reply struct {
TurnID string `json:"turn_id"`
ReplyToken string `json:"reply_token"`
Output []replyItem `json:"output"`
Error string `json:"error,omitempty"` // client gave up on this turn
}
type replyItem struct {
Type string `json:"type"` // function_call | message
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"` // JSON object or a JSON string holding one
Text string `json:"text,omitempty"`
}
type pending struct {
turn Turn
reply chan Reply
takenAt time.Time // zero while queued
}
type conversation struct {
system string
items []openai.Item
turn int
first string
}
// Responder implements agent.Responder; one instance serves every external-* diagnosis.
type Responder struct {
TurnTimeout time.Duration // how long one turn waits for the client (bounded by the caller's context as well)
RequeueAfter time.Duration // a taken turn with no reply after this long goes back to the queue with a new token
mu sync.Mutex
queue []*pending // issued, not yet taken by a client
taken map[string]*pending // taken, waiting for the reply
conv map[string]conversation
order []string
seq int
notify chan struct{}
lastPoll time.Time
lastTurn time.Time
}
func New(turnTimeout time.Duration) *Responder {
if turnTimeout <= 0 {
turnTimeout = 20 * time.Minute
}
return &Responder{TurnTimeout: turnTimeout, RequeueAfter: 3 * time.Minute, taken: map[string]*pending{}, conv: map[string]conversation{}, notify: make(chan struct{}, 1)}
}
// CreateResponse queues the turn and blocks until the client replies, the context ends or TurnTimeout passes.
func (r *Responder) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
var hist conversation
if req.PreviousResponseID != "" {
r.mu.Lock()
prev, ok := r.conv[req.PreviousResponseID]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("external: unknown previous_response_id %q", req.PreviousResponseID)
}
hist = conversation{system: prev.system, items: append([]openai.Item{}, prev.items...), turn: prev.turn, first: prev.first}
}
for _, it := range req.Input {
if it.Type == "message" && it.Role == "system" {
if s, _ := it.Content.(string); s != "" {
if hist.system != "" {
hist.system += "\n\n"
}
hist.system += s
}
continue
}
hist.items = append(hist.items, it)
}
if len(hist.items) == 0 {
return nil, fmt.Errorf("external: no input items")
}
forced := false
if m, ok := req.ToolChoice.(map[string]any); ok && m["type"] == "function" {
forced = true
}
id, tok := "ext_"+randHex(8), randHex(16)
r.mu.Lock()
r.seq++
now := time.Now()
p := &pending{turn: Turn{TurnID: id, ReplyToken: tok, Session: hist.first, Model: req.Model, Turn: hist.turn + 1, System: hist.system, Input: hist.items, Tools: req.Tools,
ForceSubmit: forced, MaxOutputTokens: req.MaxOutputTokens, IssuedAt: now, DeadlineAt: now.Add(r.TurnTimeout)}, reply: make(chan Reply, 1)}
r.queue = append(r.queue, p)
r.lastTurn = now
r.mu.Unlock()
select {
case r.notify <- struct{}{}:
default:
}
var rep Reply
select {
case rep = <-p.reply:
case <-ctx.Done():
r.drop(id)
return nil, fmt.Errorf("external: turn %s not answered before the diagnosis deadline (%v)", id, ctx.Err())
case <-time.After(r.TurnTimeout):
r.drop(id)
return nil, fmt.Errorf("external: turn %s not answered within %s", id, r.TurnTimeout)
}
if rep.Error != "" {
return nil, fmt.Errorf("external: client error on turn %s: %s", id, rep.Error)
}
out := &openai.Response{ID: id, Status: "completed", Model: req.Model}
var text strings.Builder
for i, it := range rep.Output {
switch it.Type {
case "function_call":
out.Output = append(out.Output, openai.Item{Type: "function_call", CallID: fmt.Sprintf("%s_call_%d", id, i+1), ID: fmt.Sprintf("%s_call_%d", id, i+1), Name: it.Name, Arguments: argString(it.Arguments), Status: "completed"})
case "message":
text.WriteString(it.Text)
}
}
if text.Len() > 0 {
out.Output = append([]openai.Item{{Type: "message", Role: "assistant", Content: []any{map[string]any{"type": "output_text", "text": text.String()}}}}, out.Output...)
}
// remember the conversation (assistant output items are replayed to the client on the next turn)
first := hist.first
if first == "" {
first = id
}
items := append(append([]openai.Item{}, hist.items...), out.Output...)
r.mu.Lock()
r.conv[id] = conversation{system: hist.system, items: items, turn: hist.turn + 1, first: first}
r.order = append(r.order, id)
for len(r.order) > maxConvCached {
delete(r.conv, r.order[0])
r.order = r.order[1:]
}
r.mu.Unlock()
return out, nil
}
// argString returns the function arguments as the JSON object text (a JSON string value is unwrapped).
func argString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" {
return "{}"
}
if strings.HasPrefix(s, `"`) {
var inner string
if json.Unmarshal(raw, &inner) == nil {
if strings.TrimSpace(inner) == "" {
return "{}"
}
return inner
}
}
return s
}
// requeueStale (mu held) returns taken turns whose reply never came (client lost the output, crashed, was replaced)
// to the front of the queue with a fresh reply token, so the next poll serves them again; the old token is void.
func (r *Responder) requeueStale() {
if r.RequeueAfter <= 0 {
return
}
var stale []*pending
for id, p := range r.taken {
if time.Since(p.takenAt) >= r.RequeueAfter {
delete(r.taken, id)
p.turn.ReplyToken = randHex(16)
p.turn.Requeued++
p.takenAt = time.Time{}
stale = append(stale, p)
}
}
if len(stale) > 0 {
r.queue = append(stale, r.queue...)
}
}
func (r *Responder) drop(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.taken, id)
for i, p := range r.queue {
if p.turn.TurnID == id {
r.queue = append(r.queue[:i], r.queue[i+1:]...)
return
}
}
}
// Next is GET /external/next?wait=<s>: the oldest queued turn (200), or 204 when none arrived within wait seconds.
func (r *Responder) Next(w http.ResponseWriter, req *http.Request) {
wait := 20 * time.Second
if v, err := strconv.Atoi(req.URL.Query().Get("wait")); err == nil && v >= 0 && v <= 60 {
wait = time.Duration(v) * time.Second
}
deadline := time.NewTimer(wait)
defer deadline.Stop()
for {
r.mu.Lock()
r.lastPoll = time.Now()
r.requeueStale()
if len(r.queue) > 0 {
p := r.queue[0]
r.queue = r.queue[1:]
p.takenAt = time.Now()
r.taken[p.turn.TurnID] = p
r.mu.Unlock()
writeJSON(w, 200, p.turn)
return
}
r.mu.Unlock()
select {
case <-r.notify:
case <-deadline.C:
w.WriteHeader(204)
return
case <-req.Context().Done():
return
}
}
}
// ReplyHandler is POST /external/reply with a Reply body; 404 when the turn is unknown (already answered, dropped
// after its deadline, or never taken through /external/next).
func (r *Responder) ReplyHandler(w http.ResponseWriter, req *http.Request) {
var rep Reply
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 1<<20)).Decode(&rep); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
if rep.Error == "" {
n := 0
for _, it := range rep.Output {
switch it.Type {
case "function_call":
if it.Name == "" {
writeJSON(w, 400, map[string]string{"error": "function_call without name"})
return
}
if a := argString(it.Arguments); !json.Valid([]byte(a)) || !strings.HasPrefix(strings.TrimSpace(a), "{") {
writeJSON(w, 400, map[string]string{"error": "function_call arguments must be a JSON object"})
return
}
n++
case "message":
n++
default:
writeJSON(w, 400, map[string]string{"error": "output item type must be function_call or message"})
return
}
}
if n == 0 {
writeJSON(w, 400, map[string]string{"error": "output must hold at least one function_call or message"})
return
}
}
r.mu.Lock()
p, ok := r.taken[rep.TurnID]
if ok && subtle.ConstantTimeCompare([]byte(p.turn.ReplyToken), []byte(rep.ReplyToken)) != 1 {
r.mu.Unlock()
writeJSON(w, 403, map[string]string{"error": "reply_token does not match turn " + rep.TurnID})
return
}
if ok {
delete(r.taken, rep.TurnID)
}
r.mu.Unlock()
if !ok {
writeJSON(w, 404, map[string]string{"error": "unknown or expired turn_id " + rep.TurnID})
return
}
p.reply <- rep
writeJSON(w, 200, map[string]any{"ok": true, "turn_id": rep.TurnID})
}
// Status is shown by /selfcheck.
func (r *Responder) Status() map[string]any {
r.mu.Lock()
defer r.mu.Unlock()
out := map[string]any{"queued": len(r.queue), "awaiting_reply": len(r.taken), "turn_timeout_s": int(r.TurnTimeout.Seconds()), "requeue_after_s": int(r.RequeueAfter.Seconds())}
if !r.lastPoll.IsZero() {
out["last_poll_age_s"] = int(time.Since(r.lastPoll).Seconds())
}
if !r.lastTurn.IsZero() {
out["last_turn_age_s"] = int(time.Since(r.lastTurn).Seconds())
}
return out
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand: " + err.Error())
}
return hex.EncodeToString(b)
}
// Handler serves the two endpoints; mount it on the dedicated external listener only.
func (r *Responder) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /external/next", r.Next)
mux.HandleFunc("POST /external/reply", r.ReplyHandler)
return mux
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
+222
View File
@@ -0,0 +1,222 @@
package external
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
// Two loop turns through the HTTP surface: the client takes turn 1 (system + user), answers with a tool call,
// then turn 2 (with previous_response_id) must replay the assistant call and the function_call_output and carry
// the forced-submit flag; the reply's arguments may be a JSON string or object.
func TestTurnsOverHTTP(t *testing.T) {
r := New(5 * time.Second)
srv := httptest.NewServer(r.Handler())
defer srv.Close()
tools := []openai.Tool{{Type: "function", Strict: true, Name: "get_probe_status", Parameters: map[string]any{"type": "object"}}}
type res struct {
resp *openai.Response
err error
}
ch := make(chan res, 1)
go func() {
resp, err := r.CreateResponse(context.Background(), openai.Request{Model: "external-claude-mythos-5-1", Tools: tools, ToolChoice: "auto", MaxOutputTokens: 4000,
Input: []openai.Item{{Type: "message", Role: "system", Content: "SYS"}, {Type: "message", Role: "user", Content: "alert"}}})
ch <- res{resp, err}
}()
turn := poll(t, srv.URL)
if !strings.HasPrefix(turn.TurnID, "ext_") || len(turn.TurnID) != 20 || len(turn.ReplyToken) != 32 || turn.Session != "" || turn.Turn != 1 || turn.System != "SYS" || len(turn.Input) != 1 || turn.Input[0].Content != "alert" || len(turn.Tools) != 1 || turn.ForceSubmit || turn.MaxOutputTokens != 4000 {
t.Fatalf("turn 1: %+v", turn)
}
// second poll while nothing is queued -> 204 within the wait
if code := get(t, srv.URL+"/external/next?wait=0"); code != 204 {
t.Fatalf("empty queue must answer 204, got %d", code)
}
// a reply without the turn's token is refused (the turn stays open)
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"wrong","output":[{"type":"message","text":"x"}]}`); code != 403 {
t.Fatalf("wrong token must be 403, got %d", code)
}
code, body := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"`+turn.ReplyToken+`","output":[{"type":"message","text":"checking"},{"type":"function_call","name":"get_probe_status","arguments":"{}"}]}`)
if code != 200 {
t.Fatalf("reply 1: %d %s", code, body)
}
r1 := <-ch
if r1.err != nil {
t.Fatal(r1.err)
}
fcs := r1.resp.FunctionCalls()
if r1.resp.ID != turn.TurnID || len(fcs) != 1 || fcs[0].Name != "get_probe_status" || fcs[0].Arguments != "{}" || fcs[0].CallID == "" || r1.resp.Usage.TotalTokens != 0 {
t.Fatalf("response 1: %+v", r1.resp)
}
if !strings.Contains(r1.resp.Text(), "checking") {
t.Fatalf("text not carried: %q", r1.resp.Text())
}
go func() {
resp, err := r.CreateResponse(context.Background(), openai.Request{Model: "external-claude-mythos-5-1", Tools: tools, PreviousResponseID: turn.TurnID,
ToolChoice: map[string]any{"type": "function", "name": "submit_diagnosis"},
Input: []openai.Item{{Type: "function_call_output", CallID: fcs[0].CallID, Output: `{"alb":1}`}}})
ch <- res{resp, err}
}()
turn2 := poll(t, srv.URL)
if turn2.TurnID == turn.TurnID || turn2.Session != turn.TurnID || turn2.Turn != 2 || turn2.System != "SYS" || !turn2.ForceSubmit {
t.Fatalf("turn 2 header: %+v", turn2)
}
// history: user alert / assistant text / assistant function_call / function_call_output
if len(turn2.Input) != 4 || turn2.Input[2].Type != "function_call" || turn2.Input[2].CallID != fcs[0].CallID || turn2.Input[3].Type != "function_call_output" || turn2.Input[3].Output != `{"alb":1}` {
t.Fatalf("turn 2 history: %s", mustJSON(turn2.Input))
}
code, body = post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn2.TurnID+`","reply_token":"`+turn2.ReplyToken+`","output":[{"type":"function_call","name":"submit_diagnosis","arguments":{"root_cause_label":"x","confidence":0.9}}]}`)
if code != 200 {
t.Fatalf("reply 2: %d %s", code, body)
}
r2 := <-ch
if r2.err != nil {
t.Fatal(r2.err)
}
if got := r2.resp.FunctionCalls(); len(got) != 1 || got[0].Name != "submit_diagnosis" || !strings.Contains(got[0].Arguments, `"root_cause_label":"x"`) {
t.Fatalf("response 2: %+v", r2.resp.Output)
}
// an answered turn cannot be answered twice; malformed replies are refused before touching the queue
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn2.TurnID+`","reply_token":"`+turn2.ReplyToken+`","output":[{"type":"message","text":"again"}]}`); code != 404 {
t.Fatalf("second reply must be 404, got %d", code)
}
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"ext_9","output":[{"type":"function_call","name":"x","arguments":"[1]"}]}`); code != 400 {
t.Fatalf("non-object arguments must be 400, got %d", code)
}
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"ext_9","output":[]}`); code != 400 {
t.Fatalf("empty output must be 400, got %d", code)
}
st := r.Status()
if st["queued"] != 0 || st["awaiting_reply"] != 0 {
t.Fatalf("status: %v", st)
}
}
// Deadlines: the diagnosis context ending drops the queued turn with an error; a client "error" reply surfaces as an
// error; an unknown previous_response_id is refused.
func TestDeadlinesAndErrors(t *testing.T) {
r := New(300 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := r.CreateResponse(ctx, openai.Request{Model: "external-x", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}})
if err == nil || !strings.Contains(err.Error(), "not answered before the diagnosis deadline") {
t.Fatalf("expected deadline error, got %v", err)
}
if st := r.Status(); st["queued"] != 0 {
t.Fatalf("dropped turn still queued: %v", st)
}
_, err = r.CreateResponse(context.Background(), openai.Request{Model: "external-x", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}})
if err == nil || !strings.Contains(err.Error(), "not answered within") {
t.Fatalf("expected turn timeout, got %v", err)
}
// error reply
r2 := New(5 * time.Second)
srv := httptest.NewServer(r2.Handler())
defer srv.Close()
ch := make(chan error, 1)
go func() {
_, err := r2.CreateResponse(context.Background(), openai.Request{Model: "external-x", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}})
ch <- err
}()
turn := poll(t, srv.URL)
post(t, srv.URL+"/external/reply", `{"turn_id":"`+turn.TurnID+`","reply_token":"`+turn.ReplyToken+`","error":"gave up"}`)
if err := <-ch; err == nil || !strings.Contains(err.Error(), "gave up") {
t.Fatalf("expected client error, got %v", err)
}
_, err = r2.CreateResponse(context.Background(), openai.Request{Model: "external-x", PreviousResponseID: "nope", Input: []openai.Item{{Type: "function_call_output", CallID: "a", Output: "b"}}})
if err == nil || !strings.Contains(err.Error(), "unknown previous_response_id") {
t.Fatalf("expected unknown id error, got %v", err)
}
}
// A taken turn whose reply never comes is handed out again after RequeueAfter with a new token; the old token is
// refused and the new one completes the turn.
func TestRequeueUnansweredTurn(t *testing.T) {
r := New(10 * time.Second)
r.RequeueAfter = 200 * time.Millisecond
srv := httptest.NewServer(r.Handler())
defer srv.Close()
ch := make(chan error, 1)
go func() {
resp, err := r.CreateResponse(context.Background(), openai.Request{Model: "external-x", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}})
if err == nil && len(resp.FunctionCalls()) != 1 {
err = fmt.Errorf("unexpected response %+v", resp)
}
ch <- err
}()
first := poll(t, srv.URL) // taken, never answered
if first.Requeued != 0 {
t.Fatalf("fresh turn marked requeued: %+v", first)
}
if code := get(t, srv.URL+"/external/next?wait=0"); code != 204 {
t.Fatalf("taken turn must not be served again at once, got %d", code)
}
time.Sleep(250 * time.Millisecond)
second := poll(t, srv.URL)
if second.TurnID != first.TurnID || second.ReplyToken == first.ReplyToken || second.Requeued != 1 {
t.Fatalf("requeue: first=%+v second=%+v", first, second)
}
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+first.TurnID+`","reply_token":"`+first.ReplyToken+`","output":[{"type":"function_call","name":"x","arguments":{}}]}`); code != 403 {
t.Fatalf("old token must be refused, got %d", code)
}
if code, _ := post(t, srv.URL+"/external/reply", `{"turn_id":"`+second.TurnID+`","reply_token":"`+second.ReplyToken+`","output":[{"type":"function_call","name":"x","arguments":{}}]}`); code != 200 {
t.Fatalf("new token must be accepted, got %d", code)
}
if err := <-ch; err != nil {
t.Fatal(err)
}
}
func poll(t *testing.T, base string) Turn {
t.Helper()
for i := 0; i < 20; i++ {
resp, err := http.Get(base + "/external/next?wait=1")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode == 200 {
var turn Turn
if err := json.NewDecoder(resp.Body).Decode(&turn); err != nil {
t.Fatal(err)
}
resp.Body.Close()
return turn
}
resp.Body.Close()
}
t.Fatal("no turn served")
return Turn{}
}
func get(t *testing.T, u string) int {
t.Helper()
resp, err := http.Get(u)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
func post(t *testing.T, u, body string) (int, string) {
t.Helper()
resp, err := http.Post(u, "application/json", bytes.NewReader([]byte(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var b bytes.Buffer
b.ReadFrom(resp.Body)
return resp.StatusCode, b.String()
}
func mustJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
+21
View File
@@ -57,6 +57,27 @@ type Proposal struct {
Approved bool `json:"approved"` Approved bool `json:"approved"`
} }
// ProposalState is GET /proposals/{token}: the decision as the harness knows it (approval may come through the API).
type ProposalState struct {
Token string `json:"token"`
ActionID string `json:"action_id"`
Approved bool `json:"approved"`
ApprovedAt *string `json:"approved_at"`
ApprovedBy *string `json:"approved_by"`
Executed bool `json:"executed"`
ExecutedAt *string `json:"executed_at"`
ExpiresAt string `json:"expires_at"`
}
// GetProposal reads the harness-side state of a proposal.
func (c *Client) GetProposal(ctx context.Context, token string) (*ProposalState, error) {
var out ProposalState
if _, err := c.call(ctx, "GET", "/proposals/"+token, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// Propose registers a layer-2 proposal (POST /proposals); the harness records the proposal event and returns the token. // Propose registers a layer-2 proposal (POST /proposals); the harness records the proposal event and returns the token.
func (c *Client) Propose(ctx context.Context, runID, scenarioID *string, actionID string, target, diagnosis map[string]any, ttl time.Duration) (*Proposal, error) { func (c *Client) Propose(ctx context.Context, runID, scenarioID *string, actionID string, target, diagnosis map[string]any, ttl time.Duration) (*Proposal, error) {
body := map[string]any{"run_id": runID, "scenario_id": scenarioID, "action_id": actionID, "target": target, "diagnosis": diagnosis} body := map[string]any{"run_id": runID, "scenario_id": scenarioID, "action_id": actionID, "target": target, "diagnosis": diagnosis}
+50
View File
@@ -230,12 +230,62 @@ func (b *Bot) edit(m *rag.HitlMessage, status string, color int, result map[stri
return b.dc.Edit(m.ChannelID, m.MessageID, em, b.buttons(m.Token, true)) return b.dc.Edit(m.ChannelID, m.MessageID, em, b.buttons(m.Token, true))
} }
// syncOne mirrors an approval recorded by the harness (API path, not the button) into the message. Returns true when
// the message was decided here.
func (b *Bot) syncOne(ctx context.Context, m *rag.HitlMessage) bool {
st, err := b.harness.GetProposal(ctx, m.Token)
if err != nil || !st.Approved {
return false
}
// Pending() carries no diagnosis; re-read the full row so the edited embed keeps label, evidence and rationale
// (campaign 5 C-G2, 2026-10-09: the mirrored message showed <nil> for every diagnosis field)
if full, err := b.store.GetHitl(ctx, m.Token); err == nil {
m = full
}
by := "unknown"
if st.ApprovedBy != nil && *st.ApprovedBy != "" {
by = *st.ApprovedBy
}
first, err := b.store.Decide(ctx, m.Token, "approved", by, map[string]any{"via": "harness-api", "executed": st.Executed})
if err != nil || !first {
return false
}
b.disarm(m.Token)
_ = b.edit(m, b.t.StatusApprovedAPI(by, st.Executed), colorApproved, nil)
log.Printf("proposal %s approved outside Discord by %s (executed=%v)", m.Token[len(m.Token)-6:], by, st.Executed)
return true
}
// SyncLoop polls the harness for pending proposals decided outside Discord (campaign 3/4 2026-10-08: approvals through
// POST /proposals/{token}/approve left the message to expire by timer).
func (b *Bot) SyncLoop(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
list, err := b.store.Pending(ctx)
if err != nil {
continue
}
for i := range list {
b.syncOne(ctx, &list[i])
}
}
}
}
func (b *Bot) expire(token string) { func (b *Bot) expire(token string) {
ctx := context.Background() ctx := context.Background()
m, err := b.store.GetHitl(ctx, token) m, err := b.store.GetHitl(ctx, token)
if err != nil { if err != nil {
return return
} }
if b.syncOne(ctx, m) {
return
}
first, err := b.store.Decide(ctx, token, "expired", "timer", nil) first, err := b.store.Decide(ctx, token, "expired", "timer", nil)
if err != nil || !first { if err != nil || !first {
return return
+13
View File
@@ -25,6 +25,7 @@ type Strings struct {
StatusExecFailed func(by, err string) string StatusExecFailed func(by, err string) string
StatusExpiredBefore string StatusExpiredBefore string
StatusNoDecision string StatusNoDecision string
StatusApprovedAPI func(by string, executed bool) string
ExpiryMessage func(actionID, target, runID, scenarioID string) string ExpiryMessage func(actionID, target, runID, scenarioID string) string
EscalationTitle func(label string) string EscalationTitle func(label string) string
EscalationStatus string EscalationStatus string
@@ -48,6 +49,12 @@ var langs = map[string]Strings{
StatusApproveFailed: func(e string) string { return "approval failed: " + e }, StatusApproveFailed: func(e string) string { return "approval failed: " + e },
StatusExecFailed: func(by, e string) string { return "approved by " + by + "; execution failed: " + e }, StatusExecFailed: func(by, e string) string { return "approved by " + by + "; execution failed: " + e },
StatusExpiredBefore: "expired before approval", StatusExpiredBefore: "expired before approval",
StatusApprovedAPI: func(by string, executed bool) string {
if executed {
return "approved through the harness API by " + by + "; executed"
}
return "approved through the harness API by " + by
},
StatusNoDecision: "no decision within the window; moved to layer 3", StatusNoDecision: "no decision within the window; moved to layer 3",
ExpiryMessage: func(a, t, r, s string) string { ExpiryMessage: func(a, t, r, s string) string {
return fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", a, t, r, s) return fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", a, t, r, s)
@@ -72,6 +79,12 @@ var langs = map[string]Strings{
StatusApproveFailed: func(e string) string { return "承認の記録に失敗: " + e }, StatusApproveFailed: func(e string) string { return "承認の記録に失敗: " + e },
StatusExecFailed: func(by, e string) string { return by + " が承認したが実行に失敗: " + e }, StatusExecFailed: func(by, e string) string { return by + " が承認したが実行に失敗: " + e },
StatusExpiredBefore: "承認前に期限切れ", StatusExpiredBefore: "承認前に期限切れ",
StatusApprovedAPI: func(by string, executed bool) string {
if executed {
return by + " がハーネス API で承認、実行済み"
}
return by + " がハーネス API で承認"
},
StatusNoDecision: "期限内に判断がなかったため第3層へ引き継ぎ", StatusNoDecision: "期限内に判断がなかったため第3層へ引き継ぎ",
ExpiryMessage: func(a, t, r, s string) string { ExpiryMessage: func(a, t, r, s string) string {
return fmt.Sprintf("提案 `%s`(対象 `%s`、run %s、シナリオ %s)は承認期限内に判断されませんでした。第3層の対応です。ランブックに従って手動で対処するか、オペレーター用トークンでハーネスの `/actions/execute` を呼び出してください。", a, t, r, s) return fmt.Sprintf("提案 `%s`(対象 `%s`、run %s、シナリオ %s)は承認期限内に判断されませんでした。第3層の対応です。ランブックに従って手動で対処するか、オペレーター用トークンでハーネスの `/actions/execute` を呼び出してください。", a, t, r, s)
+2
View File
@@ -127,6 +127,8 @@ func (a *App) authorize(ctx context.Context, req ActionRequest) (string, error)
return "", errors.New("invalid token") return "", errors.New("invalid token")
} }
switch { switch {
case p.RejectedAt != nil:
return "", errors.New("proposal rejected")
case p.ApprovedAt == nil: case p.ApprovedAt == nil:
return "", errors.New("proposal not approved") return "", errors.New("proposal not approved")
case p.ExecutedAt != nil: case p.ExecutedAt != nil:
+97 -3
View File
@@ -9,6 +9,8 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"sort"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -22,10 +24,41 @@ type AlertRouter struct {
app *App app *App
mu sync.Mutex mu sync.Mutex
timers map[string]*time.Timer // fingerprint -> escalation timer timers map[string]*time.Timer // fingerprint -> escalation timer
firing map[string]string // fingerprint -> alertname while Grafana reports it firing (judge: no recovery with an open alert)
// alertname+scenario -> when the last layer-2 diagnosis was dispatched. Grafana emits one alert per label set
// (ApiMemoryPressure: one per pod) and resends the group 30 s later; campaign 4 (2026-10-08) turned one fault into
// four diagnoses and four proposals. Inside cfg.AlertDedupeS the later alerts are recorded and skipped.
lastDiag map[string]time.Time
} }
func NewAlertRouter(a *App) *AlertRouter { func NewAlertRouter(a *App) *AlertRouter {
return &AlertRouter{app: a, timers: map[string]*time.Timer{}} return &AlertRouter{app: a, timers: map[string]*time.Timer{}, firing: map[string]string{}, lastDiag: map[string]time.Time{}}
}
// Firing lists the alert names currently firing (deduplicated, sorted).
func (r *AlertRouter) Firing() []string {
r.mu.Lock()
defer r.mu.Unlock()
seen := map[string]bool{}
var out []string
for _, n := range r.firing {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
sort.Strings(out)
return out
}
func (r *AlertRouter) setFiring(fp, name string, on bool) {
r.mu.Lock()
defer r.mu.Unlock()
if on {
r.firing[fp] = name
} else {
delete(r.firing, fp)
}
} }
// Grafana webhook payload (https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) // Grafana webhook payload (https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/)
@@ -80,8 +113,12 @@ func (r *AlertRouter) handleWebhook(w http.ResponseWriter, req *http.Request) {
"startsAt": al.StartsAt, "fingerprint": al.Fingerprint, "layer": al.Labels["layer"], "signature": sig} "startsAt": al.StartsAt, "fingerprint": al.Fingerprint, "layer": al.Labels["layer"], "signature": sig}
r.app.store.Event(bg, runID, sid, "alert", "grafana", payload) r.app.store.Event(bg, runID, sid, "alert", "grafana", payload)
handled++ handled++
// layer-1 signature alerts are informational (the platform owns the recovery); they must not hold the judge
// (campaign 3, 2026-10-08: LegacyUnhealthy resolved 8.5 minutes after the container was back, V-K1 MTTR 631 s)
r.setFiring(al.Fingerprint, name, al.Status == "firing" && sig == "")
if al.Status != "firing" { if al.Status != "firing" {
r.cancelTimer(al.Fingerprint) r.cancelTimer(al.Fingerprint)
r.clearDedupe(name, sid)
continue continue
} }
if sig != "" { if sig != "" {
@@ -98,11 +135,53 @@ func (r *AlertRouter) handleWebhook(w http.ResponseWriter, req *http.Request) {
r.startEscalation(al.Fingerprint, payload, runID, sid) r.startEscalation(al.Fingerprint, payload, runID, sid)
continue continue
} }
if since, dup := r.dedupe(name, sid); dup {
r.app.store.Event(bg, runID, sid, "note", "runner", map[string]any{"phase": "dedupe", "alertname": name, "fingerprint": al.Fingerprint,
"since_last_diagnosis_s": since.Seconds(), "note": "same alert name already under diagnosis; no second /diagnose"})
continue
}
go r.diagnose(payload, runID, sid) go r.diagnose(payload, runID, sid)
} }
writeJSON(w, 200, map[string]any{"received": handled, "run_id": runID, "scenario_id": sid}) writeJSON(w, 200, map[string]any{"received": handled, "run_id": runID, "scenario_id": sid})
} }
// dedupe reports whether a layer-2 diagnosis for this alert name (within the scenario) was dispatched less than
// cfg.AlertDedupeS ago; when not, it records now as the dispatch time.
func (r *AlertRouter) dedupe(name string, sid *string) (time.Duration, bool) {
if r.app.cfg.AlertDedupeS <= 0 {
return 0, false
}
key := name
if sid != nil {
key += "|" + *sid
}
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if t, ok := r.lastDiag[key]; ok && now.Sub(t) < time.Duration(r.app.cfg.AlertDedupeS)*time.Second {
return now.Sub(t), true
}
r.lastDiag[key] = now
return 0, false
}
// clearDedupe forgets the dispatch time once no alert of that name is firing any more, so the next firing starts a
// new diagnosis even inside the window.
func (r *AlertRouter) clearDedupe(name string, sid *string) {
key := name
if sid != nil {
key += "|" + *sid
}
r.mu.Lock()
defer r.mu.Unlock()
for _, n := range r.firing {
if n == name {
return
}
}
delete(r.lastDiag, key)
}
func targetFromLabels(l map[string]string) map[string]any { func targetFromLabels(l map[string]string) map[string]any {
t := map[string]any{} t := map[string]any{}
for _, k := range []string{"namespace", "pod", "deployment", "node", "instance"} { for _, k := range []string{"namespace", "pod", "deployment", "node", "instance"} {
@@ -148,11 +227,26 @@ func (r *AlertRouter) cancelTimer(fp string) {
} }
} }
// diagnoseTimeout bounds one /diagnose call: 3 minutes for API models (the AI VM gives up at 170 s); external-*
// models (reference runs answered by a client outside the VM, apps 2026-10-16) get 26 minutes, one more than the AI
// VM's EXTERNAL_DIAGNOSE_TIMEOUT_S default, so the VM's own error reaches the diagnosis event first.
func diagnoseTimeout(model string) time.Duration {
if strings.HasPrefix(model, "external-") {
return 26 * time.Minute
}
return 3 * time.Minute
}
// diagnose asks the AI VM (design 6.3 strict schema). Until WP-12 the endpoint is absent; the failure is recorded as an event. // diagnose asks the AI VM (design 6.3 strict schema). Until WP-12 the endpoint is absent; the failure is recorded as an event.
func (r *AlertRouter) diagnose(alert map[string]any, runID, sid *string) { func (r *AlertRouter) diagnose(alert map[string]any, runID, sid *string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
reqBody := map[string]any{"alert": alert, "run_id": runID, "scenario_id": sid} reqBody := map[string]any{"alert": alert, "run_id": runID, "scenario_id": sid}
model := ""
if cur := r.app.runs.current(); cur != nil && runID != nil && cur.RunID == *runID && cur.Model != "" {
model = cur.Model
reqBody["model"] = model // campaign-level model selection (the AI VM falls back to its default when absent)
}
ctx, cancel := context.WithTimeout(context.Background(), diagnoseTimeout(model))
defer cancel()
b, _ := json.Marshal(reqBody) b, _ := json.Marshal(reqBody)
req, _ := http.NewRequestWithContext(ctx, "POST", r.app.cfg.AIDiagnoseURL, bytes.NewReader(b)) req, _ := http.NewRequestWithContext(ctx, "POST", r.app.cfg.AIDiagnoseURL, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"testing"
"time"
)
// one fault, several alerts with the same name (per-pod label sets, group resend): one diagnosis inside the window
func TestDedupeSameAlertName(t *testing.T) {
r := NewAlertRouter(&App{cfg: Config{AlertDedupeS: 300}})
sid := "C-G2"
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("first alert must be diagnosed")
}
if _, dup := r.dedupe("ApiMemoryPressure", &sid); !dup {
t.Fatal("second alert inside the window must be skipped")
}
other := "V-G1"
if _, dup := r.dedupe("ApiMemoryPressure", &other); dup {
t.Fatal("another scenario is a new fault")
}
// all alerts of that name resolved -> the next firing starts a new diagnosis
r.setFiring("fp1", "ApiMemoryPressure", true)
r.clearDedupe("ApiMemoryPressure", &sid) // still firing: keep
if _, dup := r.dedupe("ApiMemoryPressure", &sid); !dup {
t.Fatal("still firing: keep the window")
}
r.setFiring("fp1", "ApiMemoryPressure", false)
r.clearDedupe("ApiMemoryPressure", &sid)
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("after resolve the next firing is a new fault")
}
// window expiry
r.lastDiag["ApiMemoryPressure|"+sid] = time.Now().Add(-301 * time.Second)
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("outside the window a new diagnosis is allowed")
}
// off
r2 := NewAlertRouter(&App{cfg: Config{AlertDedupeS: 0}})
if _, dup := r2.dedupe("X", nil); dup {
t.Fatal("dedupe off")
}
if _, dup := r2.dedupe("X", nil); dup {
t.Fatal("dedupe off")
}
}
// external-* models (reference runs served by a client outside the AI VM) get the long diagnose timeout; the model id
// itself passes the run request bound.
func TestDiagnoseTimeoutByModel(t *testing.T) {
if d := diagnoseTimeout("claude-sonnet-5-5"); d != 3*time.Minute {
t.Fatalf("api model timeout %v", d)
}
if d := diagnoseTimeout(""); d != 3*time.Minute {
t.Fatalf("default model timeout %v", d)
}
if d := diagnoseTimeout("external-claude-mythos-5-1"); d != 26*time.Minute {
t.Fatalf("external model timeout %v", d)
}
if !modelRe.MatchString("external-claude-mythos-5-1") {
t.Fatal("external model id must pass modelRe")
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"time"
)
// autoApprove is the measurement-mode stand-in for the Discord approver (run option auto_approve_after_s, 2026-10-15):
// after the configured delay the harness approves the layer-2 proposal (actor "auto") and executes it exactly as the
// HITL bot does after a button press (actor "ai-approved", MarkProposalExecuted). MTTA therefore equals the delay;
// the verdict logic is unchanged (layer 2 from the ai-approved action). A proposal already decided, expired or
// recorded-only is handled the same way as the API path. The claim (approve + executed_at) is a single conditional
// UPDATE, so a human approval/execution or a rejection that arrives first takes precedence (no double execution).
func (a *App) autoApprove(token string, req ProposalRequest, after time.Duration) {
time.Sleep(after)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
p, err := a.store.GetProposal(ctx, token)
if err != nil {
return
}
// one conditional update claims approval + execution: a human/bot decision (approve, reject, execute) that lands
// first wins and this goroutine does nothing
claimed, err := a.store.ClaimProposal(ctx, token, "auto")
if err != nil || !claimed {
return
}
suffix := token[len(token)-6:]
a.store.Event(ctx, p.RunID, p.ScenarioID, "approval", "auto", map[string]any{"action_id": p.ActionID, "token_suffix": suffix, "auto_approve_after_s": after.Seconds()})
ce := catalogEntry(p.ActionID)
if ce == nil {
return
}
note := "auto-approved by the harness (measurement mode)"
if !ce.Exec {
a.store.Event(ctx, p.RunID, p.ScenarioID, "action", "ai-approved", map[string]any{"action_id": p.ActionID, "target": req.Target, "recorded_only": true, "note": note, "layer": ce.Layer})
mActs.WithLabelValues(p.ActionID, "ai-approved", "recorded").Inc()
return
}
res, err := a.executeAction(ctx, p.ActionID, req.Target, "ai-approved", p.RunID, p.ScenarioID)
result := "ok"
if err != nil {
result = "error"
}
mActs.WithLabelValues(p.ActionID, "ai-approved", result).Inc()
a.store.Event(ctx, p.RunID, p.ScenarioID, "action", "ai-approved", map[string]any{"action_id": p.ActionID, "target": req.Target, "result": res, "error": errStr(err), "note": note})
}
+36
View File
@@ -60,6 +60,9 @@ func (a *App) gcpListInstances(ctx context.Context, prefix string) ([]string, er
// resolveInstance turns "legacy:N" (N-th MIG instance by name) or a pet VM short name (db-primary -> selfheal-db-primary) into an instance. // resolveInstance turns "legacy:N" (N-th MIG instance by name) or a pet VM short name (db-primary -> selfheal-db-primary) into an instance.
func (a *App) resolveInstance(ctx context.Context, ref string) (gceInstance, error) { func (a *App) resolveInstance(ctx context.Context, ref string) (gceInstance, error) {
if ref == "legacy:busy" {
return a.resolveBusyLegacy(ctx)
}
if strings.HasPrefix(ref, "legacy:") { if strings.HasPrefix(ref, "legacy:") {
n, _ := strconv.Atoi(strings.TrimPrefix(ref, "legacy:")) n, _ := strconv.Atoi(strings.TrimPrefix(ref, "legacy:"))
l, err := a.gcpInstances(ctx, "legacy-") l, err := a.gcpInstances(ctx, "legacy-")
@@ -170,3 +173,36 @@ func (a *App) gcpOp(ctx context.Context, op, instance, mig string, size int64) (
func (a *App) region(zone string) string { return zone[:strings.LastIndex(zone, "-")] } func (a *App) region(zone string) string { return zone[:strings.LastIndex(zone, "-")] }
func (a *App) regionOfMIG() string { return env("GCP_REGION", "asia-northeast1") } func (a *App) regionOfMIG() string { return env("GCP_REGION", "asia-northeast1") }
// resolveBusyLegacy picks the MIG instance that served the most legacy-api requests over the last 2 minutes. The ILB
// hashes the api pods' few keep-alive connections onto one instance (campaign 2, 2026-10-08: legacy:0 served 0 rps while
// the delay was on it), so a fault must go where the traffic is. The choice is cached per run+scenario so the revert
// hits the same instance after the traffic has moved away from it.
func (a *App) resolveBusyLegacy(ctx context.Context) (gceInstance, error) {
key := ""
if cur := a.runs.current(); cur != nil {
key = cur.RunID + "/" + cur.ScenarioID
}
if key != "" {
if v, ok := a.busyLegacy.Load(key); ok {
return v.(gceInstance), nil
}
}
name, err := a.mimirTopInstance(ctx, `topk(1, sum by (instance) (rate(legacy_http_requests_total{route!="healthz"}[2m])))`)
if err != nil {
return gceInstance{}, fmt.Errorf("legacy:busy: %w", err)
}
l, err := a.gcpInstances(ctx, "legacy-")
if err != nil {
return gceInstance{}, err
}
for _, i := range l {
if i.Name == name {
if key != "" {
a.busyLegacy.Store(key, i)
}
return i, nil
}
}
return gceInstance{}, fmt.Errorf("legacy:busy: instance %q from Mimir not in the MIG (%d instances)", name, len(l))
}
BIN
View File
Binary file not shown.
+36 -1
View File
@@ -45,6 +45,35 @@ func (a *App) mimirScalar(ctx context.Context, expr string) (float64, error) {
return strconv.ParseFloat(s, 64) return strconv.ParseFloat(s, 64)
} }
// mimirTopInstance runs an instant query and returns the "instance" label of the first sample.
func (a *App) mimirTopInstance(ctx context.Context, expr string) (string, error) {
u := a.cfg.MimirURL + "/api/v1/query?query=" + url.QueryEscape(expr)
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("mimir %s: %s", resp.Status, truncate(string(b), 300))
}
var r struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(b, &r); err != nil {
return "", err
}
if len(r.Data.Result) == 0 || r.Data.Result[0].Metric["instance"] == "" {
return "", fmt.Errorf("no instance sample for %s", expr)
}
return r.Data.Result[0].Metric["instance"], nil
}
// SLOState is one evaluation of experiment design 4.2 (probe 12 consecutive successes = min over 3m; p95 and 5xx ratio over 5m). // SLOState is one evaluation of experiment design 4.2 (probe 12 consecutive successes = min over 3m; p95 and 5xx ratio over 5m).
// Values are nil when the series is absent (JSON has no NaN). // Values are nil when the series is absent (JSON has no NaN).
// 2026-10-08 (campaign 2, C-G1): a 300 ms delay that lasted 3 minutes was declared recovered while still active because the // 2026-10-08 (campaign 2, C-G1): a 300 ms delay that lasted 3 minutes was declared recovered while still active because the
@@ -59,6 +88,8 @@ type SLOState struct {
LatencyOK bool `json:"latency_ok"` LatencyOK bool `json:"latency_ok"`
LatencyNowOK bool `json:"latency_1m_ok"` LatencyNowOK bool `json:"latency_1m_ok"`
ErrorsOK bool `json:"errors_ok"` ErrorsOK bool `json:"errors_ok"`
AlertsFiring []string `json:"alerts_firing,omitempty"` // Grafana alerts received firing and not yet resolved (2026-10-08: grey-zone symptoms live outside the three SLO series)
AlertsOK bool `json:"alerts_ok"`
Recovered bool `json:"recovered"` Recovered bool `json:"recovered"`
MissingData []string `json:"missing_data,omitempty"` MissingData []string `json:"missing_data,omitempty"`
QueryErrors []string `json:"query_errors,omitempty"` QueryErrors []string `json:"query_errors,omitempty"`
@@ -93,6 +124,10 @@ func (a *App) evaluateSLO(ctx context.Context) SLOState {
st.LatencyNowOK = st.P95Now == nil || *st.P95Now < 0.5 st.LatencyNowOK = st.P95Now == nil || *st.P95Now < 0.5
st.ErrorRatio = get("errors", qErrRatio) st.ErrorRatio = get("errors", qErrRatio)
st.ErrorsOK = st.ErrorRatio == nil || *st.ErrorRatio < 0.01 st.ErrorsOK = st.ErrorRatio == nil || *st.ErrorRatio < 0.01
st.Recovered = st.ProbeOK && st.LatencyOK && st.LatencyNowOK && st.ErrorsOK && len(st.QueryErrors) == 0 if a.hooks != nil {
st.AlertsFiring = a.hooks.Firing()
}
st.AlertsOK = len(st.AlertsFiring) == 0
st.Recovered = st.ProbeOK && st.LatencyOK && st.LatencyNowOK && st.ErrorsOK && st.AlertsOK && len(st.QueryErrors) == 0
return st return st
} }
+15
View File
@@ -9,7 +9,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@@ -32,6 +34,7 @@ type Config struct {
DBPassFile string // harness role password on db-primary / db-replica DBPassFile string // harness role password on db-primary / db-replica
HumanTokFile string HumanTokFile string
AIDiagnoseURL string AIDiagnoseURL string
AlertDedupeS int // layer-2 alerts with the same name inside this window share one diagnosis (0 = off)
ScenarioDir string ScenarioDir string
JudgeEvery time.Duration JudgeEvery time.Duration
ObserveMax time.Duration ObserveMax time.Duration
@@ -61,6 +64,7 @@ func loadConfig() Config {
DBPassFile: env("DB_PASSWORD_FILE", "/run/secrets/db-password"), DBPassFile: env("DB_PASSWORD_FILE", "/run/secrets/db-password"),
HumanTokFile: env("HUMAN_TOKEN_FILE", "/run/secrets/harness-token"), HumanTokFile: env("HUMAN_TOKEN_FILE", "/run/secrets/harness-token"),
AIDiagnoseURL: env("AI_DIAGNOSE_URL", "http://10.20.0.12:8080/diagnose"), AIDiagnoseURL: env("AI_DIAGNOSE_URL", "http://10.20.0.12:8080/diagnose"),
AlertDedupeS: envInt("ALERT_DEDUPE_S", 300),
ScenarioDir: env("SCENARIO_DIR", "/etc/harness/scenarios"), ScenarioDir: env("SCENARIO_DIR", "/etc/harness/scenarios"),
JudgeEvery: 30 * time.Second, JudgeEvery: 30 * time.Second,
ObserveMax: 30 * time.Minute, ObserveMax: 30 * time.Minute,
@@ -79,6 +83,7 @@ type App struct {
scen map[string]*Scenario scen map[string]*Scenario
runs *RunManager runs *RunManager
hooks *AlertRouter hooks *AlertRouter
busyLegacy sync.Map // run/scenario -> gceInstance chosen for legacy:busy (gcp.go)
} }
func main() { func main() {
@@ -113,6 +118,7 @@ func main() {
mux.HandleFunc("POST /runs/{id}/abort", app.handleRunAbort) mux.HandleFunc("POST /runs/{id}/abort", app.handleRunAbort)
mux.HandleFunc("POST /hooks/alert", app.hooks.handleWebhook) mux.HandleFunc("POST /hooks/alert", app.hooks.handleWebhook)
mux.HandleFunc("POST /proposals", app.handleProposal) mux.HandleFunc("POST /proposals", app.handleProposal)
mux.HandleFunc("GET /proposals/{token}", app.handleProposalGet)
mux.HandleFunc("POST /proposals/{token}/approve", app.handleProposalApprove) mux.HandleFunc("POST /proposals/{token}/approve", app.handleProposalApprove)
mux.HandleFunc("POST /proposals/{token}/reject", app.handleProposalReject) mux.HandleFunc("POST /proposals/{token}/reject", app.handleProposalReject)
mux.HandleFunc("POST /actions/execute", app.handleActionExecute) mux.HandleFunc("POST /actions/execute", app.handleActionExecute)
@@ -206,3 +212,12 @@ func (a *App) handleScenarios(w http.ResponseWriter, r *http.Request) {
sortScenarios(list) sortScenarios(list)
writeJSON(w, 200, list) writeJSON(w, 200, list)
} }
func envInt(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
+19
View File
@@ -59,9 +59,24 @@ func (a *App) handleProposal(w http.ResponseWriter, r *http.Request) {
_ = a.store.ApproveProposal(r.Context(), token, by) _ = a.store.ApproveProposal(r.Context(), token, by)
a.store.Event(r.Context(), req.RunID, req.ScenarioID, "approval", by, map[string]any{"action_id": req.ActionID, "token_suffix": token[len(token)-6:]}) a.store.Event(r.Context(), req.RunID, req.ScenarioID, "approval", by, map[string]any{"action_id": req.ActionID, "token_suffix": token[len(token)-6:]})
} }
if cur := a.runs.current(); !req.Approved && cur != nil && cur.AutoApproveAfter > 0 && req.RunID != nil && *req.RunID == cur.RunID {
go a.autoApprove(token, req, cur.AutoApproveAfter) // measurement mode (run option auto_approve_after_s)
}
writeJSON(w, 201, map[string]any{"token": token, "expires_in_s": ttl.Seconds(), "approved": req.Approved}) writeJSON(w, 201, map[string]any{"token": token, "expires_in_s": ttl.Seconds(), "approved": req.Approved})
} }
// GET /proposals/{token}: the HITL bot reads the decision state (approval may arrive through this API instead of the
// Discord button; the bot mirrors it into its message instead of letting the message expire, campaign 3/4 2026-10-08).
func (a *App) handleProposalGet(w http.ResponseWriter, r *http.Request) {
p, err := a.store.GetProposal(r.Context(), r.PathValue("token"))
if err != nil {
writeErr(w, 404, errors.New("unknown proposal"))
return
}
writeJSON(w, 200, map[string]any{"token": p.Token, "run_id": p.RunID, "scenario_id": p.ScenarioID, "action_id": p.ActionID,
"approved": p.ApprovedAt != nil, "approved_at": p.ApprovedAt, "approved_by": p.ApprovedBy, "executed": p.ExecutedAt != nil, "executed_at": p.ExecutedAt, "expires_at": p.ExpiresAt})
}
// POST /proposals/{token}/approve: the HITL bot records the human decision (design 6.5 step 4). The proposal was // POST /proposals/{token}/approve: the HITL bot records the human decision (design 6.5 step 4). The proposal was
// registered at diagnosis time, so MTTA = approval - proposal (design 5.4) measures the real waiting time. // registered at diagnosis time, so MTTA = approval - proposal (design 5.4) measures the real waiting time.
func (a *App) handleProposalApprove(w http.ResponseWriter, r *http.Request) { func (a *App) handleProposalApprove(w http.ResponseWriter, r *http.Request) {
@@ -79,6 +94,9 @@ func (a *App) handleProposalApprove(w http.ResponseWriter, r *http.Request) {
return return
} }
switch { switch {
case p.RejectedAt != nil:
writeErr(w, 409, errors.New("proposal rejected"))
return
case p.ApprovedAt != nil: case p.ApprovedAt != nil:
writeErr(w, 409, errors.New("proposal already approved")) writeErr(w, 409, errors.New("proposal already approved"))
return return
@@ -107,6 +125,7 @@ func (a *App) handleProposalReject(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, errors.New("unknown proposal")) writeErr(w, 404, errors.New("unknown proposal"))
return return
} }
_ = a.store.RejectProposal(r.Context(), token) // blocks a later approval (button, API or auto-approval)
a.store.Event(r.Context(), p.RunID, p.ScenarioID, "note", req.RejectedBy, map[string]any{"rejected": true, "action_id": p.ActionID, "reason": req.Reason, "token_suffix": token[len(token)-6:]}) a.store.Event(r.Context(), p.RunID, p.ScenarioID, "note", req.RejectedBy, map[string]any{"rejected": true, "action_id": p.ActionID, "reason": req.Reason, "token_suffix": token[len(token)-6:]})
writeJSON(w, 200, map[string]any{"token": token, "rejected": true}) writeJSON(w, 200, map[string]any{"token": token, "rejected": true})
} }
+33 -5
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -25,6 +26,8 @@ type RunManager struct {
type activeRun struct { type activeRun struct {
RunID string RunID string
Campaign string Campaign string
Model string // LLM for this run's diagnoses ("" = AI VM default); passed to the AI VM on every /diagnose
AutoApproveAfter time.Duration // >0: measurement mode, the harness approves and executes layer-2 proposals after this delay
ScenarioID string // "" between scenarios ScenarioID string // "" between scenarios
Scenario *Scenario Scenario *Scenario
TInject time.Time TInject time.Time
@@ -33,6 +36,7 @@ type activeRun struct {
type RunRequest struct { type RunRequest struct {
Campaign string `json:"campaign"` Campaign string `json:"campaign"`
Model string `json:"model"` // optional: LLM id for the diagnoses (e.g. gpt-6-luna, claude-sonnet-5-5); "" = AI VM default
ScenarioIDs []string `json:"scenario_ids"` ScenarioIDs []string `json:"scenario_ids"`
BaselineS *int `json:"baseline_s"` // default 300 (design 8) BaselineS *int `json:"baseline_s"` // default 300 (design 8)
CooldownS *int `json:"cooldown_s"` // default 300 CooldownS *int `json:"cooldown_s"` // default 300
@@ -42,8 +46,13 @@ type RunRequest struct {
GitSHA string `json:"git_sha"` GitSHA string `json:"git_sha"`
Notes string `json:"notes"` Notes string `json:"notes"`
SkipRevert bool `json:"skip_revert"` SkipRevert bool `json:"skip_revert"`
AutoApproveAfterS *int `json:"auto_approve_after_s"` // measurement mode (model comparison without a human in the loop): nil/0 = off
} }
// modelRe bounds the model id (empty = AI VM default). The AI VM routes by prefix: claude-* -> Anthropic,
// external-* -> external responder (reference runs, longer diagnose timeout in alerts.go), else OpenAI.
var modelRe = regexp.MustCompile(`^[A-Za-z0-9._:-]{0,64}$`)
func NewRunManager(a *App) *RunManager { return &RunManager{app: a} } func NewRunManager(a *App) *RunManager { return &RunManager{app: a} }
func (m *RunManager) current() *activeRun { func (m *RunManager) current() *activeRun {
@@ -78,6 +87,10 @@ func (a *App) handleRunCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if !modelRe.MatchString(req.Model) {
writeErr(w, 400, errors.New("model must match [A-Za-z0-9._:-]{1,64}"))
return
}
m := a.runs m := a.runs
m.mu.Lock() m.mu.Lock()
if m.active != nil { if m.active != nil {
@@ -88,9 +101,13 @@ func (a *App) handleRunCreate(w http.ResponseWriter, r *http.Request) {
} }
runID := newID("run-") runID := newID("run-")
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
m.active = &activeRun{RunID: runID, Campaign: req.Campaign, cancel: cancel} var autoAfter time.Duration
if req.AutoApproveAfterS != nil && *req.AutoApproveAfterS > 0 {
autoAfter = time.Duration(*req.AutoApproveAfterS) * time.Second
}
m.active = &activeRun{RunID: runID, Campaign: req.Campaign, Model: req.Model, AutoApproveAfter: autoAfter, cancel: cancel}
m.mu.Unlock() m.mu.Unlock()
if err := a.store.CreateRun(r.Context(), runID, req.Campaign, req.DriftBefore, req.GitSHA, req.Notes); err != nil { if err := a.store.CreateRun(r.Context(), runID, req.Campaign, req.DriftBefore, req.GitSHA, req.Notes, req.Model, req.AutoApproveAfterS); err != nil {
m.mu.Lock() m.mu.Lock()
m.active = nil m.active = nil
m.mu.Unlock() m.mu.Unlock()
@@ -100,7 +117,7 @@ func (a *App) handleRunCreate(w http.ResponseWriter, r *http.Request) {
} }
mRuns.Set(1) mRuns.Set(1)
go m.execute(ctx, runID, req) go m.execute(ctx, runID, req)
writeJSON(w, 202, map[string]any{"run_id": runID, "scenario_ids": req.ScenarioIDs}) writeJSON(w, 202, map[string]any{"run_id": runID, "scenario_ids": req.ScenarioIDs, "model": req.Model, "auto_approve_after_s": req.AutoApproveAfterS})
} }
func (a *App) handleRunAbort(w http.ResponseWriter, r *http.Request) { func (a *App) handleRunAbort(w http.ResponseWriter, r *http.Request) {
@@ -262,11 +279,22 @@ func (m *RunManager) runScenario(ctx context.Context, runID string, sc *Scenario
aborted := ctx.Err() != nil aborted := ctx.Err() != nil
// revert (always, even on abort, unless the scenario is irreversible by design or skip_revert) // revert (always, even on abort, unless the scenario is irreversible by design or skip_revert)
if !skipRevert && sc.Revert.Method != "none" { if !skipRevert && sc.Revert.Method != "none" {
// a failed revert leaves the fault in place (2026-10-18: one SSH connection reset at the X-M2 revert left a
// netem qdisc on a legacy instance and broke the next 14 runs of two scenarios), so retry a few times
var rres map[string]any
var rerr error
for attempt := 1; attempt <= 3; attempt++ {
rvCtx, cancelRv := context.WithTimeout(bg, 10*time.Minute) rvCtx, cancelRv := context.WithTimeout(bg, 10*time.Minute)
rres, rerr := a.runStep(rvCtx, sc, sc.Revert, runID, params) rres, rerr = a.runStep(rvCtx, sc, sc.Revert, runID, params)
cancelRv() cancelRv()
if rerr == nil {
break
}
a.store.Event(bg, &runID, &sid, "note", "runner", map[string]any{"phase": "revert", "attempt": attempt, "error": rerr.Error(), "result": rres})
time.Sleep(15 * time.Second)
}
if rerr != nil { if rerr != nil {
a.store.Event(bg, &runID, &sid, "note", "runner", map[string]any{"phase": "revert", "error": rerr.Error(), "result": rres}) a.store.Event(bg, &runID, &sid, "note", "runner", map[string]any{"phase": "revert", "error": rerr.Error(), "result": rres, "note": "revert failed after 3 attempts; the fault may still be in place"})
} else { } else {
a.store.Event(bg, &runID, &sid, "note", "runner", map[string]any{"phase": "revert", "result": rres}) a.store.Event(bg, &runID, &sid, "note", "runner", map[string]any{"phase": "revert", "result": rres})
} }
+29 -7
View File
@@ -24,6 +24,9 @@ CREATE TABLE IF NOT EXISTS runs (
status text NOT NULL DEFAULT 'running', status text NOT NULL DEFAULT 'running',
notes text notes text
); );
ALTER TABLE runs ADD COLUMN IF NOT EXISTS model text; -- LLM requested for the run's diagnoses (campaign-level model selection, 2026-10-14)
ALTER TABLE runs ADD COLUMN IF NOT EXISTS auto_approve_after_s int; -- measurement mode: the harness approves + executes layer-2 proposals after this delay (2026-10-15)
ALTER TABLE proposals ADD COLUMN IF NOT EXISTS rejected_at timestamptz; -- POST /proposals/{token}/reject (a rejected proposal can no longer be approved or executed)
CREATE TABLE IF NOT EXISTS scenarios ( CREATE TABLE IF NOT EXISTS scenarios (
scenario_id text PRIMARY KEY, scenario_id text PRIMARY KEY,
path text NOT NULL, path text NOT NULL,
@@ -119,8 +122,8 @@ func (s *Store) UpsertScenarios(ctx context.Context, m map[string]*Scenario) err
return nil return nil
} }
func (s *Store) CreateRun(ctx context.Context, id, campaign string, driftBefore *int, gitSHA, notes string) error { func (s *Store) CreateRun(ctx context.Context, id, campaign string, driftBefore *int, gitSHA, notes, model string, autoApproveAfterS *int) error {
_, err := s.pool.Exec(ctx, `INSERT INTO runs (run_id,campaign,drift_before,git_sha,notes) VALUES ($1,$2,$3,$4,$5)`, id, campaign, driftBefore, nz(gitSHA), nz(notes)) _, err := s.pool.Exec(ctx, `INSERT INTO runs (run_id,campaign,drift_before,git_sha,notes,model,auto_approve_after_s) VALUES ($1,$2,$3,$4,$5,$6,$7)`, id, campaign, driftBefore, nz(gitSHA), nz(notes), nz(model), autoApproveAfterS)
return err return err
} }
@@ -222,10 +225,12 @@ type RunRow struct {
GitSHA *string `json:"git_sha"` GitSHA *string `json:"git_sha"`
Status string `json:"status"` Status string `json:"status"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Model *string `json:"model"` // nil for runs before campaign-level model selection
AutoApprove *int `json:"auto_approve_after_s"` // nil/0 = human approval (Discord); >0 = harness auto-approved layer-2 proposals after this delay
} }
func (s *Store) Runs(ctx context.Context, runID string) ([]RunRow, error) { func (s *Store) Runs(ctx context.Context, runID string) ([]RunRow, error) {
rows, err := s.pool.Query(ctx, `SELECT run_id,campaign,started_at,finished_at,drift_before,drift_after,git_sha,status,notes FROM runs WHERE ($1='' OR run_id=$1) ORDER BY started_at DESC LIMIT 100`, runID) rows, err := s.pool.Query(ctx, `SELECT run_id,campaign,started_at,finished_at,drift_before,drift_after,git_sha,status,notes,model,auto_approve_after_s FROM runs WHERE ($1='' OR run_id=$1) ORDER BY started_at DESC LIMIT 100`, runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -233,7 +238,7 @@ func (s *Store) Runs(ctx context.Context, runID string) ([]RunRow, error) {
var out []RunRow var out []RunRow
for rows.Next() { for rows.Next() {
var r RunRow var r RunRow
if err := rows.Scan(&r.RunID, &r.Campaign, &r.StartedAt, &r.FinishedAt, &r.DriftBefore, &r.DriftAfter, &r.GitSHA, &r.Status, &r.Notes); err != nil { if err := rows.Scan(&r.RunID, &r.Campaign, &r.StartedAt, &r.FinishedAt, &r.DriftBefore, &r.DriftAfter, &r.GitSHA, &r.Status, &r.Notes, &r.Model, &r.AutoApprove); err != nil {
return nil, err return nil, err
} }
out = append(out, r) out = append(out, r)
@@ -283,13 +288,14 @@ type ProposalRow struct {
ApprovedAt *time.Time ApprovedAt *time.Time
ApprovedBy *string ApprovedBy *string
ExecutedAt *time.Time ExecutedAt *time.Time
RejectedAt *time.Time
ExpiresAt time.Time ExpiresAt time.Time
} }
func (s *Store) GetProposal(ctx context.Context, token string) (*ProposalRow, error) { func (s *Store) GetProposal(ctx context.Context, token string) (*ProposalRow, error) {
var p ProposalRow var p ProposalRow
err := s.pool.QueryRow(ctx, `SELECT token,run_id,scenario_id,action_id,approved_at,approved_by,executed_at,expires_at FROM proposals WHERE token=$1`, token). err := s.pool.QueryRow(ctx, `SELECT token,run_id,scenario_id,action_id,approved_at,approved_by,executed_at,rejected_at,expires_at FROM proposals WHERE token=$1`, token).
Scan(&p.Token, &p.RunID, &p.ScenarioID, &p.ActionID, &p.ApprovedAt, &p.ApprovedBy, &p.ExecutedAt, &p.ExpiresAt) Scan(&p.Token, &p.RunID, &p.ScenarioID, &p.ActionID, &p.ApprovedAt, &p.ApprovedBy, &p.ExecutedAt, &p.RejectedAt, &p.ExpiresAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -297,7 +303,23 @@ func (s *Store) GetProposal(ctx context.Context, token string) (*ProposalRow, er
} }
func (s *Store) ApproveProposal(ctx context.Context, token, by string) error { func (s *Store) ApproveProposal(ctx context.Context, token, by string) error {
_, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2 WHERE token=$1 AND approved_at IS NULL`, token, by) _, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2 WHERE token=$1 AND approved_at IS NULL AND rejected_at IS NULL`, token, by)
return err
}
// ClaimProposal approves and marks executed in one conditional update, so exactly one caller (auto-approval or a
// human/bot execute racing with it) wins: it reports whether this call claimed the proposal.
func (s *Store) ClaimProposal(ctx context.Context, token, by string) (bool, error) {
tag, err := s.pool.Exec(ctx, `UPDATE proposals SET approved_at=now(), approved_by=$2, executed_at=now()
WHERE token=$1 AND approved_at IS NULL AND executed_at IS NULL AND rejected_at IS NULL AND expires_at > now()`, token, by)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Store) RejectProposal(ctx context.Context, token string) error {
_, err := s.pool.Exec(ctx, `UPDATE proposals SET rejected_at=now() WHERE token=$1 AND approved_at IS NULL AND rejected_at IS NULL`, token)
return err return err
} }