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:
Ubuntu 2026-04-17 02:17:19 +00:00
parent 47ab86eef9
commit 3e20085204
18 changed files with 2819 additions and 22 deletions

95
internal/router/router.go Normal file
View file

@ -0,0 +1,95 @@
// Package router decides whether a task should run on a local Claude Code
// session (current Phase 1 behaviour) or be delegated to the centralized
// SecuAAS secutools AI platform (Phase 2 chantier E).
//
// The decision is driven entirely by the task's YAML frontmatter; no
// network call is performed in Decide.
package router
import "strings"
// Provider is the destination chosen for a task.
type Provider string
const (
// ProviderClaudeCode means dispatch to a local ccl-auto tmux session
// running Claude Code. This is the Phase 1 behaviour.
ProviderClaudeCode Provider = "claude-code"
// ProviderAuto means delegate to secutools and let its smart_triage
// router pick the actual backend (GPU > Claude > Gemini fallback chain).
ProviderAuto Provider = "auto"
// ProviderGPU pins delegation to the in-cluster vLLM GPU pool.
ProviderGPU Provider = "gpu"
// ProviderGemini pins delegation to Google Gemini via secutools.
ProviderGemini Provider = "gemini"
// ProviderClaudeAPI pins delegation to the Anthropic API (NOT Claude
// Code locally — this means stateless API calls billed to the secutools
// account).
ProviderClaudeAPI Provider = "claude-api"
)
// IsDelegated reports whether p means "submit to secutools" (i.e. not the
// local Claude Code path).
func (p Provider) IsDelegated() bool {
switch p {
case ProviderAuto, ProviderGPU, ProviderGemini, ProviderClaudeAPI:
return true
default:
return false
}
}
// Decision is the output of Decide: which provider to use, plus a short
// human-readable reason for logging and the /api/delegated/status endpoint.
type Decision struct {
Provider Provider
Reason string
}
// Task is the slice of frontmatter fields the router cares about. It is
// intentionally narrower than the dispatcher's full TaskFrontmatter so that
// router tests don't need to import the dispatcher.
type Task struct {
PreferredAI string // auto | claude-code | gpu | gemini | claude-api (case-insensitive)
AllowDelegation bool // default false → backward-compatible (Claude Code)
NeedsClaudeCode bool // legacy bypass — forces ProviderClaudeCode
ComplexityHint string // low | medium | high (informational only for now)
}
// Decide returns the routing decision for the given task.
//
// Order of precedence:
// 1. needs_claude_code: true → ProviderClaudeCode (legacy bypass, never delegate)
// 2. preferred_ai: claude-code → ProviderClaudeCode (explicit)
// 3. allow_delegation: false → ProviderClaudeCode (default safety net)
// 4. preferred_ai parses to a delegated provider → that provider
// 5. fallback → ProviderAuto (let secutools smart_triage decide)
func Decide(t Task) Decision {
if t.NeedsClaudeCode {
return Decision{ProviderClaudeCode, "needs_claude_code=true"}
}
pref := strings.ToLower(strings.TrimSpace(t.PreferredAI))
if pref == string(ProviderClaudeCode) {
return Decision{ProviderClaudeCode, "preferred_ai=claude-code"}
}
if !t.AllowDelegation {
return Decision{ProviderClaudeCode, "allow_delegation=false (default)"}
}
switch Provider(pref) {
case ProviderGPU, ProviderGemini, ProviderClaudeAPI:
return Decision{Provider(pref), "preferred_ai=" + pref}
case ProviderAuto, "":
return Decision{ProviderAuto, "preferred_ai=auto (smart_triage)"}
default:
// Unknown value: fail safe to local Claude Code so a typo doesn't
// silently route real work to GPU.
return Decision{ProviderClaudeCode, "unknown preferred_ai=" + pref + " → fail-safe"}
}
}

View file

@ -0,0 +1,93 @@
package router
import "testing"
// TestDecide_BackwardCompatible: a vanilla task (no new fields) must keep
// going through Claude Code. This is the contract that protects every
// existing inbox/*.md file.
func TestDecide_BackwardCompatible(t *testing.T) {
d := Decide(Task{})
if d.Provider != ProviderClaudeCode {
t.Fatalf("default task must route to Claude Code, got %v (%s)", d.Provider, d.Reason)
}
}
func TestDecide_NeedsClaudeCodeWinsOverEverything(t *testing.T) {
d := Decide(Task{
NeedsClaudeCode: true,
AllowDelegation: true,
PreferredAI: "gpu",
})
if d.Provider != ProviderClaudeCode {
t.Errorf("needs_claude_code must take precedence, got %v (%s)", d.Provider, d.Reason)
}
}
func TestDecide_ExplicitClaudeCode(t *testing.T) {
d := Decide(Task{PreferredAI: "claude-code", AllowDelegation: true})
if d.Provider != ProviderClaudeCode {
t.Errorf("explicit claude-code must route locally, got %v", d.Provider)
}
}
func TestDecide_AllowDelegationFalseBlocksDelegation(t *testing.T) {
d := Decide(Task{PreferredAI: "gpu", AllowDelegation: false})
if d.Provider != ProviderClaudeCode {
t.Errorf("allow_delegation=false must override preferred_ai, got %v (%s)",
d.Provider, d.Reason)
}
}
func TestDecide_DelegatedProviders(t *testing.T) {
cases := []struct {
pref string
want Provider
}{
{"gpu", ProviderGPU},
{"GPU", ProviderGPU},
{"gemini", ProviderGemini},
{"claude-api", ProviderClaudeAPI},
}
for _, c := range cases {
d := Decide(Task{PreferredAI: c.pref, AllowDelegation: true})
if d.Provider != c.want {
t.Errorf("preferred_ai=%q want %v, got %v (%s)",
c.pref, c.want, d.Provider, d.Reason)
}
if !d.Provider.IsDelegated() {
t.Errorf("preferred_ai=%q should be delegated", c.pref)
}
}
}
func TestDecide_AutoMeansSecutoolsTriage(t *testing.T) {
for _, pref := range []string{"auto", ""} {
d := Decide(Task{PreferredAI: pref, AllowDelegation: true})
if d.Provider != ProviderAuto {
t.Errorf("preferred_ai=%q want ProviderAuto, got %v", pref, d.Provider)
}
}
}
func TestDecide_UnknownProviderFailsSafe(t *testing.T) {
d := Decide(Task{PreferredAI: "claude-3-mystery", AllowDelegation: true})
if d.Provider != ProviderClaudeCode {
t.Errorf("unknown provider must fail-safe to Claude Code, got %v (%s)",
d.Provider, d.Reason)
}
}
func TestProvider_IsDelegated(t *testing.T) {
cases := map[Provider]bool{
ProviderClaudeCode: false,
ProviderAuto: true,
ProviderGPU: true,
ProviderGemini: true,
ProviderClaudeAPI: true,
}
for p, want := range cases {
if got := p.IsDelegated(); got != want {
t.Errorf("%v.IsDelegated() = %v, want %v", p, got, want)
}
}
}