Files
foxhunt/docs/plans/2026-02-27-autonomous-agents-implementation.md
2026-02-27 08:20:56 +01:00

58 KiB

Autonomous CI Agents — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Deploy an autonomous agent system that detects CI failures and @agent mentions, then runs a 5-phase fix loop (Diagnose → Plan → Implement → Verify → Deliver) using OpenHands with LLM routing through LiteLLM.

Architecture: Rust Axum dispatcher receives GitLab webhooks, creates/labels issues, and invokes OpenHands headless with phase-specific microagents. LiteLLM proxy routes to Opus 4.6 (diagnose/plan), Sonnet 4.6 (implement), and Scaleway devstral-2-123b (verify/deliver). GitLab issues are the durable bus — labels track state, comments carry handoff context.

Tech Stack: Rust (Axum, reqwest, tokio), LiteLLM, OpenHands, GitLab CE API, Kubernetes (Kapsule)

Design Doc: docs/plans/2026-02-27-autonomous-agents-design.md


Phase 0: LiteLLM Proxy

Deploy LiteLLM on Kapsule ci pool, verify all 3 model routes work.


Task 1: Create K8s namespace and secrets

Files:

  • Create: infra/k8s/agents/namespace.yaml
  • Create: infra/k8s/agents/secrets.yaml

Step 1: Write namespace manifest

# infra/k8s/agents/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: foxhunt-agents
  labels:
    app.kubernetes.io/part-of: foxhunt
    app.kubernetes.io/component: agents

Step 2: Write secrets manifest (template — real values via kubectl create secret)

# infra/k8s/agents/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: agent-credentials
  namespace: foxhunt-agents
type: Opaque
stringData:
  ANTHROPIC_API_KEY: "REPLACE_VIA_KUBECTL"
  SCW_GENERATIVE_API_KEY: "REPLACE_VIA_KUBECTL"
  GITLAB_TOKEN: "REPLACE_VIA_KUBECTL"

Step 3: Apply namespace

Run: kubectl apply -f infra/k8s/agents/namespace.yaml Expected: namespace/foxhunt-agents created

Step 4: Create real secret (not from template)

Run: kubectl create secret generic agent-credentials -n foxhunt-agents --from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" --from-literal=SCW_GENERATIVE_API_KEY="$SCW_GENERATIVE_API_KEY" --from-literal=GITLAB_TOKEN="$GITLAB_TOKEN" --dry-run=client -o yaml | kubectl apply -f - Expected: secret/agent-credentials created

Step 5: Commit

git add -f infra/k8s/agents/namespace.yaml infra/k8s/agents/secrets.yaml
git commit -m "infra(agents): add foxhunt-agents namespace and secrets template"

Task 2: Deploy LiteLLM proxy

Files:

  • Create: infra/k8s/agents/litellm-config.yaml
  • Create: infra/k8s/agents/litellm.yaml

Step 1: Write LiteLLM config

# infra/k8s/agents/litellm-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: litellm-config
  namespace: foxhunt-agents
data:
  config.yaml: |
    model_list:
      - model_name: "opus"
        litellm_params:
          model: "claude-opus-4-6"
          api_key: "os.environ/ANTHROPIC_API_KEY"
      - model_name: "sonnet"
        litellm_params:
          model: "claude-sonnet-4-6"
          api_key: "os.environ/ANTHROPIC_API_KEY"
      - model_name: "devstral"
        litellm_params:
          model: "openai/devstral-2-123b-instruct-2512"
          api_base: "https://api.scaleway.ai/v1"
          api_key: "os.environ/SCW_GENERATIVE_API_KEY"

    litellm_settings:
      max_budget: 50.0
      budget_duration: "1d"

Step 2: Write LiteLLM deployment + service

# infra/k8s/agents/litellm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: litellm
  namespace: foxhunt-agents
  labels:
    app: litellm
spec:
  replicas: 1
  selector:
    matchLabels:
      app: litellm
  template:
    metadata:
      labels:
        app: litellm
    spec:
      nodeSelector:
        pool: ci
      containers:
        - name: litellm
          image: ghcr.io/berriai/litellm:main-latest
          args: ["--config", "/etc/litellm/config.yaml"]
          ports:
            - containerPort: 4000
          envFrom:
            - secretRef:
                name: agent-credentials
          volumeMounts:
            - name: config
              mountPath: /etc/litellm
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 4000
            initialDelaySeconds: 10
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 4000
            initialDelaySeconds: 15
            periodSeconds: 20
      volumes:
        - name: config
          configMap:
            name: litellm-config
---
apiVersion: v1
kind: Service
metadata:
  name: litellm
  namespace: foxhunt-agents
spec:
  selector:
    app: litellm
  ports:
    - port: 4000
      targetPort: 4000

Step 3: Apply

Run: kubectl apply -f infra/k8s/agents/litellm-config.yaml -f infra/k8s/agents/litellm.yaml Expected: configmap/litellm-config created, deployment.apps/litellm created, service/litellm created

Step 4: Wait for ready

Run: kubectl rollout status deployment/litellm -n foxhunt-agents --timeout=120s Expected: deployment "litellm" successfully rolled out

Step 5: Commit

git add -f infra/k8s/agents/litellm-config.yaml infra/k8s/agents/litellm.yaml
git commit -m "infra(agents): deploy LiteLLM proxy with 3-model routing"

Task 3: Smoke-test LiteLLM model routing

Step 1: Port-forward LiteLLM

Run: kubectl port-forward -n foxhunt-agents svc/litellm 4000:4000 &

Step 2: Test Opus route

Run:

curl -s http://localhost:4000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"opus","messages":[{"role":"user","content":"Say OK"}],"max_tokens":5}' \
  | jq '.choices[0].message.content'

Expected: A short response (confirms Anthropic API key works)

Step 3: Test Sonnet route

Run:

curl -s http://localhost:4000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"sonnet","messages":[{"role":"user","content":"Say OK"}],"max_tokens":5}' \
  | jq '.choices[0].message.content'

Expected: A short response

Step 4: Test Devstral route

Run:

curl -s http://localhost:4000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"devstral","messages":[{"role":"user","content":"Say OK"}],"max_tokens":5}' \
  | jq '.choices[0].message.content'

Expected: A short response (confirms SCW Generative API key works)

Step 5: Kill port-forward

Run: kill %1

No commit needed — this is a manual verification step.


Phase 1: Rust Dispatcher — Webhook Receiver + Issue Creator

Scaffold the Axum service that receives GitLab webhooks and creates issues.


Task 4: Scaffold dispatcher crate

Files:

  • Create: services/agent_dispatcher/Cargo.toml
  • Create: services/agent_dispatcher/src/main.rs
  • Modify: Cargo.toml (root — add workspace member)

