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

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

1db594c·

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

[?
# Handoff To Claude: entire review RedoClaude Code·42 steps](/content/gh/entireio/cli/session/93833a17-c2c6-4cb0-85b2-663c867b105f#timeline-116f0cd95d56/index.html)

Changes

11

846 unmodified lines

847
848
849
850
851
852
853
854
855
853
856
857
858
859
860
861
862
860
861
863
864
865
863
866
867
868
811 unmodified lines

1680
1681
1682
1681
1682
1683
1684
1685
1686
1687
1688

846 unmodified lines

}
    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, profile.Task, outputMode, timeout, cfg, installed, deps, out)
            }
            return runSingleAgentPath(ctx, cmd, profileName, workerName, baseOverride, perRunPrompt, profile.Task, outputMode, timeout, cfg, installed, deps, out)
        }

if missing := missingInstalledProfileAgents(profile.Agents, installed); len(missing) > 0 {
    811 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
1419 unmodified lines

1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
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
1478
1479
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

5 unmodified lines

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

1419 unmodified lines

})
    }
}
// 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))
    }
}

Mcmd/entire/cli/review/cmd_test.go+106

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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

// Package review — see env.go for package-level rationale.
//
// fanout.go implements skill fan-out: a worker configured with N skills is
// exploded into N single-skill workers before planning, so the skills run
// concurrently as ordinary worker slots. Previously all N skills were joined
// into one child's prompt and executed sequentially (or blended) — selecting
// more skills made the user wait for the SUM of their durations; after
// explosion the wait is the slowest skill.
package review

import (
    "fmt"

"github.com/entireio/cli/cmd/entire/cli/settings"
)

// explodeSkillWorkers returns a copy of profile whose multi-skill workers are
// split into one worker per skill. Single-skill and skill-less workers pass
// through unchanged under their original keys. Exploded workers keep the
// source worker's model and prompt, carry an explicit Agent so the derived
// key still resolves to the real agent, and get deterministic keys
// (<worker>:<skill-slug>, deduped against existing keys).
func explodeSkillWorkers(profile settings.ReviewProfileConfig) settings.ReviewProfileConfig {
    out := profile
    agents := make(map[string]settings.ReviewConfig, len(profile.Agents))

// Pass-through workers claim their keys first so exploded keys can never
    // clobber an existing worker that happens to match a derived name.
    multiSkill := make([]string, 0, len(profile.Agents))
    for _, name := range sortedMapKeys(profile.Agents) {
        cfg := profile.Agents[name]
        if len(cfg.Skills) <= 1 {
            agents[name] = cfg
            continue
        }
        multiSkill = append(multiSkill, name)
    }

for _, name := range multiSkill {
        cfg := profile.Agents[name]
        agentName := reviewAgentName(name, cfg)
        for _, skill := range cfg.Skills {
            worker := cfg
            worker.Skills = []string{skill}
            worker.Agent = agentName
            agents[workerIDForSkill(name, skill, agents)] = worker
        }
    }

out.Agents = agents
    return out
}

// workerIDForSkill derives a stable worker key for one exploded skill run,
// following the workerIDForAgentModel convention (<base>:<slug>, numeric
// suffix on collision).
func workerIDForSkill(base, skill string, existing map[string]settings.ReviewConfig) string {
    candidate := base + ":" + sanitizeWorkerIDPart(skill)
    for i := 2; ; i++ {
        if _, exists := existing[candidate]; !exists {
            return candidate
        }
        candidate = fmt.Sprintf("%s:%s-%d", base, sanitizeWorkerIDPart(skill), i)
        }
}

// applyAgentOverride narrows profile.Agents to the workers matching the
// --agent selector, applying an optional model override to each. Exactly one
// match returns (workerName, cfg, true) for the single-agent path; multiple
// matches — the agent's exploded skill workers — narrow the profile in place
// and run as a filtered crew through the normal fan-out flow.
func applyAgentOverride(profile *settings.ReviewProfileConfig, agentOverride, modelOverride string) (string, settings.ReviewConfig, bool, error) {
    matched, err := selectProfileWorkers(*profile, agentOverride)
    if err != nil {
        return "", settings.ReviewConfig{}, false, err
    }
    if modelOverride != "" {
        for workerName, cfg := range matched {
            cfg.Model = modelOverride
            matched[workerName] = cfg
        }
    }
    if len(matched) == 1 {
        for workerName, cfg := range matched {
            return workerName, cfg, true, nil
        }
    }
    profile.Agents = matched
    return "", settings.ReviewConfig{}, false, nil
}