# feat(review): fan multi-skill workers out into parallel single-skill runs

`4c6f584`·

peyton-alt·1w ago·11 files·+497 added/-9 removed

A worker configured with N skills previously joined them into one child's prompt: skills executed sequentially (or blended), so selecting more skills made the user wait for the SUM of their durations. Measured live: a two-skill claude worker ran ~9 minutes as one child.

explodeSkillWorkers splits each multi-skill worker into one worker per skill at plan time (keys like claude-code:review, deduped against existing workers), so skills run concurrently as ordinary slots — the wait becomes the slowest skill. Two exploded workers also mean the judge consolidates per-skill reports, extending the crew+judge value prop to single-agent multi-skill profiles.

--agent now selects ALL of that agent's workers as a filtered crew (previously an ambiguity error), running the single-agent path only when exactly one worker matches.

Same-agent SAME-model exploded workers defeat the existing agent+model session matching (assignments could cross, attributing tokens and transcripts to the wrong skill). AgentRun gains Skills, propagated through planned runs and both run paths, and the matcher requires skill-set agreement when both sides carry skills — mirroring the same-agent different-model disambiguation from #1313.

Verified end-to-end with a claude shim: a two-skill profile spawns two one-skill children in parallel (~3s wall for both) plus the judge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

## Sessions

116f0cd95d56View transcript

## Changes

11

- cmd/entire/cli/review

- Mcmd.go+9/-6

- Mcmd\_test.go+106

- Afanout.go+90

- Afanout\_internal\_test.go+139

- Mmanifest.go+29/-2

- Mmanifest\_test.go+61

- Mprofile.go+32

- Mrun.go+16

- Mrun\_multi.go+8

- types

- Msink.go+6

- docs/architecture

- Mreview-command.md+1/-1

```
850 unmodified lines

851
852
853
854
855
856
857
858
859
857
860
861
862
863
864
865
866
864
865
867
868
869
867
870
871
872
812 unmodified lines

1685
1686
1687
1686
1687
1688
1689
1690
1691
1692
1693

850 unmodified lines

userTask := strings.TrimSpace(profile.Task)
	profile.Task = profileTask(profileName, profile)
	profile.Agents = nonZeroAgentConfigs(profile.Agents)
	// Fan out multi-skill workers into one worker per skill so skills run
	// concurrently: the wait is the slowest skill, not the sum.
	profile = explodeSkillWorkers(profile)
	outputMode := profileOutput(profile)

if agentOverride != "" {
		workerName, cfg, selectErr := selectProfileWorker(profile, agentOverride)
		workerName, cfg, single, selectErr := applyAgentOverride(&profile, agentOverride, modelOverride)
		if selectErr != nil {
			cmd.SilenceUsage = true
			err := fmt.Errorf("%w in review profile %q", selectErr, profileName)
			fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
			return silentErr(err)
		}
		if modelOverride != "" {
			cfg.Model = modelOverride
		if single {
			return runSingleAgentPath(ctx, cmd, profileName, workerName, baseOverride, perRunPrompt, workerTask(profileName, userTask, cfg), outputMode, timeout, cfg, installed, deps, out)
		}
		return runSingleAgentPath(ctx, cmd, profileName, workerName, baseOverride, perRunPrompt, workerTask(profileName, userTask, cfg), outputMode, timeout, cfg, installed, deps, out)
	}

if missing := missingInstalledProfileAgents(profile.Agents, installed); len(missing) > 0 {
812 unmodified lines

}
return r.inner.Name()
}
func (r *perAgentConfiguredReviewer) ActualAgentName() string { return r.inner.Name() }
func (r *perAgentConfiguredReviewer) ModelName() string       { return strings.TrimSpace(r.cfg.Model) }
func (r *perAgentConfiguredReviewer) ActualAgentName() string  { return r.inner.Name() }
func (r *perAgentConfiguredReviewer) ModelName() string        { return strings.TrimSpace(r.cfg.Model) }
func (r *perAgentConfiguredReviewer) ReviewerSkills() []string { return r.cfg.Skills }
func (r *perAgentConfiguredReviewer) Start(ctx context.Context, _ reviewtypes.RunConfig) (reviewtypes.Process, error) {
	return r.inner.Start(ctx, r.cfg) //nolint:wrapcheck // transparent adapter; callers see inner's error type directly
}
```

Mcmd/entire/cli/review/cmd.go+9/-6

```
5 unmodified lines

6
7
8
9
10
11
12
1440 unmodified lines

1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560

5 unmodified lines

"errors"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

1440 unmodified lines

t.Error("skill-less worker got empty Task, want the built-in default brief")
	}
}

// multiStartCaptureReviewer records every Start call — the fan-out spawns
// the same agent multiple times, once per exploded skill worker.
type multiStartCaptureReviewer struct {
	name string
	mu   sync.Mutex
	got  []reviewtypes.RunConfig
}

func (r *multiStartCaptureReviewer) Name() string { return r.name }
func (r *multiStartCaptureReviewer) Start(_ context.Context, cfg reviewtypes.RunConfig) (reviewtypes.Process, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.got = append(r.got, cfg)
	return &stubDispatchProcess{}, nil
}

func (r *multiStartCaptureReviewer) captured() []reviewtypes.RunConfig {
	r.mu.Lock()
	defer r.mu.Unlock()
	return append([]reviewtypes.RunConfig(nil), r.got...)
}

func multiCaptureDeps(reviewer *multiStartCaptureReviewer) review.Deps {
	return review.Deps{
		GetAgentsWithHooksInstalled: func(_ context.Context) []types.AgentName {
			return []types.AgentName{types.AgentName(reviewer.name)}
		},
		NewSilentError: func(err error) error { return err },
		HeadHasReviewCheckpoint: func(_ context.Context) (bool, string) {
			return false, ""
		},
		ReviewerFor: func(agentName string) reviewtypes.AgentReviewer {
			if agentName == reviewer.name {
				return reviewer
			}
			return nil
		},
	}
}

// TestRunReview_MultiSkillWorkerFansOut verifies a worker with two skills
// spawns two parallel children, one skill each — wait is the slowest skill,
// not the sum.
func TestRunReview_MultiSkillWorkerFansOut(t *testing.T) {
	setupCmdTestRepo(t)
	if err := seedReviewProfile(context.Background(), settings.ReviewProfileConfig{
		Agents: map[string]settings.ReviewConfig{
			testAgentName: {Skills: []string{"/review", "/security-review"}},
		},
	}); err != nil {
		t.Fatal(err)
	}

reviewer := &multiStartCaptureReviewer{name: testAgentName}
	cmd := review.NewCommand(multiCaptureDeps(reviewer))
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"general"})
	if err := cmd.Execute(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

got := reviewer.captured()
	if len(got) != 2 {
		t.Fatalf("Start called %d times, want 2 (one per skill)", len(got))
	}
	skills := map[string]bool{}
	for _, cfg := range got {
		if len(cfg.Skills) != 1 {
			t.Errorf("run Skills = %v, want exactly one per exploded worker", cfg.Skills)
			continue
		}
		skills[cfg.Skills[0]] = true
		}
	if !skills["/review"] || !skills["/security-review"] {
		t.Errorf("fan-out skills = %v, want both configured skills", skills)
	}
}

// TestRunReview_AgentOverrideRunsAllExplodedWorkers verifies --agent with a
// multi-skill agent runs every exploded worker for that agent instead of
// erroring on ambiguity.
func TestRunReview_AgentOverrideRunsAllExplodedWorkers(t *testing.T) {
	setupCmdTestRepo(t)
	if err := seedReviewProfile(context.Background(), settings.ReviewProfileConfig{
		Agents: map[string]settings.ReviewConfig{
			testAgentName: {Skills: []string{"/review", "/security-review"}},
		},
	}); err != nil {
		t.Fatal(err)
	}

reviewer := &multiStartCaptureReviewer{name: testAgentName}
	cmd := review.NewCommand(multiCaptureDeps(reviewer))
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"general", "--agent", testAgentName})
	if err := cmd.Execute(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got := reviewer.captured(); len(got) != 2 {
		t.Fatalf("Start called %d times, want 2 (agent override filters, not selects one)", len(got))
	}
}
```
