From 5ed62dc025ba3896618b2c1858eb357034ea6739 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 23 Feb 2026 05:53:22 +0000 Subject: [PATCH] feat: modernise to Go 1.26 iterators and stdlib helpers Add iter.Seq iterators for Poller, Spinner, and Journal. Use slices.Sort, slices.SortFunc, maps.DeleteFunc for cleaner collection operations. Co-Authored-By: Gemini Co-Authored-By: Virgil --- clotho.go | 14 +++++++++- cmd/dispatch/cmd.go | 4 +-- cmd/mcp/plugin_info.go | 6 ++--- config.go | 12 ++++----- jobrunner/handlers/dispatch.go | 2 +- jobrunner/journal.go | 31 ++++++++++++++++++++++ jobrunner/poller.go | 47 +++++++++++++++++++++++++++------- 7 files changed, 93 insertions(+), 23 deletions(-) diff --git a/clotho.go b/clotho.go index efd3d30..b78ac25 100644 --- a/clotho.go +++ b/clotho.go @@ -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 } diff --git a/cmd/dispatch/cmd.go b/cmd/dispatch/cmd.go index 4f0a017..874a4b2 100644 --- a/cmd/dispatch/cmd.go +++ b/cmd/dispatch/cmd.go @@ -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 } diff --git a/cmd/mcp/plugin_info.go b/cmd/mcp/plugin_info.go index 6e6d02d..a870064 100644 --- a/cmd/mcp/plugin_info.go +++ b/cmd/mcp/plugin_info.go @@ -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 } diff --git a/config.go b/config.go index 867559c..b74a13e 100644 --- a/config.go +++ b/config.go @@ -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 } diff --git a/jobrunner/handlers/dispatch.go b/jobrunner/handlers/dispatch.go index 54d2902..64129b8 100644 --- a/jobrunner/handlers/dispatch.go +++ b/jobrunner/handlers/dispatch.go @@ -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, diff --git a/jobrunner/journal.go b/jobrunner/journal.go index 99eaf94..eff90f4 100644 --- a/jobrunner/journal.go +++ b/jobrunner/journal.go @@ -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 { diff --git a/jobrunner/poller.go b/jobrunner/poller.go index be6b213..d6024c3 100644 --- a/jobrunner/poller.go +++ b/jobrunner/poller.go @@ -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 }