perf(subagents): index detached agent state
- commit
- 2191ad6d155ecd7f2bb5faf316ec4ca2123188d7
- author
- brice · · 2026-08-06 15:52 UTC
- parent
- 92fc3cc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 380 | 380 | ||
| 381 | 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 | 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 | ||
| @@ -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 { | |||
| 49 | 49 | // session. Wired by Open(); when nil the inline 'R' shortcut is | |
| 50 | 50 | // disabled. | |
| 51 | 51 | 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 | 56 | rows []subagents.AgentSnapshot | |
| 54 | 57 | cursor int | |
| @@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||
| 123 | 126 | 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 | 134 | func (d *subagentsDialog) editorPrompt(th tui.Theme) string { | |
| 127 | 135 | if d.lineInput { | |
| 128 | 136 | return "" | |
| @@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing( | |||
| 362 | 370 | rows := d.snapshot() | |
| 363 | 371 | for i, r := range rows { | |
| 364 | 372 | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | |
| 373 | + | if d.loadTranscript != nil { | |
| 374 | + | _ = d.loadTranscript(r.ID) | |
| 375 | + | } | |
| 365 | 376 | d.cursor = i | |
| 366 | 377 | d.viewing = true | |
| 367 | 378 | // 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 | 526 | case tui.KeyEnter: | |
| 516 | 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 | 533 | d.viewing = true | |
| 518 | 534 | // Auto-open the inline editor (à la /btw) so the user | |
| 519 | 535 | // 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 is | 49 | // session. Wired by Open(); when nil the inline 'R' shortcut is | ||
| 50 | // disabled. | 50 | // disabled. | ||
| 51 | resume func(id string) error | 51 | 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.AgentSnapshot | 56 | rows []subagents.AgentSnapshot | ||
| 54 | cursor int | 57 | cursor int | ||
| @@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||||
| 123 | d.lineInput = enabled | 126 | 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 = i | 376 | d.cursor = i | ||
| 366 | d.viewing = true | 377 | d.viewing = true | ||
| 367 | // Auto-open the inline editor for running agents so | 378 | // 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 = true | 533 | d.viewing = true | ||
| 518 | // Auto-open the inline editor (à la /btw) so the user | 534 | // Auto-open the inline editor (à la /btw) so the user | ||
| 519 | // can start typing immediately. Skipped for non-running | 535 | // 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) { | |||
| 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 | 147 | func TestSubagentsDialogEscClosesList(t *testing.T) { | |
| 117 | 148 | d := newSubagentsDialog() | |
| 118 | 149 | 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) { | |||
| 77 | 77 | // editor, by typing /model on its own line to pop the picker. | |
| 78 | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | |
| 79 | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |
| 80 | + | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | |
| 80 | 81 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 81 | 82 | if i.cfg.LoggedInProviders != nil { | |
| 82 | 83 | i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |
| @@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 205 | 206 | i.subagentsStatus("", "/subagents logs <id>: missing id") | |
| 206 | 207 | return | |
| 207 | 208 | } | |
| 209 | + | if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil { | |
| 210 | + | i.subagentsStatus("", "logs: "+err.Error()) | |
| 211 | + | return | |
| 212 | + | } | |
| 208 | 213 | ok := i.subagentsDialog.OpenViewing( | |
| 209 | 214 | rest, | |
| 210 | 215 | 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 | return | 207 | 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 { | |||
| 110 | 110 | // Supervisor.Spawn so callers do not need to manage the socket directly. | |
| 111 | 111 | inbox *Inbox | |
| 112 | 112 | ||
| 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 | |
| 120 | 123 | ||
| 121 | 124 | lifecycleMu sync.Mutex | |
| 122 | 125 | processState ProcessState | |
| @@ -310,8 +313,10 @@ func (a *Agent) setResult(result *TurnResult) { | |||
| 310 | 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 | 318 | func (a *Agent) Transcript() []string { | |
| 319 | + | _ = a.loadTranscript() | |
| 315 | 320 | a.mu.Lock() | |
| 316 | 321 | defer a.mu.Unlock() | |
| 317 | 322 | 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 *Inbox | 111 | inbox *Inbox | ||
| 112 | 112 | ||||
| 113 | - | mu sync.Mutex | 113 | + | mu sync.Mutex |
| 114 | - | status Status | 114 | + | status Status |
| 115 | - | activity string | 115 | + | activity string |
| 116 | - | transcript []string | 116 | + | transcript []string |
| 117 | - | lastAssistant string | 117 | + | lastAssistant string |
| 118 | - | finished time.Time | 118 | + | finished time.Time |
| 119 | - | lastErr error | 119 | + | lastErr error |
| 120 | + | transcriptLoaded bool | |||
| 121 | + | legacyEventState bool | |||
| 122 | + | transcriptMu sync.Mutex | |||
| 120 | 123 | ||||
| 121 | lifecycleMu sync.Mutex | 124 | lifecycleMu sync.Mutex | ||
| 122 | processState ProcessState | 125 | 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 | |||
| 3 | 3 | // On-disk persistence for subagent agents. | |
| 4 | 4 | // | |
| 5 | 5 | // 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. | |
| 12 | 11 | // | |
| 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. | |
| 19 | 14 | ||
| 20 | 15 | import ( | |
| 21 | 16 | "context" | |
| @@ -61,6 +56,10 @@ type agentMeta struct { | |||
| 61 | 56 | MaxTurns int `json:"max_turns,omitempty"` | |
| 62 | 57 | Timeout time.Duration `json:"timeout,omitempty"` | |
| 63 | 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 | 63 | ProcessState ProcessState `json:"process_state,omitempty"` | |
| 65 | 64 | TurnState TurnState `json:"turn_state,omitempty"` | |
| 66 | 65 | CurrentTurnID string `json:"current_turn_id,omitempty"` | |
| @@ -92,6 +91,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||
| 92 | 91 | // half-parsable file that fails Reload. | |
| 93 | 92 | func writeAgentMeta(stateDir string, a *Agent) error { | |
| 94 | 93 | 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 | 103 | a.lifecycleMu.Lock() | |
| 96 | 104 | processState := a.processState | |
| 97 | 105 | turnState := a.turnState | |
| @@ -128,6 +136,10 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 128 | 136 | MaxTurns: a.MaxTurns, | |
| 129 | 137 | Timeout: a.Timeout, | |
| 130 | 138 | Tools: append([]string(nil), a.Tools...), | |
| 139 | + | Status: status, | |
| 140 | + | Activity: activity, | |
| 141 | + | Finished: finished, | |
| 142 | + | Error: errText, | |
| 131 | 143 | ProcessState: processState, | |
| 132 | 144 | TurnState: turnState, | |
| 133 | 145 | CurrentTurnID: currentTurnID, | |
| @@ -320,13 +332,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||
| 320 | 332 | return loaded, errs | |
| 321 | 333 | } | |
| 322 | 334 | ||
| 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. | |
| 327 | 338 | // | |
| 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. | |
| 330 | 341 | func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |
| 331 | 342 | // Metadata without a repository identity uses the current RepoRoot; | |
| 332 | 343 | // records with one retain their persisted repository so a restart from | |
| @@ -352,12 +363,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 352 | 363 | if lastActivity.IsZero() { | |
| 353 | 364 | lastActivity = m.Started | |
| 354 | 365 | } | |
| 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 | + | } | |
| 357 | 394 | if inboxLive(m.InboxPath) { | |
| 358 | 395 | processState = ProcessAlive | |
| 359 | 396 | activity = "live (detached)" | |
| 360 | 397 | } | |
| 398 | + | var lastErr error | |
| 399 | + | if m.Error != "" { | |
| 400 | + | lastErr = errors.New(m.Error) | |
| 401 | + | } | |
| 361 | 402 | agentStateDir := f.agentStateDir(m.ID) | |
| 362 | 403 | if m.EventLogPath != "" { | |
| 363 | 404 | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | |
| @@ -397,8 +438,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 397 | 438 | SessionPath: m.SessionPath, | |
| 398 | 439 | SessionID: m.SessionID, | |
| 399 | 440 | inbox: NewInbox(m.InboxPath), | |
| 400 | - | status: StatusDetached, | |
| 441 | + | status: status, | |
| 401 | 442 | activity: activity, | |
| 443 | + | finished: m.Finished, | |
| 444 | + | lastErr: lastErr, | |
| 445 | + | legacyEventState: legacyEventState, | |
| 402 | 446 | processState: processState, | |
| 403 | 447 | turnState: turnState, | |
| 404 | 448 | currentTurnID: m.CurrentTurnID, | |
| @@ -418,14 +462,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 418 | 462 | } | |
| 419 | 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 | 465 | // The append-only log may contain a terminal event from an earlier | |
| 430 | 466 | // attempt. A live socket is stronger evidence for the current process; | |
| 431 | 467 | // 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 | 483 | 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 | 560 | // replayEventsIntoAgent re-derives an agent's transcript and last | |
| 451 | 561 | // known status hint from its event log. Mirrors applyEventToSink in | |
| 452 | 562 | // runner.go but writes directly to the Agent fields (no Sink because | |
| @@ -487,36 +597,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 487 | 597 | a.status = StatusDone | |
| 488 | 598 | a.activity = "done (offline)" | |
| 489 | 599 | 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) | |
| 520 | 602 | case "agent_stopped": | |
| 521 | 603 | terminal = true | |
| 522 | 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 | 684 | // Resume re-attaches a Runner to a previously-spawned agent. Durable | |
| 559 | 685 | // session and event files are kept, while the transient inbox path is | |
| 560 | 686 | // 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 | 865 | inbox: NewInbox(m.InboxPath), | |
| 740 | 866 | status: StatusPending, | |
| 741 | 867 | activity: "resuming", | |
| 868 | + | transcriptLoaded: true, | |
| 742 | 869 | processState: ProcessPending, | |
| 743 | 870 | turnState: TurnQueued, | |
| 744 | 871 | 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 and | 5 | // 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 the | 7 | + | // compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks |
| 8 | - | // agent later. On a fresh zut launch, Supervisor.Reload() walks | 8 | + | // <root>/agents/*/meta.json and re-registers every agent without replaying |
| 9 | - | // <root>/agents/*/meta.json and re-registers every agent it finds in | 9 | + | // its full history. The event log remains the durable transcript source and |
| 10 | - | // StatusDetached so the user can see, view, resume, or remove them | 10 | + | // 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) and | 13 | + | // 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.FastMode | 93 | 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.processState | 104 | processState := a.processState | ||
| 97 | turnState := a.turnState | 105 | 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, errs | 332 | return loaded, errs | ||
| 321 | } | 333 | } | ||
| 322 | 334 | ||||
| 323 | - | // buildDetachedAgent constructs an Agent from a meta.json with no | 335 | + | // buildDetachedAgent constructs an Agent from compact metadata with no running |
| 324 | - | // running Runner. The agent's transcript is populated from the tail | 336 | + | // 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 should | 339 | + | // 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 from | 343 | // 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.Started | 364 | lastActivity = m.Started | ||
| 354 | } | 365 | } | ||
| 355 | - | processState := ProcessDetached | 366 | + | 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 = ProcessAlive | 395 | 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 reloaded | 404 | // 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 earlier | 465 | // 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 a | 467 | // 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 a | 483 | 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 last | 560 | // replayEventsIntoAgent re-derives an agent's transcript and last | ||
| 451 | // known status hint from its event log. Mirrors applyEventToSink in | 561 | // known status hint from its event log. Mirrors applyEventToSink in | ||
| 452 | // runner.go but writes directly to the Agent fields (no Sink because | 562 | // 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 = StatusDone | 597 | 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 []string | 601 | + | 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 = true | 603 | 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. Durable | 684 | // Resume re-attaches a Runner to a previously-spawned agent. Durable | ||
| 559 | // session and event files are kept, while the transient inbox path is | 685 | // session and event files are kept, while the transient inbox path is | ||
| 560 | // recalculated for the current runtime environment. Use this to | 686 | // 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) { | |||
| 288 | 288 | if a == nil { | |
| 289 | 289 | t.Fatal("reloaded agent not found") | |
| 290 | 290 | } | |
| 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()) | |
| 294 | 295 | } | |
| 295 | 296 | got := a.Transcript() | |
| 297 | + | if a.Status() != StatusDone { | |
| 298 | + | t.Errorf("hydrated status = %q; want done (offline)", a.Status()) | |
| 299 | + | } | |
| 296 | 300 | found := false | |
| 297 | 301 | for _, line := range got { | |
| 298 | 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 | 385 | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | |
| 308 | 386 | // doesn't blow up the whole reload. Directories with no meta.json | |
| 309 | 387 | // 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 := false | 300 | 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.json | 385 | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | ||
| 308 | // doesn't blow up the whole reload. Directories with no meta.json | 386 | // doesn't blow up the whole reload. Directories with no meta.json | ||
| 309 | // at all are silently ignored (Spawn that failed mid-way leaves | 387 | // 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 | |||
| 499 | 499 | inbox: NewInbox(inboxPath), | |
| 500 | 500 | status: StatusPending, | |
| 501 | 501 | activity: "queued", | |
| 502 | + | transcriptLoaded: true, | |
| 502 | 503 | processState: ProcessPending, | |
| 503 | 504 | turnState: TurnQueued, | |
| 504 | 505 | 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, | ||