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
267
cmd/ccl-delegate/main.go
Normal file
267
cmd/ccl-delegate/main.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// ccl-delegate is a thin CLI wrapper around internal/secutools. It exists
|
||||
// because the dispatcher that scans agent-queue inboxes is written in
|
||||
// bash (dev-management/agent-orchestrator/dispatcher.sh). Rather than
|
||||
// duplicate the HTTP contract in bash + curl + jq, we shell out to this
|
||||
// binary for every delegation action.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// ccl-delegate submit --prompt=... [--preferred-ai=gpu] [--priority=default]
|
||||
// Prints JSON to stdout: {"job_id": "...", "status": "pending"}.
|
||||
//
|
||||
// ccl-delegate get --job-id=xyz
|
||||
// Prints JSON to stdout: {"job_id":"...","status":"...","provider":"..."}
|
||||
//
|
||||
// ccl-delegate result --job-id=xyz [--timeout=5m]
|
||||
// Waits for completion (timeout-bounded) and prints result JSON.
|
||||
//
|
||||
// Auth: reads CCL_SECUTOOLS_API_KEY first, then SECUTOOLS_API_KEY. URL
|
||||
// override via CCL_SECUTOOLS_URL (default https://api.secutools.secuaas.ovh),
|
||||
// or CCL_SECUTOOLS_MOCK_URL for tests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/secutools"
|
||||
)
|
||||
|
||||
const defaultURL = "https://api.secutools.secuaas.ovh"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
sub := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch sub {
|
||||
case "submit":
|
||||
runSubmit(args)
|
||||
case "get":
|
||||
runGet(args)
|
||||
case "result":
|
||||
runResult(args)
|
||||
case "decide":
|
||||
runDecide(args)
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
os.Exit(0)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "ccl-delegate: unknown subcommand %q\n", sub)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `ccl-delegate — submit/poll secutools jobs from bash
|
||||
|
||||
usage:
|
||||
ccl-delegate submit --prompt=... [--preferred-ai=gpu] [--priority=default] [--source=...]
|
||||
ccl-delegate get --job-id=xyz
|
||||
ccl-delegate result --job-id=xyz [--timeout=5m]
|
||||
ccl-delegate decide --frontmatter=path/to/task.md
|
||||
|
||||
env:
|
||||
CCL_SECUTOOLS_API_KEY (preferred) or SECUTOOLS_API_KEY
|
||||
CCL_SECUTOOLS_URL (default https://api.secutools.secuaas.ovh)
|
||||
CCL_SECUTOOLS_MOCK_URL (overrides CCL_SECUTOOLS_URL, for tests)
|
||||
`)
|
||||
}
|
||||
|
||||
func newClient() *secutools.HTTPClient {
|
||||
url := os.Getenv("CCL_SECUTOOLS_MOCK_URL")
|
||||
if url == "" {
|
||||
url = os.Getenv("CCL_SECUTOOLS_URL")
|
||||
}
|
||||
if url == "" {
|
||||
url = defaultURL
|
||||
}
|
||||
key := os.Getenv("CCL_SECUTOOLS_API_KEY")
|
||||
if key == "" {
|
||||
key = os.Getenv("SECUTOOLS_API_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "ccl-delegate: missing CCL_SECUTOOLS_API_KEY / SECUTOOLS_API_KEY")
|
||||
os.Exit(4)
|
||||
}
|
||||
return secutools.NewHTTPClient(url, key, nil)
|
||||
}
|
||||
|
||||
func runSubmit(args []string) {
|
||||
fs := flag.NewFlagSet("submit", flag.ExitOnError)
|
||||
prompt := fs.String("prompt", "", "prompt text (required)")
|
||||
preferred := fs.String("preferred-ai", "", "preferred provider (gpu|gemini|claude-api|claude-opus|claude-sonnet|claude-haiku|auto)")
|
||||
priority := fs.String("priority", "default", "priority (critical|high|default|low)")
|
||||
jobType := fs.String("type", string(secutools.TypeAnalyze), "job type (ai:analyze|ai:batch|ai:report|ai:correlate)")
|
||||
source := fs.String("source", "ccl-delegate", "source identifier")
|
||||
maxTokens := fs.Int("max-tokens", 0, "max tokens (0 = server default)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *prompt == "" {
|
||||
fmt.Fprintln(os.Stderr, "ccl-delegate submit: --prompt is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
c := newClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.SubmitJob(ctx, &secutools.JobRequest{
|
||||
Type: secutools.JobType(*jobType),
|
||||
Priority: secutools.Priority(*priority),
|
||||
Prompt: *prompt,
|
||||
PreferredAI: *preferred,
|
||||
Source: *source,
|
||||
MaxTokens: *maxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "submit: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writeJSON(resp)
|
||||
}
|
||||
|
||||
func runGet(args []string) {
|
||||
fs := flag.NewFlagSet("get", flag.ExitOnError)
|
||||
id := fs.String("job-id", "", "job id (required)")
|
||||
_ = fs.Parse(args)
|
||||
if *id == "" {
|
||||
fmt.Fprintln(os.Stderr, "ccl-delegate get: --job-id required")
|
||||
os.Exit(2)
|
||||
}
|
||||
c := newClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
st, err := c.GetJob(ctx, *id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "get: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writeJSON(st)
|
||||
}
|
||||
|
||||
func runResult(args []string) {
|
||||
fs := flag.NewFlagSet("result", flag.ExitOnError)
|
||||
id := fs.String("job-id", "", "job id (required)")
|
||||
timeout := fs.Duration("timeout", 5*time.Minute, "wait timeout")
|
||||
_ = fs.Parse(args)
|
||||
if *id == "" {
|
||||
fmt.Fprintln(os.Stderr, "ccl-delegate result: --job-id required")
|
||||
os.Exit(2)
|
||||
}
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
res, err := c.WaitForResult(ctx, *id, *timeout)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "result: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writeJSON(res)
|
||||
}
|
||||
|
||||
func runDecide(args []string) {
|
||||
fs := flag.NewFlagSet("decide", flag.ExitOnError)
|
||||
frontmatterPath := fs.String("frontmatter", "", "path to task .md with YAML frontmatter")
|
||||
_ = fs.Parse(args)
|
||||
if *frontmatterPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "ccl-delegate decide: --frontmatter required")
|
||||
os.Exit(2)
|
||||
}
|
||||
data, err := os.ReadFile(*frontmatterPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "decide: read %s: %v\n", *frontmatterPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fm := parseFrontmatterMap(data)
|
||||
fmt.Println(secutools.DecideProvider(fm))
|
||||
}
|
||||
|
||||
// parseFrontmatterMap extracts a flat key/value map from a YAML frontmatter
|
||||
// block delimited by "---". Only top-level scalars; nested structures are
|
||||
// ignored. Intentionally dependency-free (no yaml unmarshal) so this CLI
|
||||
// stays tiny and predictable.
|
||||
func parseFrontmatterMap(content []byte) map[string]any {
|
||||
out := map[string]any{}
|
||||
s := string(content)
|
||||
if len(s) < 4 || s[:4] != "---\n" {
|
||||
return out
|
||||
}
|
||||
end := -1
|
||||
for i := 4; i < len(s)-3; i++ {
|
||||
if s[i] == '\n' && s[i+1:i+4] == "---" && (i+4 == len(s) || s[i+4] == '\n') {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
body := s[4:end]
|
||||
start := 0
|
||||
for i := 0; i <= len(body); i++ {
|
||||
if i == len(body) || body[i] == '\n' {
|
||||
line := body[start:i]
|
||||
start = i + 1
|
||||
// find "key: value"
|
||||
colon := -1
|
||||
for j := 0; j < len(line); j++ {
|
||||
if line[j] == ':' {
|
||||
colon = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if colon <= 0 {
|
||||
continue
|
||||
}
|
||||
key := trim(line[:colon])
|
||||
val := trim(line[colon+1:])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
// strip surrounding quotes
|
||||
if len(val) >= 2 &&
|
||||
((val[0] == '"' && val[len(val)-1] == '"') ||
|
||||
(val[0] == '\'' && val[len(val)-1] == '\'')) {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
// bool coercion
|
||||
switch val {
|
||||
case "true":
|
||||
out[key] = true
|
||||
case "false":
|
||||
out[key] = false
|
||||
default:
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trim(s string) string {
|
||||
start := 0
|
||||
for start < len(s) && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
end := len(s)
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func writeJSON(v any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,18 +12,20 @@ import (
|
|||
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/api"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/delegation"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/dispatcher"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/janitor"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/lifecycle"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/notify"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/quota"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/secutools"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/switcher"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/tmux"
|
||||
"forge.secuaas.ovh/olivier/claude-failover/internal/watcher"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
const version = "0.4.0"
|
||||
|
||||
func main() {
|
||||
var cfgPath string
|
||||
|
|
@ -84,8 +86,37 @@ func main() {
|
|||
as := switcher.New(tmuxClient, s, cfg, qm.SwitchChan(), notifier)
|
||||
go as.Run(ctx)
|
||||
|
||||
// Phase 2 chantier E — delegation to secutools (GPU/Gemini/Claude API).
|
||||
// Disabled when the SECUTOOLS_API_KEY env var is empty so existing
|
||||
// deployments keep their Phase 1 behaviour with zero config change.
|
||||
var delegMgr *delegation.Manager
|
||||
if key := os.Getenv("SECUTOOLS_API_KEY"); key != "" {
|
||||
url := os.Getenv("SECUTOOLS_URL")
|
||||
if url == "" {
|
||||
url = "https://api.secutools.secuaas.ovh"
|
||||
}
|
||||
client := secutools.NewHTTPClient(url, key, nil)
|
||||
delegMgr = delegation.New(client, 30*time.Second)
|
||||
|
||||
// Restore in-flight markers from disk after a restart.
|
||||
var projectDirs []string
|
||||
for _, ds := range cfg.Pool.Dedicated {
|
||||
projectDirs = append(projectDirs, ds.Project)
|
||||
}
|
||||
if err := delegMgr.LoadFromDisk(projectDirs); err != nil {
|
||||
log.Printf("delegation LoadFromDisk warning: %v", err)
|
||||
}
|
||||
go delegMgr.Run(ctx)
|
||||
log.Printf("delegation enabled: secutools=%s", url)
|
||||
} else {
|
||||
log.Printf("delegation disabled: SECUTOOLS_API_KEY unset (Phase 1 behaviour)")
|
||||
}
|
||||
|
||||
// Dispatcher — assigns inbox tasks to idle sessions.
|
||||
disp := dispatcher.New(tmuxClient, s, cfg, sw.DoneChan())
|
||||
if delegMgr != nil {
|
||||
disp.WithDelegation(delegMgr)
|
||||
}
|
||||
go disp.Run(ctx)
|
||||
|
||||
// Janitor — periodic cleanup of orphaned files and stale status.json.
|
||||
|
|
@ -112,6 +143,9 @@ func main() {
|
|||
listenAddr = "127.0.0.1:9090"
|
||||
}
|
||||
srv := api.New(listenAddr, s)
|
||||
if delegMgr != nil {
|
||||
srv.WithDelegation(delegMgr)
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Start(); err != nil {
|
||||
log.Printf("API server error: %v", err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue