detached d45d6ef

Load full detached swarm history only on demand

commit
d45d6efe29e7f104a428a58bbbe69e6a62235e69
author
patriceckhart · · 2026-09-24 12:05 UTC
parent
01a4224
signature
unsigned
5 files · +62 -42
M README.md +1 -1
@@ -448,7 +448,7 @@ Background subagents that run alongside your main session. Each one is a separat
448448
449449 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
450450
451-**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off.
451+**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Startup reads only the last 4 MiB of each large event log. Opening `/swarm` or resuming an agent loads its full log on demand, which can briefly delay that action for very large logs. The displayed transcript still retains at most 2000 lines. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off.
452452
453453 **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`.
454454
@@ -448,7 +448,7 @@ Background subagents that run alongside your main session. Each one is a separat
448 448
449 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.449 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
450 450
451-**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off.451+**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Startup reads only the last 4 MiB of each large event log. Opening `/swarm` or resuming an agent loads its full log on demand, which can briefly delay that action for very large logs. The displayed transcript still retains at most 2000 lines. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off.
452 452
453 **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`.453 **Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`.
454 454
M packages/agent/swarm/agent.go +4 -2
@@ -69,8 +69,10 @@ type Agent struct {
6969 activity string
7070 transcript []string
7171 lastAssistant string
72- finished time.Time
73- lastErr error
72+ // Large detached logs are loaded in full only when their transcript is viewed or resumed.
73+ needsFullReplay bool
74+ finished time.Time
75+ lastErr error
7476
7577 // OnTurnEnd, if set, fires once per prompt-level turn_end event
7678 // emitted by the swarm daemon wrapper. Provider/tool-loop
@@ -69,8 +69,10 @@ type Agent struct {
69 activity string69 activity string
70 transcript []string70 transcript []string
71 lastAssistant string71 lastAssistant string
72- finished time.Time72+ // Large detached logs are loaded in full only when their transcript is viewed or resumed.
73- lastErr error73+ needsFullReplay bool
74+ finished time.Time
75+ lastErr error
74 76
75 // OnTurnEnd, if set, fires once per prompt-level turn_end event77 // OnTurnEnd, if set, fires once per prompt-level turn_end event
76 // emitted by the swarm daemon wrapper. Provider/tool-loop78 // emitted by the swarm daemon wrapper. Provider/tool-loop
M packages/agent/swarm/persist.go +24 -32
@@ -180,8 +180,8 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
180180 return loaded, errs
181181 }
182182
183-// detachedReplayBytes is the initial replay window. Expand it when a
184-// sparse transcript or a large event leaves relevant history outside it.
183+// detachedReplayBytes bounds startup replay; full history is loaded
184+// when the dashboard is opened or the agent is resumed.
185185 const detachedReplayBytes = 4 << 20
186186
187187 // buildDetachedAgent constructs an Agent from a meta.json with no
@@ -227,43 +227,34 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
227227 // effort: a missing or unreadable log just leaves the agent
228228 // detached with an empty transcript.
229229 if a.EventLogPath != "" {
230- replayDetachedEventLog(a)
230+ if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil {
231+ replayEventsIntoAgent(a, evs)
232+ }
233+ if fi, err := os.Stat(a.EventLogPath); err == nil && fi.Size() > detachedReplayBytes {
234+ a.needsFullReplay = true
235+ }
231236 }
232237 return a
233238 }
234239
235-// replayDetachedEventLog starts at the end of the file, expanding the
236-// window only when the bounded transcript or latest assistant reply may
237-// still depend on earlier events. In sparse logs, preserving those fields
238-// can require reading the entire file.
239-func replayDetachedEventLog(a *Agent) {
240- fi, err := os.Stat(a.EventLogPath)
241- if err != nil {
240+// loadFullTranscript backfills a detached agent on demand. Keep the
241+// status inferred from the newest lifecycle event in the startup tail.
242+// The agent's mutex serializes concurrent dashboard refreshes and resume.
243+func (a *Agent) loadFullTranscript() {
244+ a.mu.Lock()
245+ defer a.mu.Unlock()
246+ if !a.needsFullReplay {
242247 return
243248 }
244- size := fi.Size()
245- limit := int64(detachedReplayBytes)
246- for {
247- evs, err := ReadEventLogTail(a.EventLogPath, limit)
248- if err != nil {
249- return
250- }
251- if limit >= size {
252- replayEventsIntoAgent(a, evs)
253- return
254- }
255- trial := &Agent{status: StatusDetached, activity: "detached"}
256- replayEventsIntoAgent(trial, evs)
257- if len(trial.transcript) == 2000 && trial.lastAssistant != "" {
258- replayEventsIntoAgent(a, evs)
259- return
260- }
261- if limit >= size/2 {
262- limit = size
263- } else {
264- limit *= 2
265- }
249+ evs, err := ReadEventLog(a.EventLogPath)
250+ if err != nil {
251+ return
266252 }
253+ full := &Agent{status: StatusDetached, activity: "detached"}
254+ replayEventsIntoAgent(full, evs)
255+ a.transcript = full.transcript
256+ a.lastAssistant = full.lastAssistant
257+ a.needsFullReplay = false
267258 }
268259
269260 // replayEventsIntoAgent re-derives an agent's transcript and last
@@ -369,6 +360,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
369360 if st == StatusRunning || st == StatusPending {
370361 return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)
371362 }
363+ existing.loadFullTranscript()
372364
373365 // Rebuild from the meta record so we don't carry stale runner
374366 // state from a previous incarnation. The inbox is transient and
@@ -180,8 +180,8 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
180 return loaded, errs180 return loaded, errs
181 }181 }
182 182
183-// detachedReplayBytes is the initial replay window. Expand it when a183+// detachedReplayBytes bounds startup replay; full history is loaded
184-// sparse transcript or a large event leaves relevant history outside it.184+// when the dashboard is opened or the agent is resumed.
185 const detachedReplayBytes = 4 << 20185 const detachedReplayBytes = 4 << 20
186 186
187 // buildDetachedAgent constructs an Agent from a meta.json with no187 // buildDetachedAgent constructs an Agent from a meta.json with no
@@ -227,43 +227,34 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
227 // effort: a missing or unreadable log just leaves the agent227 // effort: a missing or unreadable log just leaves the agent
228 // detached with an empty transcript.228 // detached with an empty transcript.
229 if a.EventLogPath != "" {229 if a.EventLogPath != "" {
230- replayDetachedEventLog(a)230+ if evs, err := ReadEventLogTail(a.EventLogPath, detachedReplayBytes); err == nil {
231+ replayEventsIntoAgent(a, evs)
232+ }
233+ if fi, err := os.Stat(a.EventLogPath); err == nil && fi.Size() > detachedReplayBytes {
234+ a.needsFullReplay = true
235+ }
231 }236 }
232 return a237 return a
233 }238 }
234 239
235-// replayDetachedEventLog starts at the end of the file, expanding the240+// loadFullTranscript backfills a detached agent on demand. Keep the
236-// window only when the bounded transcript or latest assistant reply may241+// status inferred from the newest lifecycle event in the startup tail.
237-// still depend on earlier events. In sparse logs, preserving those fields242+// The agent's mutex serializes concurrent dashboard refreshes and resume.
238-// can require reading the entire file.243+func (a *Agent) loadFullTranscript() {
239-func replayDetachedEventLog(a *Agent) {244+ a.mu.Lock()
240- fi, err := os.Stat(a.EventLogPath)245+ defer a.mu.Unlock()
241- if err != nil {246+ if !a.needsFullReplay {
242 return247 return
243 }248 }
244- size := fi.Size()249+ evs, err := ReadEventLog(a.EventLogPath)
245- limit := int64(detachedReplayBytes)250+ if err != nil {
246- for {251+ return
247- evs, err := ReadEventLogTail(a.EventLogPath, limit)
248- if err != nil {
249- return
250- }
251- if limit >= size {
252- replayEventsIntoAgent(a, evs)
253- return
254- }
255- trial := &Agent{status: StatusDetached, activity: "detached"}
256- replayEventsIntoAgent(trial, evs)
257- if len(trial.transcript) == 2000 && trial.lastAssistant != "" {
258- replayEventsIntoAgent(a, evs)
259- return
260- }
261- if limit >= size/2 {
262- limit = size
263- } else {
264- limit *= 2
265- }
266 }252 }
253+ full := &Agent{status: StatusDetached, activity: "detached"}
254+ replayEventsIntoAgent(full, evs)
255+ a.transcript = full.transcript
256+ a.lastAssistant = full.lastAssistant
257+ a.needsFullReplay = false
267 }258 }
268 259
269 // replayEventsIntoAgent re-derives an agent's transcript and last260 // replayEventsIntoAgent re-derives an agent's transcript and last
@@ -369,6 +360,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
369 if st == StatusRunning || st == StatusPending {360 if st == StatusRunning || st == StatusPending {
370 return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)361 return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)
371 }362 }
363+ existing.loadFullTranscript()
372 364
373 // Rebuild from the meta record so we don't carry stale runner365 // Rebuild from the meta record so we don't carry stale runner
374 // state from a previous incarnation. The inbox is transient and366 // state from a previous incarnation. The inbox is transient and
M packages/agent/swarm/persist_test.go +30 -6
@@ -243,12 +243,36 @@ func TestDetachedReplayKeepsLastAnswerAcrossLargeEvents(t *testing.T) {
243243 t.Fatal(err)
244244 }
245245
246- a := New(Config{Root: root, RepoRoot: root}).buildDetachedAgent(agentMeta{
247- ID: "large", EventLogPath: path,
248- })
249- if got := a.Snapshot(); got.LastAssistant != "final answer" ||
250- len(got.Lines) != 1 || got.Lines[0] != "final answer" || got.Status != StatusDone {
251- t.Fatalf("replayed snapshot: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status)
246+ f := New(Config{Root: root, RepoRoot: root})
247+ a := f.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path})
248+ if got := a.Snapshot(); got.LastAssistant != "" || len(got.Lines) != 0 || got.Status != StatusDone {
249+ t.Fatalf("startup should use a bounded tail: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status)
250+ }
251+ f.agents[a.ID] = a
252+ f.order = append(f.order, a.ID)
253+ got := f.SnapshotAll()
254+ if len(got) != 1 || got[0].LastAssistant != "final answer" ||
255+ len(got[0].Lines) != 1 || got[0].Lines[0] != "final answer" || got[0].Status != StatusDone {
256+ t.Fatalf("dashboard snapshot: %v", got)
257+ }
258+
259+ // Resume must backfill even when the dashboard was never opened.
260+ f2 := New(Config{Root: root, RepoRoot: root, NewRunner: func(*Agent) Runner {
261+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
262+ <-ctx.Done()
263+ return ctx.Err()
264+ })
265+ }})
266+ defer f2.StopAll()
267+ b := f2.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path})
268+ f2.agents[b.ID] = b
269+ f2.order = append(f2.order, b.ID)
270+ resumed, err := f2.Resume(context.Background(), b.ID)
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if lines := resumed.Transcript(); len(lines) != 1 || lines[0] != "final answer" {
275+ t.Fatalf("resumed transcript: %v", lines)
252276 }
253277 }
254278
@@ -243,12 +243,36 @@ func TestDetachedReplayKeepsLastAnswerAcrossLargeEvents(t *testing.T) {
243 t.Fatal(err)243 t.Fatal(err)
244 }244 }
245 245
246- a := New(Config{Root: root, RepoRoot: root}).buildDetachedAgent(agentMeta{246+ f := New(Config{Root: root, RepoRoot: root})
247- ID: "large", EventLogPath: path,247+ a := f.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path})
248- })248+ if got := a.Snapshot(); got.LastAssistant != "" || len(got.Lines) != 0 || got.Status != StatusDone {
249- if got := a.Snapshot(); got.LastAssistant != "final answer" ||249+ t.Fatalf("startup should use a bounded tail: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status)
250- len(got.Lines) != 1 || got.Lines[0] != "final answer" || got.Status != StatusDone {250+ }
251- t.Fatalf("replayed snapshot: assistant=%q, lines=%v, status=%s", got.LastAssistant, got.Lines, got.Status)251+ f.agents[a.ID] = a
252+ f.order = append(f.order, a.ID)
253+ got := f.SnapshotAll()
254+ if len(got) != 1 || got[0].LastAssistant != "final answer" ||
255+ len(got[0].Lines) != 1 || got[0].Lines[0] != "final answer" || got[0].Status != StatusDone {
256+ t.Fatalf("dashboard snapshot: %v", got)
257+ }
258+
259+ // Resume must backfill even when the dashboard was never opened.
260+ f2 := New(Config{Root: root, RepoRoot: root, NewRunner: func(*Agent) Runner {
261+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
262+ <-ctx.Done()
263+ return ctx.Err()
264+ })
265+ }})
266+ defer f2.StopAll()
267+ b := f2.buildDetachedAgent(agentMeta{ID: "large", EventLogPath: path})
268+ f2.agents[b.ID] = b
269+ f2.order = append(f2.order, b.ID)
270+ resumed, err := f2.Resume(context.Background(), b.ID)
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if lines := resumed.Transcript(); len(lines) != 1 || lines[0] != "final answer" {
275+ t.Fatalf("resumed transcript: %v", lines)
252 }276 }
253 }277 }
254 278
M packages/agent/swarm/swarm.go +3 -1
@@ -540,7 +540,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
540540 }
541541
542542 // SnapshotAll returns snapshots of every agent in creation order,
543-// scoped to the active session when one is set.
543+// scoped to the active session when one is set. Opening the dashboard
544+// backfills large detached logs once, rather than delaying startup.
544545 //
545546 // Scoping rules:
546547 // - activeSession == "": no filter, every agent is returned
@@ -561,6 +562,7 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot {
561562 if active != "" && a.SessionID != "" && a.SessionID != active {
562563 continue
563564 }
565+ a.loadFullTranscript()
564566 out = append(out, a.Snapshot())
565567 }
566568 // Sort by start time for a stable, deterministic listing.
@@ -540,7 +540,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
540 }540 }
541 541
542 // SnapshotAll returns snapshots of every agent in creation order,542 // SnapshotAll returns snapshots of every agent in creation order,
543-// scoped to the active session when one is set.543+// scoped to the active session when one is set. Opening the dashboard
544+// backfills large detached logs once, rather than delaying startup.
544 //545 //
545 // Scoping rules:546 // Scoping rules:
546 // - activeSession == "": no filter, every agent is returned547 // - activeSession == "": no filter, every agent is returned
@@ -561,6 +562,7 @@ func (f *Swarm) SnapshotAll() []AgentSnapshot {
561 if active != "" && a.SessionID != "" && a.SessionID != active {562 if active != "" && a.SessionID != "" && a.SessionID != active {
562 continue563 continue
563 }564 }
565+ a.loadFullTranscript()
564 out = append(out, a.Snapshot())566 out = append(out, a.Snapshot())
565 }567 }
566 // Sort by start time for a stable, deterministic listing.568 // Sort by start time for a stable, deterministic listing.

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