Step 1: Create Cargo.toml

# services/agent_dispatcher/Cargo.toml
[package]
name = "agent_dispatcher"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true

[[bin]]
name = "agent-dispatcher"
path = "src/main.rs"

[dependencies]
axum.workspace = true
tokio.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
reqwest = { workspace = true, features = ["json"] }
tracing.workspace = true
tracing-subscriber.workspace = true
tower-http = { version = "0.6", features = ["cors", "trace"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"

Step 2: Add to workspace members in root Cargo.toml

Add "services/agent_dispatcher" to the members list.

Step 3: Write minimal main.rs with health endpoint

// services/agent_dispatcher/src/main.rs
use axum::{Router, routing::get};
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let app = Router::new()
        .route("/health", get(|| async { "ok" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .expect("failed to bind port 8080");

    tracing::info!("dispatcher listening on :8080");
    axum::serve(listener, app).await.expect("server error");
}

Step 4: Verify it compiles

Run: SQLX_OFFLINE=true cargo check -p agent_dispatcher Expected: zero errors

Step 5: Commit

git add services/agent_dispatcher/Cargo.toml services/agent_dispatcher/src/main.rs Cargo.toml
git commit -m "feat(agent-dispatcher): scaffold Axum service with health endpoint"

Task 5: GitLab webhook types and signature verification

Files:

  • Create: services/agent_dispatcher/src/gitlab.rs
  • Create: services/agent_dispatcher/src/webhook.rs

Step 1: Write the failing test (webhook signature verification)

// services/agent_dispatcher/src/webhook.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_verify_valid_signature() {
        let secret = "test-secret";
        let body = b"hello world";
        // Pre-computed HMAC-SHA256 of "hello world" with key "test-secret"
        let sig = compute_hmac(secret.as_bytes(), body);
        assert!(verify_signature(secret, &sig, body));
    }

    #[test]
    fn test_reject_invalid_signature() {
        assert!(!verify_signature("secret", "bad-sig", b"body"));
    }
}

Step 2: Run test to verify it fails

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- webhook Expected: FAIL (functions not defined)

Step 3: Implement webhook signature verification and GitLab types

webhook.rs — HMAC-SHA256 verification for X-Gitlab-Token header:

// services/agent_dispatcher/src/webhook.rs
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

pub fn compute_hmac(key: &[u8], body: &[u8]) -> String {
    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key");
    mac.update(body);
    hex::encode(mac.finalize().into_bytes())
}

pub fn verify_signature(secret: &str, signature: &str, body: &[u8]) -> bool {
    let expected = compute_hmac(secret.as_bytes(), body);
    // Constant-time comparison
    expected.len() == signature.len()
        && expected
            .bytes()
            .zip(signature.bytes())
            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
            == 0
}

gitlab.rs — webhook payload types:

// services/agent_dispatcher/src/gitlab.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct PipelineWebhook {
    pub object_kind: String,
    pub object_attributes: PipelineAttributes,
    pub project: Project,
    pub builds: Option<Vec<Build>>,
}

#[derive(Debug, Deserialize)]
pub struct PipelineAttributes {
    pub id: u64,
    pub status: String,
    #[serde(rename = "ref")]
    pub ref_name: String,
    pub sha: String,
}

#[derive(Debug, Deserialize)]
pub struct NoteWebhook {
    pub object_kind: String,
    pub object_attributes: NoteAttributes,
    pub project: Project,
    pub issue: Option<IssueRef>,
}

#[derive(Debug, Deserialize)]
pub struct NoteAttributes {
    pub id: u64,
    pub note: String,
    pub noteable_type: String,
}

#[derive(Debug, Deserialize)]
pub struct Project {
    pub id: u64,
    pub name: String,
    pub web_url: String,
}

#[derive(Debug, Deserialize)]
pub struct Build {
    pub id: u64,
    pub name: String,
    pub status: String,
    pub stage: String,
}

#[derive(Debug, Deserialize)]
pub struct IssueRef {
    pub iid: u64,
    pub title: String,
}

Step 4: Run tests to verify they pass

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- webhook Expected: 2 tests PASS

Step 5: Commit

git add services/agent_dispatcher/src/webhook.rs services/agent_dispatcher/src/gitlab.rs
git commit -m "feat(agent-dispatcher): webhook signature verification + GitLab types"

Task 6: GitLab API client

Files:

  • Create: services/agent_dispatcher/src/gitlab_client.rs

Step 1: Write the failing test

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_client_builds_issue_body() {
        let body = format_ci_failure_issue(
            "check",
            "cargo check --workspace",
            "error[E0433]: failed to resolve",
            "abc1234",
            "main",
        );
        assert!(body.contains("## CI Failure"));
        assert!(body.contains("error[E0433]"));
        assert!(body.contains("abc1234"));
    }
}

Step 2: Run test to verify it fails

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- gitlab_client Expected: FAIL

Step 3: Implement GitLab API client

// services/agent_dispatcher/src/gitlab_client.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Clone)]
pub struct GitLabClient {
    client: Client,
    base_url: String,
    token: String,
    project_id: u64,
}

#[derive(Serialize)]
struct CreateIssueRequest {
    title: String,
    description: String,
    labels: String,
}

#[derive(Serialize)]
struct CreateCommentRequest {
    body: String,
}

#[derive(Serialize)]
struct UpdateLabelsRequest {
    labels: String,
}

#[derive(Deserialize)]
pub struct Issue {
    pub iid: u64,
    pub title: String,
    pub labels: Vec<String>,
}

impl GitLabClient {
    pub fn new(base_url: String, token: String, project_id: u64) -> Self {
        Self {
            client: Client::new(),
            base_url,
            token,
            project_id,
        }
    }

    pub async fn fetch_job_log(&self, job_id: u64) -> Result<String, reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/jobs/{}/trace",
            self.base_url, self.project_id, job_id
        );
        self.client
            .get(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .send()
            .await?
            .text()
            .await
    }

    pub async fn create_issue(
        &self,
        title: &str,
        description: &str,
        labels: &[&str],
    ) -> Result<Issue, reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/issues",
            self.base_url, self.project_id
        );
        self.client
            .post(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&CreateIssueRequest {
                title: title.to_string(),
                description: description.to_string(),
                labels: labels.join(","),
            })
            .send()
            .await?
            .json()
            .await
    }

    pub async fn add_comment(
        &self,
        issue_iid: u64,
        body: &str,
    ) -> Result<(), reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/issues/{}/notes",
            self.base_url, self.project_id, issue_iid
        );
        self.client
            .post(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&CreateCommentRequest {
                body: body.to_string(),
            })
            .send()
            .await?
            .error_for_status()?;
        Ok(())
    }

    pub async fn update_labels(
        &self,
        issue_iid: u64,
        labels: &[&str],
    ) -> Result<(), reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/issues/{}",
            self.base_url, self.project_id, issue_iid
        );
        self.client
            .put(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&UpdateLabelsRequest {
                labels: labels.join(","),
            })
            .send()
            .await?
            .error_for_status()?;
        Ok(())
    }

    pub async fn get_issue_labels(&self, issue_iid: u64) -> Result<Vec<String>, reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/issues/{}",
            self.base_url, self.project_id, issue_iid
        );
        let issue: Issue = self.client
            .get(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .send()
            .await?
            .json()
            .await?;
        Ok(issue.labels)
    }
}

