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.
This commit is contained in:
parent
47ab86eef9
commit
3e20085204
18 changed files with 2819 additions and 22 deletions
86
internal/secutools/routing.go
Normal file
86
internal/secutools/routing.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue