fix(review): keep skill fan-out to one dashboard row per agent · Entire
fix(review): keep skill fan-out to one dashboard row per agent
0d959ae·
peyton-alt·3d ago·4 files·+192 added/-6 removed
The fan-out was meant to be behavior-only (run an agent's skills as parallel children instead of one blended pass), but it leaked into the TUI: because rows are drawn one-per-worker, a two-skill agent showed as two rows (claude-code:review + claude-code:pr-review). Restore the pre-fan-out display — one row per agent — without changing the parallel execution.
The collapse lives entirely in the TUI sink; execution, the summary, the manifest, and skills-based session matching stay per-worker (all load-bearing for attribution). TUISink.groupWorkers takes the per-agent row order plus a worker->agent map; AgentEvent routes each worker's events to its agent row (live events already key by name, so this is a lookup), and RunFinished folds the per-worker summary into one entry per agent row (worst-status wins, tokens summed) so the model's by-index row sync still aligns. Grouping key is agent+model, so genuine agent+model workers stay separate rows — only the skill dimension collapses.
Verified: sink unit tests pin routing + collapse; a live shim run showed a single claude-code row with two parallel children in flight.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Sessions
01KXH1KM896NXV3ZV3XVJ8FEXK View transcript
[?
test(review): pin that codex $name skills survive the legacy repair + native invocation Claude Code·3 steps](/content/gh/entireio/cli/session/99f0d90a-6e31-4c60-ba62-aa2dccd29033#timeline-01KXH1KM896NXV3ZV3XVJ8FEXK/index.html)
Changes
4
cmd/entire/cli/review
Mcmd.go+20/-4
Mprofile.go+11
Mtui_sink.go+90/-2
Mtui_sink_test.go+71
1228 unmodified lines
1229
1230
1231
1232
1233
1234
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
6 unmodified lines
1255
1256
1257
1247
1258
1259
1260
1261
1262
94 unmodified lines
1357
1358
1359
1360
1361
1362
1363
35 unmodified lines
1399
1400
1401
1402
1403
1404
1405
1406
1407
1228 unmodified lines
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
agentNames := make([]string, len(reviewers))
for i, r := range reviewers {
agentNames[i] = r.Name()
}
// The dashboard shows one row per agent (skill fan-out is behavior-only:
// N parallel workers per agent must not leak into per-skill rows).
// rowNames is the ordered per-agent row labels (agent + optional model);
// workerToAgent folds each worker's events and summary entry into its row.
rowNames := make([]string, 0, len(reviewers))
workerToAgent := make(map[string]string, len(reviewers))
seenRow := make(map[string]bool, len(reviewers))
for _, r := range reviewers {
row := agentRowLabel(reviewerActualAgentName(r), reviewerModelName(r))
workerToAgent[r.Name()] = row
if !seenRow[row] {
seenRow[row] = true
rowNames = append(rowNames, row)
}
}
aggregateOutput := ""
var synthErr error
6 unmodified lines
scope: scopeCtx,
out: out,
isTTY: interactive.IsTerminalWriter(out) && interactive.CanPromptInteractively(),
agentNames: agentNames,
agentNames: rowNames,
workerToAgent: workerToAgent,
cancelRun: cancelRun,
runContext: runCtx,
synthesisProvider: synthProvider,
94 unmodified lines
out io.Writer
isTTY bool
agentNames []string
workerToAgent map[string]string
cancelRun context.CancelFunc
runContext context.Context
synthesisProvider SynthesisProvider
35 unmodified lines
sinks := []reviewtypes.Sink{}
if in.isTTY {
tui := NewTUISink(in.agentNames, in.cancelRun, in.out, os.Stdin)
if in.workerToAgent != nil {
tui.groupWorkers(in.agentNames, in.workerToAgent)
}
sinks = append(sinks, tui)
if in.synthesisProvider != nil {
postRunOut := &bytes.Buffer{}
Mcmd/entire/cli/review/cmd.go+20/-4
153 unmodified lines
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
153 unmodified lines
return out
}
// agentRowLabel is the per-agent dashboard row label: the agent name plus its
// model when set. Skill fan-out workers of the same agent+model collapse to
// one row under this label; genuinely distinct agent+model workers stay
// separate rows, matching the pre-fan-out display.
func agentRowLabel(agentName, model string) string {
if strings.TrimSpace(model) != "" {
return agentName + " (" + model + ")"
}
return agentName
}
func reviewAgentName(workerName string, cfg settings.ReviewConfig) string {
if strings.TrimSpace(cfg.Agent) != "" {
return strings.TrimSpace(cfg.Agent)
Mcmd/entire/cli/review/profile.go+11
62 unmodified lines
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
143 unmodified lines
300
301
302
215
303
304
305
306
39 unmodified lines
346
347
348
261
349
350
351
352
62 unmodified lines
msgs chan tea.Msg // bounded dispatch queue drained by the pump
done chan struct{} // closed when the tea.Program exits
pumpDone chan struct{} // closed when the pump goroutine exits
// One-row-per-agent grouping. Skill fan-out runs N parallel workers per
// agent (claude-code:review, claude-code:pr-review), but the fan-out is
// behavior-only: the dashboard shows one row per agent. rowOrder is the
// per-agent row labels; workerToAgent maps a worker's label to its row.
// Nil when there is nothing to collapse (single-agent path), so events
// and the summary pass through unchanged.
rowOrder []string
workerToAgent map[string]string
}
// groupWorkers configures one-row-per-agent collapsing: rowOrder is the
// ordered per-agent row labels (matching the model's rows), and workerToAgent
// maps each worker label to the agent row its events and summary entry fold
// into. Called before Start.
func (s *TUISink) groupWorkers(rowOrder []string, workerToAgent map[string]string) {
s.rowOrder = rowOrder
s.workerToAgent = workerToAgent
}
// agentRowFor resolves a worker label to its agent row, passing through any
// name absent from the map (single-agent path, judge/master labels).
func (s *TUISink) agentRowFor(name string) string {
if s.workerToAgent != nil {
if row, ok := s.workerToAgent[name]; ok {
return row
}
}
return name
}
// collapseSummaryForRows folds a per-worker summary into one AgentRun per
// agent row, in rowOrder, so the model's by-index row sync still aligns:
// worst status wins (Failed > Cancelled > Succeeded), tokens sum, the first
// non-nil error is kept. Returns the summary unchanged when no grouping is
// configured.
func (s *TUISink) collapseSummaryForRows(summary reviewtypes.RunSummary) reviewtypes.RunSummary {
if s.workerToAgent == nil {
return summary
}
byRow := make(map[string]*reviewtypes.AgentRun, len(s.rowOrder))
for _, run := range summary.AgentRuns {
row := s.agentRowFor(run.Name)
agg, ok := byRow[row]
if !ok {
cloned := run
cloned.Name = row
byRow[row] = &cloned
continue
}
agg.Tokens.In += run.Tokens.In
agg.Tokens.Out += run.Tokens.Out
if reviewStatusWorse(run.Status, agg.Status) {
agg.Status = run.Status
}
if agg.Err == nil && run.Err != nil {
agg.Err = run.Err
}
}
out := summary
out.AgentRuns = make([]reviewtypes.AgentRun, 0, len(byRow))
for _, row := range s.rowOrder {
if agg, ok := byRow[row]; ok {
out.AgentRuns = append(out.AgentRuns, *agg)
}
}
return out
}
// reviewStatusWorse reports whether a is a worse terminal status than b, for
// worst-wins aggregation across an agent's workers.
func reviewStatusWorse(a, b reviewtypes.AgentStatus) bool {
return reviewStatusRank(a) > reviewStatusRank(b)
}
func reviewStatusRank(s reviewtypes.AgentStatus) int {
switch s {
case reviewtypes.AgentStatusFailed:
return 3
case reviewtypes.AgentStatusCancelled:
return 2
case reviewtypes.AgentStatusSucceeded:
return 1
case reviewtypes.AgentStatusUnknown:
return 0
default:
return 0
}
}
// Compile-time interface check.
143 unmodified lines
return
}
select {
case s.msgs <- agentEventMsg{agent: agent, ev: ev}:
case s.msgs <- agentEventMsg{agent: s.agentRowFor(agent), ev: ev}:
default:
s.mu.Lock()
s.dropped++
39 unmodified lines
s.finished = true
s.mu.Unlock()
s.enqueueControl(runFinishedMsg{summary: summary})
s.enqueueControl(runFinishedMsg{summary: s.collapseSummaryForRows(summary)})
// FinalPhaseStarted updates the TUI with a visible post-run phase such as the