v0.12.1 b9ff687

persist.go RawHistoryFolder

1package subagents
2
3// On-disk persistence for subagent agents.
4//
5// Every Spawn writes a meta.json next to the agent's events.jsonl and
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.
11//
12// Metadata writes are atomic (tmp + rename). The supervisor is the sole
13// writer, so its lifecycle snapshots cannot race with another host process.
14
15import (
16	"context"
17	"encoding/json"
18	"errors"
19	"fmt"
20	"os"
21	"path/filepath"
22	"sort"
23	"strings"
24	"time"
25
26	"github.com/google/uuid"
27)
28
29// agentMeta is the durable identity record for one agent. Only fields
30// the supervisor needs to rebuild an Agent after a restart live here.
31// Adding a field leaves it zero when absent; removing or renaming a field
32// changes the persisted contract.
33//
34// Historical fields like `branch` and `isolated` are silently dropped
35// by encoding/json's permissive decoder when an older meta.json is
36// loaded; we don't need to keep them in the struct.
37type agentMeta struct {
38	ID               string          `json:"id"`
39	Task             string          `json:"task"`
40	OriginalTask     string          `json:"original_task,omitempty"`
41	Dir              string          `json:"dir"`
42	RepositoryRoot   string          `json:"repository_root,omitempty"`
43	Started          time.Time       `json:"started"`
44	ParentID         string          `json:"parent_id,omitempty"`
45	BatchID          string          `json:"batch_id,omitempty"`
46	RootSessionID    string          `json:"root_session_id,omitempty"`
47	Model            string          `json:"model,omitempty"`
48	Provider         string          `json:"provider,omitempty"`
49	BaseURL          string          `json:"base_url,omitempty"`
50	InsecureTLS      bool            `json:"insecure_tls,omitempty"`
51	Reasoning        string          `json:"reasoning,omitempty"`
52	FastMode         *bool           `json:"fast_mode,omitempty"`
53	Subagent         string          `json:"subagent,omitempty"`
54	WorkspaceMode    WorkspaceMode   `json:"workspace_mode,omitempty"`
55	WorkspacePath    string          `json:"workspace_path,omitempty"`
56	WorkspaceBase    string          `json:"workspace_base,omitempty"`
57	WorkspaceCapture CaptureMode     `json:"workspace_capture,omitempty"`
58	MaxTurns         int             `json:"max_turns,omitempty"`
59	LifetimeTurns    int             `json:"lifetime_turns,omitempty"`
60	CurrentRunTurns  int             `json:"current_run_turns,omitempty"`
61	Timeout          time.Duration   `json:"timeout,omitempty"`
62	Tools            []string        `json:"tools,omitempty"`
63	WebSearchPolicy  WebSearchPolicy `json:"web_search_policy,omitempty"`
64	Status           Status          `json:"status,omitempty"`
65	Activity         string          `json:"activity,omitempty"`
66	Finished         time.Time       `json:"finished,omitempty"`
67	Error            string          `json:"error,omitempty"`
68	ProcessState     ProcessState    `json:"process_state,omitempty"`
69	TurnState        TurnState       `json:"turn_state,omitempty"`
70	CurrentTurnID    string          `json:"current_turn_id,omitempty"`
71	Attempt          int             `json:"attempt,omitempty"`
72	ProcessPID       int             `json:"process_pid,omitempty"`
73	UpdatedAt        time.Time       `json:"updated_at,omitempty"`
74	LastActivity     time.Time       `json:"last_activity,omitempty"`
75	ResultRef        string          `json:"result_ref,omitempty"`
76	PatchRef         string          `json:"patch_ref,omitempty"`
77	ChangedFiles     []string        `json:"changed_files,omitempty"`
78	InboxPath        string          `json:"inbox_path"`
79	EventLogPath     string          `json:"event_log_path"`
80	SessionPath      string          `json:"session_path"`
81	// ResumePrompt is a follow-up accepted by subagent_resume but not yet
82	// acknowledged by the child with a turn.started event. It is durable so a
83	// host exit while the worker is queued does not lose the manager signal.
84	ResumePrompt   string               `json:"resume_prompt,omitempty"`
85	ResumePromptAt time.Time            `json:"resume_prompt_at,omitempty"`
86	ResumePromptID string               `json:"resume_prompt_id,omitempty"`
87	ResumeQueue    []queuedResumePrompt `json:"resume_queue,omitempty"`
88
89	// SessionID, when non-empty, scopes the agent to a particular
90	// host zut session: the dashboard only shows agents whose
91	// SessionID matches the active session. Older meta.json files
92	// (and agents spawned outside of any session, e.g. by tests or
93	// scripted callers that didn't call SetActiveSession) have an
94	// empty SessionID and are visible from every session as a
95	// unscoped-agent fallback.
96	SessionID string `json:"session_id,omitempty"`
97}
98
99func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
100
101// writeAgentMeta serialises a's identity into stateDir/meta.json. The
102// write is atomic (tmp + rename) so a crash mid-write can't leave a
103// half-parsable file that fails Reload.
104func writeAgentMeta(stateDir string, a *Agent) error {
105	if a == nil {
106		return errors.New("subagent meta: nil agent")
107	}
108	a.persistMu.Lock()
109	defer a.persistMu.Unlock()
110
111	fastMode := a.FastMode
112	webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)
113	a.mu.Lock()
114	status := a.status
115	activity := a.activity
116	finished := a.finished
117	resumePrompt := a.resumePromptText
118	resumePromptAt := a.resumePromptAt
119	resumePromptID := a.resumePromptID
120	resumeQueue := append([]queuedResumePrompt(nil), a.resumePromptQueue...)
121	errText := ""
122	if a.lastErr != nil {
123		errText = a.lastErr.Error()
124	}
125	a.mu.Unlock()
126	a.lifecycleMu.Lock()
127	processState := a.processState
128	turnState := a.turnState
129	currentTurnID := a.currentTurnID
130	lifetimeTurns := a.LifetimeTurns
131	currentRunTurns := a.CurrentRunTurns
132	attempt := a.Attempt
133	processPID := a.ProcessPID
134	updatedAt := a.updatedAt
135	lastActivity := a.lastActivity
136	resultRef := a.resultRef
137	patchRef := a.patchRef
138	changedFiles := append([]string(nil), a.changedFiles...)
139	a.lifecycleMu.Unlock()
140	m := agentMeta{
141		ID:               a.ID,
142		Task:             a.Task,
143		OriginalTask:     a.OriginalTask,
144		Dir:              a.Dir,
145		RepositoryRoot:   a.RepositoryRoot,
146		Started:          a.Started,
147		ParentID:         a.ParentID,
148		BatchID:          a.BatchID,
149		RootSessionID:    a.RootSessionID,
150		Model:            a.Model,
151		Provider:         a.Provider,
152		BaseURL:          a.BaseURL,
153		InsecureTLS:      a.InsecureTLS,
154		Reasoning:        a.Reasoning,
155		FastMode:         &fastMode,
156		Subagent:         a.Subagent,
157		WorkspaceMode:    a.WorkspaceMode,
158		WorkspacePath:    a.WorkspacePath,
159		WorkspaceBase:    a.WorkspaceBase,
160		WorkspaceCapture: a.WorkspaceCapture,
161		MaxTurns:         a.MaxTurns,
162		LifetimeTurns:    lifetimeTurns,
163		CurrentRunTurns:  currentRunTurns,
164		Timeout:          a.Timeout,
165		Tools:            append([]string(nil), a.Tools...),
166		WebSearchPolicy:  webSearchPolicy,
167		Status:           status,
168		Activity:         activity,
169		Finished:         finished,
170		Error:            errText,
171		ProcessState:     processState,
172		TurnState:        turnState,
173		CurrentTurnID:    currentTurnID,
174		Attempt:          attempt,
175		ProcessPID:       processPID,
176		UpdatedAt:        updatedAt,
177		LastActivity:     lastActivity,
178		ResultRef:        resultRef,
179		PatchRef:         patchRef,
180		ChangedFiles:     changedFiles,
181		InboxPath:        a.InboxPath,
182		EventLogPath:     a.EventLogPath,
183		SessionPath:      a.SessionPath,
184		ResumePrompt:     resumePrompt,
185		ResumePromptAt:   resumePromptAt,
186		ResumePromptID:   resumePromptID,
187		ResumeQueue:      resumeQueue,
188		SessionID:        a.SessionID,
189	}
190	b, err := json.MarshalIndent(m, "", "  ")
191	if err != nil {
192		return fmt.Errorf("subagent meta marshal: %w", err)
193	}
194	if err := os.MkdirAll(stateDir, 0o700); err != nil {
195		return fmt.Errorf("subagents meta dir: %w", err)
196	}
197	final := metaPath(stateDir)
198	// Use a unique temporary file while holding the per-agent persistence
199	// lock. A fixed meta.json.tmp is vulnerable to a concurrent writer (or a
200	// stale file after a crash) replacing another writer's snapshot.
201	tmpFile, err := os.CreateTemp(stateDir, ".meta.json.tmp-*")
202	if err != nil {
203		return fmt.Errorf("subagents meta temp: %w", err)
204	}
205	tmp := tmpFile.Name()
206	removeTemp := true
207	defer func() {
208		_ = tmpFile.Close()
209		if removeTemp {
210			_ = os.Remove(tmp)
211		}
212	}()
213	if err := tmpFile.Chmod(0o600); err != nil {
214		return fmt.Errorf("subagents meta permissions: %w", err)
215	}
216	if _, err := tmpFile.Write(append(b, '\n')); err != nil {
217		return fmt.Errorf("subagents meta write: %w", err)
218	}
219	if err := tmpFile.Sync(); err != nil {
220		return fmt.Errorf("subagents meta sync: %w", err)
221	}
222	if err := tmpFile.Close(); err != nil {
223		return fmt.Errorf("subagents meta close: %w", err)
224	}
225	if err := os.Rename(tmp, final); err != nil {
226		return fmt.Errorf("subagent meta rename: %w", err)
227	}
228	removeTemp = false
229	// Sync the containing directory where the platform supports it so the
230	// rename itself survives a host crash, not just the temporary file bytes.
231	if err := syncDirectory(stateDir); err != nil {
232		return fmt.Errorf("subagents meta directory sync: %w", err)
233	}
234	return nil
235}
236
237// readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped)
238// when the file is missing so callers can distinguish "no such agent"
239// from "corrupt metadata".
240func readAgentMeta(stateDir string) (agentMeta, error) {
241	var m agentMeta
242	b, err := os.ReadFile(metaPath(stateDir))
243	if err != nil {
244		return m, err
245	}
246	if err := json.Unmarshal(b, &m); err != nil {
247		return m, fmt.Errorf("subagent meta parse %s: %w", stateDir, err)
248	}
249	if m.ID == "" {
250		return m, fmt.Errorf("subagent meta %s: missing id", stateDir)
251	}
252	return m, nil
253}
254
255func safeAgentID(id string) bool {
256	return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`)
257}
258
259func (f *Supervisor) sanitizeReloadMeta(stateDir, root string, m *agentMeta) error {
260	if m == nil {
261		return errors.New("subagents reload: nil metadata")
262	}
263	if m.EventLogPath == "" {
264		m.EventLogPath = filepath.Join(stateDir, "events.jsonl")
265	}
266	if m.SessionPath == "" {
267		m.SessionPath = filepath.Join(stateDir, "session.json")
268	}
269	if filepath.Base(m.EventLogPath) != "events.jsonl" || !pathWithin(m.EventLogPath, stateDir) {
270		return fmt.Errorf("subagents reload %s: event log path escapes agent state", stateDir)
271	}
272	if filepath.Base(m.SessionPath) != "session.json" || !pathWithin(m.SessionPath, stateDir) {
273		return fmt.Errorf("subagents reload %s: session path escapes agent state", stateDir)
274	}
275	if m.InboxPath == "" || !validRuntimeInboxPath(m.InboxPath, m.ID) {
276		// Old metadata may point at a runtime directory from a previous
277		// environment. Recompute that transient path rather than ever
278		// dialing an arbitrary socket supplied by metadata.
279		expectedInbox, err := inboxSocketPath(root, m.ID)
280		if err != nil {
281			return fmt.Errorf("subagents reload %s: inbox path: %w", stateDir, err)
282		}
283		m.InboxPath = expectedInbox
284	}
285	return nil
286}
287
288func validRuntimeInboxPath(path, id string) bool {
289	if !filepath.IsAbs(path) || (filepath.Base(path) != id+".sock" && filepath.Base(path) != shortHash(id)+".sock") {
290		return false
291	}
292	dir := filepath.Dir(path)
293	if !strings.HasPrefix(filepath.Base(dir), "zut-subagents-") {
294		return false
295	}
296	bases := []string{os.TempDir(), "/tmp"}
297	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) {
298		bases = append(bases, runtimeDir)
299	}
300	for _, base := range bases {
301		if pathWithin(dir, filepath.Clean(base)) {
302			return true
303		}
304	}
305	return false
306}
307
308func firstNonEmpty(values ...string) string {
309	for _, value := range values {
310		if strings.TrimSpace(value) != "" {
311			return value
312		}
313	}
314	return ""
315}
316
317// Reload scans <root>/agents/*/meta.json and re-registers every
318// previously-spawned agent as a StatusDetached entry. Agents already
319// present in memory are left alone (Reload is idempotent and safe to
320// call after Spawn, though in practice the cli invokes it exactly
321// once just after New()).
322//
323// The reloaded agents have no live Runner; the user can:
324//   - view their transcript (the dashboard reads from EventLogPath),
325//   - resume them via Supervisor.Resume (starts a fresh subprocess on the
326//     same worktree / session / inbox path),
327//   - remove them (worktree + meta + events log gone).
328//
329// Reload returns the number of agents loaded plus any per-directory
330// error encountered. Malformed entries are skipped rather than
331// failing the whole reload — one bad meta.json shouldn't hide the
332// rest of the subagents.
333func (f *Supervisor) Reload() (loaded int, errs []error) {
334	root := filepath.Clean(f.cfg.Root)
335	if root == "." {
336		return 0, nil
337	}
338	loaded, errs = f.reloadRoot(root)
339	errs = append(errs, f.reloadBatches(root)...)
340	return loaded, errs
341}
342
343func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
344	agentsDir := filepath.Join(root, "agents")
345	entries, err := os.ReadDir(agentsDir)
346	if err != nil {
347		if errors.Is(err, os.ErrNotExist) {
348			return 0, nil
349		}
350		return 0, []error{fmt.Errorf("subagents reload %s: %w", root, err)}
351	}
352
353	// Sort by directory name so the load order is stable across runs.
354	names := make([]string, 0, len(entries))
355	for _, e := range entries {
356		if e.IsDir() {
357			names = append(names, e.Name())
358		}
359	}
360	sort.Strings(names)
361
362	for _, name := range names {
363		stateDir := filepath.Join(agentsDir, name)
364		m, err := readAgentMeta(stateDir)
365		if err != nil {
366			if errors.Is(err, os.ErrNotExist) {
367				continue
368			}
369			errs = append(errs, err)
370			continue
371		}
372		if filepath.Base(stateDir) != m.ID || !safeAgentID(m.ID) {
373			errs = append(errs, fmt.Errorf("subagents reload %s: unsafe or mismatched agent id", stateDir))
374			continue
375		}
376		if err := f.sanitizeReloadMeta(stateDir, root, &m); err != nil {
377			errs = append(errs, err)
378			continue
379		}
380
381		f.mu.Lock()
382		if _, exists := f.agents[m.ID]; exists {
383			f.mu.Unlock()
384			continue
385		}
386		a := f.buildDetachedAgent(m)
387		f.agents[m.ID] = a
388		f.order = append(f.order, m.ID)
389		f.mu.Unlock()
390		loaded++
391	}
392	return loaded, errs
393}
394
395// buildDetachedAgent constructs an Agent from compact metadata with no running
396// Runner. Its transcript stays unloaded until a caller requests it, while the
397// persisted lifecycle summary keeps the dashboard immediately useful.
398//
399// The returned Agent has a closed `done` channel because Wait should return
400// instantly: there is nothing to wait for.
401func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
402	// Metadata without a repository identity uses the current RepoRoot;
403	// records with one retain their persisted repository so a restart from
404	// another checkout cannot silently redirect the child.
405	dir := m.Dir
406	if m.WorkspaceMode != WorkspaceWorktree && m.RepositoryRoot == "" && f.cfg.RepoRoot != "" {
407		// Preserve the best-effort migration only for records without an
408		// explicit repository identity.
409		dir = f.cfg.RepoRoot
410	}
411	if dir == "" {
412		dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot)
413	}
414	fastMode := f.cfg.FastMode
415	if m.FastMode != nil {
416		fastMode = *m.FastMode
417	}
418	turnState := m.TurnState
419	if turnState == "" {
420		turnState = TurnIdle
421	}
422	lastActivity := m.LastActivity
423	if lastActivity.IsZero() {
424		lastActivity = m.Started
425	}
426	persistedStatus := m.Status
427	status := StatusDetached
428	activity := strings.TrimSpace(m.Activity)
429	processState := m.ProcessState
430	legacyEventState := persistedStatus == ""
431	if legacyEventState {
432		activity = "detached"
433		processState = ProcessDetached
434	}
435	if processState == "" {
436		processState = ProcessDetached
437	}
438	if activity == "" {
439		switch persistedStatus {
440		case StatusDone:
441			activity = "done"
442		case StatusFailed:
443			activity = "failed"
444		case StatusKilled:
445			activity = "cancelled"
446		default:
447			activity = "detached"
448		}
449	}
450	if persistedStatus == StatusRunning || persistedStatus == StatusPending {
451		activity = "detached (resume to continue)"
452		processState = ProcessDetached
453	}
454	if inboxLive(m.InboxPath) {
455		processState = ProcessAlive
456		activity = "live (detached)"
457	}
458	var lastErr error
459	if m.Error != "" {
460		lastErr = errors.New(m.Error)
461	}
462	agentStateDir := f.agentStateDir(m.ID)
463	if m.EventLogPath != "" {
464		// EventLogPath is <state-dir>/events.jsonl; keep the reloaded
465		// agent's state scoped to its own directory. Using the parent
466		// agents/ directory here would make Remove delete sibling agents
467		// and would make resumed result/patch writes collide.
468		agentStateDir = filepath.Dir(m.EventLogPath)
469	}
470	a := &Agent{
471		ID:                m.ID,
472		Task:              m.Task,
473		OriginalTask:      firstNonEmpty(m.OriginalTask, m.Task),
474		Dir:               dir,
475		RepositoryRoot:    firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
476		Started:           m.Started,
477		ParentID:          m.ParentID,
478		BatchID:           m.BatchID,
479		RootSessionID:     m.RootSessionID,
480		Model:             m.Model,
481		Provider:          m.Provider,
482		BaseURL:           m.BaseURL,
483		InsecureTLS:       m.InsecureTLS,
484		Reasoning:         m.Reasoning,
485		FastMode:          fastMode,
486		Subagent:          m.Subagent,
487		WorkspaceMode:     m.WorkspaceMode,
488		WorkspacePath:     m.WorkspacePath,
489		WorkspaceBase:     m.WorkspaceBase,
490		WorkspaceCapture:  m.WorkspaceCapture,
491		MaxTurns:          m.MaxTurns,
492		LifetimeTurns:     m.LifetimeTurns,
493		CurrentRunTurns:   m.CurrentRunTurns,
494		Timeout:           m.Timeout,
495		Tools:             append([]string(nil), m.Tools...),
496		WebSearchPolicy:   childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
497		Attempt:           m.Attempt,
498		ProcessPID:        m.ProcessPID,
499		InboxPath:         m.InboxPath,
500		EventLogPath:      m.EventLogPath,
501		SessionPath:       m.SessionPath,
502		resumePromptText:  m.ResumePrompt,
503		resumePromptAt:    m.ResumePromptAt,
504		resumePromptID:    m.ResumePromptID,
505		resumePromptQueue: append([]queuedResumePrompt(nil), m.ResumeQueue...),
506		SessionID:         m.SessionID,
507		inbox:             NewInbox(m.InboxPath),
508		status:            status,
509		activity:          activity,
510		finished:          m.Finished,
511		lastErr:           lastErr,
512		legacyEventState:  legacyEventState,
513		processState:      processState,
514		turnState:         turnState,
515		currentTurnID:     m.CurrentTurnID,
516		updatedAt:         m.UpdatedAt,
517		lastActivity:      lastActivity,
518		stateDir:          agentStateDir,
519		resultRef:         m.ResultRef,
520		patchRef:          m.PatchRef,
521		changedFiles:      append([]string(nil), m.ChangedFiles...),
522		maxOutputBytes:    f.cfg.Policy.MaxOutputBytes,
523		maxOutputLines:    f.cfg.Policy.MaxOutputLines,
524		done:              make(chan struct{}),
525		turnResults:       make(chan *TurnResult, 16),
526	}
527	if a.updatedAt.IsZero() {
528		a.updatedAt = lastActivity
529	}
530	a.closeDone()
531
532	// The append-only log may contain a terminal event from an earlier
533	// attempt. A live socket is stronger evidence for the current process;
534	// keep the reloaded entry detached/live so Stop and Remove cannot treat a
535	// resumed worker as already finished.
536	if inboxLive(a.InboxPath) {
537		a.mu.Lock()
538		a.status = StatusDetached
539		a.activity = "live (detached)"
540		a.mu.Unlock()
541		a.setProcessState(ProcessAlive)
542	}
543	resultDir := f.agentStateDir(a.ID)
544	if a.EventLogPath != "" {
545		resultDir = filepath.Dir(a.EventLogPath)
546	}
547	if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil {
548		a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines))
549	}
550	return a
551}
552
553// LoadTranscript hydrates one reloaded agent's durable event history. Reload
554// deliberately skips this work so startup stays bounded by metadata size;
555// callers request it only when opening the transcript or otherwise reading it.
556func (f *Supervisor) LoadTranscript(id string) error {
557	a := f.Get(id)
558	if a == nil {
559		return fmt.Errorf("subagent: no such agent %q", id)
560	}
561	return a.loadTranscript()
562}
563
564func (a *Agent) loadTranscript() error {
565	if a == nil {
566		return nil
567	}
568	a.transcriptMu.Lock()
569	defer a.transcriptMu.Unlock()
570
571	a.mu.Lock()
572	if a.transcriptLoaded {
573		a.mu.Unlock()
574		return nil
575	}
576	path := a.EventLogPath
577	legacyEventState := a.legacyEventState
578	a.mu.Unlock()
579	var summaryUpdatedAt, summaryLastActivity time.Time
580	if !legacyEventState {
581		a.lifecycleMu.Lock()
582		summaryUpdatedAt = a.updatedAt
583		summaryLastActivity = a.lastActivity
584		a.lifecycleMu.Unlock()
585	}
586	if path == "" {
587		a.mu.Lock()
588		a.transcriptLoaded = true
589		a.mu.Unlock()
590		return nil
591	}
592
593	events, err := ReadEventLog(path)
594	if err != nil {
595		return fmt.Errorf("read subagent transcript: %w", err)
596	}
597	if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, prompt, acceptedAt) {
598		a.clearResumePrompt()
599	}
600	a.mu.Lock()
601	if a.transcriptLoaded {
602		a.mu.Unlock()
603		return nil
604	}
605	a.transcript = nil
606	a.lastAssistant = ""
607	a.outputBytes = 0
608	a.outputLines = 0
609	a.outputTruncated = false
610	a.mu.Unlock()
611
612	if legacyEventState {
613		// Metadata written before the dashboard summary existed has no
614		// terminal status to trust. Preserve its historical event-derived
615		// behavior once the user explicitly opens its transcript.
616		replayEventsIntoAgent(a, events)
617	} else {
618		replayTranscriptIntoAgent(a, events)
619		a.lifecycleMu.Lock()
620		a.updatedAt = summaryUpdatedAt
621		a.lastActivity = summaryLastActivity
622		a.lifecycleMu.Unlock()
623	}
624	a.mu.Lock()
625	a.transcriptLoaded = true
626	a.mu.Unlock()
627	return nil
628}
629
630// replayEventsIntoAgent re-derives an agent's transcript and last
631// known status hint from its event log. Mirrors applyEventToSink in
632// runner.go but writes directly to the Agent fields (no Sink because
633// the agent isn't being driven by a runner yet).
634//
635// Status precedence: explicit lifecycle events (agent_stopped) win
636// over inferred ones (assistant_message → idle). If the log contains
637// no terminator we keep status=StatusDetached so the user can resume.
638func resumePromptAcknowledged(events []Event, prompt string, acceptedAt time.Time) bool {
639	if prompt == "" || acceptedAt.IsZero() {
640		return false
641	}
642	userDurable := false
643	for _, ev := range events {
644		if ev.Time.Before(acceptedAt) {
645			continue
646		}
647		if ev.Type == "user_message" && userMessageText(ev) == prompt {
648			// The worker emits this event only after the matching session
649			// message has been appended and flushed. A crash here leaves the
650			// prompt pending, which is intentionally safe to replay.
651			userDurable = true
652			continue
653		}
654		if userDurable && isDelegatedTurnStart(ev) {
655			return true
656		}
657	}
658	return false
659}
660
661func replayEventsIntoAgent(a *Agent, evs []Event) {
662	terminal := false
663	for _, ev := range evs {
664		replayEventTranscript(a, ev)
665		switch ev.Type {
666		case EventTurnStarted:
667			if !isDelegatedTurnStart(ev) {
668				continue
669			}
670			a.setTurnState(TurnRunning, eventTurnID(ev))
671		case EventTurnResult, "turn_result":
672			if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
673				a.setResult(result)
674				switch result.Status {
675				case ResultFailed:
676					a.setTurnState(TurnFailed, result.TurnID)
677				case ResultCanceled:
678					a.setTurnState(TurnCanceled, result.TurnID)
679				default:
680					a.setTurnState(TurnSucceeded, result.TurnID)
681				}
682			}
683		case EventAgentIdle, "agent_idle":
684			a.setTurnState(TurnIdle, ev.TurnID)
685		case EventAgentExited, "agent_exited":
686			terminal = true
687			a.mu.Lock()
688			a.status = StatusDone
689			a.activity = "done (offline)"
690			a.mu.Unlock()
691		case "agent_stopped":
692			terminal = true
693			reason, _ := ev.Data["reason"].(string)
694			a.mu.Lock()
695			switch reason {
696			case "cancelled":
697				a.status = StatusKilled
698				a.activity = "cancelled (offline)"
699			case "shutdown":
700				a.status = StatusDone
701				a.activity = "shutdown (offline)"
702			case "exit":
703				if code, ok := ev.Data["code"].(float64); ok && code != 0 {
704					a.status = StatusFailed
705					a.activity = fmt.Sprintf("exit %d (offline)", int(code))
706				} else {
707					a.status = StatusDone
708					a.activity = "done (offline)"
709				}
710			default:
711				a.status = StatusDone
712				a.activity = "stopped (offline)"
713			}
714			a.mu.Unlock()
715		}
716	}
717	if !terminal {
718		// Non-terminal log means the previous parent died mid-run.
719		// Leave status=StatusDetached but record a hint so the
720		// dashboard shows something useful.
721		a.mu.Lock()
722		if a.activity == "detached" && len(a.transcript) > 0 {
723			a.activity = "detached (resume to continue)"
724		}
725		a.mu.Unlock()
726	}
727}
728
729// replayTranscriptIntoAgent rebuilds only the visible transcript. Newer
730// metadata already contains the authoritative dashboard lifecycle summary,
731// so opening history must not overwrite it with an earlier event attempt.
732func replayTranscriptIntoAgent(a *Agent, evs []Event) {
733	for _, ev := range evs {
734		replayEventTranscript(a, ev)
735	}
736}
737
738func replayEventTranscript(a *Agent, ev Event) {
739	if isAssistantStreamBoundary(ev.Type) {
740		a.resetStreamingAssistant()
741	}
742	switch ev.Type {
743	case "message.delta":
744		if delta, _ := ev.Data["delta"].(string); delta != "" {
745			a.appendAssistantDelta(delta)
746		}
747	case "assistant_message", "user_message":
748		var text []string
749		if c, ok := ev.Data["content"].([]any); ok {
750			for _, blk := range c {
751				m, _ := blk.(map[string]any)
752				if t, _ := m["type"].(string); t == "text" {
753					if txt, _ := m["text"].(string); txt != "" {
754						text = append(text, txt)
755					}
756				}
757			}
758		}
759		message := strings.Join(text, "\n")
760		if ev.Type == "assistant_message" {
761			a.appendAssistantMessage(message)
762		} else {
763			a.appendUserMessage(message)
764		}
765	case "stdout":
766		if txt, _ := ev.Data["text"].(string); txt != "" {
767			a.appendTranscript(txt)
768		}
769	case "stderr":
770		if txt, _ := ev.Data["text"].(string); txt != "" {
771			a.appendTranscript("stderr: " + txt)
772		}
773	case "error":
774		if msg, _ := ev.Data["message"].(string); msg != "" {
775			a.appendTranscript("error: " + msg)
776		}
777	}
778}
779
780// Resume re-attaches a Runner to a previously-spawned agent. Durable
781// session and event files are kept, while the transient inbox path is
782// recalculated for the current runtime environment. Use this to
783// continue a subagent session across zut restarts:
784//
785//	subagentMgr.Reload()
786//	a, err := subagentMgr.Resume(ctx, "alpha-12345")
787//	subagentMgr.SendUserTurn(a.ID, "where were we?")
788//
789// The agent must be in a non-running state (Detached, Done, Failed,
790// Killed). Resuming a still-running agent returns an error so two
791// runners don't race for the same session.
792func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
793	return f.resume(ctx, id, true, "")
794}
795
796// ResumeSession continues the existing child session without replaying the
797// original task. It is the explicit canonical spelling for Resume.
798func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
799	return f.resume(ctx, id, true, "")
800}
801
802// ResumeWithPrompt continues an agent with a manager follow-up while retaining
803// its existing session. An idle live worker receives the turn through its
804// inbox; an active live worker queues the follow-up for the next idle turn; a
805// terminal worker is restarted with the follow-up as its initial prompt,
806// avoiding a race with inbox listener startup.
807func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
808	if ctx == nil {
809		ctx = context.Background()
810	}
811	if err := ctx.Err(); err != nil {
812		return nil, err
813	}
814	if strings.TrimSpace(prompt) == "" {
815		return nil, fmt.Errorf("subagent: follow-up prompt is required")
816	}
817
818	f.operationMu.Lock()
819	existing := f.Get(id)
820	if existing == nil {
821		f.operationMu.Unlock()
822		return nil, fmt.Errorf("subagent: no such agent %q", id)
823	}
824	status := existing.Status()
825	if status == StatusRunning {
826		if existing.ProcessState() != ProcessAlive {
827			f.operationMu.Unlock()
828			return nil, fmt.Errorf("subagent: agent %s is still working; wait for its worker to become available before sending a follow-up", existing.ID)
829		}
830		if existing.inbox == nil {
831			f.operationMu.Unlock()
832			return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)
833		}
834		if err := ctx.Err(); err != nil {
835			f.operationMu.Unlock()
836			return nil, err
837		}
838		if existing.TurnState() != TurnRunning && existing.hasQueuedResumePrompt() {
839			f.operationMu.Unlock()
840			return nil, fmt.Errorf("subagent: agent %s already has a follow-up pending delivery", existing.ID)
841		}
842		if existing.TurnState() == TurnRunning {
843			// Active turns cannot accept a command through the worker inbox yet.
844			// Keep the prompt durable and let the idle transition deliver it in
845			// order after the current message turn finishes.
846			acceptedAt := f.cfg.Now()
847			commandID := existing.queueResumePrompt(prompt, acceptedAt)
848			if err := f.persistAgent(existing); err != nil {
849				existing.removeQueuedResumePrompt(commandID)
850				f.operationMu.Unlock()
851				return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
852			}
853			f.operationMu.Unlock()
854			return existing, nil
855		}
856		if existing.TurnState() != TurnIdle {
857			f.operationMu.Unlock()
858			return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID)
859		}
860		turnID := existing.CurrentTurnID()
861		previousRunTurns := existing.resetCurrentRunTurns()
862		// Reserve the worker before writing to its inbox. A second manager call
863		// must not enqueue another turn in the small interval before the worker
864		// reports its turn.start event. Persist the prompt first so a host exit
865		// before that event can resume and redeliver it.
866		previousPrompt := existing.setResumePrompt(prompt, f.cfg.Now())
867		existing.setTurnState(TurnQueued, turnID)
868		inbox := existing.inbox
869		command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
870		command.MessageID = existing.resumePromptCommandID()
871		if err := f.persistAgent(existing); err != nil {
872			existing.setTurnState(TurnIdle, turnID)
873			existing.setCurrentRunTurns(previousRunTurns)
874			existing.restoreResumePrompt(previousPrompt)
875			f.operationMu.Unlock()
876			return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
877		}
878		f.operationMu.Unlock()
879
880		if err := inbox.SendCommandContext(ctx, command); err != nil {
881			retryUnknown := false
882			f.operationMu.Lock()
883			if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {
884				existing.setTurnState(TurnIdle, turnID)
885				existing.setCurrentRunTurns(previousRunTurns)
886				if errors.Is(err, ErrDeliveryUnknown) {
887					existing.rejectActiveResumePrompt()
888					retryUnknown = true
889				} else {
890					existing.restoreResumePrompt(previousPrompt)
891				}
892				if persistErr := f.persistAgent(existing); persistErr != nil {
893					err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
894				}
895			}
896			f.operationMu.Unlock()
897			if retryUnknown {
898				// The command keeps its durable identity. A retry is safe whether
899				// the worker parsed the first write or only received a prefix.
900				f.dispatchQueuedResume(ctx, existing)
901			}
902			return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)
903		}
904		return existing, nil
905	}
906	f.operationMu.Unlock()
907
908	return f.resume(ctx, id, true, prompt)
909}
910
911// dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
912// manager request, an idle transition has no caller context to propagate.
913func (f *Supervisor) dispatchQueuedResumeWithTimeout(a *Agent) {
914	timeout := f.cfg.Policy.ReconnectTimeout
915	if timeout <= 0 {
916		timeout = 5 * time.Second
917	}
918	ctx, cancel := context.WithTimeout(context.Background(), timeout)
919	defer cancel()
920	f.dispatchQueuedResume(ctx, a)
921}
922
923// dispatchQueuedResume delivers the oldest follow-up accepted during an
924// active turn after the worker reports that its inbox can accept a new turn.
925// The operation lock serializes this transition with manager resume calls and
926// the agent's durable prompt state prevents a host exit from losing it.
927func (f *Supervisor) dispatchQueuedResume(ctx context.Context, a *Agent) {
928	if ctx == nil {
929		ctx = context.Background()
930	}
931	if a == nil {
932		return
933	}
934	f.operationMu.Lock()
935	if a.Status() != StatusRunning || a.ProcessState() != ProcessAlive || a.TurnState() != TurnIdle || a.inbox == nil {
936		f.operationMu.Unlock()
937		return
938	}
939	prompt, acceptedAt, commandID, ok := a.startQueuedResumePrompt()
940	if !ok {
941		f.operationMu.Unlock()
942		return
943	}
944	turnID := a.CurrentTurnID()
945	previousRunTurns := a.resetCurrentRunTurns()
946	a.setTurnState(TurnQueued, turnID)
947	inbox := a.inbox
948	command := NewCommand(CommandTurnStart, a.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
949	command.MessageID = commandID
950	if err := f.persistAgent(a); err != nil {
951		a.restoreResumePromptToQueue(prompt, acceptedAt)
952		a.setCurrentRunTurns(previousRunTurns)
953		a.setTurnState(TurnIdle, turnID)
954		f.operationMu.Unlock()
955		return
956	}
957	f.operationMu.Unlock()
958
959	if err := inbox.SendCommandContext(ctx, command); err == nil {
960		return
961	}
962
963	// A failed delivery must put the prompt back at the head of the queue.
964	// Keep the worker idle so a later retry, or an explicit resume, can send
965	// it without skipping any subsequent follow-ups.
966	f.operationMu.Lock()
967	if a.Status() == StatusRunning && a.ProcessState() == ProcessAlive && a.TurnState() == TurnQueued && a.CurrentTurnID() == turnID {
968		if a.restoreResumePromptToQueue(prompt, acceptedAt) {
969			a.setCurrentRunTurns(previousRunTurns)
970			a.setTurnState(TurnIdle, turnID)
971			if persistErr := f.persistAgent(a); persistErr != nil {
972				// persistAgent records the failure on the agent. Keep the explicit
973				// check here because this recovery write is the only durable copy
974				// of the restored queue head.
975				a.recordPersistenceError(persistErr)
976			}
977		}
978	}
979	f.operationMu.Unlock()
980}
981
982// RestartTask intentionally runs the stored original task again in a fresh
983// worker attempt. Callers should use ResumeSession for normal recovery.
984func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
985	return f.resume(ctx, id, false, "")
986}
987
988func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
989	f.operationMu.Lock()
990	defer f.operationMu.Unlock()
991	if ctx == nil {
992		ctx = context.Background()
993	}
994	existing := f.Get(id)
995	if existing == nil {
996		return nil, fmt.Errorf("subagent: no such agent %q", id)
997	}
998	existing.mu.Lock()
999	st := existing.status
1000	existing.mu.Unlock()
1001	if st == StatusRunning || st == StatusPending {
1002		return nil, fmt.Errorf("subagent: agent %s is still %s; stop it first", existing.ID, st)
1003	}
1004	select {
1005	case <-existing.done:
1006	default:
1007		return nil, fmt.Errorf("subagent: agent %s is still finalizing; wait before resuming", existing.ID)
1008	}
1009	// A reloaded worker may still be alive even though this supervisor has no
1010	// in-process Runner (the old host can disappear after the child daemon
1011	// starts). Fence resume before touching the shared session/worktree: a
1012	// second worker must never own the same inbox and files concurrently.
1013	if inboxLive(existing.InboxPath) {
1014		return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID)
1015	}
1016	// A fresh Reload does not hydrate its event log. Before replaying a pending
1017	// follow-up, hydrate it to see whether the child already acknowledged the
1018	// turn before the previous host exited.
1019	if resuming && existing.resumePrompt() != "" {
1020		if err := existing.loadTranscript(); err != nil {
1021			return nil, fmt.Errorf("subagent: replay pending follow-up: %w", err)
1022		}
1023	}
1024	stateDir := existing.stateDirectory(f.cfg.Root)
1025	lease, err := acquireAgentLease(stateDir)
1026	if err != nil {
1027		return nil, fmt.Errorf("subagents resume lease: %w", err)
1028	}
1029	leaseOwned := true
1030	defer func() {
1031		if leaseOwned {
1032			_ = lease.Close()
1033		}
1034	}()
1035	existingSnapshot := existing.Snapshot()
1036
1037	// Rebuild from the meta record so we don't carry stale runner
1038	// state from a previous incarnation. The inbox is transient and
1039	// may have been persisted under an incompatible filesystem by an
1040	// older zut version, so always select it again on resume.
1041	inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID)
1042	if err != nil {
1043		return nil, fmt.Errorf("subagent inbox path: %w", err)
1044	}
1045	now := f.cfg.Now()
1046	fastMode := existing.FastMode
1047	lifetimeTurns := existing.LifetimeTurnsValue()
1048	currentRunTurns := existing.CurrentRunTurnsValue()
1049	pendingResume := existing.resumePromptState()
1050	pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID
1051	pendingResumeQueue := existing.resumePromptQueueSnapshot()
1052	if pendingResumePrompt != "" && pendingResumePromptID == "" {
1053		pendingResumePromptID = uuid.NewString()
1054	}
1055	if resumePrompt != "" {
1056		currentRunTurns = 0
1057		if pendingResumePrompt == "" {
1058			pendingResumePrompt = resumePrompt
1059			pendingResumePromptAt = now
1060			pendingResumePromptID = uuid.NewString()
1061		} else {
1062			pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1063		}
1064	}
1065	if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1066		// A host can exit after accepting an active-worker follow-up but before
1067		// dispatching it. Make the first durable queued prompt the resumed
1068		// worker's initial turn rather than leaving the queue stranded.
1069		queued := pendingResumeQueue[0]
1070		pendingResumePrompt = queued.Prompt
1071		pendingResumePromptAt = queued.AcceptedAt
1072		pendingResumePromptID = queued.CommandID
1073		if pendingResumePromptID == "" {
1074			pendingResumePromptID = uuid.NewString()
1075		}
1076		pendingResumeQueue = pendingResumeQueue[1:]
1077	}
1078	if resuming && pendingResumePrompt != "" {
1079		// A retained follow-up is an explicit new run, including a queued
1080		// follow-up recovered after a host restart. Preserve lifetime turns but
1081		// never carry the previous run's allowance into this one.
1082		currentRunTurns = 0
1083	}
1084	if !resuming {
1085		currentRunTurns = 0
1086	}
1087	m := agentMeta{
1088		ID: existing.ID, Task: existing.Task,
1089		OriginalTask: existing.OriginalTask,
1090		Dir:          existing.Dir, RepositoryRoot: existing.RepositoryRoot, Started: existing.Started,
1091		ParentID: existing.ParentID, BatchID: existing.BatchID, RootSessionID: existing.RootSessionID,
1092		Model: existing.Model, Provider: existing.Provider,
1093		BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS,
1094		Reasoning: existing.Reasoning, FastMode: &fastMode,
1095		Subagent:      existing.Subagent,
1096		WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
1097		WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
1098		MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
1099		WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
1100		CurrentTurnID:   existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
1101		SessionID: existing.SessionID,
1102		InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
1103		SessionPath:    existing.SessionPath,
1104		ResumePrompt:   pendingResumePrompt,
1105		ResumePromptAt: pendingResumePromptAt,
1106		ResumePromptID: pendingResumePromptID,
1107		ResumeQueue:    pendingResumeQueue,
1108	}
1109
1110	if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
1111		m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...)
1112	}
1113	if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil {
1114		return nil, err
1115	}
1116	workspaceMode := m.WorkspaceMode
1117	if workspaceMode == "" {
1118		workspaceMode = WorkspaceShared
1119	}
1120	if workspaceMode != WorkspaceShared && workspaceMode != WorkspaceWorktree {
1121		return nil, fmt.Errorf("subagents: unknown workspace mode %q", workspaceMode)
1122	}
1123	if m.WorkspaceCapture != "" && m.WorkspaceCapture != CapturePatch && m.WorkspaceCapture != CaptureDiff {
1124		return nil, fmt.Errorf("subagents: unknown workspace capture mode %q", m.WorkspaceCapture)
1125	}
1126	f.mu.Lock()
1127	allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...)
1128	f.mu.Unlock()
1129	repositoryRoot := firstNonEmpty(m.RepositoryRoot, m.Dir, f.cfg.RepoRoot)
1130	if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
1131		return nil, err
1132	}
1133	maxTurns := m.MaxTurns
1134	if maxTurns <= 0 {
1135		maxTurns = f.cfg.Policy.MaxTurns
1136	}
1137	existingWorkspacePath := ""
1138	if workspaceMode == WorkspaceWorktree {
1139		existingWorkspacePath = m.WorkspacePath
1140	}
1141	workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{
1142		Mode:           workspaceMode,
1143		RepositoryRoot: repositoryRoot,
1144		StateDir:       stateDir,
1145		AgentID:        existing.ID,
1146		Base:           m.WorkspaceBase,
1147		Capture:        m.WorkspaceCapture,
1148		AllowedRoots:   allowedRoots,
1149		ExistingPath:   existingWorkspacePath,
1150	})
1151	if err != nil {
1152		return nil, err
1153	}
1154	m.Dir = workspace.Dir()
1155	m.RepositoryRoot = workspace.RepositoryRoot()
1156	m.WorkspacePath = workspace.Dir()
1157	m.WorkspaceMode = workspace.Mode()
1158	timeout := m.Timeout
1159	if timeout <= 0 {
1160		timeout = f.cfg.Policy.DefaultTimeout
1161	}
1162	runCtx, cancel := f.workerContext()
1163	a := &Agent{
1164		ID:                m.ID,
1165		Task:              m.Task,
1166		OriginalTask:      firstNonEmpty(m.OriginalTask, m.Task),
1167		Dir:               m.Dir,
1168		RepositoryRoot:    firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
1169		Started:           m.Started,
1170		ParentID:          m.ParentID,
1171		BatchID:           m.BatchID,
1172		RootSessionID:     m.RootSessionID,
1173		Model:             m.Model,
1174		Provider:          m.Provider,
1175		BaseURL:           m.BaseURL,
1176		InsecureTLS:       m.InsecureTLS,
1177		Reasoning:         m.Reasoning,
1178		FastMode:          fastMode,
1179		Subagent:          m.Subagent,
1180		SessionID:         m.SessionID,
1181		WorkspaceMode:     m.WorkspaceMode,
1182		WorkspacePath:     m.WorkspacePath,
1183		WorkspaceBase:     m.WorkspaceBase,
1184		WorkspaceCapture:  m.WorkspaceCapture,
1185		MaxTurns:          maxTurns,
1186		LifetimeTurns:     m.LifetimeTurns,
1187		CurrentRunTurns:   m.CurrentRunTurns,
1188		Timeout:           timeout,
1189		Attempt:           m.Attempt,
1190		HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
1191		Tools:             append([]string(nil), m.Tools...),
1192		WebSearchPolicy:   childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
1193		InboxPath:         m.InboxPath,
1194		EventLogPath:      m.EventLogPath,
1195		SessionPath:       m.SessionPath,
1196		Resuming:          resuming,
1197		resumePromptText:  pendingResumePrompt,
1198		resumePromptAt:    pendingResumePromptAt,
1199		resumePromptID:    m.ResumePromptID,
1200		resumePromptQueue: append([]queuedResumePrompt(nil), pendingResumeQueue...),
1201		inbox:             NewInbox(m.InboxPath),
1202		status:            StatusPending,
1203		activity:          "resuming",
1204		transcriptLoaded:  true,
1205		processState:      ProcessPending,
1206		turnState:         TurnQueued,
1207		updatedAt:         now,
1208		lastActivity:      now,
1209		currentTurnID:     m.CurrentTurnID,
1210		stateDir:          stateDir,
1211		lease:             lease,
1212		maxOutputBytes:    f.cfg.Policy.MaxOutputBytes,
1213		maxOutputLines:    f.cfg.Policy.MaxOutputLines,
1214		done:              make(chan struct{}),
1215		turnResults:       make(chan *TurnResult, 16),
1216	}
1217	// Carry the previous transcript forward so the dashboard doesn't
1218	// flash empty between resume and the first new event.
1219	prev := existing.Transcript()
1220	if len(prev) > 0 {
1221		a.appendTranscript(strings.Join(prev, "\n"))
1222	}
1223	a.ctx, a.cancel = runCtx, cancel
1224	a.persistFn = f.persistAgent
1225	a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
1226	a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
1227	a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
1228	a.runner = f.cfg.NewRunner(a)
1229	if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
1230		if a.cancel != nil {
1231			a.cancel()
1232		}
1233		_ = workspace.Cleanup(context.Background())
1234		return nil, fmt.Errorf("subagents resume metadata: %w", err)
1235	}
1236
1237	f.mu.Lock()
1238	f.agents[a.ID] = a
1239	f.queue = append(f.queue, a)
1240	// Keep the agent's slot in f.order; replacing in-place avoids
1241	// reshuffling the dashboard's row ordering on resume.
1242	found := false
1243	for _, k := range f.order {
1244		if k == a.ID {
1245			found = true
1246			break
1247		}
1248	}
1249	if !found {
1250		f.order = append(f.order, a.ID)
1251	}
1252	f.mu.Unlock()
1253	leaseOwned = false
1254
1255	// Refreshing metadata happened before queue admission, so a later
1256	// supervisor can reconstruct this resumed attempt even if the runner
1257	// has not reached its first event yet.
1258	f.armQueueTimeout(a)
1259	f.schedule()
1260	return a, nil
1261}

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 477

↑↓ moveenter openesc close