pub fn format_ci_failure_issue(
    stage: &str,
    job_name: &str,
    log_excerpt: &str,
    sha: &str,
    ref_name: &str,
) -> String {
    // Truncate log to last 200 lines if needed
    let lines: Vec<&str> = log_excerpt.lines().collect();
    let truncated = if lines.len() > 200 {
        lines[lines.len() - 200..].join("\n")
    } else {
        log_excerpt.to_string()
    };

    format!(
        "## CI Failure: `{job_name}` in stage `{stage}`\n\n\
         **Commit**: `{sha}`\n\
         **Branch**: `{ref_name}`\n\n\
         ### Log Excerpt\n\n\
         ```\n{truncated}\n```\n\n\
         ---\n\
         *Auto-created by agent dispatcher. Agent will diagnose shortly.*"
    )
}

Step 4: Run test to verify it passes

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- gitlab_client Expected: PASS

Step 5: Commit

git add services/agent_dispatcher/src/gitlab_client.rs
git commit -m "feat(agent-dispatcher): GitLab API client (issues, comments, labels, job logs)"

Task 7: Webhook handler + pipeline failure flow

Files:

  • Modify: services/agent_dispatcher/src/main.rs
  • Create: services/agent_dispatcher/src/config.rs
  • Create: services/agent_dispatcher/src/handler.rs

Step 1: Write config module

// services/agent_dispatcher/src/config.rs
#[derive(Clone)]
pub struct AppConfig {
    pub gitlab_base_url: String,
    pub gitlab_token: String,
    pub gitlab_project_id: u64,
    pub webhook_secret: String,
    pub max_concurrent_agents: usize,
    pub max_retries: u8,
}

impl AppConfig {
    pub fn from_env() -> Self {
        Self {
            gitlab_base_url: std::env::var("GITLAB_BASE_URL")
                .unwrap_or_else(|_| "https://git.fxhnt.ai".to_string()),
            gitlab_token: std::env::var("GITLAB_TOKEN")
                .expect("GITLAB_TOKEN required"),
            gitlab_project_id: std::env::var("GITLAB_PROJECT_ID")
                .expect("GITLAB_PROJECT_ID required")
                .parse()
                .expect("GITLAB_PROJECT_ID must be u64"),
            webhook_secret: std::env::var("WEBHOOK_SECRET")
                .unwrap_or_default(),
            max_concurrent_agents: std::env::var("MAX_CONCURRENT_AGENTS")
                .unwrap_or_else(|_| "2".to_string())
                .parse()
                .unwrap_or(2),
            max_retries: 3,
        }
    }
}

Step 2: Write the failing test (handler routes webhook to correct flow)

// services/agent_dispatcher/src/handler.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_failed_jobs() {
        let builds = vec![
            Build { id: 1, name: "check".to_string(), status: "success".to_string(), stage: "check".to_string() },
            Build { id: 2, name: "test-ml".to_string(), status: "failed".to_string(), stage: "test".to_string() },
            Build { id: 3, name: "test-risk".to_string(), status: "failed".to_string(), stage: "test".to_string() },
        ];
        let failed = extract_failed_jobs(&builds);
        assert_eq!(failed.len(), 2);
        assert_eq!(failed[0].name, "test-ml");
    }

    #[test]
    fn test_detect_agent_mention() {
        assert!(contains_agent_mention("Please fix this @agent"));
        assert!(contains_agent_mention("@agent can you look at this?"));
        assert!(!contains_agent_mention("No mention here"));
    }
}

Step 3: Run tests to verify they fail

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- handler Expected: FAIL

Step 4: Implement handler

// services/agent_dispatcher/src/handler.rs
use axum::{
    body::Bytes,
    extract::State,
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
};
use std::sync::Arc;
use tokio::sync::Semaphore;

use crate::config::AppConfig;
use crate::gitlab::{Build, NoteWebhook, PipelineWebhook};
use crate::gitlab_client::{format_ci_failure_issue, GitLabClient};
use crate::webhook::verify_signature;

pub struct AppState {
    pub config: AppConfig,
    pub gitlab: GitLabClient,
    pub concurrency: Arc<Semaphore>,
}

pub fn extract_failed_jobs(builds: &[Build]) -> Vec<&Build> {
    builds.iter().filter(|b| b.status == "failed").collect()
}

pub fn contains_agent_mention(text: &str) -> bool {
    text.contains("@agent")
}

pub async fn handle_webhook(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    body: Bytes,
) -> impl IntoResponse {
    // Verify signature if secret is configured
    if !state.config.webhook_secret.is_empty() {
        let sig = headers
            .get("X-Gitlab-Token")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");
        if sig != state.config.webhook_secret {
            return StatusCode::UNAUTHORIZED;
        }
    }

    let event = headers
        .get("X-Gitlab-Event")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    match event {
        "Pipeline Hook" => {
            if let Ok(payload) = serde_json::from_slice::<PipelineWebhook>(&body) {
                if payload.object_attributes.status == "failed" {
                    let state = Arc::clone(&state);
                    tokio::spawn(async move {
                        if let Err(e) = handle_pipeline_failure(state, payload).await {
                            tracing::error!("pipeline failure handler error: {e}");
                        }
                    });
                }
            }
            StatusCode::OK
        }
        "Note Hook" => {
            if let Ok(payload) = serde_json::from_slice::<NoteWebhook>(&body) {
                if contains_agent_mention(&payload.object_attributes.note) {
                    let state = Arc::clone(&state);
                    tokio::spawn(async move {
                        if let Err(e) = handle_agent_mention(state, payload).await {
                            tracing::error!("agent mention handler error: {e}");
                        }
                    });
                }
            }
            StatusCode::OK
        }
        _ => StatusCode::OK,
    }
}

