claude-failover/internal/secutools/routing.go
Ubuntu 3e20085204 feat(phase2-E): multi-provider routing via secutools delegation
Adds optional delegation of agent-queue tasks to the SecuAAS secutools
AI platform (GPU / Gemini / Claude API) instead of dispatching to a
local Claude Code tmux session. Per-task opt-in via YAML frontmatter
fields preferred_ai, allow_delegation, complexity_hint — absence keeps
the Phase 1 behaviour exactly (zero breaking change).

Go side:
- internal/secutools: HTTP client with exponential-backoff retries
  (SubmitJob/GetJob/WaitForResult), DecideProvider map adapter for CLI
  use, table tests.
- internal/router: struct-typed Decide() with strict precedence
  (needs_claude_code > preferred_ai=claude-code > allow_delegation=false
  > preferred_ai > fail-safe local on unknown).
- internal/delegation: Manager submits jobs, writes .md.delegated
  markers for on-restart recovery, runs a periodic reaper that moves
  completed jobs into done/ with provider/cost footer and failed jobs
  into failed/.
- internal/dispatcher: WithDelegation() opt-in, routeTask hook before
  findFreeSession, skips .md.delegated in assignNextTask.
- internal/api: /api/delegated/status (active jobs + counters),
  /watchdog/status extended with delegation counters.
- cmd/ccl-delegate: small CLI exposing submit/get/result/decide so the
  bash dispatcher can call the same contract without duplicating logic.
- cmd/claude-failover: delegation wired opt-in via SECUTOOLS_API_KEY.

Tests:
- 29+ new unit tests across router, secutools, delegation, dispatcher,
  api packages. go test -race -count=1 clean.
- tests/phase2-E-integration.sh: bash end-to-end against a Python
  stdlib mock HTTP server, exercising the dev-management scripts.

Forward-compat with watchdog (Phase 1 B1 already ignores
state=delegated_to_secutools) so delegated tasks aren't flagged stale.
2026-04-17 02:17:19 +00:00

86 lines
2.3 KiB
Go

package secutools
import (
"fmt"
"strings"
)
// DecideProvider inspects a frontmatter map (as decoded from YAML) and
// returns the provider string that should handle the task. Valid return
// values:
//
// - "local" — no delegation, dispatch on a Claude Code session (Phase 1)
// - "claude-code" — explicit local dispatch (alias of "local")
// - "gpu" — delegate to secutools with preferred_ai=gpu
// - "gemini" — delegate to secutools with preferred_ai=gemini
// - "claude-api" — delegate to secutools with preferred_ai=claude-api
// - "auto" — delegate to secutools, let smart_triage choose
//
// Precedence:
// 1. needs_claude_code: true → "local"
// 2. preferred_ai in {claude-code} → "local"
// 3. allow_delegation == false / missing → "local"
// 4. preferred_ai in {gpu,gemini,claude-api} → that provider
// 5. preferred_ai in {"", auto} → "auto"
// 6. unknown preferred_ai → "local" (fail-safe)
//
// This function is intentionally permissive on input types: YAML booleans
// may decode as bool, strings as string. It coerces common forms and
// returns "local" on malformed input rather than panicking.
func DecideProvider(fm map[string]any) string {
if fm == nil {
return "local"
}
if asBool(fm["needs_claude_code"]) {
return "local"
}
pref := strings.ToLower(strings.TrimSpace(asString(fm["preferred_ai"])))
if pref == "claude-code" || pref == "local" {
return "local"
}
if !asBool(fm["allow_delegation"]) {
return "local"
}
switch pref {
case "gpu", "gemini", "claude-api":
return pref
case "", "auto":
return "auto"
default:
// Unknown provider — fail safe to local.
return "local"
}
}
// asBool accepts bool, "true"/"false", "1"/"0". Defaults to false.
func asBool(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
s := strings.ToLower(strings.TrimSpace(t))
return s == "true" || s == "1" || s == "yes"
case int:
return t != 0
default:
return false
}
}
// asString coerces v to a trimmed string. Returns "" for nil/unknown types.
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
case nil:
return ""
default:
return fmt.Sprintf("%v", t)
}
}