From 43c11b91bd0c26d8ffdf02f6d371d7a6c30c8b5d Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 22 Mar 2026 22:10:17 +0200 Subject: [PATCH 1/2] docs: KEDA external scaler template for autoscaling (#516) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/keda-scaler-docs.md | 10 ++ .squad-templates/keda-scaler.md | 164 ++++++++++++++++++++++++++++++++ test/capabilities.test.ts | 108 +++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 .changeset/keda-scaler-docs.md create mode 100644 .squad-templates/keda-scaler.md create mode 100644 test/capabilities.test.ts diff --git a/.changeset/keda-scaler-docs.md b/.changeset/keda-scaler-docs.md new file mode 100644 index 000000000..f838ef7d8 --- /dev/null +++ b/.changeset/keda-scaler-docs.md @@ -0,0 +1,10 @@ +--- +"@bradygaster/squad-cli": patch +--- + +docs: Add KEDA external scaler template for GitHub issue-driven autoscaling + +New template documenting how to use keda-copilot-scaler for scaling +agent pods to zero when idle, with rate-limit-aware polling. + +Closes #516 diff --git a/.squad-templates/keda-scaler.md b/.squad-templates/keda-scaler.md new file mode 100644 index 000000000..ba1646c5f --- /dev/null +++ b/.squad-templates/keda-scaler.md @@ -0,0 +1,164 @@ +# KEDA External Scaler for GitHub Issue-Driven Agent Autoscaling + +> Scale agent pods to zero when idle, up when work arrives — driven by GitHub Issues. + +## Overview + +When running Squad on Kubernetes, agent pods sit idle when no work exists. [KEDA](https://keda.sh) (Kubernetes Event-Driven Autoscaler) solves this for queue-based workloads, but GitHub Issues isn't a native KEDA trigger. + +The `keda-copilot-scaler` is a KEDA External Scaler (gRPC) that bridges this gap: +1. Polls GitHub API for issues matching specific labels (e.g., `squad:copilot`) +2. Reports queue depth as a KEDA metric +3. Handles rate limits gracefully (Retry-After, exponential backoff) +4. Supports composite scaling decisions + +## Quick Start + +### Prerequisites +- Kubernetes cluster with KEDA v2.x installed +- GitHub personal access token (PAT) with `repo` scope +- Helm 3.x + +### 1. Install the Scaler + +```bash +helm install keda-copilot-scaler oci://ghcr.io/tamirdresher/keda-copilot-scaler \ + --namespace squad-scaler --create-namespace \ + --set github.owner=YOUR_ORG \ + --set github.repo=YOUR_REPO \ + --set github.token=YOUR_TOKEN +``` + +Or with Kustomize: +```bash +kubectl apply -k https://github.com/tamirdresher/keda-copilot-scaler/deploy/kustomize +``` + +### 2. Create a ScaledObject + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: picard-scaler + namespace: squad +spec: + scaleTargetRef: + name: picard-deployment + minReplicaCount: 0 # Scale to zero when idle + maxReplicaCount: 3 + pollingInterval: 30 # Check every 30 seconds + cooldownPeriod: 300 # Wait 5 minutes before scaling down + triggers: + - type: external + metadata: + scalerAddress: keda-copilot-scaler.squad-scaler.svc.cluster.local:6000 + owner: your-org + repo: your-repo + labels: squad:copilot # Only count issues with this label + threshold: "1" # Scale up when >= 1 issue exists +``` + +### 3. Verify + +```bash +# Check the scaler is running +kubectl get pods -n squad-scaler + +# Check ScaledObject status +kubectl get scaledobject picard-scaler -n squad + +# Watch scaling events +kubectl get events -n squad --watch +``` + +## Scaling Behavior + +| Open Issues | Target Replicas | Behavior | +|------------|----------------|----------| +| 0 | 0 | Scale to zero — save resources | +| 1–3 | 1 | Single agent handles work | +| 4–10 | 2 | Scale up for parallel processing | +| 10+ | 3 (max) | Maximum parallelism | + +The threshold and max replicas are configurable per ScaledObject. + +## Rate Limit Awareness + +The scaler tracks GitHub API rate limits: +- Reads `X-RateLimit-Remaining` from API responses +- Backs off when quota is low (< 100 remaining) +- Reports rate limit metrics as secondary KEDA triggers +- Never exhausts API quota from polling + +## Integration with Squad + +### Machine Capabilities (#514) + +Combine with machine capability labels for intelligent scheduling: + +```yaml +# Only scale pods on GPU-capable nodes +spec: + template: + spec: + nodeSelector: + node.squad.dev/gpu: "true" + triggers: + - type: external + metadata: + labels: squad:copilot,needs:gpu +``` + +### Cooperative Rate Limiting (#515) + +The scaler exposes rate limit metrics that feed into the cooperative rate limiting system: +- Current `X-RateLimit-Remaining` value +- Predicted time to exhaustion (from predictive circuit breaker) +- Can return 0 target replicas when rate limited → pods scale to zero + +## Architecture + +``` +GitHub API KEDA Kubernetes +┌──────────┐ ┌──────────┐ ┌──────────────┐ +│ Issues │◄── poll ──►│ Scaler │──metrics─►│ HPA / KEDA │ +│ (REST) │ │ (gRPC) │ │ Controller │ +└──────────┘ └──────────┘ └──────┬───────┘ + │ + scale up/down + │ + ┌──────▼───────┐ + │ Agent Pods │ + │ (0–N replicas)│ + └──────────────┘ +``` + +## Configuration Reference + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `github.owner` | — | Repository owner | +| `github.repo` | — | Repository name | +| `github.token` | — | GitHub PAT with `repo` scope | +| `github.labels` | `squad:copilot` | Comma-separated label filter | +| `scaler.port` | `6000` | gRPC server port | +| `scaler.pollInterval` | `30s` | GitHub API polling interval | +| `scaler.rateLimitThreshold` | `100` | Stop polling below this remaining | + +## Source & Contributing + +- **Repository:** [tamirdresher/keda-copilot-scaler](https://github.com/tamirdresher/keda-copilot-scaler) +- **License:** MIT +- **Language:** Go +- **Tests:** 51 passing (unit + integration) +- **CI:** GitHub Actions + +The scaler is maintained as a standalone project. PRs and issues welcome. + +## References + +- [KEDA External Scalers](https://keda.sh/docs/latest/concepts/external-scalers/) — KEDA documentation +- [Squad on AKS](https://github.com/tamirdresher/squad-on-aks) — Full Kubernetes deployment example +- [Machine Capabilities](machine-capabilities.md) — Capability-based routing (#514) +- [Cooperative Rate Limiting](cooperative-rate-limiting.md) — Multi-agent rate management (#515) diff --git a/test/capabilities.test.ts b/test/capabilities.test.ts new file mode 100644 index 000000000..961897914 --- /dev/null +++ b/test/capabilities.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { + extractNeeds, + canHandleIssue, + filterByCapabilities, + type MachineCapabilities, +} from '@bradygaster/squad-sdk/ralph/capabilities'; + +const gpuMachine: MachineCapabilities = { + machine: 'GPU-SERVER', + capabilities: ['gpu', 'docker', 'browser'], + missing: ['onedrive', 'teams-mcp'], + lastUpdated: '2026-03-22T00:00:00Z', +}; + +const laptopMachine: MachineCapabilities = { + machine: 'MY-LAPTOP', + capabilities: ['browser', 'personal-gh', 'onedrive'], + missing: ['gpu', 'docker'], + lastUpdated: '2026-03-22T00:00:00Z', +}; + +describe('extractNeeds', () => { + it('extracts needs:* labels', () => { + expect(extractNeeds(['bug', 'needs:gpu', 'squad:picard'])).toEqual(['gpu']); + }); + + it('handles multiple needs', () => { + expect(extractNeeds(['needs:gpu', 'needs:browser', 'needs:docker'])) + .toEqual(['gpu', 'browser', 'docker']); + }); + + it('returns empty for no needs labels', () => { + expect(extractNeeds(['bug', 'enhancement', 'squad:data'])).toEqual([]); + }); + + it('returns empty for empty array', () => { + expect(extractNeeds([])).toEqual([]); + }); +}); + +describe('canHandleIssue', () => { + it('passes issues with no needs labels', () => { + expect(canHandleIssue(['bug', 'squad:picard'], gpuMachine)).toEqual({ canHandle: true }); + }); + + it('passes when all needs are met', () => { + expect(canHandleIssue(['needs:gpu', 'needs:docker'], gpuMachine)) + .toEqual({ canHandle: true }); + }); + + it('fails when needs are missing', () => { + const result = canHandleIssue(['needs:gpu', 'needs:docker'], laptopMachine); + expect(result.canHandle).toBe(false); + if (!result.canHandle) { + expect(result.missing).toEqual(['gpu', 'docker']); + } + }); + + it('passes all issues when capabilities is null (opt-in)', () => { + expect(canHandleIssue(['needs:gpu'], null)).toEqual({ canHandle: true }); + }); + + it('reports only missing capabilities', () => { + const result = canHandleIssue(['needs:browser', 'needs:gpu'], laptopMachine); + expect(result.canHandle).toBe(false); + if (!result.canHandle) { + expect(result.missing).toEqual(['gpu']); + // browser IS available, should not be in missing + } + }); +}); + +describe('filterByCapabilities', () => { + const issues = [ + { number: 1, title: 'Bug fix', labels: [{ name: 'bug' }] }, + { number: 2, title: 'GPU task', labels: [{ name: 'needs:gpu' }] }, + { number: 3, title: 'Browser task', labels: [{ name: 'needs:browser' }] }, + { number: 4, title: 'Both', labels: [{ name: 'needs:gpu' }, { name: 'needs:browser' }] }, + ]; + + it('passes all issues when capabilities is null', () => { + const { handled, skipped } = filterByCapabilities(issues, null); + expect(handled).toHaveLength(4); + expect(skipped).toHaveLength(0); + }); + + it('filters correctly for GPU machine', () => { + const { handled, skipped } = filterByCapabilities(issues, gpuMachine); + expect(handled.map(i => i.number)).toEqual([1, 2, 3, 4]); // GPU machine has both + expect(skipped).toHaveLength(0); + }); + + it('filters correctly for laptop', () => { + const { handled, skipped } = filterByCapabilities(issues, laptopMachine); + expect(handled.map(i => i.number)).toEqual([1, 3]); // laptop has browser but not gpu + expect(skipped).toHaveLength(2); + expect(skipped[0].issue.number).toBe(2); // GPU task skipped + expect(skipped[0].missing).toEqual(['gpu']); + expect(skipped[1].issue.number).toBe(4); // Both task skipped (needs gpu) + }); + + it('handles empty issue list', () => { + const { handled, skipped } = filterByCapabilities([], gpuMachine); + expect(handled).toHaveLength(0); + expect(skipped).toHaveLength(0); + }); +}); From fad7d4bffdc22d10c479c6e3b855cdeb560d81d2 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 22 Mar 2026 21:15:59 -0700 Subject: [PATCH 2/2] fix: remove stray test/capabilities.test.ts (belongs to PR #520) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/capabilities.test.ts | 108 -------------------------------------- 1 file changed, 108 deletions(-) delete mode 100644 test/capabilities.test.ts diff --git a/test/capabilities.test.ts b/test/capabilities.test.ts deleted file mode 100644 index 961897914..000000000 --- a/test/capabilities.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - extractNeeds, - canHandleIssue, - filterByCapabilities, - type MachineCapabilities, -} from '@bradygaster/squad-sdk/ralph/capabilities'; - -const gpuMachine: MachineCapabilities = { - machine: 'GPU-SERVER', - capabilities: ['gpu', 'docker', 'browser'], - missing: ['onedrive', 'teams-mcp'], - lastUpdated: '2026-03-22T00:00:00Z', -}; - -const laptopMachine: MachineCapabilities = { - machine: 'MY-LAPTOP', - capabilities: ['browser', 'personal-gh', 'onedrive'], - missing: ['gpu', 'docker'], - lastUpdated: '2026-03-22T00:00:00Z', -}; - -describe('extractNeeds', () => { - it('extracts needs:* labels', () => { - expect(extractNeeds(['bug', 'needs:gpu', 'squad:picard'])).toEqual(['gpu']); - }); - - it('handles multiple needs', () => { - expect(extractNeeds(['needs:gpu', 'needs:browser', 'needs:docker'])) - .toEqual(['gpu', 'browser', 'docker']); - }); - - it('returns empty for no needs labels', () => { - expect(extractNeeds(['bug', 'enhancement', 'squad:data'])).toEqual([]); - }); - - it('returns empty for empty array', () => { - expect(extractNeeds([])).toEqual([]); - }); -}); - -describe('canHandleIssue', () => { - it('passes issues with no needs labels', () => { - expect(canHandleIssue(['bug', 'squad:picard'], gpuMachine)).toEqual({ canHandle: true }); - }); - - it('passes when all needs are met', () => { - expect(canHandleIssue(['needs:gpu', 'needs:docker'], gpuMachine)) - .toEqual({ canHandle: true }); - }); - - it('fails when needs are missing', () => { - const result = canHandleIssue(['needs:gpu', 'needs:docker'], laptopMachine); - expect(result.canHandle).toBe(false); - if (!result.canHandle) { - expect(result.missing).toEqual(['gpu', 'docker']); - } - }); - - it('passes all issues when capabilities is null (opt-in)', () => { - expect(canHandleIssue(['needs:gpu'], null)).toEqual({ canHandle: true }); - }); - - it('reports only missing capabilities', () => { - const result = canHandleIssue(['needs:browser', 'needs:gpu'], laptopMachine); - expect(result.canHandle).toBe(false); - if (!result.canHandle) { - expect(result.missing).toEqual(['gpu']); - // browser IS available, should not be in missing - } - }); -}); - -describe('filterByCapabilities', () => { - const issues = [ - { number: 1, title: 'Bug fix', labels: [{ name: 'bug' }] }, - { number: 2, title: 'GPU task', labels: [{ name: 'needs:gpu' }] }, - { number: 3, title: 'Browser task', labels: [{ name: 'needs:browser' }] }, - { number: 4, title: 'Both', labels: [{ name: 'needs:gpu' }, { name: 'needs:browser' }] }, - ]; - - it('passes all issues when capabilities is null', () => { - const { handled, skipped } = filterByCapabilities(issues, null); - expect(handled).toHaveLength(4); - expect(skipped).toHaveLength(0); - }); - - it('filters correctly for GPU machine', () => { - const { handled, skipped } = filterByCapabilities(issues, gpuMachine); - expect(handled.map(i => i.number)).toEqual([1, 2, 3, 4]); // GPU machine has both - expect(skipped).toHaveLength(0); - }); - - it('filters correctly for laptop', () => { - const { handled, skipped } = filterByCapabilities(issues, laptopMachine); - expect(handled.map(i => i.number)).toEqual([1, 3]); // laptop has browser but not gpu - expect(skipped).toHaveLength(2); - expect(skipped[0].issue.number).toBe(2); // GPU task skipped - expect(skipped[0].missing).toEqual(['gpu']); - expect(skipped[1].issue.number).toBe(4); // Both task skipped (needs gpu) - }); - - it('handles empty issue list', () => { - const { handled, skipped } = filterByCapabilities([], gpuMachine); - expect(handled).toHaveLength(0); - expect(skipped).toHaveLength(0); - }); -});