detached 2191ad6

perf(subagents): index detached agent state

commit
2191ad6d155ecd7f2bb5faf316ec4ca2123188d7
author
brice · · 2026-08-06 15:52 UTC
parent
92fc3cc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +334 -71
M README.md +1 -1
@@ -380,7 +380,7 @@ Background subagents that run alongside your main session. Each one is a separat
380380
381381 **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.
382382
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they reappear as **detached** while history restores in the background; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
383+**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384384
385385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386386
@@ -380,7 +380,7 @@ Background subagents that run alongside your main session. Each one is a separat
380 380
381 **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.381 **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.
382 382
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they reappear as **detached** while history restores in the background; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.383+**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384 384
385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386 386
M packages/agent/modes/subagents_dialog.go +16 -0
@@ -49,6 +49,9 @@ type subagentsDialog struct {
4949 // session. Wired by Open(); when nil the inline 'R' shortcut is
5050 // disabled.
5151 resume func(id string) error
52+ // loadTranscript hydrates a detached agent's durable event history when
53+ // its transcript view is opened. Nil preserves snapshot-only behavior.
54+ loadTranscript func(id string) error
5255
5356 rows []subagents.AgentSnapshot
5457 cursor int
@@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
123126 d.lineInput = enabled
124127 }
125128
129+// SetLoadTranscript supplies the optional on-demand durable transcript loader.
130+func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131+ d.loadTranscript = fn
132+}
133+
126134 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
127135 if d.lineInput {
128136 return ""
@@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing(
362370 rows := d.snapshot()
363371 for i, r := range rows {
364372 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
373+ if d.loadTranscript != nil {
374+ _ = d.loadTranscript(r.ID)
375+ }
365376 d.cursor = i
366377 d.viewing = true
367378 // Auto-open the inline editor for running agents so
@@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
514525 }
515526 case tui.KeyEnter:
516527 if len(d.rows) > 0 {
528+ if a := d.selected(); a != nil && d.loadTranscript != nil {
529+ if err := d.loadTranscript(a.ID); err != nil {
530+ return false, "", "load transcript: " + err.Error()
531+ }
532+ }
517533 d.viewing = true
518534 // Auto-open the inline editor (à la /btw) so the user
519535 // can start typing immediately. Skipped for non-running
@@ -49,6 +49,9 @@ type subagentsDialog struct {
49 // session. Wired by Open(); when nil the inline 'R' shortcut is49 // session. Wired by Open(); when nil the inline 'R' shortcut is
50 // disabled.50 // disabled.
51 resume func(id string) error51 resume func(id string) error
52+ // loadTranscript hydrates a detached agent's durable event history when
53+ // its transcript view is opened. Nil preserves snapshot-only behavior.
54+ loadTranscript func(id string) error
52 55
53 rows []subagents.AgentSnapshot56 rows []subagents.AgentSnapshot
54 cursor int57 cursor int
@@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
123 d.lineInput = enabled126 d.lineInput = enabled
124 }127 }
125 128
129+// SetLoadTranscript supplies the optional on-demand durable transcript loader.
130+func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131+ d.loadTranscript = fn
132+}
133+
126 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {134 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
127 if d.lineInput {135 if d.lineInput {
128 return ""136 return ""
@@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing(
362 rows := d.snapshot()370 rows := d.snapshot()
363 for i, r := range rows {371 for i, r := range rows {
364 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {372 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
373+ if d.loadTranscript != nil {
374+ _ = d.loadTranscript(r.ID)
375+ }
365 d.cursor = i376 d.cursor = i
366 d.viewing = true377 d.viewing = true
367 // Auto-open the inline editor for running agents so378 // Auto-open the inline editor for running agents so
@@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
514 }525 }
515 case tui.KeyEnter:526 case tui.KeyEnter:
516 if len(d.rows) > 0 {527 if len(d.rows) > 0 {
528+ if a := d.selected(); a != nil && d.loadTranscript != nil {
529+ if err := d.loadTranscript(a.ID); err != nil {
530+ return false, "", "load transcript: " + err.Error()
531+ }
532+ }
517 d.viewing = true533 d.viewing = true
518 // Auto-open the inline editor (à la /btw) so the user534 // Auto-open the inline editor (à la /btw) so the user
519 // can start typing immediately. Skipped for non-running535 // can start typing immediately. Skipped for non-running
M packages/agent/modes/subagents_dialog_test.go +31 -0
@@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
113113 }
114114 }
115115
116+func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) {
117+ now := time.Now()
118+ hydrated := false
119+ loads := 0
120+ snapshot := func() []subagents.AgentSnapshot {
121+ row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now}
122+ if hydrated {
123+ row.Lines = []string{"loaded history"}
124+ }
125+ return []subagents.AgentSnapshot{row}
126+ }
127+ d := newSubagentsDialog()
128+ d.SetLoadTranscript(func(id string) error {
129+ if id != "alpha-1" {
130+ t.Fatalf("load id = %q", id)
131+ }
132+ loads++
133+ hydrated = true
134+ return nil
135+ })
136+ d.Open(snapshot, nil, nil, nil, nil, nil, "")
137+ _ = d.Render(tui.Theme{}, 80)
138+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
139+ if loads != 1 {
140+ t.Fatalf("transcript loads = %d, want 1", loads)
141+ }
142+ if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") {
143+ t.Fatalf("hydrated transcript missing from view:\n%s", out)
144+ }
145+}
146+
116147 func TestSubagentsDialogEscClosesList(t *testing.T) {
117148 d := newSubagentsDialog()
118149 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
@@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
113 }113 }
114 }114 }
115 115
116+func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) {
117+ now := time.Now()
118+ hydrated := false
119+ loads := 0
120+ snapshot := func() []subagents.AgentSnapshot {
121+ row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now}
122+ if hydrated {
123+ row.Lines = []string{"loaded history"}
124+ }
125+ return []subagents.AgentSnapshot{row}
126+ }
127+ d := newSubagentsDialog()
128+ d.SetLoadTranscript(func(id string) error {
129+ if id != "alpha-1" {
130+ t.Fatalf("load id = %q", id)
131+ }
132+ loads++
133+ hydrated = true
134+ return nil
135+ })
136+ d.Open(snapshot, nil, nil, nil, nil, nil, "")
137+ _ = d.Render(tui.Theme{}, 80)
138+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
139+ if loads != 1 {
140+ t.Fatalf("transcript loads = %d, want 1", loads)
141+ }
142+ if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") {
143+ t.Fatalf("hydrated transcript missing from view:\n%s", out)
144+ }
145+}
146+
116 func TestSubagentsDialogEscClosesList(t *testing.T) {147 func TestSubagentsDialogEscClosesList(t *testing.T) {
117 d := newSubagentsDialog()148 d := newSubagentsDialog()
118 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")149 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
M packages/agent/modes/subagents_slash.go +5 -0
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
7777 // editor, by typing /model on its own line to pop the picker.
7878 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
7979 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
8081 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
8182 if i.cfg.LoggedInProviders != nil {
8283 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
205206 i.subagentsStatus("", "/subagents logs <id>: missing id")
206207 return
207208 }
209+ if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
210+ i.subagentsStatus("", "logs: "+err.Error())
211+ return
212+ }
208213 ok := i.subagentsDialog.OpenViewing(
209214 rest,
210215 i.cfg.Supervisor.SnapshotAll,
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
77 // editor, by typing /model on its own line to pop the picker.77 // editor, by typing /model on its own line to pop the picker.
78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
80 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)81 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
81 if i.cfg.LoggedInProviders != nil {82 if i.cfg.LoggedInProviders != nil {
82 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())83 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
205 i.subagentsStatus("", "/subagents logs <id>: missing id")206 i.subagentsStatus("", "/subagents logs <id>: missing id")
206 return207 return
207 }208 }
209+ if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
210+ i.subagentsStatus("", "logs: "+err.Error())
211+ return
212+ }
208 ok := i.subagentsDialog.OpenViewing(213 ok := i.subagentsDialog.OpenViewing(
209 rest,214 rest,
210 i.cfg.Supervisor.SnapshotAll,215 i.cfg.Supervisor.SnapshotAll,
M packages/agent/subagents/agent.go +13 -8
@@ -110,13 +110,16 @@ type Agent struct {
110110 // Supervisor.Spawn so callers do not need to manage the socket directly.
111111 inbox *Inbox
112112
113- mu sync.Mutex
114- status Status
115- activity string
116- transcript []string
117- lastAssistant string
118- finished time.Time
119- lastErr error
113+ mu sync.Mutex
114+ status Status
115+ activity string
116+ transcript []string
117+ lastAssistant string
118+ finished time.Time
119+ lastErr error
120+ transcriptLoaded bool
121+ legacyEventState bool
122+ transcriptMu sync.Mutex
120123
121124 lifecycleMu sync.Mutex
122125 processState ProcessState
@@ -310,8 +313,10 @@ func (a *Agent) setResult(result *TurnResult) {
310313 a.lifecycleMu.Unlock()
311314 }
312315
313-// Transcript returns a copy of the running transcript.
316+// Transcript returns a copy of the running transcript. Detached agents
317+// hydrate their durable event history the first time it is requested.
314318 func (a *Agent) Transcript() []string {
319+ _ = a.loadTranscript()
315320 a.mu.Lock()
316321 defer a.mu.Unlock()
317322 out := make([]string, len(a.transcript))
@@ -110,13 +110,16 @@ type Agent struct {
110 // Supervisor.Spawn so callers do not need to manage the socket directly.110 // Supervisor.Spawn so callers do not need to manage the socket directly.
111 inbox *Inbox111 inbox *Inbox
112 112
113- mu sync.Mutex113+ mu sync.Mutex
114- status Status114+ status Status
115- activity string115+ activity string
116- transcript []string116+ transcript []string
117- lastAssistant string117+ lastAssistant string
118- finished time.Time118+ finished time.Time
119- lastErr error119+ lastErr error
120+ transcriptLoaded bool
121+ legacyEventState bool
122+ transcriptMu sync.Mutex
120 123
121 lifecycleMu sync.Mutex124 lifecycleMu sync.Mutex
122 processState ProcessState125 processState ProcessState
@@ -310,8 +313,10 @@ func (a *Agent) setResult(result *TurnResult) {
310 a.lifecycleMu.Unlock()313 a.lifecycleMu.Unlock()
311 }314 }
312 315
313-// Transcript returns a copy of the running transcript.316+// Transcript returns a copy of the running transcript. Detached agents
317+// hydrate their durable event history the first time it is requested.
314 func (a *Agent) Transcript() []string {318 func (a *Agent) Transcript() []string {
319+ _ = a.loadTranscript()
315 a.mu.Lock()320 a.mu.Lock()
316 defer a.mu.Unlock()321 defer a.mu.Unlock()
317 out := make([]string, len(a.transcript))322 out := make([]string, len(a.transcript))
M packages/agent/subagents/persist.go +186 -59
@@ -3,19 +3,14 @@ package subagents
33 // On-disk persistence for subagent agents.
44 //
55 // Every Spawn writes a meta.json next to the agent's events.jsonl and
6-// session.json. The file captures the immutable identity bits (id,
7-// task, branch, dir) plus the paths the runner needs to resume the
8-// agent later. On a fresh zut launch, Supervisor.Reload() walks
9-// <root>/agents/*/meta.json and re-registers every agent it finds in
10-// StatusDetached so the user can see, view, resume, or remove them
11-// from the dashboard.
6+// session.json. The file captures identity, resumable lifecycle state, and
7+// compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks
8+// <root>/agents/*/meta.json and re-registers every agent without replaying
9+// its full history. The event log remains the durable transcript source and
10+// is hydrated only when a caller requests that agent's transcript.
1211 //
13-// We don't try to keep meta.json in sync with mutable state (status,
14-// activity, transcript). Those live in the events log (durable) and
15-// in-memory Agent fields (rebuilt by Reload from the log tail).
16-// Keeping meta.json immutable means we never have to worry about
17-// concurrent writers stomping on each other and the file matters
18-// only on the spawn/reload boundary.
12+// Metadata writes are atomic (tmp + rename). The supervisor is the sole
13+// writer, so its lifecycle snapshots cannot race with another host process.
1914
2015 import (
2116 "context"
@@ -61,6 +56,10 @@ type agentMeta struct {
6156 MaxTurns int `json:"max_turns,omitempty"`
6257 Timeout time.Duration `json:"timeout,omitempty"`
6358 Tools []string `json:"tools,omitempty"`
59+ Status Status `json:"status,omitempty"`
60+ Activity string `json:"activity,omitempty"`
61+ Finished time.Time `json:"finished,omitempty"`
62+ Error string `json:"error,omitempty"`
6463 ProcessState ProcessState `json:"process_state,omitempty"`
6564 TurnState TurnState `json:"turn_state,omitempty"`
6665 CurrentTurnID string `json:"current_turn_id,omitempty"`
@@ -92,6 +91,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
9291 // half-parsable file that fails Reload.
9392 func writeAgentMeta(stateDir string, a *Agent) error {
9493 fastMode := a.FastMode
94+ a.mu.Lock()
95+ status := a.status
96+ activity := a.activity
97+ finished := a.finished
98+ errText := ""
99+ if a.lastErr != nil {
100+ errText = a.lastErr.Error()
101+ }
102+ a.mu.Unlock()
95103 a.lifecycleMu.Lock()
96104 processState := a.processState
97105 turnState := a.turnState
@@ -128,6 +136,10 @@ func writeAgentMeta(stateDir string, a *Agent) error {
128136 MaxTurns: a.MaxTurns,
129137 Timeout: a.Timeout,
130138 Tools: append([]string(nil), a.Tools...),
139+ Status: status,
140+ Activity: activity,
141+ Finished: finished,
142+ Error: errText,
131143 ProcessState: processState,
132144 TurnState: turnState,
133145 CurrentTurnID: currentTurnID,
@@ -320,13 +332,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
320332 return loaded, errs
321333 }
322334
323-// buildDetachedAgent constructs an Agent from a meta.json with no
324-// running Runner. The agent's transcript is populated from the tail
325-// of its event log so the dashboard immediately shows recent output;
326-// activity is inferred from the last lifecycle event.
335+// buildDetachedAgent constructs an Agent from compact metadata with no running
336+// Runner. Its transcript stays unloaded until a caller requests it, while the
337+// persisted lifecycle summary keeps the dashboard immediately useful.
327338 //
328-// The returned Agent has a closed `done` channel because Wait should
329-// return instantly: there is nothing to wait for.
339+// The returned Agent has a closed `done` channel because Wait should return
340+// instantly: there is nothing to wait for.
330341 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
331342 // Metadata without a repository identity uses the current RepoRoot;
332343 // records with one retain their persisted repository so a restart from
@@ -352,12 +363,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
352363 if lastActivity.IsZero() {
353364 lastActivity = m.Started
354365 }
355- processState := ProcessDetached
356- activity := "detached"
366+ persistedStatus := m.Status
367+ status := StatusDetached
368+ activity := strings.TrimSpace(m.Activity)
369+ processState := m.ProcessState
370+ legacyEventState := persistedStatus == ""
371+ if legacyEventState {
372+ activity = "detached"
373+ processState = ProcessDetached
374+ }
375+ if processState == "" {
376+ processState = ProcessDetached
377+ }
378+ if activity == "" {
379+ switch persistedStatus {
380+ case StatusDone:
381+ activity = "done"
382+ case StatusFailed:
383+ activity = "failed"
384+ case StatusKilled:
385+ activity = "cancelled"
386+ default:
387+ activity = "detached"
388+ }
389+ }
390+ if persistedStatus == StatusRunning || persistedStatus == StatusPending {
391+ activity = "detached (resume to continue)"
392+ processState = ProcessDetached
393+ }
357394 if inboxLive(m.InboxPath) {
358395 processState = ProcessAlive
359396 activity = "live (detached)"
360397 }
398+ var lastErr error
399+ if m.Error != "" {
400+ lastErr = errors.New(m.Error)
401+ }
361402 agentStateDir := f.agentStateDir(m.ID)
362403 if m.EventLogPath != "" {
363404 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded
@@ -397,8 +438,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
397438 SessionPath: m.SessionPath,
398439 SessionID: m.SessionID,
399440 inbox: NewInbox(m.InboxPath),
400- status: StatusDetached,
441+ status: status,
401442 activity: activity,
443+ finished: m.Finished,
444+ lastErr: lastErr,
445+ legacyEventState: legacyEventState,
402446 processState: processState,
403447 turnState: turnState,
404448 currentTurnID: m.CurrentTurnID,
@@ -418,14 +462,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
418462 }
419463 a.closeDone()
420464
421- // Recover transcript + activity hints from the event log. Best
422- // effort: a missing or unreadable log just leaves the agent
423- // detached with an empty transcript.
424- if a.EventLogPath != "" {
425- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
426- replayEventsIntoAgent(a, evs)
427- }
428- }
429465 // The append-only log may contain a terminal event from an earlier
430466 // attempt. A live socket is stronger evidence for the current process;
431467 // keep the reloaded entry detached/live so Stop and Remove cannot treat a
@@ -447,6 +483,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
447483 return a
448484 }
449485
486+// LoadTranscript hydrates one reloaded agent's durable event history. Reload
487+// deliberately skips this work so startup stays bounded by metadata size;
488+// callers request it only when opening the transcript or otherwise reading it.
489+func (f *Supervisor) LoadTranscript(id string) error {
490+ a := f.Get(id)
491+ if a == nil {
492+ return fmt.Errorf("subagent: no such agent %q", id)
493+ }
494+ return a.loadTranscript()
495+}
496+
497+func (a *Agent) loadTranscript() error {
498+ if a == nil {
499+ return nil
500+ }
501+ a.transcriptMu.Lock()
502+ defer a.transcriptMu.Unlock()
503+
504+ a.mu.Lock()
505+ if a.transcriptLoaded {
506+ a.mu.Unlock()
507+ return nil
508+ }
509+ path := a.EventLogPath
510+ legacyEventState := a.legacyEventState
511+ a.mu.Unlock()
512+ var summaryUpdatedAt, summaryLastActivity time.Time
513+ if !legacyEventState {
514+ a.lifecycleMu.Lock()
515+ summaryUpdatedAt = a.updatedAt
516+ summaryLastActivity = a.lastActivity
517+ a.lifecycleMu.Unlock()
518+ }
519+ if path == "" {
520+ a.mu.Lock()
521+ a.transcriptLoaded = true
522+ a.mu.Unlock()
523+ return nil
524+ }
525+
526+ events, err := ReadEventLog(path)
527+ if err != nil {
528+ return fmt.Errorf("read subagent transcript: %w", err)
529+ }
530+ a.mu.Lock()
531+ if a.transcriptLoaded {
532+ a.mu.Unlock()
533+ return nil
534+ }
535+ a.transcript = nil
536+ a.lastAssistant = ""
537+ a.outputBytes = 0
538+ a.outputLines = 0
539+ a.outputTruncated = false
540+ a.mu.Unlock()
541+
542+ if legacyEventState {
543+ // Metadata written before the dashboard summary existed has no
544+ // terminal status to trust. Preserve its historical event-derived
545+ // behavior once the user explicitly opens its transcript.
546+ replayEventsIntoAgent(a, events)
547+ } else {
548+ replayTranscriptIntoAgent(a, events)
549+ a.lifecycleMu.Lock()
550+ a.updatedAt = summaryUpdatedAt
551+ a.lastActivity = summaryLastActivity
552+ a.lifecycleMu.Unlock()
553+ }
554+ a.mu.Lock()
555+ a.transcriptLoaded = true
556+ a.mu.Unlock()
557+ return nil
558+}
559+
450560 // replayEventsIntoAgent re-derives an agent's transcript and last
451561 // known status hint from its event log. Mirrors applyEventToSink in
452562 // runner.go but writes directly to the Agent fields (no Sink because
@@ -487,36 +597,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
487597 a.status = StatusDone
488598 a.activity = "done (offline)"
489599 a.mu.Unlock()
490- case "assistant_message", "user_message":
491- var text []string
492- if c, ok := ev.Data["content"].([]any); ok {
493- for _, blk := range c {
494- m, _ := blk.(map[string]any)
495- if t, _ := m["type"].(string); t == "text" {
496- if txt, _ := m["text"].(string); txt != "" {
497- text = append(text, txt)
498- }
499- }
500- }
501- }
502- message := strings.Join(text, "\n")
503- if ev.Type == "assistant_message" {
504- a.appendAssistantMessage(message)
505- } else {
506- a.appendUserMessage(message)
507- }
508- case "stdout":
509- if txt, _ := ev.Data["text"].(string); txt != "" {
510- a.appendTranscript(txt)
511- }
512- case "stderr":
513- if txt, _ := ev.Data["text"].(string); txt != "" {
514- a.appendTranscript("stderr: " + txt)
515- }
516- case "error":
517- if msg, _ := ev.Data["message"].(string); msg != "" {
518- a.appendTranscript("error: " + msg)
519- }
600+ case "assistant_message", "user_message", "stdout", "stderr", "error":
601+ replayEventTranscript(a, ev)
520602 case "agent_stopped":
521603 terminal = true
522604 reason, _ := ev.Data["reason"].(string)
@@ -555,6 +637,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
555637 }
556638 }
557639
640+// replayTranscriptIntoAgent rebuilds only the visible transcript. Newer
641+// metadata already contains the authoritative dashboard lifecycle summary,
642+// so opening history must not overwrite it with an earlier event attempt.
643+func replayTranscriptIntoAgent(a *Agent, evs []Event) {
644+ for _, ev := range evs {
645+ replayEventTranscript(a, ev)
646+ }
647+}
648+
649+func replayEventTranscript(a *Agent, ev Event) {
650+ switch ev.Type {
651+ case "assistant_message", "user_message":
652+ var text []string
653+ if c, ok := ev.Data["content"].([]any); ok {
654+ for _, blk := range c {
655+ m, _ := blk.(map[string]any)
656+ if t, _ := m["type"].(string); t == "text" {
657+ if txt, _ := m["text"].(string); txt != "" {
658+ text = append(text, txt)
659+ }
660+ }
661+ }
662+ }
663+ message := strings.Join(text, "\n")
664+ if ev.Type == "assistant_message" {
665+ a.appendAssistantMessage(message)
666+ } else {
667+ a.appendUserMessage(message)
668+ }
669+ case "stdout":
670+ if txt, _ := ev.Data["text"].(string); txt != "" {
671+ a.appendTranscript(txt)
672+ }
673+ case "stderr":
674+ if txt, _ := ev.Data["text"].(string); txt != "" {
675+ a.appendTranscript("stderr: " + txt)
676+ }
677+ case "error":
678+ if msg, _ := ev.Data["message"].(string); msg != "" {
679+ a.appendTranscript("error: " + msg)
680+ }
681+ }
682+}
683+
558684 // Resume re-attaches a Runner to a previously-spawned agent. Durable
559685 // session and event files are kept, while the transient inbox path is
560686 // recalculated for the current runtime environment. Use this to
@@ -739,6 +865,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
739865 inbox: NewInbox(m.InboxPath),
740866 status: StatusPending,
741867 activity: "resuming",
868+ transcriptLoaded: true,
742869 processState: ProcessPending,
743870 turnState: TurnQueued,
744871 updatedAt: now,
@@ -3,19 +3,14 @@ package subagents
3 // On-disk persistence for subagent agents.3 // On-disk persistence for subagent agents.
4 //4 //
5 // Every Spawn writes a meta.json next to the agent's events.jsonl and5 // Every Spawn writes a meta.json next to the agent's events.jsonl and
6-// session.json. The file captures the immutable identity bits (id,6+// session.json. The file captures identity, resumable lifecycle state, and
7-// task, branch, dir) plus the paths the runner needs to resume the7+// compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks
8-// agent later. On a fresh zut launch, Supervisor.Reload() walks8+// <root>/agents/*/meta.json and re-registers every agent without replaying
9-// <root>/agents/*/meta.json and re-registers every agent it finds in9+// its full history. The event log remains the durable transcript source and
10-// StatusDetached so the user can see, view, resume, or remove them10+// is hydrated only when a caller requests that agent's transcript.
11-// from the dashboard.
12 //11 //
13-// We don't try to keep meta.json in sync with mutable state (status,12+// Metadata writes are atomic (tmp + rename). The supervisor is the sole
14-// activity, transcript). Those live in the events log (durable) and13+// writer, so its lifecycle snapshots cannot race with another host process.
15-// in-memory Agent fields (rebuilt by Reload from the log tail).
16-// Keeping meta.json immutable means we never have to worry about
17-// concurrent writers stomping on each other and the file matters
18-// only on the spawn/reload boundary.
19 14
20 import (15 import (
21 "context"16 "context"
@@ -61,6 +56,10 @@ type agentMeta struct {
61 MaxTurns int `json:"max_turns,omitempty"`56 MaxTurns int `json:"max_turns,omitempty"`
62 Timeout time.Duration `json:"timeout,omitempty"`57 Timeout time.Duration `json:"timeout,omitempty"`
63 Tools []string `json:"tools,omitempty"`58 Tools []string `json:"tools,omitempty"`
59+ Status Status `json:"status,omitempty"`
60+ Activity string `json:"activity,omitempty"`
61+ Finished time.Time `json:"finished,omitempty"`
62+ Error string `json:"error,omitempty"`
64 ProcessState ProcessState `json:"process_state,omitempty"`63 ProcessState ProcessState `json:"process_state,omitempty"`
65 TurnState TurnState `json:"turn_state,omitempty"`64 TurnState TurnState `json:"turn_state,omitempty"`
66 CurrentTurnID string `json:"current_turn_id,omitempty"`65 CurrentTurnID string `json:"current_turn_id,omitempty"`
@@ -92,6 +91,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
92 // half-parsable file that fails Reload.91 // half-parsable file that fails Reload.
93 func writeAgentMeta(stateDir string, a *Agent) error {92 func writeAgentMeta(stateDir string, a *Agent) error {
94 fastMode := a.FastMode93 fastMode := a.FastMode
94+ a.mu.Lock()
95+ status := a.status
96+ activity := a.activity
97+ finished := a.finished
98+ errText := ""
99+ if a.lastErr != nil {
100+ errText = a.lastErr.Error()
101+ }
102+ a.mu.Unlock()
95 a.lifecycleMu.Lock()103 a.lifecycleMu.Lock()
96 processState := a.processState104 processState := a.processState
97 turnState := a.turnState105 turnState := a.turnState
@@ -128,6 +136,10 @@ func writeAgentMeta(stateDir string, a *Agent) error {
128 MaxTurns: a.MaxTurns,136 MaxTurns: a.MaxTurns,
129 Timeout: a.Timeout,137 Timeout: a.Timeout,
130 Tools: append([]string(nil), a.Tools...),138 Tools: append([]string(nil), a.Tools...),
139+ Status: status,
140+ Activity: activity,
141+ Finished: finished,
142+ Error: errText,
131 ProcessState: processState,143 ProcessState: processState,
132 TurnState: turnState,144 TurnState: turnState,
133 CurrentTurnID: currentTurnID,145 CurrentTurnID: currentTurnID,
@@ -320,13 +332,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
320 return loaded, errs332 return loaded, errs
321 }333 }
322 334
323-// buildDetachedAgent constructs an Agent from a meta.json with no335+// buildDetachedAgent constructs an Agent from compact metadata with no running
324-// running Runner. The agent's transcript is populated from the tail336+// Runner. Its transcript stays unloaded until a caller requests it, while the
325-// of its event log so the dashboard immediately shows recent output;337+// persisted lifecycle summary keeps the dashboard immediately useful.
326-// activity is inferred from the last lifecycle event.
327 //338 //
328-// The returned Agent has a closed `done` channel because Wait should339+// The returned Agent has a closed `done` channel because Wait should return
329-// return instantly: there is nothing to wait for.340+// instantly: there is nothing to wait for.
330 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {341 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
331 // Metadata without a repository identity uses the current RepoRoot;342 // Metadata without a repository identity uses the current RepoRoot;
332 // records with one retain their persisted repository so a restart from343 // records with one retain their persisted repository so a restart from
@@ -352,12 +363,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
352 if lastActivity.IsZero() {363 if lastActivity.IsZero() {
353 lastActivity = m.Started364 lastActivity = m.Started
354 }365 }
355- processState := ProcessDetached366+ persistedStatus := m.Status
356- activity := "detached"367+ status := StatusDetached
368+ activity := strings.TrimSpace(m.Activity)
369+ processState := m.ProcessState
370+ legacyEventState := persistedStatus == ""
371+ if legacyEventState {
372+ activity = "detached"
373+ processState = ProcessDetached
374+ }
375+ if processState == "" {
376+ processState = ProcessDetached
377+ }
378+ if activity == "" {
379+ switch persistedStatus {
380+ case StatusDone:
381+ activity = "done"
382+ case StatusFailed:
383+ activity = "failed"
384+ case StatusKilled:
385+ activity = "cancelled"
386+ default:
387+ activity = "detached"
388+ }
389+ }
390+ if persistedStatus == StatusRunning || persistedStatus == StatusPending {
391+ activity = "detached (resume to continue)"
392+ processState = ProcessDetached
393+ }
357 if inboxLive(m.InboxPath) {394 if inboxLive(m.InboxPath) {
358 processState = ProcessAlive395 processState = ProcessAlive
359 activity = "live (detached)"396 activity = "live (detached)"
360 }397 }
398+ var lastErr error
399+ if m.Error != "" {
400+ lastErr = errors.New(m.Error)
401+ }
361 agentStateDir := f.agentStateDir(m.ID)402 agentStateDir := f.agentStateDir(m.ID)
362 if m.EventLogPath != "" {403 if m.EventLogPath != "" {
363 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded404 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded
@@ -397,8 +438,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
397 SessionPath: m.SessionPath,438 SessionPath: m.SessionPath,
398 SessionID: m.SessionID,439 SessionID: m.SessionID,
399 inbox: NewInbox(m.InboxPath),440 inbox: NewInbox(m.InboxPath),
400- status: StatusDetached,441+ status: status,
401 activity: activity,442 activity: activity,
443+ finished: m.Finished,
444+ lastErr: lastErr,
445+ legacyEventState: legacyEventState,
402 processState: processState,446 processState: processState,
403 turnState: turnState,447 turnState: turnState,
404 currentTurnID: m.CurrentTurnID,448 currentTurnID: m.CurrentTurnID,
@@ -418,14 +462,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
418 }462 }
419 a.closeDone()463 a.closeDone()
420 464
421- // Recover transcript + activity hints from the event log. Best
422- // effort: a missing or unreadable log just leaves the agent
423- // detached with an empty transcript.
424- if a.EventLogPath != "" {
425- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
426- replayEventsIntoAgent(a, evs)
427- }
428- }
429 // The append-only log may contain a terminal event from an earlier465 // The append-only log may contain a terminal event from an earlier
430 // attempt. A live socket is stronger evidence for the current process;466 // attempt. A live socket is stronger evidence for the current process;
431 // keep the reloaded entry detached/live so Stop and Remove cannot treat a467 // keep the reloaded entry detached/live so Stop and Remove cannot treat a
@@ -447,6 +483,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
447 return a483 return a
448 }484 }
449 485
486+// LoadTranscript hydrates one reloaded agent's durable event history. Reload
487+// deliberately skips this work so startup stays bounded by metadata size;
488+// callers request it only when opening the transcript or otherwise reading it.
489+func (f *Supervisor) LoadTranscript(id string) error {
490+ a := f.Get(id)
491+ if a == nil {
492+ return fmt.Errorf("subagent: no such agent %q", id)
493+ }
494+ return a.loadTranscript()
495+}
496+
497+func (a *Agent) loadTranscript() error {
498+ if a == nil {
499+ return nil
500+ }
501+ a.transcriptMu.Lock()
502+ defer a.transcriptMu.Unlock()
503+
504+ a.mu.Lock()
505+ if a.transcriptLoaded {
506+ a.mu.Unlock()
507+ return nil
508+ }
509+ path := a.EventLogPath
510+ legacyEventState := a.legacyEventState
511+ a.mu.Unlock()
512+ var summaryUpdatedAt, summaryLastActivity time.Time
513+ if !legacyEventState {
514+ a.lifecycleMu.Lock()
515+ summaryUpdatedAt = a.updatedAt
516+ summaryLastActivity = a.lastActivity
517+ a.lifecycleMu.Unlock()
518+ }
519+ if path == "" {
520+ a.mu.Lock()
521+ a.transcriptLoaded = true
522+ a.mu.Unlock()
523+ return nil
524+ }
525+
526+ events, err := ReadEventLog(path)
527+ if err != nil {
528+ return fmt.Errorf("read subagent transcript: %w", err)
529+ }
530+ a.mu.Lock()
531+ if a.transcriptLoaded {
532+ a.mu.Unlock()
533+ return nil
534+ }
535+ a.transcript = nil
536+ a.lastAssistant = ""
537+ a.outputBytes = 0
538+ a.outputLines = 0
539+ a.outputTruncated = false
540+ a.mu.Unlock()
541+
542+ if legacyEventState {
543+ // Metadata written before the dashboard summary existed has no
544+ // terminal status to trust. Preserve its historical event-derived
545+ // behavior once the user explicitly opens its transcript.
546+ replayEventsIntoAgent(a, events)
547+ } else {
548+ replayTranscriptIntoAgent(a, events)
549+ a.lifecycleMu.Lock()
550+ a.updatedAt = summaryUpdatedAt
551+ a.lastActivity = summaryLastActivity
552+ a.lifecycleMu.Unlock()
553+ }
554+ a.mu.Lock()
555+ a.transcriptLoaded = true
556+ a.mu.Unlock()
557+ return nil
558+}
559+
450 // replayEventsIntoAgent re-derives an agent's transcript and last560 // replayEventsIntoAgent re-derives an agent's transcript and last
451 // known status hint from its event log. Mirrors applyEventToSink in561 // known status hint from its event log. Mirrors applyEventToSink in
452 // runner.go but writes directly to the Agent fields (no Sink because562 // runner.go but writes directly to the Agent fields (no Sink because
@@ -487,36 +597,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
487 a.status = StatusDone597 a.status = StatusDone
488 a.activity = "done (offline)"598 a.activity = "done (offline)"
489 a.mu.Unlock()599 a.mu.Unlock()
490- case "assistant_message", "user_message":600+ case "assistant_message", "user_message", "stdout", "stderr", "error":
491- var text []string601+ replayEventTranscript(a, ev)
492- if c, ok := ev.Data["content"].([]any); ok {
493- for _, blk := range c {
494- m, _ := blk.(map[string]any)
495- if t, _ := m["type"].(string); t == "text" {
496- if txt, _ := m["text"].(string); txt != "" {
497- text = append(text, txt)
498- }
499- }
500- }
501- }
502- message := strings.Join(text, "\n")
503- if ev.Type == "assistant_message" {
504- a.appendAssistantMessage(message)
505- } else {
506- a.appendUserMessage(message)
507- }
508- case "stdout":
509- if txt, _ := ev.Data["text"].(string); txt != "" {
510- a.appendTranscript(txt)
511- }
512- case "stderr":
513- if txt, _ := ev.Data["text"].(string); txt != "" {
514- a.appendTranscript("stderr: " + txt)
515- }
516- case "error":
517- if msg, _ := ev.Data["message"].(string); msg != "" {
518- a.appendTranscript("error: " + msg)
519- }
520 case "agent_stopped":602 case "agent_stopped":
521 terminal = true603 terminal = true
522 reason, _ := ev.Data["reason"].(string)604 reason, _ := ev.Data["reason"].(string)
@@ -555,6 +637,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
555 }637 }
556 }638 }
557 639
640+// replayTranscriptIntoAgent rebuilds only the visible transcript. Newer
641+// metadata already contains the authoritative dashboard lifecycle summary,
642+// so opening history must not overwrite it with an earlier event attempt.
643+func replayTranscriptIntoAgent(a *Agent, evs []Event) {
644+ for _, ev := range evs {
645+ replayEventTranscript(a, ev)
646+ }
647+}
648+
649+func replayEventTranscript(a *Agent, ev Event) {
650+ switch ev.Type {
651+ case "assistant_message", "user_message":
652+ var text []string
653+ if c, ok := ev.Data["content"].([]any); ok {
654+ for _, blk := range c {
655+ m, _ := blk.(map[string]any)
656+ if t, _ := m["type"].(string); t == "text" {
657+ if txt, _ := m["text"].(string); txt != "" {
658+ text = append(text, txt)
659+ }
660+ }
661+ }
662+ }
663+ message := strings.Join(text, "\n")
664+ if ev.Type == "assistant_message" {
665+ a.appendAssistantMessage(message)
666+ } else {
667+ a.appendUserMessage(message)
668+ }
669+ case "stdout":
670+ if txt, _ := ev.Data["text"].(string); txt != "" {
671+ a.appendTranscript(txt)
672+ }
673+ case "stderr":
674+ if txt, _ := ev.Data["text"].(string); txt != "" {
675+ a.appendTranscript("stderr: " + txt)
676+ }
677+ case "error":
678+ if msg, _ := ev.Data["message"].(string); msg != "" {
679+ a.appendTranscript("error: " + msg)
680+ }
681+ }
682+}
683+
558 // Resume re-attaches a Runner to a previously-spawned agent. Durable684 // Resume re-attaches a Runner to a previously-spawned agent. Durable
559 // session and event files are kept, while the transient inbox path is685 // session and event files are kept, while the transient inbox path is
560 // recalculated for the current runtime environment. Use this to686 // recalculated for the current runtime environment. Use this to
@@ -739,6 +865,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
739 inbox: NewInbox(m.InboxPath),865 inbox: NewInbox(m.InboxPath),
740 status: StatusPending,866 status: StatusPending,
741 activity: "resuming",867 activity: "resuming",
868+ transcriptLoaded: true,
742 processState: ProcessPending,869 processState: ProcessPending,
743 turnState: TurnQueued,870 turnState: TurnQueued,
744 updatedAt: now,871 updatedAt: now,
M packages/agent/subagents/persist_test.go +81 -3
@@ -288,11 +288,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
288288 if a == nil {
289289 t.Fatal("reloaded agent not found")
290290 }
291- // Status should reflect the lifecycle terminator we wrote.
292- if a.Status() != StatusDone {
293- t.Errorf("status = %q; want done (offline)", a.Status())
291+ // Legacy metadata has no persisted dashboard status, so startup keeps the
292+ // agent detached until the user asks to inspect its durable history.
293+ if a.Status() != StatusDetached {
294+ t.Errorf("startup status = %q; want detached", a.Status())
294295 }
295296 got := a.Transcript()
297+ if a.Status() != StatusDone {
298+ t.Errorf("hydrated status = %q; want done (offline)", a.Status())
299+ }
296300 found := false
297301 for _, line := range got {
298302 if line == "hello from the past" {
@@ -304,6 +308,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
304308 }
305309 }
306310
311+// TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup
312+// rebuilds the dashboard from compact metadata rather than replaying the
313+// event log. Opening the transcript remains the explicit point that loads
314+// durable history.
315+func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) {
316+ root := t.TempDir()
317+ id := "summary-1"
318+ stateDir := filepath.Join(root, "agents", id)
319+ agent := &Agent{
320+ ID: id,
321+ Task: "persist summary",
322+ OriginalTask: "persist summary",
323+ Dir: root,
324+ Started: time.Now().Add(-time.Hour),
325+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
326+ SessionPath: filepath.Join(stateDir, "session.json"),
327+ status: StatusDone,
328+ activity: "done",
329+ finished: time.Now().Add(-time.Minute),
330+ processState: ProcessExited,
331+ turnState: TurnSucceeded,
332+ currentTurnID: "turn-1",
333+ done: make(chan struct{}),
334+ }
335+ if err := writeAgentMeta(stateDir, agent); err != nil {
336+ t.Fatal(err)
337+ }
338+
339+ log, err := OpenEventLog(agent.EventLogPath)
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ if err := log.Append(NewEvent("assistant_message", map[string]any{
344+ "content": []any{map[string]any{"type": "text", "text": "historical reply"}},
345+ })); err != nil {
346+ t.Fatal(err)
347+ }
348+ if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil {
349+ t.Fatal(err)
350+ }
351+ if err := log.Close(); err != nil {
352+ t.Fatal(err)
353+ }
354+
355+ supervisor := New(Config{Root: root, RepoRoot: root})
356+ if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 {
357+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
358+ }
359+ reloaded := supervisor.Get(id)
360+ if reloaded == nil {
361+ t.Fatal("reloaded agent missing")
362+ }
363+ before := reloaded.Snapshot()
364+ if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded {
365+ t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState)
366+ }
367+ if len(before.Lines) != 0 {
368+ t.Fatalf("startup loaded transcript lines: %v", before.Lines)
369+ }
370+ lastActivity := before.LastActivity
371+
372+ lines := reloaded.Transcript()
373+ if len(lines) != 1 || lines[0] != "historical reply" {
374+ t.Fatalf("hydrated transcript = %v", lines)
375+ }
376+ after := reloaded.Snapshot()
377+ if after.Status != StatusDetached || after.Activity != "done" {
378+ t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity)
379+ }
380+ if !after.LastActivity.Equal(lastActivity) {
381+ t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity)
382+ }
383+}
384+
307385 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json
308386 // doesn't blow up the whole reload. Directories with no meta.json
309387 // at all are silently ignored (Spawn that failed mid-way leaves
@@ -288,11 +288,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
288 if a == nil {288 if a == nil {
289 t.Fatal("reloaded agent not found")289 t.Fatal("reloaded agent not found")
290 }290 }
291- // Status should reflect the lifecycle terminator we wrote.291+ // Legacy metadata has no persisted dashboard status, so startup keeps the
292- if a.Status() != StatusDone {292+ // agent detached until the user asks to inspect its durable history.
293- t.Errorf("status = %q; want done (offline)", a.Status())293+ if a.Status() != StatusDetached {
294+ t.Errorf("startup status = %q; want detached", a.Status())
294 }295 }
295 got := a.Transcript()296 got := a.Transcript()
297+ if a.Status() != StatusDone {
298+ t.Errorf("hydrated status = %q; want done (offline)", a.Status())
299+ }
296 found := false300 found := false
297 for _, line := range got {301 for _, line := range got {
298 if line == "hello from the past" {302 if line == "hello from the past" {
@@ -304,6 +308,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
304 }308 }
305 }309 }
306 310
311+// TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup
312+// rebuilds the dashboard from compact metadata rather than replaying the
313+// event log. Opening the transcript remains the explicit point that loads
314+// durable history.
315+func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) {
316+ root := t.TempDir()
317+ id := "summary-1"
318+ stateDir := filepath.Join(root, "agents", id)
319+ agent := &Agent{
320+ ID: id,
321+ Task: "persist summary",
322+ OriginalTask: "persist summary",
323+ Dir: root,
324+ Started: time.Now().Add(-time.Hour),
325+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
326+ SessionPath: filepath.Join(stateDir, "session.json"),
327+ status: StatusDone,
328+ activity: "done",
329+ finished: time.Now().Add(-time.Minute),
330+ processState: ProcessExited,
331+ turnState: TurnSucceeded,
332+ currentTurnID: "turn-1",
333+ done: make(chan struct{}),
334+ }
335+ if err := writeAgentMeta(stateDir, agent); err != nil {
336+ t.Fatal(err)
337+ }
338+
339+ log, err := OpenEventLog(agent.EventLogPath)
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ if err := log.Append(NewEvent("assistant_message", map[string]any{
344+ "content": []any{map[string]any{"type": "text", "text": "historical reply"}},
345+ })); err != nil {
346+ t.Fatal(err)
347+ }
348+ if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil {
349+ t.Fatal(err)
350+ }
351+ if err := log.Close(); err != nil {
352+ t.Fatal(err)
353+ }
354+
355+ supervisor := New(Config{Root: root, RepoRoot: root})
356+ if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 {
357+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
358+ }
359+ reloaded := supervisor.Get(id)
360+ if reloaded == nil {
361+ t.Fatal("reloaded agent missing")
362+ }
363+ before := reloaded.Snapshot()
364+ if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded {
365+ t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState)
366+ }
367+ if len(before.Lines) != 0 {
368+ t.Fatalf("startup loaded transcript lines: %v", before.Lines)
369+ }
370+ lastActivity := before.LastActivity
371+
372+ lines := reloaded.Transcript()
373+ if len(lines) != 1 || lines[0] != "historical reply" {
374+ t.Fatalf("hydrated transcript = %v", lines)
375+ }
376+ after := reloaded.Snapshot()
377+ if after.Status != StatusDetached || after.Activity != "done" {
378+ t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity)
379+ }
380+ if !after.LastActivity.Equal(lastActivity) {
381+ t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity)
382+ }
383+}
384+
307 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json385 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json
308 // doesn't blow up the whole reload. Directories with no meta.json386 // doesn't blow up the whole reload. Directories with no meta.json
309 // at all are silently ignored (Spawn that failed mid-way leaves387 // at all are silently ignored (Spawn that failed mid-way leaves
M packages/agent/subagents/supervisor.go +1 -0
@@ -499,6 +499,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
499499 inbox: NewInbox(inboxPath),
500500 status: StatusPending,
501501 activity: "queued",
502+ transcriptLoaded: true,
502503 processState: ProcessPending,
503504 turnState: TurnQueued,
504505 updatedAt: now,
@@ -499,6 +499,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
499 inbox: NewInbox(inboxPath),499 inbox: NewInbox(inboxPath),
500 status: StatusPending,500 status: StatusPending,
501 activity: "queued",501 activity: "queued",
502+ transcriptLoaded: true,
502 processState: ProcessPending,503 processState: ProcessPending,
503 turnState: TurnQueued,504 turnState: TurnQueued,
504 updatedAt: now,505 updatedAt: now,

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