async fn handle_pipeline_failure(
    state: Arc<AppState>,
    payload: PipelineWebhook,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let failed = payload.builds.as_deref().map(extract_failed_jobs).unwrap_or_default();
    if failed.is_empty() {
        return Ok(());
    }

    // Fetch log from first failed job
    let job = failed[0];
    let log = state.gitlab.fetch_job_log(job.id).await.unwrap_or_default();

    let title = format!(
        "[CI] {} failed on {}",
        job.name, payload.object_attributes.ref_name
    );
    let body = format_ci_failure_issue(
        &job.stage,
        &job.name,
        &log,
        &payload.object_attributes.sha,
        &payload.object_attributes.ref_name,
    );

    let labels = &["trigger/ci-failure", "phase/diagnose", "agent/active"];
    let issue = state.gitlab.create_issue(&title, &body, labels).await?;

    tracing::info!(
        issue_iid = issue.iid,
        job_name = job.name,
        "created agent issue for CI failure"
    );

    // TODO Phase 2: invoke OpenHands diagnose agent here
    Ok(())
}

async fn handle_agent_mention(
    state: Arc<AppState>,
    payload: NoteWebhook,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let issue = match payload.issue {
        Some(issue) => issue,
        None => return Ok(()), // Only handle issue comments
    };

    let labels = &["trigger/feature", "phase/diagnose", "agent/active"];
    state.gitlab.update_labels(issue.iid, labels).await?;

    tracing::info!(
        issue_iid = issue.iid,
        "activated agent for @agent mention"
    );

    // TODO Phase 2: invoke OpenHands diagnose agent here
    Ok(())
}

Step 5: Wire up main.rs

// services/agent_dispatcher/src/main.rs
mod config;
mod gitlab;
mod gitlab_client;
mod handler;
mod webhook;

use std::sync::Arc;

use axum::{Router, routing::{get, post}};
use tokio::sync::Semaphore;
use tracing_subscriber::EnvFilter;

use config::AppConfig;
use gitlab_client::GitLabClient;
use handler::{AppState, handle_webhook};

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let config = AppConfig::from_env();
    let gitlab = GitLabClient::new(
        config.gitlab_base_url.clone(),
        config.gitlab_token.clone(),
        config.gitlab_project_id,
    );
    let concurrency = Arc::new(Semaphore::new(config.max_concurrent_agents));

    let state = Arc::new(AppState {
        config,
        gitlab,
        concurrency,
    });

    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/webhook", post(handle_webhook))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .expect("failed to bind port 8080");

    tracing::info!("dispatcher listening on :8080");
    axum::serve(listener, app).await.expect("server error");
}

Step 6: Run tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib Expected: all tests PASS

Step 7: Run check

Run: SQLX_OFFLINE=true cargo check -p agent_dispatcher Expected: zero errors

Step 8: Commit

git add services/agent_dispatcher/src/
git commit -m "feat(agent-dispatcher): webhook handler with pipeline failure → issue creation"

Task 8: Deploy dispatcher to Kapsule

Files:

  • Create: infra/k8s/agents/dispatcher.yaml

Step 1: Write deployment manifest

# infra/k8s/agents/dispatcher.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dispatcher
  namespace: foxhunt-agents
  labels:
    app: dispatcher
spec:
  replicas: 1
  selector:
    matchLabels:
      app: dispatcher
  template:
    metadata:
      labels:
        app: dispatcher
    spec:
      nodeSelector:
        pool: ci
      containers:
        - name: dispatcher
          image: rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:latest
          ports:
            - containerPort: 8080
          env:
            - name: GITLAB_BASE_URL
              value: "https://git.fxhnt.ai"
            - name: GITLAB_PROJECT_ID
              value: "1"  # Adjust to actual project ID
            - name: WEBHOOK_SECRET
              valueFrom:
                secretKeyRef:
                  name: agent-credentials
                  key: GITLAB_TOKEN
            - name: MAX_CONCURRENT_AGENTS
              value: "2"
            - name: RUST_LOG
              value: "info"
          envFrom:
            - secretRef:
                name: agent-credentials
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: dispatcher
  namespace: foxhunt-agents
spec:
  selector:
    app: dispatcher
  ports:
    - port: 8080
      targetPort: 8080

Step 2: Create Dockerfile

# infra/docker/Dockerfile.agent-dispatcher
FROM rust:1.89-bookworm AS builder
RUN apt-get update && apt-get install -y mold clang
WORKDIR /build
COPY . .
RUN SQLX_OFFLINE=true cargo build --release -p agent_dispatcher
RUN strip target/release/agent-dispatcher

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/agent-dispatcher /usr/local/bin/
ENTRYPOINT ["agent-dispatcher"]

Step 3: Commit

git add -f infra/k8s/agents/dispatcher.yaml infra/docker/Dockerfile.agent-dispatcher
git commit -m "infra(agents): dispatcher deployment manifest + Dockerfile"

Step 4: Build and push (manual)

Run: docker build -f infra/docker/Dockerfile.agent-dispatcher -t rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:latest . && docker push rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:latest

Step 5: Deploy

Run: kubectl apply -f infra/k8s/agents/dispatcher.yaml


Task 9: Configure GitLab webhook

Step 1: Create labels via GitLab API

Run:

for label in "agent/active:#28a745" "agent/blocked:#dc3545" "agent/done:#6c757d" \
  "phase/diagnose:#007bff" "phase/plan:#6f42c1" "phase/implement:#fd7e14" \
  "phase/verify:#ffc107" "phase/deliver:#28a745" \
  "trigger/ci-failure:#dc3545" "trigger/feature:#17a2b8" \
  "retry/1:#6c757d" "retry/2:#6c757d" "retry/3:#6c757d"; do
  name="${label%%:*}"
  color="${label#*:}"
  curl -s --request POST "https://git.fxhnt.ai/api/v4/projects/1/labels" \
    --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
    --data "name=$name&color=$color"
done

Step 2: Register webhook in GitLab

Run:

curl -s --request POST "https://git.fxhnt.ai/api/v4/projects/1/hooks" \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --data "url=http://dispatcher.foxhunt-agents.svc.cluster.local:8080/webhook" \
  --data "pipeline_events=true" \
  --data "note_events=true" \
  --data "token=$WEBHOOK_SECRET"

Expected: 201 Created with hook ID

No commit — this is GitLab configuration.


Phase 2: OpenHands + Diagnose Agent

Deploy OpenHands with K8s runtime and implement the first microagent.


Task 10: OpenHands RBAC and config

Files:

  • Create: infra/k8s/agents/openhands-rbac.yaml
  • Create: infra/k8s/agents/openhands-config.yaml

Step 1: Write RBAC for OpenHands pod scheduling

# infra/k8s/agents/openhands-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: openhands-agent
  namespace: foxhunt-agents
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: openhands-pod-manager
  namespace: foxhunt-agents
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log", "pods/exec"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["persistentvolumeclaims"]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: openhands-pod-manager
  namespace: foxhunt-agents
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: openhands-pod-manager
subjects:
  - kind: ServiceAccount
    name: openhands-agent
    namespace: foxhunt-agents

