iop/packages/go/agentconfig/watcher.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

167 lines
3.8 KiB
Go

package agentconfig
import (
"context"
"fmt"
"sync"
"time"
)
const DefaultRuntimeConfigPollInterval = 250 * time.Millisecond
// RuntimeConfigWatcher polls both source documents and atomically publishes
// only valid changed snapshots. Invalid edits are reported while the last
// valid snapshot remains current.
type RuntimeConfigWatcher struct {
repoGlobalPath string
userLocalPath string
pollInterval time.Duration
mu sync.RWMutex
current RuntimeSnapshot
updates chan RuntimeSnapshot
errors chan error
done chan struct{}
cancel context.CancelFunc
closeOnce sync.Once
}
// WatchRuntimeConfig starts a watcher using the default poll interval.
func WatchRuntimeConfig(
ctx context.Context,
repoGlobalPath string,
userLocalPath string,
) (*RuntimeConfigWatcher, error) {
return NewRuntimeConfigWatcher(ctx, repoGlobalPath, userLocalPath, DefaultRuntimeConfigPollInterval)
}
// NewRuntimeConfigWatcher loads the initial snapshot before starting its
// polling goroutine. A non-positive interval is rejected.
func NewRuntimeConfigWatcher(
ctx context.Context,
repoGlobalPath string,
userLocalPath string,
pollInterval time.Duration,
) (*RuntimeConfigWatcher, error) {
if ctx == nil {
return nil, fmt.Errorf("agentconfig: watcher context is required")
}
if pollInterval <= 0 {
return nil, fmt.Errorf("agentconfig: watcher poll interval must be positive")
}
initial, err := LoadRuntimeConfig(repoGlobalPath, userLocalPath)
if err != nil {
return nil, fmt.Errorf("agentconfig: watcher initial load: %w", err)
}
watchContext, cancel := context.WithCancel(ctx)
watcher := &RuntimeConfigWatcher{
repoGlobalPath: repoGlobalPath,
userLocalPath: userLocalPath,
pollInterval: pollInterval,
current: initial,
updates: make(chan RuntimeSnapshot, 1),
errors: make(chan error, 1),
done: make(chan struct{}),
cancel: cancel,
}
go watcher.run(watchContext)
return watcher, nil
}
// Snapshot returns the immutable snapshot pinned for a new invocation.
func (w *RuntimeConfigWatcher) Snapshot() RuntimeSnapshot {
w.mu.RLock()
defer w.mu.RUnlock()
return w.current
}
// Updates reports valid revisions after the initial snapshot. The channel is
// bounded and coalesces unread values to the latest valid revision.
func (w *RuntimeConfigWatcher) Updates() <-chan RuntimeSnapshot {
return w.updates
}
// Errors reports invalid reloads without changing Snapshot.
func (w *RuntimeConfigWatcher) Errors() <-chan error {
return w.errors
}
// Done closes after the watcher stops.
func (w *RuntimeConfigWatcher) Done() <-chan struct{} {
return w.done
}
// Close stops the watcher and waits for its channels to close.
func (w *RuntimeConfigWatcher) Close() {
w.closeOnce.Do(w.cancel)
<-w.done
}
func (w *RuntimeConfigWatcher) run(ctx context.Context) {
defer close(w.done)
defer close(w.errors)
defer close(w.updates)
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
lastError := ""
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
next, err := LoadRuntimeConfig(w.repoGlobalPath, w.userLocalPath)
if err != nil {
message := err.Error()
if message != lastError {
w.publishError(err)
lastError = message
}
continue
}
lastError = ""
w.mu.Lock()
if next.Revision() == w.current.Revision() {
w.mu.Unlock()
continue
}
w.current = next
w.mu.Unlock()
w.publishUpdate(next)
}
}
}
func (w *RuntimeConfigWatcher) publishUpdate(snapshot RuntimeSnapshot) {
select {
case w.updates <- snapshot:
return
default:
}
select {
case <-w.updates:
default:
}
select {
case w.updates <- snapshot:
default:
}
}
func (w *RuntimeConfigWatcher) publishError(err error) {
select {
case w.errors <- err:
return
default:
}
select {
case <-w.errors:
default:
}
select {
case w.errors <- err:
default:
}
}