Files
gastown/internal/feed/curator.go
gastown/crew/max 131dac91c8 refactor(zfc): derive state from files instead of in-memory cache
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>
2026-01-09 22:23:44 -08:00

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)
}
}