diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml index 9fdefd33c1..5bc3a734a9 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml +++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml @@ -30,7 +30,8 @@ name: "Go File Size Reduction Campaign (Project 64)" permissions: {} concurrency: - group: "gh-aw-${{ github.workflow }}" + cancel-in-progress: false + group: campaign-go-file-size-reduction-project64-orchestrator-${{ github.ref }} run-name: "Go File Size Reduction Campaign (Project 64)" @@ -2002,6 +2003,18 @@ jobs: This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination: + ### Traffic and rate limits (required) + + - Minimize API calls: avoid full rescans when possible and avoid repeated reads of the same data in a single run. + - Prefer incremental processing: use deterministic ordering (e.g., by updated time) and process a bounded slice each run. + - Use strict pagination budgets: if a query would require many pages, stop early and continue next run. + - Use a durable cursor/checkpoint: persist the last processed boundary (e.g., updatedAt cutoff + last seen ID) so the next run can continue without rescanning. + - On throttling (HTTP 429 / rate limit 403), do not retry aggressively. Use backoff and end the run after reporting what remains. + + + + + ### Core Principles 1. **Workers are immutable** - Worker workflows never change based on campaign state diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.md b/.github/workflows/go-file-size-reduction-project64.campaign.g.md index 1e5686d62b..85ff3c1cbd 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.g.md +++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.md @@ -5,6 +5,9 @@ on: schedule: - cron: "0 18 * * *" workflow_dispatch: +concurrency: + group: "campaign-go-file-size-reduction-project64-orchestrator-${{ github.ref }}" + cancel-in-progress: false engine: copilot safe-outputs: add-comment: @@ -36,6 +39,18 @@ This workflow orchestrates the 'Go File Size Reduction Campaign (Project 64)' ca This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination: +### Traffic and rate limits (required) + +- Minimize API calls: avoid full rescans when possible and avoid repeated reads of the same data in a single run. +- Prefer incremental processing: use deterministic ordering (e.g., by updated time) and process a bounded slice each run. +- Use strict pagination budgets: if a query would require many pages, stop early and continue next run. +- Use a durable cursor/checkpoint: persist the last processed boundary (e.g., updatedAt cutoff + last seen ID) so the next run can continue without rescanning. +- On throttling (HTTP 429 / rate limit 403), do not retry aggressively. Use backoff and end the run after reporting what remains. + + + + + ### Core Principles 1. **Workers are immutable** - Worker workflows never change based on campaign state diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.md b/.github/workflows/go-file-size-reduction-project64.campaign.md deleted file mode 100644 index 5d6b1278d6..0000000000 --- a/.github/workflows/go-file-size-reduction-project64.campaign.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -id: go-file-size-reduction-project64 -version: "v1" -name: "Go File Size Reduction Campaign (Project 64)" -description: "Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions." - -project-url: "https://github.com/orgs/githubnext/projects/64" -project-github-token: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" - -workflows: - - daily-file-diet - -memory-paths: - - "memory/campaigns/go-file-size-reduction-project64-*/**" - -owners: - - "platform-engineering" - - "developer-experience" - -executive-sponsors: - - "vp-engineering" - -risk-level: "medium" -state: "active" -tags: - - "code-health" - - "refactoring" - - "maintainability" - - "technical-debt" - -tracker-label: "campaign:go-file-size-reduction-project64" - -metrics-glob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json" - -allowed-safe-outputs: - - "create-issue" - - "add-comment" - - "upload-assets" - - "update-project" - -approval-policy: - required-approvals: 1 - required-roles: - - "platform-eng-lead" - change-control: false ---- - -# Go File Size Reduction Campaign (Project 64) - -This campaign systematically reduces oversized Go files in the codebase to improve maintainability and code quality. - -## Campaign Objectives - -- **Goal**: Reduce all non-test Go files to ≤800 lines of code (LOC) -- **Scope**: All non-test Go files under `pkg/` directory -- **Success Criteria**: - - All files ≤800 LOC - - Maintain test coverage - - No regressions in functionality - - Improved code maintainability - -## Tracking - -- **Issues**: Labeled with `campaign:go-file-size-reduction-project64` -- **Project Board**: [GitHub Project #64](https://github.com/orgs/githubnext/projects/64) -- **Metrics**: Daily snapshots stored under `memory/campaigns/go-file-size-reduction-project64-*/metrics/` - -## Approach - -1. **Identify**: Scan `pkg/` for Go files exceeding 800 LOC -2. **Prioritize**: Focus on largest files first for maximum impact -3. **Refactor**: Break down oversized files into logical, focused modules -4. **Validate**: Ensure all tests pass and coverage is maintained -5. **Track**: Monitor progress through metrics and project board - -## Workflow Integration - -The `daily-file-diet` worker workflow and campaign orchestrator work together: -- **Worker** (`daily-file-diet`): Scans for oversized Go files independently, creates tracking issues, records daily metrics snapshots -- **Orchestrator**: Monitors worker workflow runs (via `tracker-id`), discovers issues created by workers, adds them to project board, updates board status, reports on campaign progress - -## Setup - -**One-time manual setup**: Create the GitHub Project in the UI and configure views (board/table, grouping, filters). The workflows will update items and fields but do not create or configure Project views. - -## Governance - -- **Risk Level**: Medium - refactoring existing code carries moderate risk -- **Approvals**: Requires 1 approval from platform-eng-lead -- **Change Control**: Not required for routine refactoring PRs - -Use this specification as the authoritative description of the campaign for owners, sponsors, and reporting purposes. diff --git a/docs/src/content/docs/guides/campaigns.md b/docs/src/content/docs/guides/campaigns.md index fff69aa19c..5ec83fbd1d 100644 --- a/docs/src/content/docs/guides/campaigns.md +++ b/docs/src/content/docs/guides/campaigns.md @@ -41,9 +41,8 @@ You do not need agentic campaigns just to run a workflow across many repositorie Once you decide to use an agentic campaign, most implementations follow the same shape: -- **Launcher workflow (required)**: finds work and creates tracking artifacts (issues/Project items), plus (optionally) a baseline in repo-memory. -- **Worker workflows (optional)**: process campaign-labeled issues to do the actual work (open PRs, apply fixes, etc.). -- **Monitor/orchestrator (recommended for multi-day work)**: posts periodic status updates and stores metrics snapshots. +- **Orchestrator workflow (generated)**: maintains the campaign dashboard by syncing tracker-labeled issues/PRs to the GitHub Project board, updating status fields, and posting periodic reports. The orchestrator handles both initial discovery and ongoing synchronization. +- **Worker workflows (optional)**: process campaign-labeled issues to do the actual work (open PRs, apply fixes, etc.). Workers include a `tracker-id` so the orchestrator can discover their created assets. You can track agentic campaigns with just labels and issues, but agentic campaigns become much more reusable when you also store baselines, metrics, and learnings in repo-memory (a git branch used for machine-generated snapshots). @@ -69,23 +68,24 @@ This design allows workers to operate independently without knowledge of the age Generated orchestrator workflows follow a four-phase execution model each time they run: **Phase 1: Read State (Discovery)** -- Query for worker-created issues using tracker-id search +- Query for tracker-labeled issues/PRs matching the campaign +- Query for worker-created issues using tracker-id search (if workers are configured) - Read current state of the GitHub Project board -- Compare discovered issues against board state to identify gaps +- Compare discovered items against board state to identify gaps **Phase 2: Make Decisions (Planning)** -- Decide which new issues to add to the board -- Determine status updates for existing items +- Decide which new items to add to the board (respecting governance limits) +- Determine status updates for existing items (respecting governance rules like no-downgrade) - Check campaign completion criteria **Phase 3: Write State (Execution)** -- Add new issues to project board via `update-project` safe output +- Add new items to project board via `update-project` safe output - Update status fields for existing board items - Record completion state if campaign is done **Phase 4: Report (Output)** - Generate status report summarizing execution -- Record metrics: issues discovered, added, updated +- Record metrics: items discovered, added, updated, skipped - Report any failures encountered #### Core Design Principles @@ -97,6 +97,7 @@ The orchestrator/worker pattern enforces these principles: - **Campaign logic is external** - All orchestration happens in the orchestrator, not workers - **Single source of truth** - The GitHub Project board is the authoritative campaign state - **Idempotent operations** - Re-execution produces the same result without corruption +- **Governed operations** - Orchestrators respect pacing limits and opt-out policies These principles ensure workers can be reused across agentic campaigns and remain simple, while orchestrators handle all coordination complexity. diff --git a/docs/src/content/docs/guides/campaigns/getting-started.md b/docs/src/content/docs/guides/campaigns/getting-started.md index 5faadf8251..d64a8027d5 100644 --- a/docs/src/content/docs/guides/campaigns/getting-started.md +++ b/docs/src/content/docs/guides/campaigns/getting-started.md @@ -45,7 +45,7 @@ Optional but recommended for "kanban lanes": ### 3. Have workflows keep the board in sync using `GITHUB_TOKEN` -Enable the `update-project` safe output in the launcher/monitor workflows. +The generated orchestrator workflow automatically keeps the board in sync using the `update-project` safe output. Default behavior is **update-only**: if the board does not exist, the project job fails with instructions. diff --git a/docs/src/content/docs/guides/campaigns/specs.md b/docs/src/content/docs/guides/campaigns/specs.md index f8f69821e2..f053c18844 100644 --- a/docs/src/content/docs/guides/campaigns/specs.md +++ b/docs/src/content/docs/guides/campaigns/specs.md @@ -31,9 +31,31 @@ Common fields you'll reach for as the initiative grows: - `tracker-label`: the label that ties issues/PRs back to the agentic campaign - `memory-paths` / `metrics-glob`: where baselines and metrics snapshots live on your repo-memory branch - `approval-policy`: the expectations for human approval (required approvals/roles) +- `governance`: pacing and opt-out policies for orchestrator operations (see below) Once you have a spec, the remaining question is consistency: what should every agentic campaign produce so people can follow along? +## Governance policies + +The `governance` section provides lightweight controls for how the orchestrator manages campaign tracking: + +```yaml +governance: + max-new-items-per-run: 10 # Limit new items added to Project per run + max-discovery-items-per-run: 100 # Limit items scanned during discovery + max-discovery-pages-per-run: 5 # Limit API result pages fetched + opt-out-labels: ["campaign:skip"] # Labels that exclude items from tracking + do-not-downgrade-done-items: true # Prevent Done → In Progress transitions + max-project-updates-per-run: 50 # Limit Project update operations + max-comments-per-run: 10 # Limit comment operations +``` + +**Common use cases**: +- **Pacing**: Use `max-new-items-per-run` to gradually roll out tracking (e.g., 10 items per day) +- **Rate limiting**: Use `max-project-updates-per-run` to avoid GitHub API throttling +- **Opt-out**: Use `opt-out-labels` to let teams mark items as out-of-scope +- **Stability**: Use `do-not-downgrade-done-items` to prevent reopened items from disrupting reports + ## Recommended default wiring To keep agentic campaigns consistent and easy to read, most teams use a predictable set of primitives: @@ -43,7 +65,7 @@ To keep agentic campaigns consistent and easy to read, most teams use a predicta - **GitHub Project** as the dashboard (primary campaign dashboard). - **Repo-memory metrics** (daily JSON snapshots) to compute velocity/ETAs and enable trend reporting. - **Tracker IDs in worker workflows** (e.g., `tracker-id: "worker-name"`) to enable orchestrator discovery of worker-created assets. -- **Monitor/orchestrator** to aggregate and post periodic updates. +- **Generated orchestrator** to keep the Project in sync and post periodic updates. - **Custom date fields** (optional, for roadmap views) like `Start Date` and `End Date` to visualize campaign timeline. If you want to try this end-to-end quickly, start with the [Getting Started guide](/gh-aw/guides/campaigns/getting-started/). @@ -52,4 +74,12 @@ If you want to try this end-to-end quickly, start with the [Getting Started guid When the spec has meaningful details (tracker label, workflows, memory paths, or a metrics glob), `gh aw compile` will also generate an orchestrator workflow named `.github/workflows/.campaign.g.md` and compile it to a corresponding `.lock.yml`. +The generated orchestrator: +- Discovers tracker-labeled issues and PRs matching the campaign +- Discovers worker-created assets (if workers are configured with tracker-ids) +- Adds new items to the GitHub Project board +- Updates status fields as work progresses +- Enforces governance rules (e.g., max items per run, no downgrade of completed items) +- Posts periodic status reports + See [Agentic campaign specs and orchestrators](/gh-aw/setup/cli/#compile) for details. diff --git a/docs/src/content/docs/labs.mdx b/docs/src/content/docs/labs.mdx index 95f7e2c2be..89a1440f10 100644 --- a/docs/src/content/docs/labs.mdx +++ b/docs/src/content/docs/labs.mdx @@ -67,6 +67,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Glossary Maintainer](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/glossary-maintainer.md) | copilot | [![Glossary Maintainer](https://github.com/githubnext/gh-aw/actions/workflows/glossary-maintainer.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/glossary-maintainer.lock.yml) | `0 10 * * 1-5` | - | | [Go Fan](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/go-fan.md) | claude | [![Go Fan](https://github.com/githubnext/gh-aw/actions/workflows/go-fan.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/go-fan.lock.yml) | `0 7 * * 1-5` | - | | [Go File Size Reduction Campaign (Project 64)](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/go-file-size-reduction-project64.campaign.g.md) | copilot | [![Go File Size Reduction Campaign (Project 64)](https://github.com/githubnext/gh-aw/actions/workflows/go-file-size-reduction-project64.campaign.g.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/go-file-size-reduction-project64.campaign.g.lock.yml) | `0 18 * * *` | - | +| [Go File Size Reduction Campaign (Project 64) (launcher)](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/go-file-size-reduction-project64.campaign.launcher.g.md) | copilot | [![Go File Size Reduction Campaign (Project 64) (launcher)](https://github.com/githubnext/gh-aw/actions/workflows/go-file-size-reduction-project64.campaign.launcher.g.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/go-file-size-reduction-project64.campaign.launcher.g.lock.yml) | `0 17 * * *` | - | | [Go Logger Enhancement](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/go-logger.md) | claude | [![Go Logger Enhancement](https://github.com/githubnext/gh-aw/actions/workflows/go-logger.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/go-logger.lock.yml) | - | - | | [Go Pattern Detector](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/go-pattern-detector.md) | claude | [![Go Pattern Detector](https://github.com/githubnext/gh-aw/actions/workflows/go-pattern-detector.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/go-pattern-detector.lock.yml) | `0 14 * * 1-5` | - | | [Grumpy Code Reviewer 🔥](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/grumpy-reviewer.md) | copilot | [![Grumpy Code Reviewer 🔥](https://github.com/githubnext/gh-aw/actions/workflows/grumpy-reviewer.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/grumpy-reviewer.lock.yml) | - | - | diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 3b7f322496..c7780c2888 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -358,7 +358,9 @@ safe-outputs: github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} # required: PAT with Projects access ``` -Agent output **must include a full GitHub project URL** in the `project` field (e.g., `https://github.com/orgs/myorg/projects/42` or `https://github.com/users/username/projects/5`). Project names or numbers alone are not accepted. Can also supply `content_number`, `content_type`, `fields`, and `campaign_id`. The job adds the issue or PR to the board, updates custom fields, applies `campaign:` labels, and exposes `project-id`, `project-number`, `project-url`, `campaign-id`, and `item-id` outputs. Cross-repository targeting not supported. +Agent output **must include a full GitHub project URL** in the `project` field (e.g., `https://github.com/orgs/myorg/projects/42` or `https://github.com/users/username/projects/5`). Project names or numbers alone are not accepted. Can also supply `content_number`, `content_type`, `fields`, and `campaign_id`. When `campaign_id` is provided, `update-project` treats it as the campaign tracker identifier and applies the `campaign:` label (for example, `campaign_id: security-sprint` results in `campaign:security-sprint`). See [Agentic Campaign Workflows](/gh-aw/guides/campaigns/) for the end-to-end campaign model. + +The job adds the issue or PR to the board, updates custom fields, and exposes `project-id`, `project-number`, `project-url`, `campaign-id`, and `item-id` outputs. Cross-repository targeting not supported. To opt in to creating missing project boards, include `create_if_missing: true` in the `update_project` output. Your token must have sufficient permissions: - **User-owned Projects**: Classic PAT with `project` + `repo` scopes (fine-grained PATs don't work) diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index 43e59099ff..34925dd03f 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -267,7 +267,7 @@ gh aw compile --strict --zizmor # Strict mode with security scanning gh aw compile --validate --strict # Validate schema and enforce strict mode ``` -**Agentic campaign specs and orchestrators:** When agentic campaign spec files exist under `.github/workflows/*.campaign.md`, `gh aw compile` validates those specs (including referenced `workflows`) and fails if problems are found. By default, `compile` also synthesizes an orchestrator workflow for each valid spec that has meaningful details (e.g., `go-file-size-reduction-project64.campaign.md` → `go-file-size-reduction-project64.campaign.g.md`) and compiles it to a corresponding `.lock.yml` file. Orchestrators are only generated when the agentic campaign spec includes tracker labels, workflows, memory paths, or a metrics glob. See the [`campaign` command](#campaign) for management and inspection. +**Agentic campaign specs and generated workflows:** When agentic campaign spec files exist under `.github/workflows/*.campaign.md`, `gh aw compile` validates those specs (including referenced `workflows`) and fails if problems are found. By default, `compile` also synthesizes coordinator workflows for each valid spec that has meaningful details (e.g., `go-file-size-reduction.campaign.md` → `go-file-size-reduction.campaign.g.md` and `go-file-size-reduction.campaign.launcher.g.md`) and compiles each one to a corresponding `.lock.yml` file. Coordinator workflows are only generated when the agentic campaign spec includes tracker labels, workflows, memory paths, a metrics glob, or governance settings. See the [`campaign` command](#campaign) for management and inspection. See [Strict Mode reference](/gh-aw/reference/frontmatter/#strict-mode-strict) for frontmatter configuration and [Security Guide](/gh-aw/guides/security/#strict-mode-validation) for best practices. @@ -384,6 +384,14 @@ gh aw audit 12345678 --parse # Parse logs to markdo Inspect and validate first-class agentic campaign definitions declared as `.github/workflows/*.campaign.md` files. +For safe scaling and incremental discovery, campaign specs support: + +- `cursor-glob`: durable cursor/checkpoint location in repo-memory. +- `governance.max-discovery-items-per-run`: maximum items processed during discovery. +- `governance.max-discovery-pages-per-run`: maximum pages fetched during discovery. + +See the [Agentic campaigns guide](/gh-aw/guides/campaigns/) for the full spec shape and recommended defaults. + ```bash wrap gh aw campaign # List all agentic campaigns gh aw campaign security # Filter by ID or name substring diff --git a/pkg/campaign/create_test.go b/pkg/campaign/create_test.go index 1f1d8a8ec5..bdf16e4d51 100644 --- a/pkg/campaign/create_test.go +++ b/pkg/campaign/create_test.go @@ -51,6 +51,27 @@ func TestCreateSpecSkeleton_Basic(t *testing.T) { if !strings.Contains(contentStr, "project-url: https://github.com/orgs/ORG/projects/1") { t.Error("Expected file to contain 'project-url: https://github.com/orgs/ORG/projects/1'") } + if !strings.Contains(contentStr, "governance:") { + t.Error("Expected file to contain 'governance:'") + } + if !strings.Contains(contentStr, "max-new-items-per-run: 25") { + t.Error("Expected file to contain 'max-new-items-per-run: 25'") + } + if !strings.Contains(contentStr, "max-discovery-items-per-run: 200") { + t.Error("Expected file to contain 'max-discovery-items-per-run: 200'") + } + if !strings.Contains(contentStr, "max-discovery-pages-per-run: 10") { + t.Error("Expected file to contain 'max-discovery-pages-per-run: 10'") + } + if !strings.Contains(contentStr, "max-project-updates-per-run: 10") { + t.Error("Expected file to contain 'max-project-updates-per-run: 10'") + } + if !strings.Contains(contentStr, "max-comments-per-run: 10") { + t.Error("Expected file to contain 'max-comments-per-run: 10'") + } + if !strings.Contains(contentStr, "cursor-glob: memory/campaigns/test-campaign/cursor.json") { + t.Error("Expected file to contain 'cursor-glob: memory/campaigns/test-campaign/cursor.json'") + } } func TestCreateSpecSkeleton_InvalidID_Empty(t *testing.T) { diff --git a/pkg/campaign/loader.go b/pkg/campaign/loader.go index 92bbdc4963..906d43dafc 100644 --- a/pkg/campaign/loader.go +++ b/pkg/campaign/loader.go @@ -155,6 +155,16 @@ func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { Version: "v1", State: "planned", TrackerLabel: "campaign:" + id, + CursorGlob: "memory/campaigns/" + id + "/cursor.json", + Governance: &CampaignGovernancePolicy{ + MaxNewItemsPerRun: 25, + MaxDiscoveryItemsPerRun: 200, + MaxDiscoveryPagesPerRun: 10, + OptOutLabels: []string{"no-campaign", "no-bot"}, + DoNotDowngradeDoneItems: boolPtr(true), + MaxProjectUpdatesPerRun: 10, + MaxCommentsPerRun: 10, + }, } data, err := yaml.Marshal(&spec) @@ -179,3 +189,7 @@ func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { return relPath, nil } + +func boolPtr(v bool) *bool { + return &v +} diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 30ad6b8e54..aeb22ebc17 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -38,6 +38,10 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W // Default triggers: daily schedule plus manual workflow_dispatch. onSection := "on:\n schedule:\n - cron: \"0 18 * * *\"\n workflow_dispatch:\n" + // Prevent overlapping runs. This reduces sustained automated traffic on GitHub's + // infrastructure by ensuring only one orchestrator run executes at a time per ref. + concurrency := fmt.Sprintf("concurrency:\n group: \"campaign-%s-orchestrator-${{ github.ref }}\"\n cancel-in-progress: false", spec.ID) + // Simple markdown body giving the agent context about the campaign. markdownBuilder := &strings.Builder{} markdownBuilder.WriteString("# Campaign Orchestrator\n\n") @@ -66,10 +70,46 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W markdownBuilder.WriteString(fmt.Sprintf("- Metrics glob: `%s`\n", spec.MetricsGlob)) hasDetails = true } + if spec.CursorGlob != "" { + markdownBuilder.WriteString(fmt.Sprintf("- Cursor glob: `%s`\n", spec.CursorGlob)) + hasDetails = true + } if strings.TrimSpace(spec.ProjectURL) != "" { markdownBuilder.WriteString(fmt.Sprintf("- Project URL: %s\n", strings.TrimSpace(spec.ProjectURL))) hasDetails = true } + if spec.Governance != nil { + if spec.Governance.MaxNewItemsPerRun > 0 { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: max new items per run: %d\n", spec.Governance.MaxNewItemsPerRun)) + hasDetails = true + } + if spec.Governance.MaxDiscoveryItemsPerRun > 0 { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: max discovery items per run: %d\n", spec.Governance.MaxDiscoveryItemsPerRun)) + hasDetails = true + } + if spec.Governance.MaxDiscoveryPagesPerRun > 0 { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: max discovery pages per run: %d\n", spec.Governance.MaxDiscoveryPagesPerRun)) + hasDetails = true + } + if len(spec.Governance.OptOutLabels) > 0 { + markdownBuilder.WriteString("- Governance: opt-out labels: ") + markdownBuilder.WriteString(strings.Join(spec.Governance.OptOutLabels, ", ")) + markdownBuilder.WriteString("\n") + hasDetails = true + } + if spec.Governance.DoNotDowngradeDoneItems != nil { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: do not downgrade done items: %t\n", *spec.Governance.DoNotDowngradeDoneItems)) + hasDetails = true + } + if spec.Governance.MaxProjectUpdatesPerRun > 0 { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: max project updates per run: %d\n", spec.Governance.MaxProjectUpdatesPerRun)) + hasDetails = true + } + if spec.Governance.MaxCommentsPerRun > 0 { + markdownBuilder.WriteString(fmt.Sprintf("- Governance: max comments per run: %d\n", spec.Governance.MaxCommentsPerRun)) + hasDetails = true + } + } // Return nil if the campaign spec has no meaningful details for the prompt if !hasDetails { @@ -82,8 +122,12 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W // Render orchestrator instructions using templates // All orchestrators follow the same system-agnostic rules with no conditional logic - promptData := CampaignPromptData{ - ProjectURL: strings.TrimSpace(spec.ProjectURL), + promptData := CampaignPromptData{ProjectURL: strings.TrimSpace(spec.ProjectURL)} + promptData.TrackerLabel = strings.TrimSpace(spec.TrackerLabel) + promptData.CursorGlob = strings.TrimSpace(spec.CursorGlob) + if spec.Governance != nil { + promptData.MaxDiscoveryItemsPerRun = spec.Governance.MaxDiscoveryItemsPerRun + promptData.MaxDiscoveryPagesPerRun = spec.Governance.MaxDiscoveryPagesPerRun } orchestratorInstructions := RenderOrchestratorInstructions(promptData) @@ -100,11 +144,22 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W // Enable safe outputs needed for campaign coordination. // Note: Campaign orchestrators intentionally omit explicit `permissions:` from // the generated markdown; safe-output jobs have their own scoped permissions. + maxComments := 10 + maxProjectUpdates := 10 + if spec.Governance != nil { + if spec.Governance.MaxCommentsPerRun > 0 { + maxComments = spec.Governance.MaxCommentsPerRun + } + if spec.Governance.MaxProjectUpdatesPerRun > 0 { + maxProjectUpdates = spec.Governance.MaxProjectUpdatesPerRun + } + } + safeOutputs := &workflow.SafeOutputsConfig{} // Always allow commenting on tracker issues (or other issues/PRs if needed). - safeOutputs.AddComments = &workflow.AddCommentsConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 10}} + safeOutputs.AddComments = &workflow.AddCommentsConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxComments}} // Allow updating the campaign's GitHub Project dashboard. - updateProjectConfig := &workflow.UpdateProjectConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 10}} + updateProjectConfig := &workflow.UpdateProjectConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxProjectUpdates}} // If the campaign spec specifies a custom GitHub token for Projects v2 operations, // pass it to the update-project configuration. if strings.TrimSpace(spec.ProjectGitHubToken) != "" { @@ -120,6 +175,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W Description: description, MarkdownContent: markdownBuilder.String(), On: onSection, + Concurrency: concurrency, // Use a standard Ubuntu runner for the main agent job so the // compiled orchestrator always has a valid runs-on value. RunsOn: "runs-on: ubuntu-latest", diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index 81e7bb7b8d..cf75bb968d 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -40,6 +40,13 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) { t.Fatalf("expected On section with daily schedule cron, got %q", data.On) } + if strings.TrimSpace(data.Concurrency) == "" || !strings.Contains(data.Concurrency, "concurrency:") { + t.Fatalf("expected workflow-level concurrency to be set, got %q", data.Concurrency) + } + if !strings.Contains(data.Concurrency, "campaign-go-file-size-reduction-project64-orchestrator") { + t.Fatalf("expected concurrency group to include campaign id, got %q", data.Concurrency) + } + if !strings.Contains(data.MarkdownContent, "Go File Size Reduction") { t.Fatalf("expected markdown content to mention campaign name, got: %q", data.MarkdownContent) } @@ -233,3 +240,32 @@ func TestBuildOrchestrator_GitHubToken(t *testing.T) { } }) } + +func TestBuildOrchestrator_GovernanceOverridesSafeOutputMaxima(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"test-workflow"}, + TrackerLabel: "campaign:test", + Governance: &CampaignGovernancePolicy{ + MaxCommentsPerRun: 3, + MaxProjectUpdatesPerRun: 4, + }, + } + + mdPath := ".github/workflows/test-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + if data.SafeOutputs == nil || data.SafeOutputs.AddComments == nil || data.SafeOutputs.UpdateProjects == nil { + t.Fatalf("expected SafeOutputs add-comment and update-project to be configured") + } + if data.SafeOutputs.AddComments.Max != 3 { + t.Fatalf("unexpected add-comment max: got %d, want %d", data.SafeOutputs.AddComments.Max, 3) + } + if data.SafeOutputs.UpdateProjects.Max != 4 { + t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4) + } +} diff --git a/pkg/campaign/prompts/orchestrator_instructions.md b/pkg/campaign/prompts/orchestrator_instructions.md index bbfae073a3..868afca59f 100644 --- a/pkg/campaign/prompts/orchestrator_instructions.md +++ b/pkg/campaign/prompts/orchestrator_instructions.md @@ -1,6 +1,24 @@ ## Campaign Orchestrator Rules -This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination: +This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. It also maintains the campaign dashboard by ensuring the GitHub Project stays in sync with the campaign's tracker label. + +### Traffic and rate limits (required) + +- Minimize API calls: avoid full rescans when possible and avoid repeated reads of the same data in a single run. +- Prefer incremental processing: use deterministic ordering (e.g., by updated time) and process a bounded slice each run. +- Use strict pagination budgets: if a query would require many pages, stop early and continue next run. +- Use a durable cursor/checkpoint: persist the last processed boundary (e.g., updatedAt cutoff + last seen ID) so the next run can continue without rescanning. +- On throttling (HTTP 429 / rate limit 403), do not retry aggressively. Use backoff and end the run after reporting what remains. + +{{ if .CursorGlob }} +**Cursor file (repo-memory)**: `{{ .CursorGlob }}` +{{ end }} +{{ if gt .MaxDiscoveryItemsPerRun 0 }} +**Read budget**: max discovery items per run: {{ .MaxDiscoveryItemsPerRun }} +{{ end }} +{{ if gt .MaxDiscoveryPagesPerRun 0 }} +**Read budget**: max discovery pages per run: {{ .MaxDiscoveryPagesPerRun }} +{{ end }} ### Core Principles @@ -16,6 +34,7 @@ This orchestrator follows system-agnostic rules that enforce clean separation be 10. **Predefined fields only** - Only update explicitly defined project board fields 11. **Explicit outcomes** - Record actual outcomes, never infer status 12. **Idempotent operations** - Re-execution produces the same result without corruption +13. **Dashboard synchronization** - Keep Project items in sync with tracker-labeled issues/PRs ### Orchestration Workflow @@ -23,62 +42,72 @@ Execute these steps in sequence each time this orchestrator runs: #### Phase 1: Read State (Discovery) -1. **Query worker-created issues** - Search for issues containing the worker's tracker-id +1. **Query tracker-labeled items** - Search for issues and PRs matching the campaign's tracker label + - Search: `repo:OWNER/REPO label:TRACKER_LABEL` for all open and closed items + - If governance opt-out labels are configured, exclude items with those labels + - Collect all matching issue/PR URLs + - Record metadata: number, title, state (open/closed), created date, updated date + +2. **Query worker-created issues** (if workers are configured) - Search for issues containing worker tracker-ids - For each worker in `workflows`, search: `repo:OWNER/REPO "tracker-id: WORKER_ID" in:body` - Collect all matching issue URLs - Record issue metadata: number, title, state (open/closed), created date, updated date -2. **Query current project state** - Read the GitHub Project board +3. **Query current project state** - Read the GitHub Project board - Retrieve all items currently on the project board - For each item, record: issue URL, status field value, other predefined field values - Create a snapshot of current board state -3. **Compare and identify gaps** - Determine what needs updating - - Issues found in step 1 but not on board = **new work to add** - - Issues on board with state mismatch = **status to update** - - Issues on board but no longer found = **check if archived/deleted** +4. **Compare and identify gaps** - Determine what needs updating + - Items from step 1 or 2 not on board = **new work to add** + - Items on board with state mismatch = **status to update** + - Items on board but no longer found = **check if archived/deleted** #### Phase 2: Make Decisions (Planning) -4. **Decide additions** - For each new issue discovered: - - Decision: Add to board? (Default: yes for all issues with tracker-id) - - Determine initial status field value based on issue state: - - Open issue → "Todo" status - - Closed issue → "Done" status - -5. **Decide updates** - For each existing board item with mismatched state: - - Decision: Update status field? (Default: yes if issue state changed) +5. **Decide additions (with pacing)** - For each new item discovered: + - Decision: Add to board? (Default: yes for all items with tracker label or worker tracker-id) + - If `governance.max-new-items-per-run` is set, add at most that many new items + - Prefer adding oldest (or least recently updated) missing items first + - Determine initial status field value based on item state: + - Open issue/PR → "Todo" status + - Closed issue/PR → "Done" status + +6. **Decide updates (no downgrade)** - For each existing board item with mismatched state: + - Decision: Update status field? (Default: yes if item state changed) + - If `governance.do-not-downgrade-done-items` is true, do not move items from Done back to active status - Determine new status field value: - - Open issue → "In Progress" or "Todo" - - Closed issue → "Done" + - Open issue/PR → "In Progress" or "Todo" + - Closed issue/PR → "Done" -6. **Decide completion** - Check campaign completion criteria: +7. **Decide completion** - Check campaign completion criteria: - If all discovered issues are closed AND all board items are "Done" → Campaign complete - Otherwise → Campaign in progress #### Phase 3: Write State (Execution) -7. **Execute additions** - Add new issues to project board - - Use `update-project` safe-output for each new issue +8. **Execute additions** - Add new items to project board + - Use `update-project` safe-output for each new item - Set predefined fields: `status` (required), optionally `priority`, `size` - Record outcome: success or failure with error details -8. **Execute updates** - Update existing board items +9. **Execute updates** - Update existing board items - Use `update-project` safe-output for each status change - Update only predefined fields: `status` and related metadata - Record outcome: success or failure with error details -9. **Record completion state** - If campaign is complete: - - Mark project metadata field `campaign_status` as "completed" - - Do NOT create new work or modify existing items - - This is a terminal state +10. **Record completion state** - If campaign is complete: + - Mark project metadata field `campaign_status` as "completed" + - Do NOT create new work or modify existing items + - This is a terminal state #### Phase 4: Report (Output) -10. **Generate status report** - Summarize execution results: - - Total issues discovered via tracker-id - - Issues added to board this run (count and URLs) - - Issues updated on board this run (count and status changes) +11. **Generate status report** - Summarize execution results: + - Total items discovered via tracker label and worker tracker-ids + - Items added to board this run (count and URLs) + - Items updated on board this run (count and status changes) + - Items skipped due to governance limits (and why) - Current campaign metrics: open vs closed, progress percentage - Any failures encountered during writes - Campaign completion status diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json index bc8725442b..89e6bcd365 100644 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -27,6 +27,11 @@ "pattern": "/projects/", "minLength": 1 }, + "project-github-token": { + "type": "string", + "description": "Optional GitHub token expression used for GitHub Projects v2 operations (e.g., ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }})", + "minLength": 1 + }, "version": { "type": "string", "description": "Spec version (e.g., v1)", @@ -54,6 +59,12 @@ "type": "string", "description": "Glob pattern to locate JSON metrics snapshots in memory/campaigns branch" }, + "cursor-glob": { + "type": "string", + "description": "Glob pattern to locate a durable cursor/checkpoint file in memory/campaigns branch", + "pattern": "^memory/campaigns/", + "minLength": 1 + }, "owners": { "type": "array", "description": "Primary human owners for this campaign", @@ -125,6 +136,61 @@ } }, "additionalProperties": false + }, + "launcher": { + "type": "object", + "description": "Optional launcher layer configuration. When omitted, the launcher is enabled by default.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to generate and compile the campaign launcher workflow" + } + }, + "additionalProperties": false + }, + "governance": { + "type": "object", + "description": "Lightweight pacing and opt-out policies for campaign coordinator workflows (launcher/orchestrator)", + "properties": { + "max-new-items-per-run": { + "type": "integer", + "description": "Maximum number of new items (issues/PRs) the launcher should add to the Project board per run", + "minimum": 0 + }, + "max-discovery-items-per-run": { + "type": "integer", + "description": "Maximum number of candidate issues/PRs to scan during discovery in a single run", + "minimum": 0 + }, + "max-discovery-pages-per-run": { + "type": "integer", + "description": "Maximum number of result pages to fetch during discovery in a single run", + "minimum": 0 + }, + "opt-out-labels": { + "type": "array", + "description": "Labels that opt an issue/PR out of campaign tracking", + "items": { + "type": "string", + "minLength": 1 + } + }, + "do-not-downgrade-done-items": { + "type": "boolean", + "description": "If true, prevent moving Project status backwards (e.g., Done -> In Progress) when issues/PRs are reopened" + }, + "max-project-updates-per-run": { + "type": "integer", + "description": "Maximum number of update-project safe-output operations per run for generated coordinator workflows", + "minimum": 0 + }, + "max-comments-per-run": { + "type": "integer", + "description": "Maximum number of add-comment safe-output operations per run for generated coordinator workflows", + "minimum": 0 + } + }, + "additionalProperties": false } }, "required": ["id", "name", "project-url"], diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go index 089eea75a7..bc85de7c6e 100644 --- a/pkg/campaign/spec.go +++ b/pkg/campaign/spec.go @@ -37,6 +37,13 @@ type CampaignSpec struct { // key fields. MetricsGlob string `yaml:"metrics-glob,omitempty" json:"metrics_glob,omitempty" console:"header:Metrics Glob,omitempty,maxlen:30"` + // CursorGlob is an optional glob (relative to the repository root) + // used to locate a durable cursor/checkpoint file stored in the + // memory/campaigns branch. When set, generated coordinator workflows + // will be instructed to continue incremental discovery from this cursor + // and `gh aw campaign status` will surface its freshness. + CursorGlob string `yaml:"cursor-glob,omitempty" json:"cursor_glob,omitempty" console:"header:Cursor Glob,omitempty,maxlen:30"` + // Owners lists the primary human owners for this campaign. Owners []string `yaml:"owners,omitempty" json:"owners,omitempty" console:"header:Owners,omitempty,maxlen:30"` @@ -71,6 +78,11 @@ type CampaignSpec struct { // safe output configuration in the generated orchestrator workflow. ProjectGitHubToken string `yaml:"project-github-token,omitempty" json:"project_github_token,omitempty" console:"header:Project Token,omitempty,maxlen:30"` + // Governance configures lightweight pacing and opt-out policies for campaign + // orchestrator workflows. These guardrails are primarily enforced through + // generated prompts and safe-output maxima. + Governance *CampaignGovernancePolicy `yaml:"governance,omitempty" json:"governance,omitempty"` + // ApprovalPolicy describes high-level approval expectations for this // campaign (for example: number of approvals and required roles). ApprovalPolicy *CampaignApprovalPolicy `yaml:"approval-policy,omitempty" json:"approval-policy,omitempty"` @@ -80,6 +92,42 @@ type CampaignSpec struct { ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path,maxlen:60"` } +// CampaignGovernancePolicy captures lightweight pacing and opt-out policies. +// This is intentionally scoped to what gh-aw can apply safely and consistently +// via prompts and safe-output job limits. +type CampaignGovernancePolicy struct { + // MaxNewItemsPerRun caps how many new items (issues/PRs) the launcher should + // add to the Project board per run. 0 means "use defaults". + MaxNewItemsPerRun int `yaml:"max-new-items-per-run,omitempty" json:"max_new_items_per_run,omitempty"` + + // MaxDiscoveryItemsPerRun caps how many candidate issues/PRs the launcher + // and orchestrator may scan during discovery in a single run. + // 0 means "use defaults". + MaxDiscoveryItemsPerRun int `yaml:"max-discovery-items-per-run,omitempty" json:"max_discovery_items_per_run,omitempty"` + + // MaxDiscoveryPagesPerRun caps how many pages of results the launcher and + // orchestrator may fetch in a single run. + // 0 means "use defaults". + MaxDiscoveryPagesPerRun int `yaml:"max-discovery-pages-per-run,omitempty" json:"max_discovery_pages_per_run,omitempty"` + + // OptOutLabels is a list of labels that opt an issue/PR out of campaign + // tracking. Items with any of these labels should be ignored by launcher/ + // orchestrator. + OptOutLabels []string `yaml:"opt-out-labels,omitempty" json:"opt_out_labels,omitempty"` + + // DoNotDowngradeDoneItems prevents moving Project status backwards (e.g. + // Done -> In Progress) if the underlying issue/PR is reopened. + DoNotDowngradeDoneItems *bool `yaml:"do-not-downgrade-done-items,omitempty" json:"do_not_downgrade_done_items,omitempty"` + + // MaxProjectUpdatesPerRun controls the update-project safe-output maximum + // for generated coordinator workflows. 0 means "use defaults". + MaxProjectUpdatesPerRun int `yaml:"max-project-updates-per-run,omitempty" json:"max_project_updates_per_run,omitempty"` + + // MaxCommentsPerRun controls the add-comment safe-output maximum for + // generated coordinator workflows. 0 means "use defaults". + MaxCommentsPerRun int `yaml:"max-comments-per-run,omitempty" json:"max_comments_per_run,omitempty"` +} + // CampaignApprovalPolicy captures basic approval expectations for a // campaign. It is intentionally lightweight and advisory; enforcement // is left to workflows and organizational process. @@ -110,6 +158,10 @@ type CampaignRuntimeStatus struct { MetricsTasksCompleted int `json:"metrics_tasks_completed,omitempty" console:"header:Tasks Completed,omitempty"` MetricsVelocityPerDay float64 `json:"metrics_velocity_per_day,omitempty" console:"header:Velocity/Day,omitempty"` MetricsEstimatedCompletion string `json:"metrics_estimated_completion,omitempty" console:"header:ETA,omitempty"` + + // Optional durable cursor/checkpoint info from repo-memory. + CursorPath string `json:"cursor_path,omitempty" console:"header:Cursor Path,omitempty,maxlen:40"` + CursorUpdatedAt string `json:"cursor_updated_at,omitempty" console:"header:Cursor Updated,omitempty,maxlen:30"` } // CampaignMetricsSnapshot describes the JSON structure used by campaign diff --git a/pkg/campaign/status.go b/pkg/campaign/status.go index fb72b299ca..155024f5b9 100644 --- a/pkg/campaign/status.go +++ b/pkg/campaign/status.go @@ -188,11 +188,70 @@ func FetchMetricsFromRepoMemory(metricsGlob string) (*CampaignMetricsSnapshot, e return &snapshot, nil } +// FetchCursorFreshnessFromRepoMemory finds the latest cursor/checkpoint file +// matching cursorGlob in the memory/campaigns branch and returns the matched +// path along with a best-effort freshness timestamp derived from git history. +// +// Errors are treated as "no cursor" rather than failing the command. +func FetchCursorFreshnessFromRepoMemory(cursorGlob string) (cursorPath string, cursorUpdatedAt string) { + if strings.TrimSpace(cursorGlob) == "" { + return "", "" + } + + cmd := exec.Command("git", "ls-tree", "-r", "--name-only", "memory/campaigns") + output, err := cmd.Output() + if err != nil { + log.Printf("Unable to list repo-memory branch for cursor (memory/campaigns): %v", err) + return "", "" + } + + scanner := bufio.NewScanner(bytes.NewReader(output)) + var matches []string + for scanner.Scan() { + pathStr := strings.TrimSpace(scanner.Text()) + if pathStr == "" { + continue + } + matched, err := path.Match(cursorGlob, pathStr) + if err != nil { + log.Printf("Invalid cursor_glob '%s': %v", cursorGlob, err) + return "", "" + } + if matched { + matches = append(matches, pathStr) + } + } + + if len(matches) == 0 { + return "", "" + } + + latest := matches[0] + for _, m := range matches[1:] { + if m > latest { + latest = m + } + } + + // Best-effort: use git log to get the last commit time for this path + // on the memory/campaigns branch. + logCmd := exec.Command("git", "log", "-1", "--format=%cI", "memory/campaigns", "--", latest) + logOut, err := logCmd.Output() + if err != nil { + log.Printf("Failed to read cursor freshness for '%s' from memory/campaigns: %v", latest, err) + return latest, "" + } + + return latest, strings.TrimSpace(string(logOut)) +} + // BuildRuntimeStatus builds a CampaignRuntimeStatus for a single campaign spec. func BuildRuntimeStatus(spec CampaignSpec, workflowsDir string) CampaignRuntimeStatus { compiled := ComputeCompiledState(spec, workflowsDir) issuesOpen, issuesClosed, prsOpen, prsMerged := FetchItemCounts(spec.TrackerLabel) + cursorPath, cursorUpdatedAt := FetchCursorFreshnessFromRepoMemory(spec.CursorGlob) + var metricsTasksTotal, metricsTasksCompleted int var metricsVelocity float64 var metricsETA string @@ -221,5 +280,7 @@ func BuildRuntimeStatus(spec CampaignSpec, workflowsDir string) CampaignRuntimeS MetricsTasksCompleted: metricsTasksCompleted, MetricsVelocityPerDay: metricsVelocity, MetricsEstimatedCompletion: metricsETA, + CursorPath: cursorPath, + CursorUpdatedAt: cursorUpdatedAt, } } diff --git a/pkg/campaign/template.go b/pkg/campaign/template.go index 2b8bab9d93..cbf38bb8d4 100644 --- a/pkg/campaign/template.go +++ b/pkg/campaign/template.go @@ -24,6 +24,18 @@ var closingInstructionsTemplate string type CampaignPromptData struct { // ProjectURL is the GitHub Project URL ProjectURL string + + // TrackerLabel is the label used to associate issues/PRs with this campaign. + TrackerLabel string + + // CursorGlob is a glob for locating the durable cursor/checkpoint file in repo-memory. + CursorGlob string + + // MaxDiscoveryItemsPerRun caps how many candidate items may be scanned during discovery. + MaxDiscoveryItemsPerRun int + + // MaxDiscoveryPagesPerRun caps how many pages may be fetched during discovery. + MaxDiscoveryPagesPerRun int } // renderTemplate renders a template string with the given data diff --git a/pkg/campaign/template_test.go b/pkg/campaign/template_test.go index caad62b601..28419245b2 100644 --- a/pkg/campaign/template_test.go +++ b/pkg/campaign/template_test.go @@ -16,6 +16,11 @@ func TestRenderOrchestratorInstructions(t *testing.T) { data: CampaignPromptData{}, shouldContain: []string{ "Campaign Orchestrator Rules", + "Traffic and rate limits (required)", + "Prefer incremental processing", + "strict pagination budgets", + "durable cursor/checkpoint", + "On throttling", "Workers are immutable", "Workers are campaign-agnostic", "Campaign logic is external", diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go index 2f5bc92aa6..f28d94c295 100644 --- a/pkg/campaign/validation.go +++ b/pkg/campaign/validation.go @@ -98,6 +98,24 @@ func ValidateSpec(spec *CampaignSpec) []string { } } + if spec.Governance != nil { + if spec.Governance.MaxNewItemsPerRun < 0 { + problems = append(problems, "governance.max-new-items-per-run must be >= 0") + } + if spec.Governance.MaxDiscoveryItemsPerRun < 0 { + problems = append(problems, "governance.max-discovery-items-per-run must be >= 0") + } + if spec.Governance.MaxDiscoveryPagesPerRun < 0 { + problems = append(problems, "governance.max-discovery-pages-per-run must be >= 0") + } + if spec.Governance.MaxProjectUpdatesPerRun < 0 { + problems = append(problems, "governance.max-project-updates-per-run must be >= 0") + } + if spec.Governance.MaxCommentsPerRun < 0 { + problems = append(problems, "governance.max-comments-per-run must be >= 0") + } + } + if len(problems) == 0 { validationLog.Printf("Campaign spec '%s' validation passed with no problems", spec.ID) } else { @@ -159,23 +177,36 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { // // JSON property names intentionally mirror the kebab-case YAML keys so the // JSON Schema can validate both YAML and JSON representations consistently. + type CampaignGovernancePolicyForValidation struct { + MaxNewItemsPerRun int `json:"max-new-items-per-run,omitempty"` + MaxDiscoveryItemsPerRun int `json:"max-discovery-items-per-run,omitempty"` + MaxDiscoveryPagesPerRun int `json:"max-discovery-pages-per-run,omitempty"` + OptOutLabels []string `json:"opt-out-labels,omitempty"` + DoNotDowngradeDoneItems *bool `json:"do-not-downgrade-done-items,omitempty"` + MaxProjectUpdatesPerRun int `json:"max-project-updates-per-run,omitempty"` + MaxCommentsPerRun int `json:"max-comments-per-run,omitempty"` + } + type CampaignSpecForValidation struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ProjectURL string `json:"project-url,omitempty"` - Version string `json:"version,omitempty"` - Workflows []string `json:"workflows,omitempty"` - MemoryPaths []string `json:"memory-paths,omitempty"` - MetricsGlob string `json:"metrics-glob,omitempty"` - Owners []string `json:"owners,omitempty"` - ExecutiveSponsors []string `json:"executive-sponsors,omitempty"` - RiskLevel string `json:"risk-level,omitempty"` - TrackerLabel string `json:"tracker-label,omitempty"` - State string `json:"state,omitempty"` - Tags []string `json:"tags,omitempty"` - AllowedSafeOutputs []string `json:"allowed-safe-outputs,omitempty"` - ApprovalPolicy *CampaignApprovalPolicy `json:"approval-policy,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ProjectURL string `json:"project-url,omitempty"` + ProjectGitHubToken string `json:"project-github-token,omitempty"` + Version string `json:"version,omitempty"` + Workflows []string `json:"workflows,omitempty"` + MemoryPaths []string `json:"memory-paths,omitempty"` + MetricsGlob string `json:"metrics-glob,omitempty"` + CursorGlob string `json:"cursor-glob,omitempty"` + Owners []string `json:"owners,omitempty"` + ExecutiveSponsors []string `json:"executive-sponsors,omitempty"` + RiskLevel string `json:"risk-level,omitempty"` + TrackerLabel string `json:"tracker-label,omitempty"` + State string `json:"state,omitempty"` + Tags []string `json:"tags,omitempty"` + AllowedSafeOutputs []string `json:"allowed-safe-outputs,omitempty"` + Governance *CampaignGovernancePolicyForValidation `json:"governance,omitempty"` + ApprovalPolicy *CampaignApprovalPolicy `json:"approval-policy,omitempty"` } validationSpec := CampaignSpecForValidation{ @@ -183,10 +214,12 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { Name: spec.Name, Description: spec.Description, ProjectURL: spec.ProjectURL, + ProjectGitHubToken: spec.ProjectGitHubToken, Version: spec.Version, Workflows: spec.Workflows, MemoryPaths: spec.MemoryPaths, MetricsGlob: spec.MetricsGlob, + CursorGlob: spec.CursorGlob, Owners: spec.Owners, ExecutiveSponsors: spec.ExecutiveSponsors, RiskLevel: spec.RiskLevel, @@ -194,7 +227,21 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { State: spec.State, Tags: spec.Tags, AllowedSafeOutputs: spec.AllowedSafeOutputs, - ApprovalPolicy: spec.ApprovalPolicy, + Governance: func() *CampaignGovernancePolicyForValidation { + if spec.Governance == nil { + return nil + } + return &CampaignGovernancePolicyForValidation{ + MaxNewItemsPerRun: spec.Governance.MaxNewItemsPerRun, + MaxDiscoveryItemsPerRun: spec.Governance.MaxDiscoveryItemsPerRun, + MaxDiscoveryPagesPerRun: spec.Governance.MaxDiscoveryPagesPerRun, + OptOutLabels: spec.Governance.OptOutLabels, + DoNotDowngradeDoneItems: spec.Governance.DoNotDowngradeDoneItems, + MaxProjectUpdatesPerRun: spec.Governance.MaxProjectUpdatesPerRun, + MaxCommentsPerRun: spec.Governance.MaxCommentsPerRun, + } + }(), + ApprovalPolicy: spec.ApprovalPolicy, } // Marshal spec to JSON then unmarshal to any for validation diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 8af58b0aa8..59b13ce35a 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -62,6 +62,10 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so b.WriteString(strings.TrimSuffix(data.On, "\n")) b.WriteString("\n") } + if strings.TrimSpace(data.Concurrency) != "" { + b.WriteString(strings.TrimSuffix(data.Concurrency, "\n")) + b.WriteString("\n") + } // Make the orchestrator runnable by default. b.WriteString("engine: copilot\n")