Step 2: Write OpenHands config

# infra/k8s/agents/openhands-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: openhands-config
  namespace: foxhunt-agents
data:
  config.toml: |
    [core]
    workspace_base = "/tmp/openhands-workspace"
    max_iterations = 50

    [llm]
    model = "opus"
    base_url = "http://litellm.foxhunt-agents.svc.cluster.local:4000/v1"
    api_key = "via-litellm"

    [sandbox]
    timeout = 300
    enable_gpu = false

    [kubernetes]
    namespace = "foxhunt-agents"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: agent-workspace
  namespace: foxhunt-agents
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: scw-bssd
  resources:
    requests:
      storage: 10Gi

Step 3: Apply

Run: kubectl apply -f infra/k8s/agents/openhands-rbac.yaml -f infra/k8s/agents/openhands-config.yaml

Step 4: Commit

git add -f infra/k8s/agents/openhands-rbac.yaml infra/k8s/agents/openhands-config.yaml
git commit -m "infra(agents): OpenHands RBAC, config, and workspace PVC"

Task 11: Deploy OpenHands server

Files:

  • Create: infra/k8s/agents/openhands.yaml

Step 1: Write OpenHands deployment

# infra/k8s/agents/openhands.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: openhands
  namespace: foxhunt-agents
  labels:
    app: openhands
spec:
  replicas: 1
  selector:
    matchLabels:
      app: openhands
  template:
    metadata:
      labels:
        app: openhands
    spec:
      serviceAccountName: openhands-agent
      nodeSelector:
        pool: ci
      containers:
        - name: openhands
          image: ghcr.io/openhands/openhands:latest
          ports:
            - containerPort: 3000
          env:
            - name: LLM_BASE_URL
              value: "http://litellm.foxhunt-agents.svc.cluster.local:4000/v1"
            - name: LLM_API_KEY
              value: "via-litellm"
            - name: LLM_MODEL
              value: "opus"
            - name: SANDBOX_RUNTIME_CONTAINER_IMAGE
              value: "ghcr.io/openhands/runtime:latest"
          envFrom:
            - secretRef:
                name: agent-credentials
          volumeMounts:
            - name: config
              mountPath: /app/config.toml
              subPath: config.toml
            - name: workspace
              mountPath: /tmp/openhands-workspace
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              cpu: "2"
              memory: "4Gi"
      volumes:
        - name: config
          configMap:
            name: openhands-config
        - name: workspace
          persistentVolumeClaim:
            claimName: agent-workspace
---
apiVersion: v1
kind: Service
metadata:
  name: openhands
  namespace: foxhunt-agents
spec:
  selector:
    app: openhands
  ports:
    - port: 3000
      targetPort: 3000

Step 2: Apply

Run: kubectl apply -f infra/k8s/agents/openhands.yaml

Step 3: Wait for ready

Run: kubectl rollout status deployment/openhands -n foxhunt-agents --timeout=180s

Step 4: Commit

git add -f infra/k8s/agents/openhands.yaml
git commit -m "infra(agents): deploy OpenHands server with K8s runtime"

Task 12: Write diagnose microagent

Files:

  • Create: .openhands/microagents/diagnose.md

Step 1: Write microagent definition

<!-- .openhands/microagents/diagnose.md -->
---
name: diagnose
agent: CodeActAgent
---

# Diagnose Agent

You are a Rust CI diagnostician for the foxhunt HFT trading system.

## Your Task

Analyze the CI failure described in this GitLab issue and produce a root cause analysis.

## Context

- This is a Rust workspace with 30+ crates, 4500+ tests
- Build uses `SQLX_OFFLINE=true cargo check --workspace`
- Tests use `SQLX_OFFLINE=true cargo test -p <crate> --lib`
- Clippy: `SQLX_OFFLINE=true cargo clippy --workspace`
- Crate deny rules: `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]`
- Linker: clang+mold

## Process

1. Read the issue description to understand the failure
2. Clone the repo if not already available: `git clone ssh://git@100.90.76.85:2222/root/foxhunt.git`
3. Identify the failing stage and job from the CI log excerpt
4. Reproduce the error locally: run the exact command that failed
5. Trace the error to its root cause (file, line, reason)
6. Check recent commits with `git log --oneline -10` for likely culprits

## Output Format

Post a GitLab comment with this exact structure:

Phase 1: Diagnose Complete

Agent: diagnose LLM: opus Duration: {elapsed}s Tokens: {input_tokens} in / {output_tokens} out

Root Cause

{1-2 sentence summary}

Details

  • Failing stage: {stage name}
  • Failing job: {job name}
  • Error type: {compilation|test|clippy|link}
  • File(s): {file paths with line numbers}
  • Cause: {detailed explanation}
  • Likely culprit commit: {short sha + message}

Handoff

Next phase: plan Context for next agent: {key facts the plan agent needs — affected files, error type, whether it's a simple typo or architectural issue}


## Constraints

- Do NOT attempt to fix the code — only diagnose
- Focus on precision: identify the exact file and line
- If multiple errors, identify the root one (others may cascade)
- If the issue is a feature request (not CI failure), summarize the requirements instead

Step 2: Commit

git add -f .openhands/microagents/diagnose.md
git commit -m "feat(agents): add diagnose microagent definition"

Task 13: Add OpenHands invocation to dispatcher

Files:

  • Create: services/agent_dispatcher/src/openhands.rs
  • Modify: services/agent_dispatcher/src/handler.rs

Step 1: Write the failing test

// services/agent_dispatcher/src/openhands.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_headless_task() {
        let task = build_task(
            Phase::Diagnose,
            42,
            "CI failure in test-ml",
        );
        assert!(task.contains("issue #42"));
        assert!(task.contains("diagnose"));
    }

    #[test]
    fn test_phase_to_model() {
        assert_eq!(phase_model(&Phase::Diagnose), "opus");
        assert_eq!(phase_model(&Phase::Plan), "opus");
        assert_eq!(phase_model(&Phase::Implement), "sonnet");
        assert_eq!(phase_model(&Phase::Verify), "devstral");
        assert_eq!(phase_model(&Phase::Deliver), "devstral");
    }
}

Step 2: Run test to verify it fails

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- openhands Expected: FAIL

Step 3: Implement OpenHands invocation module

// services/agent_dispatcher/src/openhands.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    Diagnose,
    Plan,
    Implement,
    Verify,
    Deliver,
}

