feat: modernise to Go 1.26 iterators and stdlib helpers
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 37s

Add iter.Seq iterators for Poller, Spinner, and Journal. Use
slices.Sort, slices.SortFunc, maps.DeleteFunc for cleaner
collection operations.

Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-23 05:53:22 +00:00
parent 99f92f28f3
commit 5ed62dc025
7 changed files with 93 additions and 23 deletions

View file

@ -2,6 +2,7 @@ package agent
import (
"context"
"iter"
"strings"
"forge.lthn.ai/core/go-agent/jobrunner"
@ -61,6 +62,17 @@ func (s *Spinner) GetVerifierModel(agentName string) string {
return agent.VerifyModel
}
// Agents returns an iterator over the configured agents.
func (s *Spinner) AgentsSeq() iter.Seq2[string, AgentConfig] {
return func(yield func(string, AgentConfig) bool) {
for name, agent := range s.Agents {
if !yield(name, agent) {
return
}
}
}
}
// FindByForgejoUser resolves a Forgejo username to the agent config key and config.
// This decouples agent naming (mythological roles) from Forgejo identity.
func (s *Spinner) FindByForgejoUser(forgejoUser string) (string, AgentConfig, bool) {
@ -72,7 +84,7 @@ func (s *Spinner) FindByForgejoUser(forgejoUser string) (string, AgentConfig, bo
return forgejoUser, agent, true
}
// Search by ForgejoUser field.
for name, agent := range s.Agents {
for name, agent := range s.AgentsSeq() {
if agent.ForgejoUser != "" && agent.ForgejoUser == forgejoUser {
return name, agent, true
}

View file

@ -10,7 +10,7 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"sort"
"slices"
"strconv"
"strings"
"syscall"
@ -674,6 +674,6 @@ func pickOldestTicket(queueDir string) (string, error) {
return "", nil
}
sort.Strings(tickets)
slices.Sort(tickets)
return tickets[0], nil
}

View file

@ -5,7 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"slices"
"github.com/mark3labs/mcp-go/mcp"
)
@ -75,7 +75,7 @@ func listCommands(path string) ([]string, error) {
return nil
})
sort.Strings(commands)
slices.Sort(commands)
return commands, nil
}
@ -98,7 +98,7 @@ func listSkills(path string) ([]string, error) {
}
}
sort.Strings(skills)
slices.Sort(skills)
return skills, nil
}

View file

@ -3,6 +3,7 @@ package agent
import (
"fmt"
"maps"
"forge.lthn.ai/core/go/pkg/config"
)
@ -61,16 +62,13 @@ func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
// LoadActiveAgents returns only active agents.
func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
all, err := LoadAgents(cfg)
active, err := LoadAgents(cfg)
if err != nil {
return nil, err
}
active := make(map[string]AgentConfig)
for name, ac := range all {
if ac.Active {
active[name] = ac
}
}
maps.DeleteFunc(active, func(_ string, ac AgentConfig) bool {
return !ac.Active
})
return active, nil
}

View file

@ -107,7 +107,7 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
if err == nil {
for _, l := range issue.Labels {
if l.Name == LabelInProgress || l.Name == LabelAgentComplete {
log.Info("issue already processed, skipping", "issue", signal.ChildNumber, "label", l.Name)
log.Info("issue already processed, skipping", "issue", signal.ChildNumber)
return &jobrunner.ActionResult{
Action: "dispatch",
Success: true,

View file

@ -1,8 +1,10 @@
package jobrunner
import (
"bufio"
"encoding/json"
"fmt"
"iter"
"os"
"path/filepath"
"regexp"
@ -87,6 +89,35 @@ func sanitizePathComponent(name string) (string, error) {
return clean, nil
}
// ReadEntries returns an iterator over JournalEntry lines in a date-partitioned file.
func (j *Journal) ReadEntries(path string) iter.Seq2[JournalEntry, error] {
return func(yield func(JournalEntry, error) bool) {
f, err := os.Open(path)
if err != nil {
yield(JournalEntry{}, err)
return
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var entry JournalEntry
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
if !yield(JournalEntry{}, err) {
return
}
continue
}
if !yield(entry, nil) {
return
}
}
if err := scanner.Err(); err != nil {
yield(JournalEntry{}, err)
}
}
}
// Append writes a journal entry for the given signal and result.
func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error {
if signal == nil {

View file

@ -2,6 +2,7 @@ package jobrunner
import (
"context"
"iter"
"sync"
"time"
@ -51,6 +52,38 @@ func (p *Poller) Cycle() int {
return p.cycle
}
// Sources returns an iterator over the poller's sources.
func (p *Poller) Sources() iter.Seq[JobSource] {
return func(yield func(JobSource) bool) {
p.mu.RLock()
sources := make([]JobSource, len(p.sources))
copy(sources, p.sources)
p.mu.RUnlock()
for _, s := range sources {
if !yield(s) {
return
}
}
}
}
// Handlers returns an iterator over the poller's handlers.
func (p *Poller) Handlers() iter.Seq[JobHandler] {
return func(yield func(JobHandler) bool) {
p.mu.RLock()
handlers := make([]JobHandler, len(p.handlers))
copy(handlers, p.handlers)
p.mu.RUnlock()
for _, h := range handlers {
if !yield(h) {
return
}
}
}
}
// DryRun returns whether dry-run mode is enabled.
func (p *Poller) DryRun() bool {
p.mu.RLock()
@ -109,15 +142,11 @@ func (p *Poller) RunOnce(ctx context.Context) error {
p.cycle++
cycle := p.cycle
dryRun := p.dryRun
sources := make([]JobSource, len(p.sources))
copy(sources, p.sources)
handlers := make([]JobHandler, len(p.handlers))
copy(handlers, p.handlers)
p.mu.Unlock()
log.Info("poller cycle starting", "cycle", cycle, "sources", len(sources), "handlers", len(handlers))
log.Info("poller cycle starting", "cycle", cycle)
for _, src := range sources {
for src := range p.Sources() {
signals, err := src.Poll(ctx)
if err != nil {
log.Error("poll failed", "source", src.Name(), "err", err)
@ -127,7 +156,7 @@ func (p *Poller) RunOnce(ctx context.Context) error {
log.Info("polled source", "source", src.Name(), "signals", len(signals))
for _, sig := range signals {
handler := p.findHandler(handlers, sig)
handler := p.findHandler(p.Handlers(), sig)
if handler == nil {
log.Debug("no matching handler", "epic", sig.EpicNumber, "child", sig.ChildNumber)
continue
@ -185,8 +214,8 @@ func (p *Poller) RunOnce(ctx context.Context) error {
}
// findHandler returns the first handler that matches the signal, or nil.
func (p *Poller) findHandler(handlers []JobHandler, sig *PipelineSignal) JobHandler {
for _, h := range handlers {
func (p *Poller) findHandler(handlers iter.Seq[JobHandler], sig *PipelineSignal) JobHandler {
for h := range handlers {
if h.Match(sig) {
return h
}