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

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