impl Phase {
    pub fn label(&self) -> &'static str {
        match self {
            Phase::Diagnose => "phase/diagnose",
            Phase::Plan => "phase/plan",
            Phase::Implement => "phase/implement",
            Phase::Verify => "phase/verify",
            Phase::Deliver => "phase/deliver",
        }
    }

    pub fn next(&self) -> Option<Phase> {
        match self {
            Phase::Diagnose => Some(Phase::Plan),
            Phase::Plan => Some(Phase::Implement),
            Phase::Implement => Some(Phase::Verify),
            Phase::Verify => Some(Phase::Deliver),
            Phase::Deliver => None,
        }
    }
}

pub fn phase_model(phase: &Phase) -> &'static str {
    match phase {
        Phase::Diagnose | Phase::Plan => "opus",
        Phase::Implement => "sonnet",
        Phase::Verify | Phase::Deliver => "devstral",
    }
}

pub fn build_task(phase: Phase, issue_iid: u64, context: &str) -> String {
    let phase_name = match phase {
        Phase::Diagnose => "diagnose",
        Phase::Plan => "plan",
        Phase::Implement => "implement",
        Phase::Verify => "verify",
        Phase::Deliver => "deliver",
    };
    format!(
        "You are the {phase_name} agent for foxhunt issue #{issue_iid}.\n\n\
         Follow the instructions in .openhands/microagents/{phase_name}.md\n\n\
         ## Issue Context\n\n{context}"
    )
}

#[derive(Serialize)]
struct HeadlessRequest {
    task: String,
    model: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    max_iterations: Option<u32>,
}

#[derive(Deserialize)]
pub struct HeadlessResponse {
    pub status: String,
    pub output: Option<String>,
}

pub struct OpenHandsClient {
    client: Client,
    base_url: String,
}

impl OpenHandsClient {
    pub fn new(base_url: String) -> Self {
        Self {
            client: Client::new(),
            base_url,
        }
    }

    pub async fn invoke(
        &self,
        phase: Phase,
        issue_iid: u64,
        context: &str,
    ) -> Result<HeadlessResponse, reqwest::Error> {
        let task = build_task(phase, issue_iid, context);
        let model = phase_model(&phase);

        self.client
            .post(format!("{}/api/submit-task", self.base_url))
            .json(&HeadlessRequest {
                task,
                model: model.to_string(),
                max_iterations: Some(50),
            })
            .send()
            .await?
            .json()
            .await
    }
}

Step 4: Run tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- openhands Expected: PASS

Step 5: Wire invocation into handler (replace TODO comments)

In handler.rs, add the OpenHands invocation call at the // TODO Phase 2 comment sites.

Step 6: Run full crate tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib Expected: all PASS

Step 7: Commit

git add services/agent_dispatcher/src/openhands.rs services/agent_dispatcher/src/handler.rs
git commit -m "feat(agent-dispatcher): OpenHands invocation with phase-based model routing"

Phase 3: Plan + Implement Agents

Add the plan and implement microagents so the loop can produce code.


Task 14: Write plan microagent

Files:

  • Create: .openhands/microagents/plan.md

Step 1: Write microagent

<!-- .openhands/microagents/plan.md -->
---
name: plan
agent: CodeActAgent
---

# Plan Agent

You are a Rust architecture planner for the foxhunt HFT trading system.

## Your Task

Read the diagnosis from Phase 1 and create a fix strategy.

## Process

1. Read the most recent "Phase 1: Diagnose Complete" comment on this issue
2. Examine the identified file(s) in the codebase
3. Assess scope: is this a 1-file fix or multi-file change?
4. Check for related tests that need updating
5. Identify risks (breaking changes, cascading effects)

## Output Format

Post a GitLab comment:

Phase 2: Plan Complete

Agent: plan LLM: opus Duration: {elapsed}s Tokens: {input_tokens} in / {output_tokens} out

Fix Strategy

{1-2 sentence summary}

Changes

File Action Description
path/to/file.rs:42 Modify {what to change}
path/to/test.rs Modify {update test expectations}

Approach

{step-by-step implementation guide, 3-7 steps}

Risks

  • {risk 1 and mitigation}

Handoff

Next phase: implement Context for next agent: {branch name to create, exact changes to make, test commands to run}


## Constraints

- Do NOT write code — only plan
- Be specific: exact file paths and line numbers
- Consider the `#![deny(clippy::unwrap_used, ...)]` rules
- Verify your proposed changes won't break other crates

Step 2: Commit

git add -f .openhands/microagents/plan.md
git commit -m "feat(agents): add plan microagent definition"

Task 15: Write implement microagent

Files:

  • Create: .openhands/microagents/implement.md

Step 1: Write microagent

<!-- .openhands/microagents/implement.md -->
---
name: implement
agent: CodeActAgent
---

# Implement Agent

You are a Rust implementation agent for the foxhunt HFT trading system.

## Your Task

Read the plan from Phase 2 and execute the code changes.

## Process

1. Read the most recent "Phase 2: Plan Complete" comment on this issue
2. Create branch: `git checkout -b agent/fix-{issue_iid}`
3. Make the planned changes, following the exact file/line references
4. Run `SQLX_OFFLINE=true cargo check --workspace` — fix any errors
5. Run `SQLX_OFFLINE=true cargo clippy --workspace` — fix any warnings
6. Run relevant tests: `SQLX_OFFLINE=true cargo test -p {crate} --lib`
7. Commit with message: `fix: {description} (agent #{issue_iid})`
8. Push: `git push origin agent/fix-{issue_iid}`

## Rules

- NEVER use `.unwrap()`, `.expect()`, `.panic!()`, or `[index]` — use `.get()`, `?`, `.ok_or()`
- NEVER push to `main` — only `agent/fix-*` branches
- Keep changes minimal — fix the issue, nothing more
- If `cargo check` fails after your changes, fix it before pushing
- If the plan is unclear, post a comment asking for clarification and stop

## Output Format

Post a GitLab comment:

Phase 3: Implement Complete

Agent: implement LLM: sonnet Duration: {elapsed}s Tokens: {input_tokens} in / {output_tokens} out

Changes Made

File Lines Changed Description
path/to/file.rs +5/-2 {what changed}

Verification

  • cargo check: PASS
  • cargo clippy: PASS
  • cargo test -p {crate}: PASS ({N} tests)

Branch

agent/fix-{issue_iid} pushed to origin

Handoff

Next phase: verify Context for next agent: Branch agent/fix-{issue_iid} is ready. Run full CI pipeline to verify.

Step 2: Commit

git add -f .openhands/microagents/implement.md
git commit -m "feat(agents): add implement microagent definition"

Task 16: Add phase transition logic to dispatcher

Files:

  • Create: services/agent_dispatcher/src/phase_machine.rs
  • Modify: services/agent_dispatcher/src/handler.rs

Step 1: Write the failing test

// services/agent_dispatcher/src/phase_machine.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_next_labels_diagnose_to_plan() {
        let current = vec!["trigger/ci-failure", "phase/diagnose", "agent/active"];
        let next = transition_labels(&current, Phase::Diagnose, Phase::Plan);
        assert!(next.contains(&"phase/plan".to_string()));
        assert!(!next.contains(&"phase/diagnose".to_string()));
        assert!(next.contains(&"agent/active".to_string()));
    }

    #[test]
    fn test_retry_labels() {
        let current = vec!["phase/verify", "agent/active", "retry/1"];
        let next = add_retry_label(&current, 2);
        assert!(next.contains(&"retry/2".to_string()));
        assert!(!next.contains(&"retry/1".to_string()));
    }

    #[test]
    fn test_blocked_on_max_retry() {
        assert!(should_block(3, 3));
        assert!(!should_block(2, 3));
    }

    #[test]
    fn test_parse_retry_count() {
        let labels = vec!["phase/verify".to_string(), "retry/2".to_string(), "agent/active".to_string()];
        assert_eq!(current_retry_count(&labels), 2);
    }

    #[test]
    fn test_parse_retry_count_none() {
        let labels = vec!["phase/verify".to_string(), "agent/active".to_string()];
        assert_eq!(current_retry_count(&labels), 0);
    }
}

Step 2: Run test to verify it fails

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- phase_machine Expected: FAIL

Step 3: Implement phase machine

// services/agent_dispatcher/src/phase_machine.rs
use crate::openhands::Phase;

pub fn transition_labels(
    current: &[impl AsRef<str>],
    from: Phase,
    to: Phase,
) -> Vec<String> {
    let from_label = from.label();
    let to_label = to.label();
    let mut result: Vec<String> = current
        .iter()
        .filter(|l| l.as_ref() != from_label)
        .map(|l| l.as_ref().to_string())
        .collect();
    if !result.iter().any(|l| l == to_label) {
        result.push(to_label.to_string());
    }
    result
}

pub fn current_retry_count(labels: &[String]) -> u8 {
    labels
        .iter()
        .filter_map(|l| l.strip_prefix("retry/"))
        .filter_map(|n| n.parse::<u8>().ok())
        .max()
        .unwrap_or(0)
}

pub fn add_retry_label(current: &[impl AsRef<str>], retry_num: u8) -> Vec<String> {
    let mut result: Vec<String> = current
        .iter()
        .filter(|l| !l.as_ref().starts_with("retry/"))
        .map(|l| l.as_ref().to_string())
        .collect();
    result.push(format!("retry/{retry_num}"));
    result
}

pub fn should_block(retry_count: u8, max_retries: u8) -> bool {
    retry_count >= max_retries
}

pub fn blocked_labels(current: &[impl AsRef<str>]) -> Vec<String> {
    let mut result: Vec<String> = current
        .iter()
        .filter(|l| l.as_ref() != "agent/active")
        .map(|l| l.as_ref().to_string())
        .collect();
    if !result.iter().any(|l| l == "agent/blocked") {
        result.push("agent/blocked".to_string());
    }
    result
}

pub fn done_labels(current: &[impl AsRef<str>]) -> Vec<String> {
    let mut result: Vec<String> = current
        .iter()
        .filter(|l| {
            let s = l.as_ref();
            s != "agent/active" && !s.starts_with("phase/")
        })
        .map(|l| l.as_ref().to_string())
        .collect();
    if !result.iter().any(|l| l == "agent/done") {
        result.push("agent/done".to_string());
    }
    result
}

Step 4: Run tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- phase_machine Expected: all PASS

Step 5: Commit

git add services/agent_dispatcher/src/phase_machine.rs services/agent_dispatcher/src/handler.rs
git commit -m "feat(agent-dispatcher): phase transition state machine with retry logic"

Phase 4: Verify + Deliver Agents

Close the loop with CI verification and MR creation.


Task 17: Write verify microagent

Files:

  • Create: .openhands/microagents/verify.md

Step 1: Write microagent

<!-- .openhands/microagents/verify.md -->
---
name: verify
agent: CodeActAgent
---

# Verify Agent

You verify that the implementation from Phase 3 passes CI.

## Your Task

1. Read the "Phase 3: Implement Complete" comment to get the branch name
2. Trigger CI pipeline on the branch (push is sufficient — GitLab auto-triggers)
3. Poll pipeline status: `glab ci status --branch agent/fix-{issue_iid}`
4. Wait up to 15 minutes for completion

## Output Format

### On GREEN pipeline:

Phase 4: Verify Complete

Agent: verify LLM: devstral Duration: {elapsed}s

Result: PASS

Pipeline #{pipeline_id} passed on agent/fix-{issue_iid}

Handoff

Next phase: deliver Context for next agent: Pipeline green. Create MR from agent/fix-{issue_iid} to main.


### On RED pipeline:

Phase 4: Verify Failed

Agent: verify LLM: devstral

Result: FAIL

Pipeline #{pipeline_id} failed on agent/fix-{issue_iid}

Failed Jobs

Job Stage Error
{name} {stage} {first error line}

Handoff

Next phase: diagnose (retry) Context for next agent: {error details for re-diagnosis}


## Constraints

- Do NOT modify code — only check pipeline status
- If pipeline takes >15 minutes, report timeout and let dispatcher handle retry

Step 2: Commit

git add -f .openhands/microagents/verify.md
git commit -m "feat(agents): add verify microagent definition"

Task 18: Write deliver microagent

Files:

  • Create: .openhands/microagents/deliver.md

Step 1: Write microagent

<!-- .openhands/microagents/deliver.md -->
---
name: deliver
agent: CodeActAgent
---

# Deliver Agent

You create merge requests for verified fixes.

## Your Task

1. Read the "Phase 4: Verify Complete" comment for confirmation
2. Create MR using `glab`:
   ```bash
   glab mr create \
     --source-branch agent/fix-{issue_iid} \
     --target-branch main \
     --title "fix: {short description} (agent #{issue_iid})" \
     --description "$(cat <<'EOF'
   ## Summary
   {1-2 bullet points from the plan}

   ## Agent Trail
   - Issue: #{issue_iid}
   - Diagnose: Phase 1 comment
   - Plan: Phase 2 comment
   - Implement: Phase 3 comment
   - Verify: Phase 4 comment (pipeline #{pipeline_id})

   ---
   *Auto-generated by foxhunt agent system*
   EOF
   )"

Auto-Merge Rules

  • If issue has trigger/ci-failure label: add --auto-merge flag (merge when MR pipeline passes)
  • If issue has trigger/feature label: do NOT auto-merge (requires human review)

Output Format

## Phase 5: Deliver Complete

**Agent**: deliver
**LLM**: devstral
**Duration**: {elapsed}s

### Merge Request
MR !{mr_iid}: {title}
URL: {mr_url}
Auto-merge: {yes/no}

### Handoff
Agent work complete. Removing `agent/active`, adding `agent/done`.

Constraints

  • NEVER force-merge or skip CI on the MR
  • NEVER target any branch other than main

**Step 2: Commit**

```bash
git add -f .openhands/microagents/deliver.md
git commit -m "feat(agents): add deliver microagent definition"

