detached ab33287

fix(subagents): address trace review findings

commit
ab332873be5d307e09bbe7c5e2e993f1aaa27816
author
brice · · 2026-08-12 05:41 UTC
parent
81ad31b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +112 -29
M README.md +1 -1
@@ -421,7 +421,7 @@ Background subagents that run alongside your main session. Each one is a separat
421421
422422 **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `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.
423423
424-**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
424+**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
425425
426426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427427
@@ -421,7 +421,7 @@ Background subagents that run alongside your main session. Each one is a separat
421 421
422 **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `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.422 **Persistence across zut restarts** — every spawn writes durable metadata, a session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `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.
423 423
424-**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.424+**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
425 425
426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427 427
M docs/subagents.md +1 -1
@@ -164,7 +164,7 @@ When the relevant manager tool is permitted by launch-time policy, the model can
164164
165165 ## Local execution trace
166166
167-Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata; set `ZUT_SUBAGENT_TRACE_MODE=detailed` only when investigating locally and when payload capture is appropriate for the session. Trace bundles are not uploaded, have no automatic retention policy, and should be removed manually after the investigation.
167+Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output in local trace files; enable it only for an appropriate investigation. Trace bundles are not uploaded or deleted automatically, and should be removed manually after the investigation.
168168
169169 Inspect a retained bundle with:
170170
@@ -164,7 +164,7 @@ When the relevant manager tool is permitted by launch-time policy, the model can
164 164
165 ## Local execution trace165 ## Local execution trace
166 166
167-Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata; set `ZUT_SUBAGENT_TRACE_MODE=detailed` only when investigating locally and when payload capture is appropriate for the session. Trace bundles are not uploaded, have no automatic retention policy, and should be removed manually after the investigation.167+Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output in local trace files; enable it only for an appropriate investigation. Trace bundles are not uploaded or deleted automatically, and should be removed manually after the investigation.
168 168
169 Inspect a retained bundle with:169 Inspect a retained bundle with:
170 170
M packages/agent/subagents/persist.go +1 -0
@@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
13251325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
13261326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
13271327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
1328+ a.trace = f.trace
13281329 a.runner = f.cfg.NewRunner(a)
13291330 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
13301331 if a.cancel != nil {
@@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })1325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
1326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }1326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
1327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }1327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
1328+ a.trace = f.trace
1328 a.runner = f.cfg.NewRunner(a)1329 a.runner = f.cfg.NewRunner(a)
1329 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {1330 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
1330 if a.cancel != nil {1331 if a.cancel != nil {
M packages/agent/subagents/projection.go +20 -4
@@ -1,6 +1,9 @@
11 package subagents
22
3-import "time"
3+import (
4+ "sort"
5+ "time"
6+)
47
58 // Operation is a factual operation inferred from paired trace boundaries. An
69 // empty FinishedAt means that no terminal boundary has been observed yet.
@@ -76,26 +79,39 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
7679 for _, operation := range operations {
7780 view.OpenOperations = append(view.OpenOperations, operation)
7881 }
82+ sort.Slice(view.OpenOperations, func(i, j int) bool {
83+ left, right := view.OpenOperations[i], view.OpenOperations[j]
84+ if !left.StartedAt.Equal(right.StartedAt) {
85+ return left.StartedAt.Before(right.StartedAt)
86+ }
87+ if left.Type != right.Type {
88+ return left.Type < right.Type
89+ }
90+ return left.TurnID < right.TurnID
91+ })
7992 views[agentID] = view
8093 }
8194 return views
8295 }
8396
8497 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
85- key = event.Type + ":" + event.TurnID
98+ callID, _ := event.Data["call_id"].(string)
99+ key = event.Type + ":" + event.TurnID + ":" + callID
86100 switch event.Type {
87101 case "turn.started":
88102 return "turn:" + event.TurnID, true, false, ""
89103 case "turn.finished", "turn.failed":
90104 return "turn:" + event.TurnID, false, true, ""
91105 case "tool.started":
92- return "tool:" + event.TurnID, true, false, ""
106+ return "tool:" + event.TurnID + ":" + callID, true, false, ""
93107 case "tool.finished":
94- return "tool:" + event.TurnID, false, true, ""
108+ return "tool:" + event.TurnID + ":" + callID, false, true, ""
95109 case "agent.finished":
96110 return key, false, false, "completed"
97111 case "agent.failed":
98112 return key, false, false, "failed"
113+ case "agent.cancelled":
114+ return key, false, false, "cancelled"
99115 }
100116 return key, false, false, ""
101117 }
@@ -1,6 +1,9 @@
1 package subagents1 package subagents
2 2
3-import "time"3+import (
4+ "sort"
5+ "time"
6+)
4 7
5 // Operation is a factual operation inferred from paired trace boundaries. An8 // Operation is a factual operation inferred from paired trace boundaries. An
6 // empty FinishedAt means that no terminal boundary has been observed yet.9 // empty FinishedAt means that no terminal boundary has been observed yet.
@@ -76,26 +79,39 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
76 for _, operation := range operations {79 for _, operation := range operations {
77 view.OpenOperations = append(view.OpenOperations, operation)80 view.OpenOperations = append(view.OpenOperations, operation)
78 }81 }
82+ sort.Slice(view.OpenOperations, func(i, j int) bool {
83+ left, right := view.OpenOperations[i], view.OpenOperations[j]
84+ if !left.StartedAt.Equal(right.StartedAt) {
85+ return left.StartedAt.Before(right.StartedAt)
86+ }
87+ if left.Type != right.Type {
88+ return left.Type < right.Type
89+ }
90+ return left.TurnID < right.TurnID
91+ })
79 views[agentID] = view92 views[agentID] = view
80 }93 }
81 return views94 return views
82 }95 }
83 96
84 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {97 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
85- key = event.Type + ":" + event.TurnID98+ callID, _ := event.Data["call_id"].(string)
99+ key = event.Type + ":" + event.TurnID + ":" + callID
86 switch event.Type {100 switch event.Type {
87 case "turn.started":101 case "turn.started":
88 return "turn:" + event.TurnID, true, false, ""102 return "turn:" + event.TurnID, true, false, ""
89 case "turn.finished", "turn.failed":103 case "turn.finished", "turn.failed":
90 return "turn:" + event.TurnID, false, true, ""104 return "turn:" + event.TurnID, false, true, ""
91 case "tool.started":105 case "tool.started":
92- return "tool:" + event.TurnID, true, false, ""106+ return "tool:" + event.TurnID + ":" + callID, true, false, ""
93 case "tool.finished":107 case "tool.finished":
94- return "tool:" + event.TurnID, false, true, ""108+ return "tool:" + event.TurnID + ":" + callID, false, true, ""
95 case "agent.finished":109 case "agent.finished":
96 return key, false, false, "completed"110 return key, false, false, "completed"
97 case "agent.failed":111 case "agent.failed":
98 return key, false, false, "failed"112 return key, false, false, "failed"
113+ case "agent.cancelled":
114+ return key, false, false, "cancelled"
99 }115 }
100 return key, false, false, ""116 return key, false, false, ""
101 }117 }
M packages/agent/subagents/projection_test.go +6 -3
@@ -30,11 +30,14 @@ func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) {
3030 views := ProjectTrace([]TraceEvent{
3131 {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
3232 {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},
33- {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
33+ {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
34+ {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
35+ {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
36+ {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
3437 })
3538 view := views["agent-1"]
36- if len(view.OpenOperations) != 0 {
37- t.Fatalf("open operations = %#v, want none", view.OpenOperations)
39+ if len(view.OpenOperations) != 1 || view.OpenOperations[0].Type != "tool.started" {
40+ t.Fatalf("open operations = %#v, want unmatched tool", view.OpenOperations)
3841 }
3942 if view.Terminal != "completed" {
4043 t.Fatalf("terminal = %q, want completed", view.Terminal)
@@ -30,11 +30,14 @@ func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) {
30 views := ProjectTrace([]TraceEvent{30 views := ProjectTrace([]TraceEvent{
31 {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},31 {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
32 {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},32 {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},
33- {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "agent.finished", AgentID: "agent-1"},33+ {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
34+ {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
35+ {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
36+ {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
34 })37 })
35 view := views["agent-1"]38 view := views["agent-1"]
36- if len(view.OpenOperations) != 0 {39+ if len(view.OpenOperations) != 1 || view.OpenOperations[0].Type != "tool.started" {
37- t.Fatalf("open operations = %#v, want none", view.OpenOperations)40+ t.Fatalf("open operations = %#v, want unmatched tool", view.OpenOperations)
38 }41 }
39 if view.Terminal != "completed" {42 if view.Terminal != "completed" {
40 t.Fatalf("terminal = %q, want completed", view.Terminal)43 t.Fatalf("terminal = %q, want completed", view.Terminal)
M packages/agent/subagents/runner.go +3 -0
@@ -928,6 +928,9 @@ func recordWorkerTrace(agent *Agent, ev Event) {
928928 if status, ok := ev.Data["status"].(string); ok && status != "" {
929929 data["status"] = status
930930 }
931+ if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {
932+ data["call_id"] = callID
933+ }
931934 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
932935 }
933936
@@ -928,6 +928,9 @@ func recordWorkerTrace(agent *Agent, ev Event) {
928 if status, ok := ev.Data["status"].(string); ok && status != "" {928 if status, ok := ev.Data["status"].(string); ok && status != "" {
929 data["status"] = status929 data["status"] = status
930 }930 }
931+ if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {
932+ data["call_id"] = callID
933+ }
931 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})934 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
932 }935 }
933 936
M packages/agent/subagents/supervisor.go +7 -1
@@ -179,6 +179,8 @@ func New(cfg Config) *Supervisor {
179179 // in-memory trace remains the sole status projection in that case.
180180 if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil {
181181 trace = bundle
182+ } else {
183+ trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}})
182184 }
183185 }
184186 lifetimeParent := cfg.Context
@@ -734,7 +736,10 @@ func (f *Supervisor) run(a *Agent) {
734736 finalStatus := a.status
735737 a.mu.Unlock()
736738 terminalType := "agent.finished"
737- if terminalErr != nil {
739+ switch {
740+ case finalStatus == StatusKilled:
741+ terminalType = "agent.cancelled"
742+ case terminalErr != nil:
738743 terminalType = "agent.failed"
739744 }
740745 f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}})
@@ -1105,6 +1110,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu
11051110 // StopAll cancels every running agent. Used on shutdown.
11061111 func (f *Supervisor) StopAll() {
11071112 _ = f.stopAll(context.Background())
1113+ _ = f.Close()
11081114 }
11091115
11101116 // StopAllContext is the context-aware shutdown variant. It returns the
@@ -179,6 +179,8 @@ func New(cfg Config) *Supervisor {
179 // in-memory trace remains the sole status projection in that case.179 // in-memory trace remains the sole status projection in that case.
180 if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil {180 if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil {
181 trace = bundle181 trace = bundle
182+ } else {
183+ trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}})
182 }184 }
183 }185 }
184 lifetimeParent := cfg.Context186 lifetimeParent := cfg.Context
@@ -734,7 +736,10 @@ func (f *Supervisor) run(a *Agent) {
734 finalStatus := a.status736 finalStatus := a.status
735 a.mu.Unlock()737 a.mu.Unlock()
736 terminalType := "agent.finished"738 terminalType := "agent.finished"
737- if terminalErr != nil {739+ switch {
740+ case finalStatus == StatusKilled:
741+ terminalType = "agent.cancelled"
742+ case terminalErr != nil:
738 terminalType = "agent.failed"743 terminalType = "agent.failed"
739 }744 }
740 f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}})745 f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}})
@@ -1105,6 +1110,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu
1105 // StopAll cancels every running agent. Used on shutdown.1110 // StopAll cancels every running agent. Used on shutdown.
1106 func (f *Supervisor) StopAll() {1111 func (f *Supervisor) StopAll() {
1107 _ = f.stopAll(context.Background())1112 _ = f.stopAll(context.Background())
1113+ _ = f.Close()
1108 }1114 }
1109 1115
1110 // StopAllContext is the context-aware shutdown variant. It returns the1116 // StopAllContext is the context-aware shutdown variant. It returns the
M packages/agent/subagents/trace.go +9 -1
@@ -101,7 +101,10 @@ type traceRecord struct {
101101 barrier chan struct{}
102102 }
103103
104-const traceQueueInitialCapacity = 64
104+const (
105+ traceQueueInitialCapacity = 64
106+ memoryTraceEventLimit = 4096
107+)
105108
106109 // NewMemoryTraceWriter creates the non-persistent projection source used when
107110 // bundle capture is disabled. It retains only normal-mode event metadata.
@@ -301,6 +304,10 @@ func (w *TraceWriter) write(record traceRecord) {
301304 if w.memory {
302305 w.mu.Lock()
303306 w.events = append(w.events, event)
307+ if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 {
308+ copy(w.events, w.events[overflow:])
309+ w.events = w.events[:memoryTraceEventLimit]
310+ }
304311 w.mu.Unlock()
305312 return
306313 }
@@ -383,6 +390,7 @@ func (w *TraceWriter) Flush() error {
383390 <-barrier
384391 } else {
385392 w.mu.Unlock()
393+ <-w.finished
386394 }
387395 return w.recordingError()
388396 }
@@ -101,7 +101,10 @@ type traceRecord struct {
101 barrier chan struct{}101 barrier chan struct{}
102 }102 }
103 103
104-const traceQueueInitialCapacity = 64104+const (
105+ traceQueueInitialCapacity = 64
106+ memoryTraceEventLimit = 4096
107+)
105 108
106 // NewMemoryTraceWriter creates the non-persistent projection source used when109 // NewMemoryTraceWriter creates the non-persistent projection source used when
107 // bundle capture is disabled. It retains only normal-mode event metadata.110 // bundle capture is disabled. It retains only normal-mode event metadata.
@@ -301,6 +304,10 @@ func (w *TraceWriter) write(record traceRecord) {
301 if w.memory {304 if w.memory {
302 w.mu.Lock()305 w.mu.Lock()
303 w.events = append(w.events, event)306 w.events = append(w.events, event)
307+ if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 {
308+ copy(w.events, w.events[overflow:])
309+ w.events = w.events[:memoryTraceEventLimit]
310+ }
304 w.mu.Unlock()311 w.mu.Unlock()
305 return312 return
306 }313 }
@@ -383,6 +390,7 @@ func (w *TraceWriter) Flush() error {
383 <-barrier390 <-barrier
384 } else {391 } else {
385 w.mu.Unlock()392 w.mu.Unlock()
393+ <-w.finished
386 }394 }
387 return w.recordingError()395 return w.recordingError()
388 }396 }
M packages/agent/subagents/trace_test.go +19 -0
@@ -69,6 +69,25 @@ func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) {
6969 }
7070 }
7171
72+func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) {
73+ writer := NewMemoryTraceWriter()
74+ writer.Record(TraceEvent{Type: "agent.started"})
75+ writer.Record(TraceEvent{Type: "turn.started", TurnID: "turn-1"})
76+ if err := writer.Flush(); err != nil {
77+ t.Fatal(err)
78+ }
79+ if got := writer.Dir(); got != "" {
80+ t.Fatalf("memory trace dir = %q, want empty", got)
81+ }
82+ events := writer.Events()
83+ if len(events) != 2 || events[0].Seq != 1 || events[1].Seq != 2 {
84+ t.Fatalf("memory events = %#v, want contiguous sequence", events)
85+ }
86+ if err := writer.Close(); err != nil {
87+ t.Fatal(err)
88+ }
89+}
90+
7291 func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) {
7392 writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal)
7493 if err != nil {
@@ -69,6 +69,25 @@ func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) {
69 }69 }
70 }70 }
71 71
72+func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) {
73+ writer := NewMemoryTraceWriter()
74+ writer.Record(TraceEvent{Type: "agent.started"})
75+ writer.Record(TraceEvent{Type: "turn.started", TurnID: "turn-1"})
76+ if err := writer.Flush(); err != nil {
77+ t.Fatal(err)
78+ }
79+ if got := writer.Dir(); got != "" {
80+ t.Fatalf("memory trace dir = %q, want empty", got)
81+ }
82+ events := writer.Events()
83+ if len(events) != 2 || events[0].Seq != 1 || events[1].Seq != 2 {
84+ t.Fatalf("memory events = %#v, want contiguous sequence", events)
85+ }
86+ if err := writer.Close(); err != nil {
87+ t.Fatal(err)
88+ }
89+}
90+
72 func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) {91 func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) {
73 writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal)92 writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal)
74 if err != nil {93 if err != nil {
M packages/agent/tools/subagent_resume.go +1 -1
@@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
125125 if !ok {
126126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
127127 }
128- return renderSubagentAction("resumed", snapshot)
128+ return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
129129 }
@@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
125 if !ok {125 if !ok {
126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
127 }127 }
128- return renderSubagentAction("resumed", snapshot)128+ return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
129 }129 }
M packages/agent/tools/subagent_status.go +1 -5
@@ -148,11 +148,7 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
148148 return match, hits == 1
149149 }
150150
151-func publicSubagentStatus(snapshot subagents.AgentSnapshot, traceViews ...subagents.AgentTraceView) subagentStatusEntry {
152- var view subagents.AgentTraceView
153- if len(traceViews) != 0 {
154- view = traceViews[0]
155- }
151+func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry {
156152 entry := subagentStatusEntry{
157153 ID: snapshot.ID,
158154 Terminal: view.Terminal,
@@ -148,11 +148,7 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
148 return match, hits == 1148 return match, hits == 1
149 }149 }
150 150
151-func publicSubagentStatus(snapshot subagents.AgentSnapshot, traceViews ...subagents.AgentTraceView) subagentStatusEntry {151+func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry {
152- var view subagents.AgentTraceView
153- if len(traceViews) != 0 {
154- view = traceViews[0]
155- }
156 entry := subagentStatusEntry{152 entry := subagentStatusEntry{
157 ID: snapshot.ID,153 ID: snapshot.ID,
158 Terminal: view.Terminal,154 Terminal: view.Terminal,
M packages/agent/tools/subagent_status_test.go +20 -8
@@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
4444 ID: "counter-agent",
4545 LifetimeTurns: 7,
4646 CurrentRunTurns: 2,
47- })
47+ }, subagents.AgentTraceView{})
4848 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
4949 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
5050 }
@@ -198,7 +198,8 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
198198 }
199199 var got struct {
200200 Agent struct {
201- ID string `json:"agent_id"`
201+ ID string `json:"agent_id"`
202+ Operation json.RawMessage `json:"operation"`
202203 } `json:"agent"`
203204 }
204205 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
@@ -207,6 +208,9 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
207208 if got.Agent.ID != queued.ID {
208209 t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)
209210 }
211+ if len(got.Agent.Operation) != 0 {
212+ t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation)
213+ }
210214
211215 close(release)
212216 active.Wait()
@@ -308,12 +312,13 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
308312
309313 func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {
310314 for _, tc := range []struct {
311- name string
312- runnerErr error
313- stop bool
315+ name string
316+ runnerErr error
317+ stop bool
318+ wantTerminal string
314319 }{
315- {name: "failed", runnerErr: errors.New("runner failed")},
316- {name: "cancelled", stop: true},
320+ {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"},
321+ {name: "cancelled", stop: true, wantTerminal: "cancelled"},
317322 } {
318323 t.Run(tc.name, func(t *testing.T) {
319324 root := t.TempDir()
@@ -356,7 +361,8 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin
356361 }
357362 var got struct {
358363 Agent struct {
359- ID string `json:"agent_id"`
364+ ID string `json:"agent_id"`
365+ Terminal string `json:"terminal"`
360366 } `json:"agent"`
361367 }
362368 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
@@ -365,6 +371,12 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin
365371 if got.Agent.ID != agent.ID {
366372 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
367373 }
374+ if got.Agent.Terminal != tc.wantTerminal {
375+ t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal)
376+ }
377+ if tc.stop && got.Agent.Terminal == "completed" {
378+ t.Fatalf("cancelled worker reported completed terminal")
379+ }
368380 })
369381 }
370382 }
@@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
44 ID: "counter-agent",44 ID: "counter-agent",
45 LifetimeTurns: 7,45 LifetimeTurns: 7,
46 CurrentRunTurns: 2,46 CurrentRunTurns: 2,
47- })47+ }, subagents.AgentTraceView{})
48 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {48 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
49 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)49 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
50 }50 }
@@ -198,7 +198,8 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
198 }198 }
199 var got struct {199 var got struct {
200 Agent struct {200 Agent struct {
201- ID string `json:"agent_id"`201+ ID string `json:"agent_id"`
202+ Operation json.RawMessage `json:"operation"`
202 } `json:"agent"`203 } `json:"agent"`
203 }204 }
204 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {205 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
@@ -207,6 +208,9 @@ func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
207 if got.Agent.ID != queued.ID {208 if got.Agent.ID != queued.ID {
208 t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)209 t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)
209 }210 }
211+ if len(got.Agent.Operation) != 0 {
212+ t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation)
213+ }
210 214
211 close(release)215 close(release)
212 active.Wait()216 active.Wait()
@@ -308,12 +312,13 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
308 312
309 func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {313 func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {
310 for _, tc := range []struct {314 for _, tc := range []struct {
311- name string315+ name string
312- runnerErr error316+ runnerErr error
313- stop bool317+ stop bool
318+ wantTerminal string
314 }{319 }{
315- {name: "failed", runnerErr: errors.New("runner failed")},320+ {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"},
316- {name: "cancelled", stop: true},321+ {name: "cancelled", stop: true, wantTerminal: "cancelled"},
317 } {322 } {
318 t.Run(tc.name, func(t *testing.T) {323 t.Run(tc.name, func(t *testing.T) {
319 root := t.TempDir()324 root := t.TempDir()
@@ -356,7 +361,8 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin
356 }361 }
357 var got struct {362 var got struct {
358 Agent struct {363 Agent struct {
359- ID string `json:"agent_id"`364+ ID string `json:"agent_id"`
365+ Terminal string `json:"terminal"`
360 } `json:"agent"`366 } `json:"agent"`
361 }367 }
362 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {368 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
@@ -365,6 +371,12 @@ func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testin
365 if got.Agent.ID != agent.ID {371 if got.Agent.ID != agent.ID {
366 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)372 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
367 }373 }
374+ if got.Agent.Terminal != tc.wantTerminal {
375+ t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal)
376+ }
377+ if tc.stop && got.Agent.Terminal == "completed" {
378+ t.Fatalf("cancelled worker reported completed terminal")
379+ }
368 })380 })
369 }381 }
370 }382 }
M packages/agent/tools/subagent_stop.go +3 -3
@@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
9898 if !ok {
9999 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100100 }
101- return renderSubagentAction("stop_requested", snapshot)
101+ return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
102102 }
103103
104104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
@@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
107107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108108 }
109109
110-func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {
110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) {
111111 response := subagentActionResponse{
112112 Action: action,
113- Agent: publicSubagentStatus(snapshot),
113+ Agent: publicSubagentStatus(snapshot, view),
114114 }
115115 data, err := json.Marshal(response)
116116 if err != nil {
@@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
98 if !ok {98 if !ok {
99 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)99 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100 }100 }
101- return renderSubagentAction("stop_requested", snapshot)101+ return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
102 }102 }
103 103
104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
@@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108 }108 }
109 109
110-func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) {
111 response := subagentActionResponse{111 response := subagentActionResponse{
112 Action: action,112 Action: action,
113- Agent: publicSubagentStatus(snapshot),113+ Agent: publicSubagentStatus(snapshot, view),
114 }114 }
115 data, err := json.Marshal(response)115 data, err := json.Marshal(response)
116 if err != nil {116 if err != nil {
M packages/agent/tracecmd.go +6 -1
@@ -46,7 +46,12 @@ func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceVie
4646 }
4747 continue
4848 }
49- if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s ago\n", id, view.LastEvent.Type, now.Sub(view.LastEvent.Timestamp).Round(time.Second)); err != nil {
49+ age := now.Sub(view.LastEvent.Timestamp).Round(time.Second)
50+ when := age.String() + " ago"
51+ if age < 0 {
52+ when = "in " + (-age).String()
53+ }
54+ if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil {
5055 return err
5156 }
5257 }
@@ -46,7 +46,12 @@ func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceVie
46 }46 }
47 continue47 continue
48 }48 }
49- if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s ago\n", id, view.LastEvent.Type, now.Sub(view.LastEvent.Timestamp).Round(time.Second)); err != nil {49+ age := now.Sub(view.LastEvent.Timestamp).Round(time.Second)
50+ when := age.String() + " ago"
51+ if age < 0 {
52+ when = "in " + (-age).String()
53+ }
54+ if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil {
50 return err55 return err
51 }56 }
52 }57 }
M packages/agent/tracecmd_test.go +14 -0
@@ -21,3 +21,17 @@ func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) {
2121 t.Fatalf("inspection = %q, want %q", got, want)
2222 }
2323 }
24+
25+func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) {
26+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
27+ views := map[string]subagents.AgentTraceView{
28+ "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}},
29+ }
30+ var out bytes.Buffer
31+ if err := renderTraceInspection(&out, views, now); err != nil {
32+ t.Fatal(err)
33+ }
34+ if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want {
35+ t.Fatalf("inspection = %q, want %q", got, want)
36+ }
37+}
@@ -21,3 +21,17 @@ func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) {
21 t.Fatalf("inspection = %q, want %q", got, want)21 t.Fatalf("inspection = %q, want %q", got, want)
22 }22 }
23 }23 }
24+
25+func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) {
26+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
27+ views := map[string]subagents.AgentTraceView{
28+ "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}},
29+ }
30+ var out bytes.Buffer
31+ if err := renderTraceInspection(&out, views, now); err != nil {
32+ t.Fatal(err)
33+ }
34+ if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want {
35+ t.Fatalf("inspection = %q, want %q", got, want)
36+ }
37+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close