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
93
internal/router/router_test.go
Normal file
93
internal/router/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue