detached 871ed2b

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) {
154154 return readEvents(f)
155155 }
156156
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+
157190 func readEvents(r io.Reader) ([]Event, error) {
158191 br := bufio.NewReader(r)
159192 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 []Event192 var out []Event
M packages/agent/swarm/persist.go +7 -1
@@ -180,6 +180,12 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
180180 return loaded, errs
181181 }
182182
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+
183189 // buildDetachedAgent constructs an Agent from a meta.json with no
184190 // running Runner. The agent's transcript is populated from the tail
185191 // of its event log so the dashboard immediately shows recent output;
@@ -223,7 +229,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
223229 // effort: a missing or unreadable log just leaves the agent
224230 // detached with an empty transcript.
225231 if a.EventLogPath != "" {
226- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
232+ if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil {
227233 replayEventsIntoAgent(a, evs)
228234 }
229235 }
@@ -180,6 +180,12 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
180 return loaded, errs180 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 no189 // buildDetachedAgent constructs an Agent from a meta.json with no
184 // running Runner. The agent's transcript is populated from the tail190 // 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 agent229 // 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close