2026-03-30 00:54:20 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
2026-03-29 23:59:48 +00:00
|
|
|
|
2026-02-21 19:27:35 +00:00
|
|
|
package agentci
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-03-29 23:59:48 +00:00
|
|
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
2026-04-02 13:26:35 +00:00
|
|
|
"math"
|
2026-02-21 19:27:35 +00:00
|
|
|
|
2026-03-21 23:54:23 +00:00
|
|
|
"dappco.re/go/core/scm/jobrunner"
|
2026-02-21 19:27:35 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// RunMode determines the execution strategy for a dispatched task.
|
|
|
|
|
type RunMode string
|
|
|
|
|
|
|
|
|
|
const (
|
2026-03-29 23:59:48 +00:00
|
|
|
//
|
2026-02-21 19:27:35 +00:00
|
|
|
ModeStandard RunMode = "standard"
|
2026-03-29 23:59:48 +00:00
|
|
|
//
|
|
|
|
|
ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification
|
2026-02-21 19:27:35 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Spinner is the Clotho orchestrator that determines the fate of each task.
|
|
|
|
|
type Spinner struct {
|
|
|
|
|
Config ClothoConfig
|
|
|
|
|
Agents map[string]AgentConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewSpinner creates a new Clotho orchestrator.
|
2026-03-30 14:11:15 +00:00
|
|
|
// Usage: NewSpinner(...)
|
2026-02-21 19:27:35 +00:00
|
|
|
func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner {
|
|
|
|
|
return &Spinner{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Agents: agents,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeterminePlan decides if a signal requires dual-run verification based on
|
|
|
|
|
// the global strategy, agent configuration, and repository criticality.
|
2026-03-30 14:11:15 +00:00
|
|
|
// Usage: DeterminePlan(...)
|
2026-02-21 19:27:35 +00:00
|
|
|
func (s *Spinner) DeterminePlan(signal *jobrunner.PipelineSignal, agentName string) RunMode {
|
|
|
|
|
if s.Config.Strategy != "clotho-verified" {
|
|
|
|
|
return ModeStandard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
agent, ok := s.Agents[agentName]
|
|
|
|
|
if !ok {
|
|
|
|
|
return ModeStandard
|
|
|
|
|
}
|
|
|
|
|
if agent.DualRun {
|
|
|
|
|
return ModeDual
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Protect critical repos with dual-run (Axiom 1).
|
|
|
|
|
if signal.RepoName == "core" || strings.Contains(signal.RepoName, "security") {
|
|
|
|
|
return ModeDual
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ModeStandard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetVerifierModel returns the model for the secondary "signed" verification run.
|
2026-03-30 14:11:15 +00:00
|
|
|
// Usage: GetVerifierModel(...)
|
2026-02-21 19:27:35 +00:00
|
|
|
func (s *Spinner) GetVerifierModel(agentName string) string {
|
|
|
|
|
agent, ok := s.Agents[agentName]
|
|
|
|
|
if !ok || agent.VerifyModel == "" {
|
|
|
|
|
return "gemini-1.5-pro"
|
|
|
|
|
}
|
|
|
|
|
return agent.VerifyModel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FindByForgejoUser resolves a Forgejo username to the agent config key and config.
|
|
|
|
|
// This decouples agent naming (mythological roles) from Forgejo identity.
|
2026-03-30 14:11:15 +00:00
|
|
|
// Usage: FindByForgejoUser(...)
|
2026-02-21 19:27:35 +00:00
|
|
|
func (s *Spinner) FindByForgejoUser(forgejoUser string) (string, AgentConfig, bool) {
|
|
|
|
|
if forgejoUser == "" {
|
|
|
|
|
return "", AgentConfig{}, false
|
|
|
|
|
}
|
|
|
|
|
// Direct match on config key first.
|
|
|
|
|
if agent, ok := s.Agents[forgejoUser]; ok {
|
|
|
|
|
return forgejoUser, agent, true
|
|
|
|
|
}
|
|
|
|
|
// Search by ForgejoUser field.
|
|
|
|
|
for name, agent := range s.Agents {
|
|
|
|
|
if agent.ForgejoUser != "" && agent.ForgejoUser == forgejoUser {
|
|
|
|
|
return name, agent, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", AgentConfig{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Weave compares primary and verifier outputs. Returns true if they converge.
|
2026-04-02 13:26:35 +00:00
|
|
|
// The comparison is a coarse token-overlap check controlled by the configured
|
|
|
|
|
// validation threshold. It is intentionally deterministic and fast; richer
|
|
|
|
|
// semantic diffing can replace it later without changing the signature.
|
2026-03-30 14:11:15 +00:00
|
|
|
// Usage: Weave(...)
|
2026-02-21 19:27:35 +00:00
|
|
|
func (s *Spinner) Weave(ctx context.Context, primaryOutput, signedOutput []byte) (bool, error) {
|
2026-04-02 13:26:35 +00:00
|
|
|
if ctx != nil {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return false, ctx.Err()
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
primary := tokenizeWeaveOutput(primaryOutput)
|
|
|
|
|
signed := tokenizeWeaveOutput(signedOutput)
|
|
|
|
|
|
|
|
|
|
if len(primary) == 0 && len(signed) == 0 {
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
threshold := s.Config.ValidationThreshold
|
|
|
|
|
if threshold <= 0 || threshold > 1 {
|
|
|
|
|
threshold = 0.85
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
similarity := weaveDiceSimilarity(primary, signed)
|
|
|
|
|
return similarity >= threshold, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func tokenizeWeaveOutput(output []byte) []string {
|
|
|
|
|
fields := strings.Fields(strings.ReplaceAll(string(output), "\r\n", "\n"))
|
|
|
|
|
if len(fields) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return fields
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func weaveDiceSimilarity(primary, signed []string) float64 {
|
|
|
|
|
if len(primary) == 0 || len(signed) == 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
counts := make(map[string]int, len(primary))
|
|
|
|
|
for _, token := range primary {
|
|
|
|
|
counts[token]++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
common := 0
|
|
|
|
|
for _, token := range signed {
|
|
|
|
|
if counts[token] == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
counts[token]--
|
|
|
|
|
common++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return math.Min(1, (2*float64(common))/float64(len(primary)+len(signed)))
|
2026-02-21 19:27:35 +00:00
|
|
|
}
|