detached 3cb020e

fix(subagents): retain worker activity turn IDs

commit
3cb020e15edd7e0ede9c086a30bff2896c9e10c7
author
brice · · 2026-08-12 12:06 UTC
committer
brice · · 2026-08-12 12:33 UTC
parent
8fd3c01
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +69 -12
M packages/agent/subagent_worker.go +14 -0
@@ -248,6 +248,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
248248 return
249249 }
250250 turnID := fmt.Sprintf("turn-%d", step)
251+ em.setTurnID(turnID)
252+ defer em.setTurnID("")
251253 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)
252254 cancelFn = cancel
253255 mu.Unlock()
@@ -487,6 +489,7 @@ type subagentEmitter struct {
487489 mu sync.Mutex
488490 w *os.File
489491 agentID string
492+ turnID string
490493 }
491494
492495 func newSubagentEmitter(w *os.File) *subagentEmitter {
@@ -499,6 +502,14 @@ func (e *subagentEmitter) setProtocolIdentity(agentID string) {
499502 e.mu.Unlock()
500503 }
501504
505+// setTurnID associates subsequent worker events with the currently admitted
506+// delegated turn. Core events do not carry the worker protocol turn ID.
507+func (e *subagentEmitter) setTurnID(turnID string) {
508+ e.mu.Lock()
509+ e.turnID = turnID
510+ e.mu.Unlock()
511+}
512+
502513 func (e *subagentEmitter) emit(typ string, data map[string]any) {
503514 if data == nil {
504515 data = map[string]any{}
@@ -511,6 +522,9 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
511522
512523 e.mu.Lock()
513524 defer e.mu.Unlock()
525+ if turnID == "" {
526+ turnID = e.turnID
527+ }
514528
515529 wireType := canonicalWorkerEvent(typ)
516530 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
@@ -248,6 +248,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
248 return248 return
249 }249 }
250 turnID := fmt.Sprintf("turn-%d", step)250 turnID := fmt.Sprintf("turn-%d", step)
251+ em.setTurnID(turnID)
252+ defer em.setTurnID("")
251 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)253 c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)
252 cancelFn = cancel254 cancelFn = cancel
253 mu.Unlock()255 mu.Unlock()
@@ -487,6 +489,7 @@ type subagentEmitter struct {
487 mu sync.Mutex489 mu sync.Mutex
488 w *os.File490 w *os.File
489 agentID string491 agentID string
492+ turnID string
490 }493 }
491 494
492 func newSubagentEmitter(w *os.File) *subagentEmitter {495 func newSubagentEmitter(w *os.File) *subagentEmitter {
@@ -499,6 +502,14 @@ func (e *subagentEmitter) setProtocolIdentity(agentID string) {
499 e.mu.Unlock()502 e.mu.Unlock()
500 }503 }
501 504
505+// setTurnID associates subsequent worker events with the currently admitted
506+// delegated turn. Core events do not carry the worker protocol turn ID.
507+func (e *subagentEmitter) setTurnID(turnID string) {
508+ e.mu.Lock()
509+ e.turnID = turnID
510+ e.mu.Unlock()
511+}
512+
502 func (e *subagentEmitter) emit(typ string, data map[string]any) {513 func (e *subagentEmitter) emit(typ string, data map[string]any) {
503 if data == nil {514 if data == nil {
504 data = map[string]any{}515 data = map[string]any{}
@@ -511,6 +522,9 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
511 522
512 e.mu.Lock()523 e.mu.Lock()
513 defer e.mu.Unlock()524 defer e.mu.Unlock()
525+ if turnID == "" {
526+ turnID = e.turnID
527+ }
514 528
515 wireType := canonicalWorkerEvent(typ)529 wireType := canonicalWorkerEvent(typ)
516 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)530 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
M packages/agent/subagent_worker_test.go +8 -4
@@ -819,7 +819,8 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
819819 defer r.Close()
820820
821821 em := newSubagentEmitter(w)
822- em.emit("turn_start", map[string]any{"step": 1})
822+ em.setTurnID("turn-1")
823+ em.emit("text_delta", map[string]any{"delta": "sensitive partial answer"})
823824 _ = w.Close()
824825
825826 body, err := io.ReadAll(r)
@@ -835,15 +836,18 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
835836 if err := json.Unmarshal(lines[0], &object); err != nil {
836837 t.Fatalf("not valid json: %v\n%s", err, lines[0])
837838 }
838- if object["type"] != "turn.started" {
839+ if object["type"] != "message.delta" {
839840 t.Errorf("type field missing or wrong: %v", object["type"])
840841 }
842+ if object["turn_id"] != "turn-1" {
843+ t.Errorf("turn_id = %v, want turn-1", object["turn_id"])
844+ }
841845 if _, ok := object["timestamp"].(string); !ok {
842846 t.Errorf("timestamp field missing: %v", object["timestamp"])
843847 }
844848 payload, ok := object["payload"].(map[string]any)
845- if !ok || payload["step"] != float64(1) {
846- t.Errorf("payload step field missing or wrong: %v", object["payload"])
849+ if !ok || payload["delta"] != "sensitive partial answer" {
850+ t.Errorf("payload delta field missing or wrong: %v", object["payload"])
847851 }
848852 }
849853
@@ -819,7 +819,8 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
819 defer r.Close()819 defer r.Close()
820 820
821 em := newSubagentEmitter(w)821 em := newSubagentEmitter(w)
822- em.emit("turn_start", map[string]any{"step": 1})822+ em.setTurnID("turn-1")
823+ em.emit("text_delta", map[string]any{"delta": "sensitive partial answer"})
823 _ = w.Close()824 _ = w.Close()
824 825
825 body, err := io.ReadAll(r)826 body, err := io.ReadAll(r)
@@ -835,15 +836,18 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
835 if err := json.Unmarshal(lines[0], &object); err != nil {836 if err := json.Unmarshal(lines[0], &object); err != nil {
836 t.Fatalf("not valid json: %v\n%s", err, lines[0])837 t.Fatalf("not valid json: %v\n%s", err, lines[0])
837 }838 }
838- if object["type"] != "turn.started" {839+ if object["type"] != "message.delta" {
839 t.Errorf("type field missing or wrong: %v", object["type"])840 t.Errorf("type field missing or wrong: %v", object["type"])
840 }841 }
842+ if object["turn_id"] != "turn-1" {
843+ t.Errorf("turn_id = %v, want turn-1", object["turn_id"])
844+ }
841 if _, ok := object["timestamp"].(string); !ok {845 if _, ok := object["timestamp"].(string); !ok {
842 t.Errorf("timestamp field missing: %v", object["timestamp"])846 t.Errorf("timestamp field missing: %v", object["timestamp"])
843 }847 }
844 payload, ok := object["payload"].(map[string]any)848 payload, ok := object["payload"].(map[string]any)
845- if !ok || payload["step"] != float64(1) {849+ if !ok || payload["delta"] != "sensitive partial answer" {
846- t.Errorf("payload step field missing or wrong: %v", object["payload"])850+ t.Errorf("payload delta field missing or wrong: %v", object["payload"])
847 }851 }
848 }852 }
849 853
M packages/agent/subagents/runner.go +20 -3
@@ -905,13 +905,26 @@ func recordWorkerTrace(agent *Agent, ev Event) {
905905 }
906906 typeName := strings.ReplaceAll(ev.Type, "_", ".")
907907 traceType := "worker.protocol.observed"
908+ nestedTurn, _ := ev.Data["nested_turn"].(bool)
908909 switch ev.Type {
909- case "turn_start", EventTurnStarted:
910+ case "turn_start", "turn_end":
911+ if nestedTurn {
912+ if ev.Type == "turn_end" {
913+ traceType = "provider.request.finished"
914+ }
915+ } else if ev.Type == "turn_start" {
916+ traceType = "turn.started"
917+ } else {
918+ traceType = "turn.finished"
919+ }
920+ case EventTurnStarted:
910921 traceType = "turn.started"
911- case "turn_end", EventTurnResult:
922+ case EventTurnResult:
912923 traceType = "turn.finished"
913924 case "turn_failed", EventTurnFailed:
914925 traceType = "turn.failed"
926+ case "request_started":
927+ traceType = "provider.request.started"
915928 case "tool_call", EventToolStarted:
916929 traceType = "tool.started"
917930 case "tool_result", EventToolFinished:
@@ -930,7 +943,11 @@ func recordWorkerTrace(agent *Agent, ev Event) {
930943 if status, ok := ev.Data["status"].(string); ok && status != "" {
931944 data["status"] = status
932945 }
933- if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {
946+ callID, _ := ev.Data["call_id"].(string)
947+ if callID == "" {
948+ callID, _ = ev.Data["id"].(string)
949+ }
950+ if callID != "" {
934951 data["call_id"] = callID
935952 }
936953 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
@@ -905,13 +905,26 @@ func recordWorkerTrace(agent *Agent, ev Event) {
905 }905 }
906 typeName := strings.ReplaceAll(ev.Type, "_", ".")906 typeName := strings.ReplaceAll(ev.Type, "_", ".")
907 traceType := "worker.protocol.observed"907 traceType := "worker.protocol.observed"
908+ nestedTurn, _ := ev.Data["nested_turn"].(bool)
908 switch ev.Type {909 switch ev.Type {
909- case "turn_start", EventTurnStarted:910+ case "turn_start", "turn_end":
911+ if nestedTurn {
912+ if ev.Type == "turn_end" {
913+ traceType = "provider.request.finished"
914+ }
915+ } else if ev.Type == "turn_start" {
916+ traceType = "turn.started"
917+ } else {
918+ traceType = "turn.finished"
919+ }
920+ case EventTurnStarted:
910 traceType = "turn.started"921 traceType = "turn.started"
911- case "turn_end", EventTurnResult:922+ case EventTurnResult:
912 traceType = "turn.finished"923 traceType = "turn.finished"
913 case "turn_failed", EventTurnFailed:924 case "turn_failed", EventTurnFailed:
914 traceType = "turn.failed"925 traceType = "turn.failed"
926+ case "request_started":
927+ traceType = "provider.request.started"
915 case "tool_call", EventToolStarted:928 case "tool_call", EventToolStarted:
916 traceType = "tool.started"929 traceType = "tool.started"
917 case "tool_result", EventToolFinished:930 case "tool_result", EventToolFinished:
@@ -930,7 +943,11 @@ func recordWorkerTrace(agent *Agent, ev Event) {
930 if status, ok := ev.Data["status"].(string); ok && status != "" {943 if status, ok := ev.Data["status"].(string); ok && status != "" {
931 data["status"] = status944 data["status"] = status
932 }945 }
933- if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {946+ callID, _ := ev.Data["call_id"].(string)
947+ if callID == "" {
948+ callID, _ = ev.Data["id"].(string)
949+ }
950+ if callID != "" {
934 data["call_id"] = callID951 data["call_id"] = callID
935 }952 }
936 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})953 agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
M packages/agent/subagents/runner_test.go +27 -5
@@ -806,12 +806,15 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
806806 t.Cleanup(func() { _ = trace.Close() })
807807 agent := &Agent{ID: "agent-1", trace: trace}
808808 recordWorkerTrace(agent, Event{Type: EventMessageDelta, TurnID: "turn-1", Data: map[string]any{"delta": "sensitive answer"}})
809+ recordWorkerTrace(agent, Event{Type: "request_started", TurnID: "turn-1", Data: map[string]any{"provider": "test", "model": "test"}})
810+ recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
811+ recordWorkerTrace(agent, Event{Type: "tool_call", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash", "args": "sensitive arguments"}})
809812 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
810813 if err := trace.Flush(); err != nil {
811814 t.Fatal(err)
812815 }
813816 events := trace.Events()
814- if len(events) != 2 {
817+ if len(events) != 5 {
815818 t.Fatalf("events = %#v", events)
816819 }
817820 if got, want := events[0].Type, "assistant.stream.observed"; got != want {
@@ -820,14 +823,33 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
820823 if _, found := events[0].Data["delta"]; found {
821824 t.Fatalf("stream payload leaked: %#v", events[0].Data)
822825 }
823- if got, want := events[1].Type, "worker.protocol.observed"; got != want {
826+ if got, want := events[1].Type, "provider.request.started"; got != want {
824827 t.Fatalf("type = %q, want %q", got, want)
825828 }
826- if got, _ := events[1].Data["source_event"].(string); got != "future.event" {
829+ if got, want := events[2].Type, "provider.request.finished"; got != want {
830+ t.Fatalf("type = %q, want %q", got, want)
831+ }
832+ if got, want := events[3].Type, "tool.started"; got != want {
833+ t.Fatalf("type = %q, want %q", got, want)
834+ }
835+ if got, _ := events[3].Data["call_id"].(string); got != "call-1" {
836+ t.Fatalf("tool call_id = %q", got)
837+ }
838+ if _, found := events[3].Data["args"]; found {
839+ t.Fatalf("tool arguments leaked: %#v", events[2].Data)
840+ }
841+ if got, want := events[4].Type, "worker.protocol.observed"; got != want {
842+ t.Fatalf("type = %q, want %q", got, want)
843+ }
844+ if got, _ := events[4].Data["source_event"].(string); got != "future.event" {
827845 t.Fatalf("source_event = %q", got)
828846 }
829- if _, found := events[1].Data["secret"]; found {
830- t.Fatalf("worker payload leaked: %#v", events[1].Data)
847+ if _, found := events[4].Data["secret"]; found {
848+ t.Fatalf("worker payload leaked: %#v", events[3].Data)
849+ }
850+ view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
851+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
852+ t.Fatalf("current-turn tool activity was not projected: %#v", view)
831853 }
832854 }
833855
@@ -806,12 +806,15 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
806 t.Cleanup(func() { _ = trace.Close() })806 t.Cleanup(func() { _ = trace.Close() })
807 agent := &Agent{ID: "agent-1", trace: trace}807 agent := &Agent{ID: "agent-1", trace: trace}
808 recordWorkerTrace(agent, Event{Type: EventMessageDelta, TurnID: "turn-1", Data: map[string]any{"delta": "sensitive answer"}})808 recordWorkerTrace(agent, Event{Type: EventMessageDelta, TurnID: "turn-1", Data: map[string]any{"delta": "sensitive answer"}})
809+ recordWorkerTrace(agent, Event{Type: "request_started", TurnID: "turn-1", Data: map[string]any{"provider": "test", "model": "test"}})
810+ recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
811+ recordWorkerTrace(agent, Event{Type: "tool_call", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash", "args": "sensitive arguments"}})
809 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})812 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
810 if err := trace.Flush(); err != nil {813 if err := trace.Flush(); err != nil {
811 t.Fatal(err)814 t.Fatal(err)
812 }815 }
813 events := trace.Events()816 events := trace.Events()
814- if len(events) != 2 {817+ if len(events) != 5 {
815 t.Fatalf("events = %#v", events)818 t.Fatalf("events = %#v", events)
816 }819 }
817 if got, want := events[0].Type, "assistant.stream.observed"; got != want {820 if got, want := events[0].Type, "assistant.stream.observed"; got != want {
@@ -820,14 +823,33 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
820 if _, found := events[0].Data["delta"]; found {823 if _, found := events[0].Data["delta"]; found {
821 t.Fatalf("stream payload leaked: %#v", events[0].Data)824 t.Fatalf("stream payload leaked: %#v", events[0].Data)
822 }825 }
823- if got, want := events[1].Type, "worker.protocol.observed"; got != want {826+ if got, want := events[1].Type, "provider.request.started"; got != want {
824 t.Fatalf("type = %q, want %q", got, want)827 t.Fatalf("type = %q, want %q", got, want)
825 }828 }
826- if got, _ := events[1].Data["source_event"].(string); got != "future.event" {829+ if got, want := events[2].Type, "provider.request.finished"; got != want {
830+ t.Fatalf("type = %q, want %q", got, want)
831+ }
832+ if got, want := events[3].Type, "tool.started"; got != want {
833+ t.Fatalf("type = %q, want %q", got, want)
834+ }
835+ if got, _ := events[3].Data["call_id"].(string); got != "call-1" {
836+ t.Fatalf("tool call_id = %q", got)
837+ }
838+ if _, found := events[3].Data["args"]; found {
839+ t.Fatalf("tool arguments leaked: %#v", events[2].Data)
840+ }
841+ if got, want := events[4].Type, "worker.protocol.observed"; got != want {
842+ t.Fatalf("type = %q, want %q", got, want)
843+ }
844+ if got, _ := events[4].Data["source_event"].(string); got != "future.event" {
827 t.Fatalf("source_event = %q", got)845 t.Fatalf("source_event = %q", got)
828 }846 }
829- if _, found := events[1].Data["secret"]; found {847+ if _, found := events[4].Data["secret"]; found {
830- t.Fatalf("worker payload leaked: %#v", events[1].Data)848+ t.Fatalf("worker payload leaked: %#v", events[3].Data)
849+ }
850+ view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
851+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
852+ t.Fatalf("current-turn tool activity was not projected: %#v", view)
831 }853 }
832 }854 }
833 855

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