Task 19: Add auto-merge support to dispatcher

Files:

  • Modify: services/agent_dispatcher/src/gitlab_client.rs

Step 1: Write the failing test

#[test]
fn test_is_ci_failure_trigger() {
    let labels = vec!["trigger/ci-failure".to_string(), "agent/active".to_string()];
    assert!(is_ci_failure(&labels));

    let labels = vec!["trigger/feature".to_string(), "agent/active".to_string()];
    assert!(!is_ci_failure(&labels));
}

Step 2: Run test to verify it fails

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- gitlab_client::tests::test_is_ci_failure Expected: FAIL

Step 3: Add auto-merge API call to GitLabClient

// Add to gitlab_client.rs

pub fn is_ci_failure(labels: &[String]) -> bool {
    labels.iter().any(|l| l == "trigger/ci-failure")
}

impl GitLabClient {
    pub async fn set_auto_merge(&self, mr_iid: u64) -> Result<(), reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/merge_requests/{}/merge",
            self.base_url, self.project_id, mr_iid
        );
        self.client
            .put(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&serde_json::json!({
                "merge_when_pipeline_succeeds": true
            }))
            .send()
            .await?
            .error_for_status()?;
        Ok(())
    }

    pub async fn close_issue(&self, issue_iid: u64) -> Result<(), reqwest::Error> {
        let url = format!(
            "{}/api/v4/projects/{}/issues/{}",
            self.base_url, self.project_id, issue_iid
        );
        self.client
            .put(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&serde_json::json!({ "state_event": "close" }))
            .send()
            .await?
            .error_for_status()?;
        Ok(())
    }
}

Step 4: Run test

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib -- gitlab_client Expected: PASS

Step 5: Commit

git add services/agent_dispatcher/src/gitlab_client.rs
git commit -m "feat(agent-dispatcher): auto-merge for CI-failure MRs + issue close"

Task 20: Wire full phase loop in dispatcher handler

Files:

  • Modify: services/agent_dispatcher/src/handler.rs

Step 1: Implement the full agent loop

Add a run_agent_loop function that:

  1. Acquires semaphore permit (concurrency control)
  2. Invokes OpenHands for the current phase
  3. Parses agent comment for handoff signals
  4. Transitions labels
  5. If verify fails → check retry count → loop or block
  6. If deliver completes → set auto-merge (if ci-failure) → close issue → add agent/done

Step 2: Run all tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib Expected: all PASS

Step 3: Run cargo check

Run: SQLX_OFFLINE=true cargo check -p agent_dispatcher Expected: zero errors

Step 4: Run clippy

Run: SQLX_OFFLINE=true cargo clippy -p agent_dispatcher Expected: zero warnings

Step 5: Commit

git add services/agent_dispatcher/src/
git commit -m "feat(agent-dispatcher): full 5-phase agent loop with retry and auto-merge"

Phase 5: @agent Mention Trigger

This was already wired in Task 7 (handle_agent_mention). Remaining work is end-to-end testing.


Task 21: End-to-end smoke test

Step 1: Create a test issue with @agent mention

Run:

curl -s --request POST "https://git.fxhnt.ai/api/v4/projects/1/issues" \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --data-urlencode "title=[Test] Agent smoke test" \
  --data-urlencode "description=@agent Please add a comment to this file: README.md" \
  | jq '.iid'

Expected: returns issue IID

Step 2: Verify dispatcher picks up the webhook

Run: kubectl logs -n foxhunt-agents deployment/dispatcher --tail=50 | grep "activated agent" Expected: log line showing agent activation for the test issue

Step 3: Verify labels applied

Run: curl -s "https://git.fxhnt.ai/api/v4/projects/1/issues/{IID}" --header "PRIVATE-TOKEN: $GITLAB_TOKEN" | jq '.labels' Expected: ["trigger/feature", "phase/diagnose", "agent/active"]

Step 4: Close test issue

Run: curl -s --request PUT "https://git.fxhnt.ai/api/v4/projects/1/issues/{IID}" --header "PRIVATE-TOKEN: $GITLAB_TOKEN" --data "state_event=close"


Task 22: Add CI job to build dispatcher image

Files:

  • Modify: .gitlab-ci.yml

Step 1: Add build stage for dispatcher

Add a build-dispatcher job in the build stage:

build-dispatcher:
  stage: build
  image: rg.fr-par.scw.cloud/foxhunt-ci/ci-builder:latest
  script:
    - docker build -f infra/docker/Dockerfile.agent-dispatcher
        -t rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:$CI_COMMIT_SHORT_SHA
        -t rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:latest .
    - docker push rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:$CI_COMMIT_SHORT_SHA
    - docker push rg.fr-par.scw.cloud/foxhunt-ci/agent-dispatcher:latest
  rules:
    - changes:
        - services/agent_dispatcher/**/*
        - infra/docker/Dockerfile.agent-dispatcher

Step 2: Commit

git add .gitlab-ci.yml
git commit -m "ci: add build job for agent-dispatcher image"

Task 23: Final workspace verification

Step 1: Cargo check entire workspace

Run: SQLX_OFFLINE=true cargo check --workspace -j12 Expected: zero errors

Step 2: Cargo clippy entire workspace

Run: SQLX_OFFLINE=true cargo clippy --workspace --examples -j12 Expected: zero warnings

Step 3: Run dispatcher tests

Run: SQLX_OFFLINE=true cargo test -p agent_dispatcher --lib Expected: all PASS

Step 4: Run ML tests (regression check)

Run: SQLX_OFFLINE=true cargo test -p ml --lib -- confidence Expected: 13 tests PASS (ported confidence aggregator still works)


Summary

Phase Tasks What Ships
0 1-3 K8s namespace, secrets, LiteLLM proxy with 3 model routes
1 4-9 Rust dispatcher (webhook → issue), GitLab labels + webhook
2 10-13 OpenHands deployment, RBAC, diagnose microagent, invocation wiring
3 14-16 Plan + implement microagents, phase transition state machine
4 17-20 Verify + deliver microagents, auto-merge, full loop closure
5 21-23 @agent trigger e2e test, CI build job, workspace verification

Total: 23 tasks across 6 rollout phases.

New files: ~15 (K8s manifests, Dockerfile, Rust service modules, microagent definitions)

Estimated LLM cost per agent run: ~$2-5 for CI fix (mostly Sonnet), ~$10-20 for feature work (Opus heavy)