Apply ZFC (Zero Forge Cache) principle across git error handling and feed curation. Agents now observe raw git output and make their own decisions rather than relying on pre-interpreted error types. - Add GitError type with raw stdout/stderr for observation - Add SwarmGitError following the same pattern - Remove in-memory deduplication maps from Curator - Curator now reads state from feed/events files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
11 KiB
Go
394 lines
11 KiB
Go
// Package feed provides the feed daemon that curates raw events into a user-facing feed.
|
|
//
|
|
// The curator:
|
|
// 1. Tails ~/gt/.events.jsonl (raw events)
|
|
// 2. Filters by visibility tag (drops audit-only events)
|
|
// 3. Deduplicates repeated updates (5 molecule updates → "agent active")
|
|
// 4. Aggregates related events (3 issues closed → "batch complete")
|
|
// 5. Writes curated events to ~/gt/.feed.jsonl
|
|
package feed
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/events"
|
|
)
|
|
|
|
// FeedFile is the name of the curated feed file.
|
|
const FeedFile = ".feed.jsonl"
|
|
|
|
// FeedEvent is the structure of events written to the feed.
|
|
type FeedEvent struct {
|
|
Timestamp string `json:"ts"`
|
|
Source string `json:"source"`
|
|
Type string `json:"type"`
|
|
Actor string `json:"actor"`
|
|
Summary string `json:"summary"`
|
|
Payload map[string]interface{} `json:"payload,omitempty"`
|
|
Count int `json:"count,omitempty"` // For aggregated events
|
|
}
|
|
|
|
// Curator manages the feed curation process.
|
|
// ZFC: State is derived from the events file, not cached in memory.
|
|
type Curator struct {
|
|
townRoot string
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// Deduplication/aggregation settings
|
|
const (
|
|
// Dedupe window for repeated done events from same actor
|
|
doneDedupeWindow = 10 * time.Second
|
|
|
|
// Aggregation window for sling events
|
|
slingAggregateWindow = 30 * time.Second
|
|
|
|
// Mail aggregation window
|
|
mailAggregateWindow = 30 * time.Second
|
|
|
|
// Minimum events to trigger aggregation
|
|
minAggregateCount = 3
|
|
)
|
|
|
|
// NewCurator creates a new feed curator.
|
|
func NewCurator(townRoot string) *Curator {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Curator{
|
|
townRoot: townRoot,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins the curator goroutine.
|
|
func (c *Curator) Start() error {
|
|
eventsPath := filepath.Join(c.townRoot, events.EventsFile)
|
|
|
|
// Open events file, creating if needed
|
|
file, err := os.OpenFile(eventsPath, os.O_RDONLY|os.O_CREATE, 0644) //nolint:gosec // G302: events file is non-sensitive operational data
|
|
if err != nil {
|
|
return fmt.Errorf("opening events file: %w", err)
|
|
}
|
|
|
|
// Seek to end to only process new events
|
|
if _, err := file.Seek(0, io.SeekEnd); err != nil {
|
|
_ = file.Close() //nolint:gosec // G104: best effort cleanup on error
|
|
return fmt.Errorf("seeking to end: %w", err)
|
|
}
|
|
|
|
c.wg.Add(1)
|
|
go c.run(file)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully stops the curator.
|
|
func (c *Curator) Stop() {
|
|
c.cancel()
|
|
c.wg.Wait()
|
|
}
|
|
|
|
// run is the main curator loop.
|
|
// ZFC: No in-memory state to clean up - state is derived from the events file.
|
|
func (c *Curator) run(file *os.File) {
|
|
defer c.wg.Done()
|
|
defer file.Close()
|
|
|
|
reader := bufio.NewReader(file)
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return
|
|
|
|
case <-ticker.C:
|
|
// Read available lines
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
break // No more data available
|
|
}
|
|
c.processLine(line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// processLine processes a single line from the events file.
|
|
func (c *Curator) processLine(line string) {
|
|
if line == "" || line == "\n" {
|
|
return
|
|
}
|
|
|
|
var rawEvent events.Event
|
|
if err := json.Unmarshal([]byte(line), &rawEvent); err != nil {
|
|
return // Skip malformed lines
|
|
}
|
|
|
|
// Filter by visibility - only process feed-visible events
|
|
if rawEvent.Visibility != events.VisibilityFeed && rawEvent.Visibility != events.VisibilityBoth {
|
|
return
|
|
}
|
|
|
|
// Apply deduplication and aggregation
|
|
if c.shouldDedupe(&rawEvent) {
|
|
return
|
|
}
|
|
|
|
// Write to feed
|
|
c.writeFeedEvent(&rawEvent)
|
|
}
|
|
|
|
// shouldDedupe checks if an event should be deduplicated.
|
|
// ZFC: Derives state from the FEED file (what we've already output), not in-memory cache.
|
|
// Returns true if the event should be dropped.
|
|
func (c *Curator) shouldDedupe(event *events.Event) bool {
|
|
switch event.Type {
|
|
case events.TypeDone:
|
|
// Dedupe repeated done events from same actor within window
|
|
// Check if we've already written a done event for this actor to the feed
|
|
recentFeedEvents := c.readRecentFeedEvents(doneDedupeWindow)
|
|
for _, e := range recentFeedEvents {
|
|
if e.Type == events.TypeDone && e.Actor == event.Actor {
|
|
return true // Skip duplicate (already in feed)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Sling and mail events are not deduplicated, only aggregated in writeFeedEvent
|
|
return false
|
|
}
|
|
|
|
// readRecentFeedEvents reads feed events from the feed file within the given time window.
|
|
// ZFC: The feed file is the observable state of what we've already output.
|
|
func (c *Curator) readRecentFeedEvents(window time.Duration) []FeedEvent {
|
|
feedPath := filepath.Join(c.townRoot, FeedFile)
|
|
|
|
data, err := os.ReadFile(feedPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-window)
|
|
var result []FeedEvent
|
|
|
|
// Parse lines from the end (most recent first) for efficiency
|
|
lines := strings.Split(string(data), "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := strings.TrimSpace(lines[i])
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var event FeedEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Parse timestamp
|
|
ts, err := time.Parse(time.RFC3339, event.Timestamp)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Stop if we've gone past the window
|
|
if ts.Before(cutoff) {
|
|
break
|
|
}
|
|
|
|
result = append(result, event)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// readRecentEvents reads events from the events file within the given time window.
|
|
// ZFC: This is the observable state that replaces in-memory caching.
|
|
// Uses tail-like reading for performance (reads last N lines).
|
|
func (c *Curator) readRecentEvents(window time.Duration) []events.Event {
|
|
eventsPath := filepath.Join(c.townRoot, events.EventsFile)
|
|
|
|
// Read the file (for small files, this is fine; for large files, consider tail-like reading)
|
|
data, err := os.ReadFile(eventsPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-window)
|
|
var result []events.Event
|
|
|
|
// Parse lines from the end (most recent first) for efficiency
|
|
lines := strings.Split(string(data), "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := strings.TrimSpace(lines[i])
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var event events.Event
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Parse timestamp
|
|
ts, err := time.Parse(time.RFC3339, event.Timestamp)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Stop if we've gone past the window
|
|
if ts.Before(cutoff) {
|
|
break
|
|
}
|
|
|
|
result = append(result, event)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// countRecentSlings counts sling events from an actor within the given window.
|
|
// ZFC: Derives count from the events file, not in-memory cache.
|
|
func (c *Curator) countRecentSlings(actor string, window time.Duration) int {
|
|
recentEvents := c.readRecentEvents(window)
|
|
count := 0
|
|
for _, e := range recentEvents {
|
|
if e.Type == events.TypeSling && e.Actor == actor {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// writeFeedEvent writes a curated event to the feed file.
|
|
// ZFC: Aggregation is derived from the events file, not in-memory cache.
|
|
func (c *Curator) writeFeedEvent(event *events.Event) {
|
|
feedEvent := FeedEvent{
|
|
Timestamp: event.Timestamp,
|
|
Source: event.Source,
|
|
Type: event.Type,
|
|
Actor: event.Actor,
|
|
Summary: c.generateSummary(event),
|
|
Payload: event.Payload,
|
|
}
|
|
|
|
// Check for aggregation opportunity (ZFC: derive from events file)
|
|
if event.Type == events.TypeSling {
|
|
slingCount := c.countRecentSlings(event.Actor, slingAggregateWindow)
|
|
if slingCount >= minAggregateCount {
|
|
feedEvent.Count = slingCount
|
|
feedEvent.Summary = fmt.Sprintf("%s dispatching work to %d agents", event.Actor, slingCount)
|
|
}
|
|
}
|
|
|
|
data, err := json.Marshal(feedEvent)
|
|
if err != nil {
|
|
return
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
feedPath := filepath.Join(c.townRoot, FeedFile)
|
|
f, err := os.OpenFile(feedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302: feed file is non-sensitive operational data
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
_, _ = f.Write(data)
|
|
}
|
|
|
|
// generateSummary creates a human-readable summary of an event.
|
|
func (c *Curator) generateSummary(event *events.Event) string {
|
|
switch event.Type {
|
|
case events.TypeSling:
|
|
if target, ok := event.Payload["target"].(string); ok {
|
|
if bead, ok := event.Payload["bead"].(string); ok {
|
|
return fmt.Sprintf("%s assigned %s to %s", event.Actor, bead, target)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s dispatched work", event.Actor)
|
|
|
|
case events.TypeDone:
|
|
if bead, ok := event.Payload["bead"].(string); ok {
|
|
return fmt.Sprintf("%s completed work on %s", event.Actor, bead)
|
|
}
|
|
return fmt.Sprintf("%s signaled done", event.Actor)
|
|
|
|
case events.TypeHandoff:
|
|
return fmt.Sprintf("%s handed off to fresh session", event.Actor)
|
|
|
|
case events.TypeMail:
|
|
if to, ok := event.Payload["to"].(string); ok {
|
|
if subj, ok := event.Payload["subject"].(string); ok {
|
|
return fmt.Sprintf("%s → %s: %s", event.Actor, to, subj)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s sent mail", event.Actor)
|
|
|
|
case events.TypePatrolStarted:
|
|
if rig, ok := event.Payload["rig"].(string); ok {
|
|
return fmt.Sprintf("%s patrol started for %s", event.Actor, rig)
|
|
}
|
|
return fmt.Sprintf("%s started patrol", event.Actor)
|
|
|
|
case events.TypePatrolComplete:
|
|
if msg, ok := event.Payload["message"].(string); ok {
|
|
return msg
|
|
}
|
|
return fmt.Sprintf("%s completed patrol", event.Actor)
|
|
|
|
case events.TypeMerged:
|
|
if worker, ok := event.Payload["worker"].(string); ok {
|
|
return fmt.Sprintf("Merged work from %s", worker)
|
|
}
|
|
return "Work merged"
|
|
|
|
case events.TypeMergeFailed:
|
|
if reason, ok := event.Payload["reason"].(string); ok {
|
|
return fmt.Sprintf("Merge failed: %s", reason)
|
|
}
|
|
return "Merge failed"
|
|
|
|
case events.TypeSessionDeath:
|
|
session, _ := event.Payload["session"].(string)
|
|
reason, _ := event.Payload["reason"].(string)
|
|
if session != "" && reason != "" {
|
|
return fmt.Sprintf("Session %s terminated: %s", session, reason)
|
|
}
|
|
if session != "" {
|
|
return fmt.Sprintf("Session %s terminated", session)
|
|
}
|
|
return "Session terminated"
|
|
|
|
case events.TypeMassDeath:
|
|
count, _ := event.Payload["count"].(float64) // JSON numbers are float64
|
|
possibleCause, _ := event.Payload["possible_cause"].(string)
|
|
if count > 0 && possibleCause != "" {
|
|
return fmt.Sprintf("MASS DEATH: %d sessions died - %s", int(count), possibleCause)
|
|
}
|
|
if count > 0 {
|
|
return fmt.Sprintf("MASS DEATH: %d sessions died simultaneously", int(count))
|
|
}
|
|
return "Multiple sessions died simultaneously"
|
|
|
|
default:
|
|
return fmt.Sprintf("%s: %s", event.Actor, event.Type)
|
|
}
|
|
}
|