tinyrelayfile
tinyrelay/internal/agentrunner/runner.go/

internal/agentrunner/runner.go raw
1// Package agentrunner runs an optional agent process outside the relay daemon.
2package agentrunner
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "sync"
12 "time"
13)
14
15var (
16 ErrDuplicate = errors.New("agent runner: duplicate event")
17 ErrUnauthorizedRoom = errors.New("agent runner: unauthorized room")
18 ErrQueueFull = errors.New("agent runner: room queue full")
19 ErrStopped = errors.New("agent runner: stopped")
20)
21
22type Mention struct {
23 Room string `json:"room"`
24 EventID string `json:"event_id"`
25 Author string `json:"author"`
26 Prompt string `json:"prompt"`
27 Root string `json:"root"`
28 Kind int `json:"kind"`
29 CreatedAt int64 `json:"created_at"`
30}
31type Handler func(context.Context, []Mention) error
32type Options struct {
33 AllowedRooms []string
34 QueueLimit int
35 StatePath string
36 Handle Handler
37 OnError func(error)
38}
39type roomQueue struct {
40 pending, active []Mention
41 cancel context.CancelFunc
42 wake chan struct{}
43}
44type journal struct {
45 Pending map[string][]Mention `json:"pending"`
46 Seen []string `json:"seen"`
47}
48type Runner struct {
49 mu sync.Mutex
50 rooms map[string]*roomQueue
51 seen map[string]bool
52 order []string
53 opts Options
54 ctx context.Context
55 cancel context.CancelFunc
56 wg sync.WaitGroup
57 stopped bool
58}
59
60func New(opts Options) (*Runner, error) {
61 if opts.Handle == nil || len(opts.AllowedRooms) == 0 || len(opts.AllowedRooms) > 32 {
62 return nil, errors.New("agent runner: a handler and 1 to 32 rooms are required")
63 }
64 if opts.QueueLimit <= 0 {
65 opts.QueueLimit = 32
66 }
67 if opts.QueueLimit > 256 {
68 return nil, errors.New("agent runner: queue limit cannot exceed 256")
69 }
70 r := &Runner{opts: opts, rooms: map[string]*roomQueue{}, seen: map[string]bool{}}
71 for _, room := range opts.AllowedRooms {
72 if room == "" {
73 return nil, ErrUnauthorizedRoom
74 }
75 r.rooms[room] = &roomQueue{wake: make(chan struct{}, 1)}
76 }
77 if err := r.restore(); err != nil {
78 return nil, err
79 }
80 return r, nil
81}
82func (r *Runner) Start(ctx context.Context) {
83 r.mu.Lock()
84 defer r.mu.Unlock()
85 if r.ctx != nil || r.stopped {
86 return
87 }
88 r.ctx, r.cancel = context.WithCancel(ctx)
89 for room, q := range r.rooms {
90 r.wg.Add(1)
91 go r.work(room, q)
92 }
93}
94func (r *Runner) Enqueue(m Mention) error {
95 if m.EventID == "" || m.Author == "" || len(m.Prompt) > 64*1024 {
96 return errors.New("agent runner: invalid mention")
97 }
98 r.mu.Lock()
99 defer r.mu.Unlock()
100 if r.stopped {
101 return ErrStopped
102 }
103 q, ok := r.rooms[m.Room]
104 if !ok {
105 return ErrUnauthorizedRoom
106 }
107 if r.seen[m.EventID] {
108 return ErrDuplicate
109 }
110 if len(q.pending) >= r.opts.QueueLimit {
111 return ErrQueueFull
112 }
113 q.pending = append(q.pending, m)
114 r.seen[m.EventID] = true
115 r.order = append(r.order, m.EventID)
116 if err := r.save(); err != nil {
117 q.pending = q.pending[:len(q.pending)-1]
118 delete(r.seen, m.EventID)
119 r.order = r.order[:len(r.order)-1]
120 return err
121 }
122 select {
123 case q.wake <- struct{}{}:
124 default:
125 }
126 return nil
127}
128func (r *Runner) CancelFrom(room, author string) bool {
129 r.mu.Lock()
130 defer r.mu.Unlock()
131 q := r.rooms[room]
132 if q == nil {
133 return false
134 }
135 allowed := false
136 for _, m := range append(append([]Mention{}, q.active...), q.pending...) {
137 if author == "" || m.Author == author {
138 allowed = true
139 break
140 }
141 }
142 if !allowed {
143 return false
144 }
145 filtered := q.pending[:0]
146 for _, m := range q.pending {
147 if author == "" || m.Author == author {
148 continue
149 }
150 filtered = append(filtered, m)
151 }
152 q.pending = filtered
153 if q.cancel != nil && anyAuthor(q.active, author) {
154 q.cancel()
155 }
156 if err := r.save(); err != nil {
157 r.report(err)
158 }
159 return true
160}
161func (r *Runner) Cancel(room string) error { r.CancelFrom(room, ""); return nil }
162func (r *Runner) Stop() {
163 r.mu.Lock()
164 r.stopped = true
165 if r.cancel != nil {
166 r.cancel()
167 }
168 r.mu.Unlock()
169 r.wg.Wait()
170}
171func (r *Runner) work(room string, q *roomQueue) {
172 defer r.wg.Done()
173 for {
174 select {
175 case <-r.ctx.Done():
176 return
177 default:
178 }
179 r.mu.Lock()
180 if len(q.pending) == 0 {
181 r.mu.Unlock()
182 select {
183 case <-r.ctx.Done():
184 return
185 case <-q.wake:
186 }
187 continue
188 }
189 batch := takeBatch(q.pending)
190 q.pending = q.pending[len(batch):]
191 q.active = batch
192 ctx, cancel := context.WithCancel(r.ctx)
193 q.cancel = cancel
194 if err := r.save(); err != nil {
195 r.report(err)
196 cancel()
197 q.pending = append(batch, q.pending...)
198 q.active = nil
199 q.cancel = nil
200 r.mu.Unlock()
201 return
202 }
203 r.mu.Unlock()
204 err := r.opts.Handle(ctx, batch)
205 cancel()
206 r.mu.Lock()
207 if r.ctx.Err() != nil {
208 q.pending = append(batch, q.pending...)
209 }
210 q.active = nil
211 q.cancel = nil
212 if saveErr := r.save(); saveErr != nil {
213 r.report(saveErr)
214 }
215 r.mu.Unlock()
216 if err != nil && !errors.Is(err, context.Canceled) {
217 r.report(fmt.Errorf("agent room %s: %w", room, err))
218 }
219 }
220}
221
222func takeBatch(pending []Mention) []Mention {
223 if len(pending) == 0 {
224 return nil
225 }
226 root := pending[0].Root
227 n := 1
228 for n < len(pending) && pending[n].Root == root {
229 n++
230 }
231 return append([]Mention(nil), pending[:n]...)
232}
233
234func anyAuthor(events []Mention, author string) bool {
235 if author == "" {
236 return len(events) > 0
237 }
238 for _, event := range events {
239 if event.Author == author {
240 return true
241 }
242 }
243 return false
244}
245func (r *Runner) report(err error) {
246 if r.opts.OnError != nil {
247 r.opts.OnError(err)
248 }
249}
250func (r *Runner) restore() error {
251 if r.opts.StatePath == "" {
252 return nil
253 }
254 raw, err := os.ReadFile(r.opts.StatePath)
255 if errors.Is(err, os.ErrNotExist) {
256 return nil
257 }
258 if err != nil {
259 return fmt.Errorf("read agent state: %w", err)
260 }
261 if len(raw) > 32<<20 {
262 return errors.New("agent state exceeds 32 MiB")
263 }
264 var state journal
265 if err := json.Unmarshal(raw, &state); err != nil {
266 return fmt.Errorf("decode agent state: %w", err)
267 }
268 for _, id := range state.Seen {
269 if !r.seen[id] {
270 r.seen[id] = true
271 r.order = append(r.order, id)
272 }
273 }
274 for room, queue := range state.Pending {
275 q := r.rooms[room]
276 if q == nil {
277 return errors.New("agent state contains an unconfigured room")
278 }
279 if len(queue) > r.opts.QueueLimit*2 {
280 return ErrQueueFull
281 }
282 q.pending = queue
283 }
284 return nil
285}
286func (r *Runner) save() error {
287 if r.opts.StatePath == "" {
288 return nil
289 }
290 // Keep a bounded replay history while retaining every queued event.
291 active := map[string]bool{}
292 pending := map[string][]Mention{}
293 for room, q := range r.rooms {
294 pending[room] = append(append([]Mention{}, q.active...), q.pending...)
295 for _, m := range pending[room] {
296 active[m.EventID] = true
297 }
298 }
299 if len(r.order) > 10000 {
300 keep := make(map[string]bool, len(active)+10000)
301 for id := range active {
302 keep[id] = true
303 }
304 remaining := 10000 - len(active)
305 if remaining < 0 {
306 remaining = 0
307 }
308 for i := len(r.order) - 1; i >= 0 && remaining > 0; i-- {
309 if !keep[r.order[i]] {
310 keep[r.order[i]] = true
311 remaining--
312 }
313 }
314 kept := make([]string, 0, len(keep))
315 for _, id := range r.order {
316 if keep[id] {
317 kept = append(kept, id)
318 }
319 }
320 for id := range r.seen {
321 if !keep[id] {
322 delete(r.seen, id)
323 }
324 }
325 r.order = kept
326 }
327 raw, err := json.Marshal(journal{Pending: pending, Seen: r.order})
328 if err != nil {
329 return err
330 }
331 dir := filepath.Dir(r.opts.StatePath)
332 if err := os.MkdirAll(dir, 0700); err != nil {
333 return err
334 }
335 file, err := os.CreateTemp(dir, ".tiny-agent-*")
336 if err != nil {
337 return err
338 }
339 name := file.Name()
340 defer os.Remove(name)
341 if _, err = file.Write(raw); err != nil {
342 file.Close()
343 return err
344 }
345 if err = file.Sync(); err != nil {
346 file.Close()
347 return err
348 }
349 if err = file.Close(); err != nil {
350 return err
351 }
352 return os.Rename(name, r.opts.StatePath)
353}
354
355// Drain waits until queued work has finished, or the caller cancels.
356func (r *Runner) Drain(ctx context.Context) error {
357 ticker := time.NewTicker(10 * time.Millisecond)
358 defer ticker.Stop()
359 for {
360 r.mu.Lock()
361 empty := true
362 for _, q := range r.rooms {
363 empty = empty && len(q.pending) == 0 && len(q.active) == 0
364 }
365 r.mu.Unlock()
366 if empty {
367 return nil
368 }
369 select {
370 case <-ctx.Done():
371 return ctx.Err()
372 case <-ticker.C:
373 }
374 }
375}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs