fetch_loop.go starts at PrepSubsystem.OnStartup, ticks on configurable interval (default 5min), respects ctx.Done() for clean shutdown. Each tick runs `git fetch origin <DefaultBranch>` via s.Core().Process() — no worktree mutation. Repo discovery sources (priority): 1. agents.fetch_repos in runtime config 2. repos / agents.*.repos in agents.yaml 3. Fallback scan of WorkspaceRoot() per RFC §7 Interval config: agents.fetch_interval, dispatch.fetch_interval, or top-level fetch_interval in agents.yaml. Loop survives individual repo failures (logs + continues — one bad repo can't kill the loop). Tests cover tick timing, failure isolation, ctx cancellation. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=165
289 lines
6.1 KiB
Go
289 lines
6.1 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
core "dappco.re/go/core"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const fetchLoopDefaultInterval = 5 * time.Minute
|
|
|
|
type fetchRepoRef struct {
|
|
Org string
|
|
Repo string
|
|
}
|
|
|
|
// go s.runFetchLoop(ctx, 5*time.Minute)
|
|
func (s *PrepSubsystem) runFetchLoop(ctx context.Context, interval time.Duration) {
|
|
if s == nil || s.ServiceRuntime == nil || ctx == nil || interval <= 0 {
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
s.runFetchLoopTicks(ctx, ticker.C)
|
|
}
|
|
|
|
func (s *PrepSubsystem) runFetchLoopTicks(ctx context.Context, ticks <-chan time.Time) {
|
|
if s == nil || s.ServiceRuntime == nil || ctx == nil || ticks == nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticks:
|
|
s.fetchRegisteredRepos(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *PrepSubsystem) fetchLoopInterval() time.Duration {
|
|
if s != nil && s.ServiceRuntime != nil {
|
|
if result := s.Core().Config().Get("agents.fetch_interval"); result.OK {
|
|
if interval := fetchLoopDuration(result.Value); interval > 0 {
|
|
return interval
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, path := range s.fetchLoopConfigPaths() {
|
|
raw := fetchLoopReadConfig(path)
|
|
if interval := fetchLoopDuration(raw["fetch_interval"]); interval > 0 {
|
|
return interval
|
|
}
|
|
if dispatch, ok := raw["dispatch"].(map[string]any); ok {
|
|
if interval := fetchLoopDuration(dispatch["fetch_interval"]); interval > 0 {
|
|
return interval
|
|
}
|
|
}
|
|
}
|
|
|
|
return fetchLoopDefaultInterval
|
|
}
|
|
|
|
func (s *PrepSubsystem) fetchRegisteredRepos(ctx context.Context) {
|
|
if s == nil || s.ServiceRuntime == nil || ctx == nil {
|
|
return
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, ref := range s.fetchLoopRepoRefs() {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
name := fetchLoopRepoName(ref)
|
|
repoDir := s.localRepoDir(ref.Org, ref.Repo)
|
|
if repoDir == "" || !fs.IsDir(core.JoinPath(repoDir, ".git")) {
|
|
core.Warn("agentic fetch loop skipped repo", "repo", name, "path", repoDir)
|
|
continue
|
|
}
|
|
if seen[repoDir] {
|
|
continue
|
|
}
|
|
seen[repoDir] = true
|
|
|
|
branch := s.DefaultBranch(repoDir)
|
|
args := []string{"git", "fetch", "origin"}
|
|
if branch != "" {
|
|
args = append(args, branch)
|
|
}
|
|
|
|
result := s.Core().Process().RunIn(ctx, repoDir, args...)
|
|
if !result.OK {
|
|
core.Warn("agentic fetch loop failed", "repo", name, "branch", branch, "reason", result.Value)
|
|
continue
|
|
}
|
|
core.Info("agentic fetch loop fetched repo", "repo", name, "branch", branch)
|
|
}
|
|
}
|
|
|
|
func (s *PrepSubsystem) fetchLoopRepoRefs() []fetchRepoRef {
|
|
seen := map[string]bool{}
|
|
refs := []fetchRepoRef{}
|
|
|
|
add := func(org, repo string) {
|
|
orgName, ok := validateName(org)
|
|
if !ok {
|
|
return
|
|
}
|
|
repoName, ok := validateName(repo)
|
|
if !ok {
|
|
return
|
|
}
|
|
key := core.Concat(orgName, "/", repoName)
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
refs = append(refs, fetchRepoRef{Org: orgName, Repo: repoName})
|
|
}
|
|
|
|
if s != nil && s.ServiceRuntime != nil {
|
|
if result := s.Core().Config().Get("agents.fetch_repos"); result.OK {
|
|
fetchLoopCollectRepoRefs(result.Value, add)
|
|
}
|
|
}
|
|
|
|
for _, path := range s.fetchLoopConfigPaths() {
|
|
raw := fetchLoopReadConfig(path)
|
|
fetchLoopCollectRepoRefs(raw["repos"], add)
|
|
if agents, ok := raw["agents"].(map[string]any); ok {
|
|
for _, value := range agents {
|
|
agent, ok := value.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
fetchLoopCollectRepoRefs(agent["repos"], add)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, repoDir := range core.PathGlob(core.JoinPath(WorkspaceRoot(), "*", "*")) {
|
|
if !fs.IsDir(repoDir) {
|
|
continue
|
|
}
|
|
org := core.PathBase(core.PathDir(repoDir))
|
|
repo := core.PathBase(repoDir)
|
|
add(org, repo)
|
|
}
|
|
|
|
return refs
|
|
}
|
|
|
|
func (s *PrepSubsystem) fetchLoopConfigPaths() []string {
|
|
paths := []string{}
|
|
seen := map[string]bool{}
|
|
add := func(path string) {
|
|
clean := core.Trim(path)
|
|
if clean == "" || seen[clean] {
|
|
return
|
|
}
|
|
seen[clean] = true
|
|
paths = append(paths, clean)
|
|
}
|
|
|
|
if s != nil && s.ServiceRuntime != nil {
|
|
if result := s.Core().Config().Get("agents.config_path"); result.OK {
|
|
if path, ok := result.Value.(string); ok {
|
|
add(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
add(core.JoinPath(CoreRoot(), "agents.yaml"))
|
|
if s != nil {
|
|
add(core.JoinPath(s.codePath, "core", "agent", "config", "agents.yaml"))
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
func fetchLoopReadConfig(path string) map[string]any {
|
|
readResult := fs.Read(path)
|
|
if !readResult.OK {
|
|
return map[string]any{}
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := yaml.Unmarshal([]byte(readResult.Value.(string)), &raw); err != nil {
|
|
return map[string]any{}
|
|
}
|
|
|
|
return raw
|
|
}
|
|
|
|
func fetchLoopCollectRepoRefs(value any, add func(org, repo string)) {
|
|
appendRepo := func(raw string) {
|
|
org, repo, ok := fetchLoopParseRepo(raw)
|
|
if !ok {
|
|
return
|
|
}
|
|
add(org, repo)
|
|
}
|
|
|
|
switch typed := value.(type) {
|
|
case string:
|
|
appendRepo(typed)
|
|
case []string:
|
|
for _, raw := range typed {
|
|
appendRepo(raw)
|
|
}
|
|
case []any:
|
|
for _, item := range typed {
|
|
if raw, ok := item.(string); ok {
|
|
appendRepo(raw)
|
|
}
|
|
}
|
|
case map[string]any:
|
|
for raw := range typed {
|
|
appendRepo(raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchLoopParseRepo(raw string) (string, string, bool) {
|
|
value := core.Trim(raw)
|
|
if value == "" {
|
|
return "", "", false
|
|
}
|
|
|
|
parts := core.Split(value, "/")
|
|
switch len(parts) {
|
|
case 1:
|
|
org, ok := validateName("core")
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
repo, ok := validateName(parts[0])
|
|
return org, repo, ok
|
|
case 2:
|
|
org, ok := validateName(parts[0])
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
repo, ok := validateName(parts[1])
|
|
return org, repo, ok
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func fetchLoopDuration(value any) time.Duration {
|
|
switch typed := value.(type) {
|
|
case time.Duration:
|
|
if typed > 0 {
|
|
return typed
|
|
}
|
|
case string:
|
|
parsed, err := time.ParseDuration(core.Trim(typed))
|
|
if err == nil && parsed > 0 {
|
|
return parsed
|
|
}
|
|
case int:
|
|
if typed > 0 {
|
|
return time.Duration(typed) * time.Second
|
|
}
|
|
case int64:
|
|
if typed > 0 {
|
|
return time.Duration(typed) * time.Second
|
|
}
|
|
case float64:
|
|
if typed > 0 {
|
|
return time.Duration(typed * float64(time.Second))
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func fetchLoopRepoName(ref fetchRepoRef) string {
|
|
return core.Concat(ref.Org, "/", ref.Repo)
|
|
}
|