Read only the event log tail for detached swarm agents
- commit
- 871ed2b7485b3d0207a79a82fe2960dacdb0bc62
- author
- Maximilian Feil · · 2026-09-24 11:05 UTC
- parent
- 6f6753c
- signature
- unsigned
2 files · +40 -1
M packages/agent/swarm/event.go +33 -0
| @@ -154,6 +154,39 @@ func ReadEventLog(path string) ([]Event, error) { | |||
| 154 | 154 | return readEvents(f) | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | // ReadEventLogTail parses only the last maxBytes of the log. Startup | |
| 158 | + | // replay of detached agents keeps a bounded transcript anyway, so | |
| 159 | + | // reading multi-gigabyte logs in full only delays the first frame. | |
| 160 | + | // The first, possibly partial, line of the window is skipped. | |
| 161 | + | func ReadEventLogTail(path string, maxBytes int64) ([]Event, error) { | |
| 162 | + | f, err := os.Open(path) | |
| 163 | + | if err != nil { | |
| 164 | + | if os.IsNotExist(err) { | |
| 165 | + | return nil, nil | |
| 166 | + | } | |
| 167 | + | return nil, err | |
| 168 | + | } | |
| 169 | + | defer f.Close() | |
| 170 | + | fi, err := f.Stat() | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | if maxBytes <= 0 || fi.Size() <= maxBytes { | |
| 175 | + | return readEvents(f) | |
| 176 | + | } | |
| 177 | + | if _, err := f.Seek(fi.Size()-maxBytes, io.SeekStart); err != nil { | |
| 178 | + | return nil, err | |
| 179 | + | } | |
| 180 | + | br := bufio.NewReader(f) | |
| 181 | + | if _, err := br.ReadBytes('\n'); err != nil { | |
| 182 | + | if errors.Is(err, io.EOF) { | |
| 183 | + | return nil, nil | |
| 184 | + | } | |
| 185 | + | return nil, err | |
| 186 | + | } | |
| 187 | + | return readEvents(br) | |
| 188 | + | } | |
| 189 | + | ||
| 157 | 190 | func readEvents(r io.Reader) ([]Event, error) { | |
| 158 | 191 | br := bufio.NewReader(r) | |
| 159 | 192 | var out []Event | |
| @@ -154,6 +154,39 @@ func ReadEventLog(path string) ([]Event, error) { | |||||
| 154 | return readEvents(f) | 154 | return readEvents(f) | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | + | // ReadEventLogTail parses only the last maxBytes of the log. Startup | |||
| 158 | + | // replay of detached agents keeps a bounded transcript anyway, so | |||
| 159 | + | // reading multi-gigabyte logs in full only delays the first frame. | |||
| 160 | + | // The first, possibly partial, line of the window is skipped. | |||
| 161 | + | func ReadEventLogTail(path string, maxBytes int64) ([]Event, error) { | |||
| 162 | + | f, err := os.Open(path) | |||
| 163 | + | if err != nil { | |||
| 164 | + | if os.IsNotExist(err) { | |||
| 165 | + | return nil, nil | |||
| 166 | + | } | |||
| 167 | + | return nil, err | |||
| 168 | + | } | |||
| 169 | + | defer f.Close() | |||
| 170 | + | fi, err := f.Stat() | |||
| 171 | + | if err != nil { | |||
| 172 | + | return nil, err | |||
| 173 | + | } | |||
| 174 | + | if maxBytes <= 0 || fi.Size() <= maxBytes { | |||
| 175 | + | return readEvents(f) | |||
| 176 | + | } | |||
| 177 | + | if _, err := f.Seek(fi.Size()-maxBytes, io.SeekStart); err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 180 | + | br := bufio.NewReader(f) | |||
| 181 | + | if _, err := br.ReadBytes('\n'); err != nil { | |||
| 182 | + | if errors.Is(err, io.EOF) { | |||
| 183 | + | return nil, nil | |||
| 184 | + | } | |||
| 185 | + | return nil, err | |||
| 186 | + | } | |||
| 187 | + | return readEvents(br) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 157 | func readEvents(r io.Reader) ([]Event, error) { | 190 | func readEvents(r io.Reader) ([]Event, error) { | ||
| 158 | br := bufio.NewReader(r) | 191 | br := bufio.NewReader(r) | ||
| 159 | var out []Event | 192 | var out []Event | ||
M packages/agent/swarm/persist.go +7 -1
| @@ -180,6 +180,12 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 180 | 180 | return loaded, errs | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | + | // detachedReplayBytes bounds how much of each event log is replayed for | |
| 184 | + | // a detached agent. The transcript keeps at most 2000 lines and the | |
| 185 | + | // terminal lifecycle event sits at the end of the log, so the tail is | |
| 186 | + | // enough; full logs are still read on demand by the dashboard. | |
| 187 | + | const detachedReplayBytes = 4 << 20 | |
| 188 | + | ||
| 183 | 189 | // buildDetachedAgent constructs an Agent from a meta.json with no | |
| 184 | 190 | // running Runner. The agent's transcript is populated from the tail | |
| 185 | 191 | // of its event log so the dashboard immediately shows recent output; | |
| @@ -223,7 +229,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 223 | 229 | // effort: a missing or unreadable log just leaves the agent | |
| 224 | 230 | // detached with an empty transcript. | |
| 225 | 231 | if a.EventLogPath != "" { | |
| 226 | - | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |
| 232 | + | if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil { | |
| 227 | 233 | replayEventsIntoAgent(a, evs) | |
| 228 | 234 | } | |
| 229 | 235 | } | |
| @@ -180,6 +180,12 @@ func (f *Swarm) Reload() (loaded int, errs []error) { | |||||
| 180 | return loaded, errs | 180 | return loaded, errs | ||
| 181 | } | 181 | } | ||
| 182 | 182 | ||||
| 183 | + | // detachedReplayBytes bounds how much of each event log is replayed for | |||
| 184 | + | // a detached agent. The transcript keeps at most 2000 lines and the | |||
| 185 | + | // terminal lifecycle event sits at the end of the log, so the tail is | |||
| 186 | + | // enough; full logs are still read on demand by the dashboard. | |||
| 187 | + | const detachedReplayBytes = 4 << 20 | |||
| 188 | + | ||||
| 183 | // buildDetachedAgent constructs an Agent from a meta.json with no | 189 | // buildDetachedAgent constructs an Agent from a meta.json with no | ||
| 184 | // running Runner. The agent's transcript is populated from the tail | 190 | // running Runner. The agent's transcript is populated from the tail | ||
| 185 | // of its event log so the dashboard immediately shows recent output; | 191 | // of its event log so the dashboard immediately shows recent output; | ||
| @@ -223,7 +229,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 223 | // effort: a missing or unreadable log just leaves the agent | 229 | // effort: a missing or unreadable log just leaves the agent | ||
| 224 | // detached with an empty transcript. | 230 | // detached with an empty transcript. | ||
| 225 | if a.EventLogPath != "" { | 231 | if a.EventLogPath != "" { | ||
| 226 | - | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | 232 | + | if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil { |
| 227 | replayEventsIntoAgent(a, evs) | 233 | replayEventsIntoAgent(a, evs) | ||
| 228 | } | 234 | } | ||
| 229 | } | 235 | } | ||