Per-repo all-items fan-out - Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the autonomous cron triage every open issue/MR in every project repo, parallelised per repo (one lane per repo, maxPerRepo=1 active Task), bounded by the existing MaxConcurrentTasks ceiling.
Architecture: Replace the global pick-N in mrScan/issueScan with a per-repo top-up: for each repo, create up to maxPerRepo - laneOccupancy(repo) Tasks (priority-then-stale). laneOccupancy excludes AwaitingApproval so proposals don't stall a lane. A backlog-aware requeue refills freed lanes within ~60s. Reuses the existing per-item Task + agent.
Tech Stack: Go 1.x, controller-runtime, kubebuilder CRD, envtest.
Worktree: off fresh tatara-operator main (git checkout main && git pull). Bots push to this repo - the auto-merge worktree feat/scm-auto-merge already exists; create a SEPARATE worktree for this work.
Spec: docs/superpowers/specs/2026-06-12-per-repo-fanout-design.md
Key existing symbols (read once, do not re-derive): - internal/controller/projectscan.go: candidate{repo,number,author,headSHA,labels,updatedAt,isPR}, selectCandidates(in, priorityLabel, n) (priority-then-stale, caps at n), mrScan/issueScan (currently selected := selectCandidates(eligible, prio, act.MaxPerCycle)), isDeduped, sanitizeRepoLabel, matchRepoForSlug, createScanTask, existingScanTasks, runScans (returns (time.Duration, error), has a consider(d time.Duration) closure reducing soonest), maxScheduleRequeue const. - Labels: labelSourceRepo (= sanitizeRepoLabel(slug)), labelSourceNumber, labelActivity. - api/v1alpha1/project_types.go: CronActivity{Schedule, MaxPerCycle} (used by MRScan + IssueScan); BrainstormActivity (separate type, untouched); ProjectSpec.MaxConcurrentTasks. - Task phases seen: Planning, Running, AwaitingApproval, Succeeded, Failed, plus "" (new). isActive = Planning|Running (in task_controller.go).
Task 1: Rename CronActivity.MaxPerCycle -> MaxPerRepo¶
Files: - Modify: api/v1alpha1/project_types.go (CronActivity field) - Modify: internal/controller/projectscan.go (2 call sites act.MaxPerCycle) - Regenerate: config/crd/... + charts/tatara-operator/crds/... via make manifests
- Step 1: Rename the field
In api/v1alpha1/project_types.go, CronActivity:
type CronActivity struct {
// Schedule is a 5-field cron (robfig ParseStandard). Empty disables this activity.
// +optional
Schedule string `json:"schedule,omitempty"`
// MaxPerRepo caps the number of in-progress Tasks per repo (one lane per repo).
// +kubebuilder:default=1
// +optional
MaxPerRepo int `json:"maxPerRepo,omitempty"`
}
BrainstormActivity keeps its own MaxPerCycle - do not touch it.
- Step 2: Update the 2 scan call sites (temporary, keeps build green)
In internal/controller/projectscan.go, in BOTH mrScan and issueScan, change act.MaxPerCycle -> act.MaxPerRepo (the per-repo top-up replaces this entirely in Tasks 4-5; this keeps the build compiling now).
Grep to be sure: grep -rn "MaxPerCycle" internal/ api/ should show only BrainstormActivity and brainstorm code after this.
- Step 3: Regenerate the CRD
Run: make manifests Expected: config/crd/bases/*project*.yaml and charts/tatara-operator/crds/*project*.yaml now show maxPerRepo (not maxPerCycle) under mrScan/issueScan; brainstorm still has maxPerCycle.
- Step 4: Build + existing scan tests
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ ./api/... -count=1 Expected: PASS (any test setting MaxPerCycle on a scan activity must be updated to MaxPerRepo - grep the test files and fix).
- Step 5: Commit
git add api/v1alpha1/project_types.go internal/controller/projectscan.go config/crd charts/tatara-operator/crds
git commit -m "feat(api): rename scan CronActivity.MaxPerCycle to MaxPerRepo"
Task 2: laneOccupancy pure helper¶
Files: - Modify: internal/controller/projectscan.go (add helper + slices import) - Test: internal/controller/projectscan_test.go (or the existing scan test file - grep for func TestSelectCandidates to find it)
- Step 1: Write the failing test
func TestLaneOccupancy(t *testing.T) {
mk := func(repo, kind, phase string) tatarav1alpha1.Task {
return tatarav1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{labelSourceRepo: sanitizeRepoLabel(repo)}},
Spec: tatarav1alpha1.TaskSpec{Kind: kind},
Status: tatarav1alpha1.TaskStatus{Phase: phase},
}
}
existing := []tatarav1alpha1.Task{
mk("o/a", "triageIssue", "Running"), // occupies a/issue lane
mk("o/a", "triageIssue", "AwaitingApproval"), // does NOT occupy (awaiting)
mk("o/a", "triageIssue", "Succeeded"), // terminal, no
mk("o/b", "review", "Planning"), // occupies b/mr lane
mk("o/a", "selfImprove", "Running"), // wrong kind for issue lane
}
require.Equal(t, 1, laneOccupancy(existing, "o/a", "triageIssue"))
require.Equal(t, 1, laneOccupancy(existing, "o/b", "review", "selfImprove"))
require.Equal(t, 0, laneOccupancy(existing, "o/c", "triageIssue"))
}
Add metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" to the test imports if missing.
- Step 2: Run, verify fail
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestLaneOccupancy -count=1 Expected: FAIL - laneOccupancy undefined.
- Step 3: Implement
In internal/controller/projectscan.go (add "slices" to imports):
// laneOccupancy counts this Project's scan Tasks for repoSlug that still occupy
// the repo's lane: Kind in kinds, phase not terminal and not AwaitingApproval
// (an awaiting-approval proposal frees the lane for the next item).
func laneOccupancy(existing []tatarav1alpha1.Task, repoSlug string, kinds ...string) int {
label := sanitizeRepoLabel(repoSlug)
n := 0
for i := range existing {
t := &existing[i]
if t.Labels[labelSourceRepo] != label || !slices.Contains(kinds, t.Spec.Kind) {
continue
}
switch t.Status.Phase {
case "Succeeded", "Failed", "AwaitingApproval":
continue
}
n++
}
return n
}
- Step 4: Run, verify pass
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestLaneOccupancy -count=1 Expected: PASS.
- Step 5: Commit
git add internal/controller/projectscan.go internal/controller/projectscan_test.go
git commit -m "feat(scan): laneOccupancy helper (per-repo, excludes AwaitingApproval)"
Task 3: selectPerRepo pure helper¶
Files: - Modify: internal/controller/projectscan.go - Test: same scan test file as Task 2
- Step 1: Write the failing test
func TestSelectPerRepo(t *testing.T) {
c := func(repo string, n int, age time.Duration) candidate {
return candidate{repo: repo, number: n, updatedAt: time.Now().Add(-age)}
}
eligible := []candidate{
c("o/a", 1, 3*time.Hour), c("o/a", 2, 1*time.Hour), // a: two items
c("o/b", 3, 2*time.Hour), // b: one item
}
// a lane already has 1 active -> a gets 0; b has 0 -> b gets its 1.
occ := func(slug string) int {
if slug == "o/a" {
return 1
}
return 0
}
got := selectPerRepo(eligible, "", 1, occ)
require.Len(t, got, 1)
require.Equal(t, "o/b", got[0].repo)
// empty lanes, maxPerRepo 1 -> one per repo, stale-first within repo.
got2 := selectPerRepo(eligible, "", 1, func(string) int { return 0 })
require.Len(t, got2, 2)
repos := []string{got2[0].repo, got2[1].repo}
require.ElementsMatch(t, []string{"o/a", "o/b"}, repos)
for _, g := range got2 {
if g.repo == "o/a" {
require.Equal(t, 1, g.number) // a#1 is older -> stale-first
}
}
}
- Step 2: Run, verify fail
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestSelectPerRepo -count=1 Expected: FAIL - selectPerRepo undefined.
- Step 3: Implement
// selectPerRepo groups eligible candidates by repo and picks, per repo, the best
// (priority-then-stale) items up to maxPerRepo minus that repo's lane occupancy.
func selectPerRepo(eligible []candidate, priorityLabel string, maxPerRepo int, occ func(repoSlug string) int) []candidate {
if maxPerRepo < 1 {
maxPerRepo = 1
}
byRepo := map[string][]candidate{}
var order []string
for _, c := range eligible {
if _, ok := byRepo[c.repo]; !ok {
order = append(order, c.repo)
}
byRepo[c.repo] = append(byRepo[c.repo], c)
}
var out []candidate
for _, slug := range order {
n := maxPerRepo - occ(slug)
if n < 1 {
continue
}
out = append(out, selectCandidates(byRepo[slug], priorityLabel, n)...)
}
return out
}
- Step 4: Run, verify pass
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestSelectPerRepo -count=1 Expected: PASS.
- Step 5: Commit
git add internal/controller/projectscan.go internal/controller/projectscan_test.go
git commit -m "feat(scan): selectPerRepo per-repo top-up selection"
Task 4: Wire issueScan to per-repo top-up + backlog cadence¶
Files: - Modify: internal/controller/projectscan.go (issueScan signature + body; runScans call site; add backlogRequeue const) - Test: scan test file (envtest)
- Step 1: Add the constant + change issueScan
Add near maxScheduleRequeue:
In issueScan, change the signature to return a backlog bool:
func (r *ProjectReconciler) issueScan(ctx context.Context, proj *tatarav1alpha1.Project, reader scm.SCMReader, repos []tatarav1alpha1.Repository, existing []tatarav1alpha1.Task, act tatarav1alpha1.CronActivity) bool {
Replace the selection line:
with:
selected := selectPerRepo(eligible, proj.Spec.Scm.PriorityLabel, act.MaxPerRepo,
func(slug string) int { return laneOccupancy(existing, slug, "triageIssue") })
At the end of issueScan (after the l.Info("issueScan complete", ...) line), return whether eligible items remain un-created:
- Step 2: Consume backlog in runScans
In runScans, the issueScan due-branch currently:
if due {
r.issueScan(ctx, proj, reader, repos, existing, cronSpec.IssueScan)
r.stampScan(ctx, proj, "issueScan")
if next2, ok2 := activityNextFire(cronSpec.IssueScan.Schedule, now); ok2 {
consider(next2)
}
} else {
change to:
if due {
backlog := r.issueScan(ctx, proj, reader, repos, existing, cronSpec.IssueScan)
r.stampScan(ctx, proj, "issueScan")
if next2, ok2 := activityNextFire(cronSpec.IssueScan.Schedule, now); ok2 {
consider(next2)
}
if backlog {
consider(backlogRequeue)
}
} else {
- Step 3: Write the failing envtest
Add to the scan test file (mirror the existing issueScan envtest - grep func TestIssueScan or issueScan( in the test file for the seed pattern). The test seeds a Project with 2 repos each having 2 open issues, a MaxPerRepo: 1 issueScan activity, runs the scan, and asserts ONE triageIssue Task per repo (not 1 total) and that backlog is reported:
func TestIssueScan_PerRepoTopUp(t *testing.T) {
// seed project + repos o/a, o/b; reader returns 2 open issues per repo.
// (reuse the existing fake SCMReader + seed helpers in this file)
// run: backlog := r.issueScan(ctx, proj, reader, repos, existing, act{MaxPerRepo:1})
// assert: exactly 1 triageIssue Task whose source repo == o/a, 1 == o/b (2 total)
// assert: backlog == true (2 more issues remain, lanes now full)
}
Fill the seed using this file's existing scan-test helpers (fake reader returning []scm.IssueRef, seed* project/repo creators). Assert via kubectl-equivalent k8sClient.List of Tasks filtered by labelActivity == "issueScan".
- Step 4: Run, verify fail then pass
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestIssueScan_PerRepoTopUp -count=1 Expected after Steps 1-2: PASS (1 Task/repo, backlog true).
- Step 5: Full controller suite (regression: global ceiling still gates)
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -count=1 Expected: PASS (existing atConcurrencyCap / scan tests still green).
- Step 6: Commit
git add internal/controller/projectscan.go internal/controller/projectscan_test.go
git commit -m "feat(scan): issueScan per-repo top-up + backlog requeue"
Task 5: Wire mrScan to per-repo top-up + backlog cadence¶
Files: - Modify: internal/controller/projectscan.go (mrScan signature + body; runScans call site) - Test: scan test file (envtest)
- Step 1: Change mrScan
Change mrScan signature to return bool:
func (r *ProjectReconciler) mrScan(ctx context.Context, proj *tatarav1alpha1.Project, reader scm.SCMReader, repos []tatarav1alpha1.Repository, existing []tatarav1alpha1.Task, act tatarav1alpha1.CronActivity) bool {
Replace the selection line:
with:
selected := selectPerRepo(eligible, proj.Spec.Scm.PriorityLabel, act.MaxPerRepo,
func(slug string) int { return laneOccupancy(existing, slug, "review", "selfImprove") })
At the end of mrScan, return:
- Step 2: Consume backlog in runScans (mrScan branch)
In runScans, mirror Task 4 Step 2 for the mrScan due-branch:
if due {
backlog := r.mrScan(ctx, proj, reader, repos, existing, cronSpec.MRScan)
r.stampScan(ctx, proj, "mrScan")
if next2, ok2 := activityNextFire(cronSpec.MRScan.Schedule, now); ok2 {
consider(next2)
}
if backlog {
consider(backlogRequeue)
}
} else {
- Step 3: Write the failing envtest
func TestMRScan_PerRepoTopUp(t *testing.T) {
// seed project + repos o/a, o/b; fake reader returns 2 open PRs per repo,
// one bot-authored (-> selfImprove) one human (-> review).
// run: backlog := r.mrScan(ctx, proj, reader, repos, existing, act{MaxPerRepo:1})
// assert: exactly 1 Task per repo (kind review|selfImprove), 2 total; backlog true.
}
Reuse the existing mrScan envtest seed pattern in this file (fake reader ListOpenPRs, bot/human authors via candidate.author == BotLogin).
- Step 4: Run, verify pass
Run: KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./internal/controller/ -run TestMRScan_PerRepoTopUp -count=1 Expected: PASS.
- Step 5: Full suite + gofmt + lint
Run:
KUBEBUILDER_ASSETS="$(cat /tmp/kba.txt)" go test ./... -count=1
gofmt -l . | grep -v '^$' || echo gofmt-clean
golangci-lint run ./internal/controller/... ./api/...
- Step 6: Commit
git add internal/controller/projectscan.go internal/controller/projectscan_test.go
git commit -m "feat(scan): mrScan per-repo top-up + backlog requeue"
Runbook - live deploy (after merge, GATED)¶
Not TDD; config/ops, run after the operator change deploys.
R1: live Project manifest¶
Update the tatara Project manifest (the standalone manifest applied to the cluster, NOT kubectl edit): - spec.scm.cron.issueScan.maxPerRepo: 1, spec.scm.cron.mrScan.maxPerRepo: 1 (rename from maxPerCycle). - spec.maxConcurrentTasks: 3 (the total-agent ceiling chosen for the cluster).
kubectl apply --server-side the regenerated Project CRD (Helm does not upgrade CRDs), then apply the updated Project manifest.
R2: deploy¶
Operator change merges to main -> CI publishes image + chart oci://harbor.szymonrichert.pl/charts/tatara-operator:0.0.0-<sha>. Bump the operator release version in the infra helmfile, helmfile diff, MR, deploy.
R3: verify¶
kubectl get project tatara -o jsonpath='{.spec.scm.cron.issueScan.maxPerRepo}' == 1; watch kubectl get tasks -n tatara create one triageIssue + one review/selfImprove per repo (<= 3 active total), and the backlog of 20 issues + 12 MRs drain into proposals over the next scans.
Self-Review¶
Spec coverage: - Per-repo top-up selection -> Tasks 3, 4, 5. - laneOccupancy excludes AwaitingApproval -> Task 2. - Global MaxConcurrentTasks ceiling retained -> unchanged (Task ⅘ regression check; R1 sets it to 3). - Backlog-aware 60s cadence -> Tasks 4, 5 (runScans consume). - CRD rename MaxPerCycle->MaxPerRepo (scans only) -> Task 1. - Live manifest maxPerRepo + maxConcurrentTasks:3 -> R1. - mrScan + issueScan only; brainstorm untouched -> Tasks ⅘ touch only those; Task 1 leaves BrainstormActivity.
Placeholder scan: envtest seed bodies in Tasks ⅘ Step 3 are described as "reuse the existing scan-test seed pattern in this file" with the exact assertions spelled out - the seed helpers exist in the repo; the implementer copies them. Not a silent TODO.
Type consistency: laneOccupancy(existing, slug, kinds...) and selectPerRepo(eligible, prio, maxPerRepo, occ) signatures identical across definition (Tasks 2,3) and call sites (Tasks 4,5). act.MaxPerRepo consistent after Task 1. issueScan/mrScan both return bool consumed by runScans.