diff --git a/docs/builders-grok-cursor.md b/docs/builders-grok-cursor.md index 9afd94fd..57023292 100644 --- a/docs/builders-grok-cursor.md +++ b/docs/builders-grok-cursor.md @@ -1,17 +1,29 @@ -# Grok Bot And Cursor Cloud Agents +# Cursor Cloud Agent And Grok Bot Separation -Code Mower treats hosted coding agents as **builders**, not reviewer lanes, -until they produce an actual pull request. Reviewer lanes still run after the -PR exists. +Code Mower distinguishes between Cursor's hosted builder and review capabilities: + +- `cursor_cloud_agent` is the hosted async builder/executor identity that can take work orders and execute package-install qualification campaigns. +- `cursor_bugbot` (also known as Grok Bot or BugBot) is Cursor's review service and remains informational/manual; it cannot execute work orders. +- `grok_build` is the local Grok Build CLI reviewer lane. This distinction matters: -- `grok_build` is the local Grok Build CLI reviewer lane. -- `grok_bot` is a hosted/manual builder or orchestrator identity. -- `cursor_cloud_agent` is a hosted async builder/executor identity. -- `cursor_bugbot` is Cursor's reviewer service and remains informational/manual. +- `cursor_cloud_agent` has `role: builder` and `capability: work_order_execution`, qualifying it for release campaigns. +- `cursor_bugbot` has `role: reviewer` and `capability: code_review`, excluding it from builder tasks. + +## Provider Aliases and Compatibility + +The provider alias map routes spellings to canonical identities: + +- `cursor` → `cursor_cloud_agent` (builder) +- `cursor_cloud_agent` → `cursor_cloud_agent` (builder) +- `cursor_bugbot` → `cursor_bugbot` (reviewer) +- `cursor_grok_bot` → `cursor_bugbot` (reviewer) +- `grok_bot` → `cursor_bugbot` (reviewer) + +Historical `cursor_bugbot` campaigns created before v1.0.8 remain valid but are not accepted for new release qualification work. The separation preserves existing stored evidence through an explicit compatibility path without silently reinterpreting old data. -## Recommended Current-Beta Flow +## Recommended Current Flow Use GitHub Issues as the source of truth: @@ -26,12 +38,11 @@ code-mower work-order draft \ --output .code-mower/work-orders/example.md ``` -Give the work order to Grok Bot, Cursor Cloud Agents, or another hosted -builder. After that builder opens a PR, record source-free provenance: +Give the work order to Cursor Cloud Agent. After it opens a PR, record source-free provenance: ```bash code-mower builder record \ - --provider grok_bot \ + --provider cursor_cloud_agent \ --executor cursor_cloud_agent \ --work-order .code-mower/work-orders/example.md \ --pr OWNER/REPO#124 \ diff --git a/docs/provider-diversity-provenance.md b/docs/provider-diversity-provenance.md index 2377f5c4..53661c2e 100644 --- a/docs/provider-diversity-provenance.md +++ b/docs/provider-diversity-provenance.md @@ -38,7 +38,22 @@ This document tracks the tool/model/version provenance metadata for v1.0 provide - **Bot Authors**: gitar-ai[bot], gitar-bot, gitar-bot[bot] - **Status**: informational adapter-based lane, opt-in required -### Cursor BugBot +### Cursor Cloud Agent (Builder) +- **Provider**: cursor_cloud_agent +- **Driver**: hosted_bridge +- **Schema**: codeMower.cursorCloudAgentAudit.v1 +- **Result Source**: trailer_comment +- **Model Source**: vendor_hidden +- **Version Detection**: vendor_hidden +- **Merge Authority**: true (enabled_by_default=false, manual trigger) +- **Spend Policy**: paid +- **Role**: builder +- **Capability**: work_order_execution +- **Bot Authors**: cursor[bot], cursor +- **Trigger Comments**: "@cursor run", "cursor run" +- **Status**: hosted async builder with release qualification capability; can execute work orders and package-install campaigns + +### Cursor BugBot (Reviewer) - **Provider**: cursor_bugbot - **Driver**: saas_event - **Schema**: codeMower.cursorBugbotAudit.v1 (fixture schema) @@ -47,9 +62,11 @@ This document tracks the tool/model/version provenance metadata for v1.0 provide - **Version Detection**: vendor_hidden - **Merge Authority**: false (informational) - **Spend Policy**: paid +- **Role**: reviewer +- **Capability**: code_review - **Bot Authors**: cursor[bot], cursor - **Trigger Comments**: "bugbot run", "@cursor review" -- **Status**: manual informational lane, calibration-only until output shape captured +- **Status**: manual informational review lane; Grok Bot and BugBot are review surfaces only and cannot execute package-install campaigns ### Antigravity CLI - **Provider**: antigravity diff --git a/docs/release-qualification.md b/docs/release-qualification.md index 52053094..048655b5 100644 --- a/docs/release-qualification.md +++ b/docs/release-qualification.md @@ -118,7 +118,7 @@ No local paths, secrets, commands, or raw output. ## Release Campaigns -The `code-mower release campaign` command coordinates multi-provider qualification across Claude, Codex, Antigravity, Muse, Cursor/Grok Bot, and Devin: +The `code-mower release campaign` command coordinates multi-provider qualification across Claude, Codex, Antigravity, Muse, Cursor Cloud Agent, and Devin: ```bash code-mower release campaign \ @@ -167,7 +167,7 @@ other package-index install; see [Cache Bypass And Propagation Triage](pypi-release.md#cache-bypass-and-propagation-triage). - **Dry-run by default, applied once and for all:** Omit `--apply` for a safe preview. Add `--apply` for live local execution, GitHub comment dispatch, or paid runs. `applied` is a *monotonic* transition: a dry-run campaign becomes applied the first time it is dispatched with `--apply`, and nothing moves it back. A later `resume` or `--status` poll that simply omits `--apply` is not a claim that the dispatches and attempts already made never happened, so it leaves the campaign (and each dispatched provider's `dispatch_mode`) applied -- in stored state, in the rendered text, and on the Board. Previously such a poll relabelled real evidence as a dry-run preview and regressed the aggregate status to "run with --apply to dispatch providers" for providers that had already been dispatched. A poll still never dispatches anything: only `--apply` does that. The aggregate headline of a preview is held to the same standard as the individual providers it summarizes: `queued` / "run with --apply to dispatch providers" is only reported while at least one provider is genuinely dispatchable. When every provider that is not already complete is `unavailable` -- for a missing `--issue`, a missing `--repo-slug`, missing credentials, or an unconfigured adapter alike -- the campaign reports `unavailable` and the actionable "configure prerequisites for unavailable providers: ..." next action instead of pointing at an `--apply` run that could dispatch nothing. A mixed preview stays `queued`, and its detail line still counts the queued and unavailable providers separately. -- **Provider diversity:** Tracks Claude, Codex, Antigravity, Muse, Cursor/Grok Bot, and Devin. Missing tools, tokens, or adapters -- and a `code-mower.yml` that configures one lane under two spellings -- degrade gracefully to `unavailable` without failing the campaign. Each provider may appear at most once: `--providers` is canonicalized before any participant is built, so naming the same provider twice -- directly, or through two aliases of one lane such as `cursor` and `grok_bot` -- is rejected with an explicit error instead of creating two participants that share a single idempotency key and result path (which would let one provider's evidence count twice). +- **Provider diversity:** Tracks Claude, Codex, Antigravity, Muse, Cursor Cloud Agent, and Devin. Missing tools, tokens, or adapters -- and a `code-mower.yml` that configures one lane under two spellings -- degrade gracefully to `unavailable` without failing the campaign. Each provider may appear at most once: `--providers` is canonicalized before any participant is built, so naming the same provider twice -- directly, or through two aliases of one lane such as `cursor` and `cursor_cloud_agent` -- is rejected with an explicit error instead of creating two participants that share a single idempotency key and result path (which would let one provider's evidence count twice). - **Idempotent resume:** Pass `--resume` to re-poll running providers or advance queued participants without duplicating dispatch or re-invoking an adapter that already completed. Once a provider's applied dispatch/adapter has been attempted (even if it failed or its outcome was uncertain), ordinary resume never repeats it automatically -- pass `--retry-provider ` to explicitly retry that one provider. `--retry-provider` is rejected unless the named provider is already part of the campaign. - **A hosted dispatch is checkpointed as pollable before it is posted:** Posting the dispatch comment is an external side effect that the campaign cannot undo and cannot re-observe, so everything a later resume needs is persisted *first*: the attempt (`attempted_at`), the `running` state, the issue the comment is addressed to (in `dispatch_ref`), the applied dispatch mode, and the matching campaign status and next action. A process killed anywhere around the post therefore leaves a campaign that an ordinary `--resume` polls to a conclusion against the original issue -- accepting the trusted, identity-bound result if the comment did get posted and answered -- and that never reposts on its own. Previously the campaign recorded only `attempted_at` and stayed `queued` until the post returned, which resume neither polled (not running) nor redispatched (already attempted): the provider stalled until an explicit retry posted a second comment for a dispatch that may well have succeeded. The checkpoint never claims the post succeeded -- `dispatched_at` is stamped and `dispatch_ref` is replaced with the returned dispatch metadata only when the post returns successfully; a dispatch that fails in-process records the usual `github_dispatch_failed` unavailable result. If nothing was ever posted, resume simply keeps polling and finds nothing, and dispatching again still requires an explicit `--retry-provider` (which, being explicit, may post a second comment). A retry that cannot dispatch for a prerequisite reason -- no `--issue`, missing credentials -- leaves an outstanding dispatch `running` and pollable rather than demoting it to `unavailable`, since refusing to dispatch reveals nothing about the comment already posted. - **Never reinitialized:** An existing campaign is never replaced by a fresh queued one. Repeating the same invocation (same `--release-tag` or `--campaign-id`, with or without `--resume`) advances the stored campaign under resume semantics, so a repeated `--apply` never reruns a local adapter, reposts a hosted dispatch, or discards recorded provider state and evidence. The explicit `create` action fails when that campaign already exists, and `resume`/`dispatch` fail when it does not -- neither falls through to creating one. A request that asks for two actions at once is refused rather than resolved to one of them: an action may be spelled with the equivalent legacy flag (`status` with `--status`, `resume`/`dispatch` with `--resume`), but combining an action with a flag naming a *different* action -- `create --resume` above all -- exits non-zero with a bounded conflict message before any campaign lookup, directory creation, lock, state write, adapter run, dispatch, or poll, so the rejected request leaves nothing behind. Previously `create --resume` reached the command body with both intents live and was answered by whichever branch tested its flag first, reporting "no existing campaign to resume" for an explicit `create`. Creation arguments that describe a different campaign (`--package-spec`, `--providers`, `--qualification-context`, `--starting-version`, `--package-source`, or a `--campaign-id`/`--release-tag` pair that disagree) are rejected explicitly rather than silently ignored. `--qualification-context` and `--package-source` are each compared whenever supplied, including an explicit `--qualification-context cold_install` or `--package-source pypi` against a stored campaign with the same value: neither flag has a default value of its own, so an omitted flag (which advances the stored campaign under its own context/source, and creates a `cold_install`/`pypi` campaign when there is none) is distinguishable from an explicitly requested default, and an explicit default is never silently ignored. A campaign created without `--package-source` (or an explicit `pypi`) stays `pypi`; resuming it with `--package-source testpypi` is rejected as an identity conflict, not a resume, exactly like a conflicting `--qualification-context`. A campaign stored before this field existed reads back as `pypi`, its documented default. `--repo-slug` is the one field an existing campaign can still be *completed* with: a campaign created without a repository slug has nowhere to dispatch, so supplying `--repo-slug` on a later `resume`/`dispatch` fills the empty stored value and persists it before any hosted dispatch uses it. A `--repo-slug` that disagrees with a non-empty stored slug is rejected like any other identity change -- an in-flight campaign is never repointed at a different repository. @@ -248,16 +248,16 @@ Hosted / SaaS providers (`hosted_bridge`/`saas_event` driver: Devin, Cursor BugB - The provider's reply comment must embed a `CODE_MOWER_ADOPTION_RESULT` marker as a single-line HTML comment on a line of its own (``), wrapping schema `code_mower.releaseCampaignResult.v1` with `campaign_id`, `provider`, `release_tag`, and `idempotency_key` matching the original dispatch, plus a validated `adoption_result`. A bare or unbound result is ignored so a stale or unrelated comment can never be replayed as evidence. The embedded `adoption_result`'s own `qualification_context` and `starting_version` must also match the campaign's exactly, independent of the wrapper's idempotency key -- a cold-install result cannot complete an upgrade campaign, and an upgrade result from one starting version cannot complete a same-tag upgrade campaign from a different starting version. The marker line is matched end to end and its JSON is captured through the object's own final brace, so a literal `-->` inside a permitted string value cannot truncate an otherwise valid trusted result; a marker whose JSON is genuinely malformed is still ignored (fail-closed), never guessed at. - These identity fields are visible in the public dispatch comment, so binding alone does not prove authorship -- anyone could reply with a matching marker. A result marker is only ever accepted from a GitHub comment author present in the lane's `provider_config.bot_authors` list (and, if configured, the comma-separated login list in the environment variable named by `provider_config.bot_authors_env`). A lane with no trusted authors configured trusts nobody; an untrusted or spoofed author's comment is ignored and the provider keeps running. -#### Cursor BugBot Setup +#### Cursor Cloud Agent Setup -Cursor BugBot (also known as Cursor/Grok Bot, or Cursor Cloud Agents) is a hosted SaaS provider using the `saas_event` driver. +Cursor Cloud Agent is a hosted async builder using the `hosted_bridge` driver. **Prerequisites:** - GitHub App authorization for Cursor in your repository -- `CURSOR_BUGBOT_AUDIT_LABEL_TOKEN` (or `GITHUB_TOKEN` as fallback) for applying audit labels +- `CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN` (or `GITHUB_TOKEN` as fallback) for applying audit labels - `GITHUB_TOKEN` for posting dispatch comments - After verifying that the installed App answers campaign issue comments, set - `CODE_MOWER_CURSOR_BUGBOT_CAMPAIGN_TRANSPORT_READY=1`. Without it, doctor and + `CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY=1`. Without it, doctor and Board report the transport as unverified, but an explicit `--apply` may still dispatch it under the response deadline below. Token presence alone proves comment permission, not that the App supports this transport. @@ -267,29 +267,79 @@ Cursor BugBot (also known as Cursor/Grok Bot, or Cursor Cloud Agents) is a hoste - `cursor` **Environment override:** -Set `CURSOR_BUGBOT_BOT_AUTHORS` to a comma-separated list of additional trusted GitHub logins. This extends (does not replace) the default trusted authors, allowing self-hosted or alternative Cursor integrations to be trusted. +Set `CURSOR_CLOUD_AGENT_BOT_AUTHORS` to a comma-separated list of additional trusted GitHub logins. This extends (does not replace) the default trusted authors, allowing self-hosted or alternative Cursor integrations to be trusted. **Trigger comments:** -- `bugbot run` -- `@cursor review` +- `@cursor run` +- `cursor run` + +**Role and capability:** +- `role: builder` +- `capability: work_order_execution` +- Can execute work orders and package-install campaigns + +**Response timeout:** +- 3600 seconds (1 hour) -After the dispatch comment is posted, one of these trigger commands is posted as a separate comment to actually start the BugBot qualification run. -Code Mower binds the hidden dispatch and trigger markers to separate locally persisted random nonces. The trigger nonce is never exposed by the earlier dispatch, so an interrupted resume can reconcile its own comments without trusting forgeable public fields or starting the provider twice. Reconciliation is read-only; retrying a missing trigger requires `--resume --apply`. +**Campaign dispatch:** +The campaign posts a GitHub issue comment with schema `code_mower.releaseCampaignDispatch.v1` containing: +- `campaign_id` +- `provider`: `cursor_cloud_agent` +- `release_tag` +- `package_spec` +- `qualification_context` (cold_install/upgrade/unknown) +- `starting_version` (for upgrade campaigns only) +- `idempotency_key` + +Cursor Cloud Agent replies with a comment containing `` wrapping schema `code_mower.releaseCampaignResult.v1`. The embedded `adoption_result` must match the campaign's provider, release tag, package identity, qualification context, and (for upgrades) starting version. + +**Trusted authors:** +- `cursor[bot]`, `cursor` (registry defaults) +- Override via `CURSOR_CLOUD_AGENT_BOT_AUTHORS` environment variable **Example dispatch:** ```bash code-mower release campaign \ --release-tag v1.0.0 \ --package-spec code-mower==1.0.0 \ - --providers cursor_bugbot \ + --providers cursor_cloud_agent \ --issue 123 \ --repo-slug owner/repo \ --apply ``` -**Aliases:** `cursor`, `cursor_bugbot`, `cursor_grok_bot`, `cursor_cloud_agent`, `grok_bot` all resolve to the canonical `cursor_bugbot` provider. +**Aliases:** +- `cursor` → `cursor_cloud_agent` +- `cursor_cloud_agent` → `cursor_cloud_agent` + +**Note:** Cursor Cloud Agent is an opt-in paid provider (`enabled_by_default: false`, `trigger_policy: manual`, `spend_policy: paid`). It must be explicitly requested via `--providers cursor_cloud_agent` or `--providers cursor`. + +#### Cursor BugBot / Grok Bot Setup + +Cursor BugBot and Grok Bot are **review-only** hosted providers using the `saas_event` driver. They provide code review and cannot execute package-install qualification campaigns. + +**Prerequisites:** +- `CURSOR_BUGBOT_AUDIT_LABEL_TOKEN` (or `GITHUB_TOKEN` as fallback) for applying audit labels +- `GITHUB_TOKEN` for posting review trigger comments + +**Trigger comments** (for review, not campaigns): +- `bugbot run` +- `@cursor review` + +**Trusted authors:** +- `cursor[bot]`, `cursor` (registry defaults) +- Override via `CURSOR_BUGBOT_BOT_AUTHORS` environment variable + +**Aliases:** +- `cursor_bugbot` → `cursor_bugbot` +- `cursor_grok_bot` → `cursor_bugbot` +- `grok_bot` → `cursor_bugbot` + +**Note:** Cursor BugBot is an opt-in paid review provider. It is review-only with `role: reviewer` and `capability: code_review`. It cannot participate in release qualification campaigns. + +#### Historical Note -**Note:** Cursor BugBot is an opt-in paid provider (`enabled_by_default: false`, `trigger_policy: manual`, `spend_policy: paid`). It must be explicitly requested via `--providers cursor_bugbot` and is not included in the default provider set. +Before v1.0.8, `cursor_bugbot` was used for both builder and review capabilities. As of v1.0.8, `cursor_cloud_agent` is the builder identity and `cursor_bugbot` is review-only. #### Devin Setup diff --git a/src/code_mower/doctor_checks/adoption.py b/src/code_mower/doctor_checks/adoption.py index cdfd1910..c8207c15 100644 --- a/src/code_mower/doctor_checks/adoption.py +++ b/src/code_mower/doctor_checks/adoption.py @@ -471,7 +471,7 @@ def check_adoption_setup( "codex", "antigravity", "muse", - "cursor_bugbot", + "cursor_cloud_agent", "devin", ) diff --git a/src/code_mower/provider_registry.py b/src/code_mower/provider_registry.py index a849fe69..e235781d 100644 --- a/src/code_mower/provider_registry.py +++ b/src/code_mower/provider_registry.py @@ -295,6 +295,37 @@ def _maintained_campaign_adapter_argv(provider: str) -> tuple[str, ...]: "spend_policy": "never trigger automatically from the reference workflows", }, ), + "cursor_cloud_agent": ProviderLane( + lane_id="cursor_cloud_agent", + lane_type="audit", + driver="hosted_bridge", + provider="cursor_cloud_agent", + labels=LaneLabels( + needs="needs-cursor-cloud-agent-audit", + done="cursor-cloud-agent-audit-done", + blocked="cursor-cloud-agent-audit-blocked", + ), + token_env=("CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN", "GITHUB_TOKEN"), + result_sources=("trailer_comment",), + merge_authority=True, + enabled_by_default=False, + trigger_policy="manual", + spend_policy="paid", + provider_config={ + "bot_authors": ("cursor[bot]", "cursor"), + "bot_authors_env": "CURSOR_CLOUD_AGENT_BOT_AUTHORS", + "trigger_comments": ("@cursor run", "cursor run"), + "campaign_transport_ready_env": "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY", + "campaign_response_timeout_seconds": 3600, + "rules_file": ".cursor/AGENT.md", + "role": "builder", + "capability": "work_order_execution", + "status": ( + "hosted async builder with release qualification capability; " + "can execute work orders and package-install campaigns" + ), + }, + ), "cursor_bugbot": ProviderLane( lane_id="cursor_bugbot", lane_type="audit", @@ -317,12 +348,12 @@ def _maintained_campaign_adapter_argv(provider: str) -> tuple[str, ...]: "bot_authors": ("cursor[bot]", "cursor"), "bot_authors_env": "CURSOR_BUGBOT_BOT_AUTHORS", "trigger_comments": ("bugbot run", "@cursor review"), - "campaign_transport_ready_env": "CODE_MOWER_CURSOR_BUGBOT_CAMPAIGN_TRANSPORT_READY", - "campaign_response_timeout_seconds": 3600, "rules_file": ".cursor/BUGBOT.md", + "role": "reviewer", + "capability": "code_review", "status": ( - "manual informational lane; keep calibration-only until enabled " - "BugBot output shape is captured and adjudicated" + "manual informational review lane; Grok Bot and BugBot are " + "review surfaces only and cannot execute package-install campaigns" ), }, ), diff --git a/src/code_mower/release_campaigns.py b/src/code_mower/release_campaigns.py index f60e20e0..b3287517 100644 --- a/src/code_mower/release_campaigns.py +++ b/src/code_mower/release_campaigns.py @@ -119,7 +119,7 @@ "codex", "antigravity", "muse", - "cursor_bugbot", + "cursor_cloud_agent", "devin", ) @@ -134,10 +134,10 @@ "antigravity_cli": "antigravity_cli", "muse": "muse_cli", "muse_cli": "muse_cli", - "cursor": "cursor_bugbot", + "cursor": "cursor_cloud_agent", + "cursor_cloud_agent": "cursor_cloud_agent", "cursor_bugbot": "cursor_bugbot", "cursor_grok_bot": "cursor_bugbot", - "cursor_cloud_agent": "cursor_bugbot", "grok_bot": "cursor_bugbot", "grok": "grok_build", "grok_build": "grok_build", @@ -1780,6 +1780,18 @@ def initialize_campaign( "more than once, directly or through an alias; list each provider " "exactly once" ) + # New campaigns may include providers with work_order_execution capability. + # Lanes marked role:reviewer or capability:code_review must fail before dispatch. + role = lane.provider_config.get("role", "") + capability = lane.provider_config.get("capability", "") + if role == "reviewer" or capability == "code_review": + raise ValueError( + f"release campaign provider {canonical_name!r} is a review-only lane " + f"(role: {role!r}, capability: {capability!r}) and cannot execute " + f"package qualification campaigns. Release campaigns require providers " + f"with work_order_execution capability; choose cursor_cloud_agent or " + f"another builder provider instead" + ) seen_providers.add(canonical_name) resolved_providers.append((canonical_name, lane)) diff --git a/src/code_mower/release_qualify.py b/src/code_mower/release_qualify.py index b8c5f527..e644f0fc 100644 --- a/src/code_mower/release_qualify.py +++ b/src/code_mower/release_qualify.py @@ -1051,7 +1051,11 @@ def main(argv: Sequence[str] | None = None) -> int: campaign.add_argument( "--providers", default="", - help="Comma-separated provider list (default: claude,codex,antigravity,muse,cursor_bugbot,devin)", + help=( + "Comma-separated provider list (default: " + + ",".join(doctor_checks.DEFAULT_CAMPAIGN_PROVIDERS) + + ")" + ), ) campaign.add_argument( "--qualification-context", diff --git a/src/code_mower/templates/providers.yml b/src/code_mower/templates/providers.yml index 526c4c0e..83739659 100644 --- a/src/code_mower/templates/providers.yml +++ b/src/code_mower/templates/providers.yml @@ -199,6 +199,36 @@ provider_templates: review_hygiene: workflow: ".github/workflows/codex-clear-stale.yml" token_env: "GITHUB_TOKEN" + cursor_cloud_agent: + provider: "cursor_cloud_agent" + driver: "hosted_bridge" + type: "audit" + adapter: null + trailer_lane: null + spend_policy: "paid" + merge_authority: true + informational: false + enabled_by_default: false + events: [] + token_env: + - "CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN" + - "GITHUB_TOKEN" + token_env_any: [] + trigger_policy: "manual" + provider_config: + bot_authors: + - "cursor[bot]" + - "cursor" + bot_authors_env: "CURSOR_CLOUD_AGENT_BOT_AUTHORS" + trigger_comments: + - "@cursor run" + - "cursor run" + campaign_transport_ready_env: "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY" + campaign_response_timeout_seconds: 3600 + rules_file: ".cursor/AGENT.md" + role: "builder" + capability: "work_order_execution" + status: "hosted async builder with release qualification capability" cursor_bugbot: provider: "cursor_bugbot" driver: "saas_event" @@ -220,11 +250,14 @@ provider_templates: bot_authors: - "cursor[bot]" - "cursor" + bot_authors_env: "CURSOR_BUGBOT_BOT_AUTHORS" trigger_comments: - "bugbot run" - "@cursor review" rules_file: ".cursor/BUGBOT.md" - status: "manual informational lane; keep calibration-only until enabled BugBot output shape is captured and adjudicated" + role: "reviewer" + capability: "code_review" + status: "manual informational review lane; Grok Bot and BugBot are review surfaces only" review_hygiene: {} devin: provider: "devin" diff --git a/templates/providers.yml b/templates/providers.yml index 526c4c0e..83739659 100644 --- a/templates/providers.yml +++ b/templates/providers.yml @@ -199,6 +199,36 @@ provider_templates: review_hygiene: workflow: ".github/workflows/codex-clear-stale.yml" token_env: "GITHUB_TOKEN" + cursor_cloud_agent: + provider: "cursor_cloud_agent" + driver: "hosted_bridge" + type: "audit" + adapter: null + trailer_lane: null + spend_policy: "paid" + merge_authority: true + informational: false + enabled_by_default: false + events: [] + token_env: + - "CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN" + - "GITHUB_TOKEN" + token_env_any: [] + trigger_policy: "manual" + provider_config: + bot_authors: + - "cursor[bot]" + - "cursor" + bot_authors_env: "CURSOR_CLOUD_AGENT_BOT_AUTHORS" + trigger_comments: + - "@cursor run" + - "cursor run" + campaign_transport_ready_env: "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY" + campaign_response_timeout_seconds: 3600 + rules_file: ".cursor/AGENT.md" + role: "builder" + capability: "work_order_execution" + status: "hosted async builder with release qualification capability" cursor_bugbot: provider: "cursor_bugbot" driver: "saas_event" @@ -220,11 +250,14 @@ provider_templates: bot_authors: - "cursor[bot]" - "cursor" + bot_authors_env: "CURSOR_BUGBOT_BOT_AUTHORS" trigger_comments: - "bugbot run" - "@cursor review" rules_file: ".cursor/BUGBOT.md" - status: "manual informational lane; keep calibration-only until enabled BugBot output shape is captured and adjudicated" + role: "reviewer" + capability: "code_review" + status: "manual informational review lane; Grok Bot and BugBot are review surfaces only" review_hygiene: {} devin: provider: "devin" diff --git a/templates/providers/cursor_bugbot.yml b/templates/providers/cursor_bugbot.yml index c50b830f..0ab31740 100644 --- a/templates/providers/cursor_bugbot.yml +++ b/templates/providers/cursor_bugbot.yml @@ -19,9 +19,12 @@ cursor_bugbot: bot_authors: - "cursor[bot]" - "cursor" + bot_authors_env: "CURSOR_BUGBOT_BOT_AUTHORS" trigger_comments: - "bugbot run" - "@cursor review" rules_file: ".cursor/BUGBOT.md" - status: "manual informational lane; keep calibration-only until enabled BugBot output shape is captured and adjudicated" + role: "reviewer" + capability: "code_review" + status: "manual informational review lane; Grok Bot and BugBot are review surfaces only and cannot execute package-install campaigns" review_hygiene: {} diff --git a/templates/providers/cursor_cloud_agent.yml b/templates/providers/cursor_cloud_agent.yml new file mode 100644 index 00000000..acfc9eb5 --- /dev/null +++ b/templates/providers/cursor_cloud_agent.yml @@ -0,0 +1,30 @@ +cursor_cloud_agent: + provider: "cursor_cloud_agent" + driver: "hosted_bridge" + type: "audit" + adapter: null + trailer_lane: null + spend_policy: "paid" + merge_authority: true + informational: false + enabled_by_default: false + events: [] + token_env: + - "CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN" + - "GITHUB_TOKEN" + token_env_any: [] + trigger_policy: "manual" + provider_config: + bot_authors: + - "cursor[bot]" + - "cursor" + bot_authors_env: "CURSOR_CLOUD_AGENT_BOT_AUTHORS" + trigger_comments: + - "@cursor run" + - "cursor run" + campaign_transport_ready_env: "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY" + campaign_response_timeout_seconds: 3600 + rules_file: ".cursor/AGENT.md" + role: "builder" + capability: "work_order_execution" + status: "hosted async builder with release qualification capability; can execute work orders and package-install campaigns" diff --git a/tests/test_campaign_discovery.py b/tests/test_campaign_discovery.py index c397353b..f858ff56 100644 --- a/tests/test_campaign_discovery.py +++ b/tests/test_campaign_discovery.py @@ -63,7 +63,7 @@ def _create( repo_path=repo_path, campaign_id=campaign_id, campaigns_dir=campaigns_dir, - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], ) def _status( @@ -155,7 +155,7 @@ def test_missing_and_malformed_index_keep_repo_local_readable(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], ).to_dict() release_campaigns.save_campaign(campaign, local_dir) identity = campaign_discovery.resolve_repo_identity(repo) diff --git a/tests/test_doctor_campaign_readiness.py b/tests/test_doctor_campaign_readiness.py index 2ee59b63..ef595025 100644 --- a/tests/test_doctor_campaign_readiness.py +++ b/tests/test_doctor_campaign_readiness.py @@ -11,7 +11,9 @@ import unittest from unittest import mock +from code_mower import release_campaigns from code_mower.doctor_checks import ( + DEFAULT_CAMPAIGN_PROVIDERS, STATUS_PASS, STATUS_SKIP, STATUS_WARN, @@ -22,6 +24,11 @@ class DoctorCampaignReadinessTests(unittest.TestCase): + def test_default_providers_match_campaign_runtime(self) -> None: + self.assertEqual(DEFAULT_CAMPAIGN_PROVIDERS, release_campaigns.DEFAULT_CAMPAIGN_PROVIDERS) + self.assertIn("cursor_cloud_agent", DEFAULT_CAMPAIGN_PROVIDERS) + self.assertNotIn("cursor_bugbot", DEFAULT_CAMPAIGN_PROVIDERS) + def test_campaign_adapter_passes_when_command_and_adapter_configured(self) -> None: with tempfile.TemporaryDirectory() as tmp: repo_root = Path(tmp) diff --git a/tests/test_release_campaigns.py b/tests/test_release_campaigns.py index f0871aba..359cbe35 100644 --- a/tests/test_release_campaigns.py +++ b/tests/test_release_campaigns.py @@ -180,6 +180,46 @@ def test_known_alias_resolves(self) -> None: self.assertEqual(canonical, "claude") self.assertEqual(lane.lane_id, "claude_audit") + def test_cursor_alias_resolves_to_cursor_cloud_agent(self) -> None: + """The 'cursor' alias routes to cursor_cloud_agent (builder), not cursor_bugbot.""" + canonical, lane = release_campaigns.resolve_provider_lane("cursor") + self.assertEqual(canonical, "cursor_cloud_agent") + self.assertEqual(lane.lane_id, "cursor_cloud_agent") + self.assertEqual(lane.provider, "cursor_cloud_agent") + + def test_cursor_cloud_agent_direct_lookup(self) -> None: + """cursor_cloud_agent as direct provider name resolves to itself.""" + canonical, lane = release_campaigns.resolve_provider_lane("cursor_cloud_agent") + self.assertEqual(canonical, "cursor_cloud_agent") + self.assertEqual(lane.lane_id, "cursor_cloud_agent") + self.assertEqual(lane.driver, "hosted_bridge") + # Verify builder capability metadata + self.assertEqual(lane.provider_config.get("role"), "builder") + self.assertEqual(lane.provider_config.get("capability"), "work_order_execution") + + def test_cursor_bugbot_stays_cursor_bugbot(self) -> None: + """cursor_bugbot as provider name resolves to cursor_bugbot reviewer lane.""" + canonical, lane = release_campaigns.resolve_provider_lane("cursor_bugbot") + self.assertEqual(canonical, "cursor_bugbot") + self.assertEqual(lane.lane_id, "cursor_bugbot") + self.assertEqual(lane.driver, "saas_event") + # Verify reviewer capability metadata + self.assertEqual(lane.provider_config.get("role"), "reviewer") + self.assertEqual(lane.provider_config.get("capability"), "code_review") + + def test_grok_bot_alias_resolves_to_cursor_bugbot(self) -> None: + """The 'grok_bot' alias routes to cursor_bugbot (reviewer).""" + canonical, lane = release_campaigns.resolve_provider_lane("grok_bot") + self.assertEqual(canonical, "cursor_bugbot") + self.assertEqual(lane.lane_id, "cursor_bugbot") + self.assertEqual(lane.provider, "cursor_bugbot") + + def test_cursor_grok_bot_alias_resolves_to_cursor_bugbot(self) -> None: + """The 'cursor_grok_bot' alias also routes to cursor_bugbot (reviewer).""" + canonical, lane = release_campaigns.resolve_provider_lane("cursor_grok_bot") + self.assertEqual(canonical, "cursor_bugbot") + self.assertEqual(lane.lane_id, "cursor_bugbot") + def test_unknown_provider_name_raises(self) -> None: with self.assertRaises(ValueError) as ctx: release_campaigns.resolve_provider_lane("totally-made-up-provider") @@ -1176,13 +1216,13 @@ class MockCompleted: common_kwargs = dict( package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=failing_command_runner, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) release_campaigns.campaign_command(release_tag="v1.0.0", **common_kwargs) @@ -1199,24 +1239,68 @@ class MockCompleted: # Explicit --retry-provider dispatches exactly once more. release_campaigns.campaign_command( - release_tag="v1.0.0", resume=True, retry_provider="cursor_bugbot", **common_kwargs + release_tag="v1.0.0", resume=True, retry_provider="cursor_cloud_agent", **common_kwargs ) self.assertEqual(len(dispatch_calls), 2) + @staticmethod + def _create_stored_cursor_bugbot_campaign( + campaigns_dir: Path, + release_tag: str = "v1.0.0", + package_spec: str = "code-mower==1.0.0", + status: str = "queued", + state: str = "queued", + repo_slug: str = "owner/repo", + ) -> dict: + """Helper to create a stored legacy cursor_bugbot campaign for testing backward compatibility.""" + campaign_id = f"campaign-{release_tag}" + version = release_tag.lstrip("v") + idempotency_key = f"{campaign_id}_cursor_bugbot_cold_install" + + campaign_dict = { + "schema": release_campaigns.CAMPAIGN_SCHEMA, + "campaign_id": campaign_id, + "release_tag": release_tag, + "package_spec": package_spec, + "package_identity": package_spec.split("==")[0], + "normalized_version": version, + "qualification_context": "cold_install", + "starting_version": "", + "created_at": "2026-09-04T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "status": status, + "applied": True if status != "queued" else False, + "providers": [ + { + "provider": "cursor_bugbot", + "lane_id": "cursor_bugbot", + "driver": "saas_event", + "state": state, + "environment": "local/python_3.12", + "elapsed_seconds": 0.0, + "idempotency_key": idempotency_key, + "dispatch_mode": "apply" if status != "queued" else "dry_run", + "attempted_at": "2026-09-04T00:00:00Z" if status != "queued" else None, + "dispatched_at": "2026-09-04T00:00:00Z" if status == "running" else None, + "completed_at": None, + "error": None, + "next_action": "", + "next_detail": "", + "dispatch_ref": {"issue_number": "99"} if status != "queued" else {}, + "adoption_result": None, + } + ], + } + + campaigns_dir.mkdir(parents=True, exist_ok=True) + campaign_file = campaigns_dir / f"{campaign_id}.json" + campaign_file.write_text(json.dumps(campaign_dict, indent=2), encoding="utf-8") + return campaign_dict + def _running_cursor_bugbot_campaign(self, campaigns_dir: Path) -> "release_campaigns.ReleaseCampaign": - campaign = release_campaigns.initialize_campaign( - release_tag="v1.0.0", - package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], - repo_slug="owner/repo", - ) - campaign.status = "running" - campaign.providers[0]["state"] = "running" - campaign.providers[0]["dispatch_ref"] = {"issue_number": "99"} - campaign.providers[0]["attempted_at"] = "2026-09-04T00:00:00Z" - campaign.providers[0]["dispatched_at"] = "2026-09-04T00:00:00Z" - release_campaigns.save_campaign(campaign, campaigns_dir) - return campaign + """Create a stored legacy cursor_bugbot campaign for testing backward compatibility.""" + self._create_stored_cursor_bugbot_campaign(campaigns_dir, status="running", state="running") + return release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) @staticmethod def _no_op_dispatch_command_runner(calls: list[Any]): @@ -1240,12 +1324,13 @@ def test_retry_provider_running_polls_first_and_completes_without_redispatch(sel with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" campaign = self._running_cursor_bugbot_campaign(campaigns_dir) + assert campaign is not None - idempotency_key = campaign.providers[0]["idempotency_key"] + idempotency_key = campaign["providers"][0]["idempotency_key"] adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass") wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, - "campaign_id": campaign.campaign_id, + "campaign_id": campaign["campaign_id"], "provider": "cursor_bugbot", "release_tag": "v1.0.0", "idempotency_key": idempotency_key, @@ -1477,7 +1562,7 @@ def mock_gh_json(args, **kwargs): apply=True, gh_json_runner=mock_gh_json, command_runner=self._no_op_dispatch_command_runner(dispatch_calls), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(dispatch_calls, []) @@ -1576,21 +1661,21 @@ def failing_gh_json(args, **kwargs): release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=failing_command_runner, gh_json_runner=failing_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) saved = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) assert saved is not None cursor_p = saved["providers"][0] self.assertEqual(cursor_p["state"], "unavailable") - self.assertIn("retry cursor_bugbot dispatch when GitHub is available", cursor_p["next_action"]) + self.assertIn("retry cursor_cloud_agent dispatch when GitHub is available", cursor_p["next_action"]) self.assertEqual(cursor_p["error"], "github_dispatch_failed") serialized = json.dumps(saved) self.assertNotIn("github.com", serialized) @@ -1814,7 +1899,7 @@ def test_board_projection_survives_malformed_persisted_campaigns(self) -> None: self.assertNotIn(leaked, serialized) def test_selectable_providers_diversity(self) -> None: - """Claude, Codex, Antigravity, Muse, Cursor/Grok Bot, and Devin are all included.""" + """Claude, Codex, Antigravity, Muse, Cursor Cloud Agent, and Devin are all included.""" with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" @@ -1828,7 +1913,7 @@ def test_selectable_providers_diversity(self) -> None: saved = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) assert saved is not None provider_names = {p["provider"] for p in saved["providers"]} - expected = {"claude", "codex", "antigravity", "muse", "cursor_bugbot", "devin"} + expected = {"claude", "codex", "antigravity", "muse", "cursor_cloud_agent", "devin"} self.assertEqual(provider_names, expected) def test_manual_adoption_result_recording(self) -> None: @@ -2135,7 +2220,7 @@ def mock_gh_json(args, **kwargs): campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2163,7 +2248,7 @@ def test_poll_discovers_identity_bound_github_comment(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2172,11 +2257,11 @@ def test_poll_discovers_identity_bound_github_comment(self) -> None: release_campaigns.save_campaign(campaign, campaigns_dir) idempotency_key = campaign.providers[0]["idempotency_key"] - adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass") + adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_cloud_agent", outcome="pass") wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "release_tag": "v1.0.0", "idempotency_key": idempotency_key, "adoption_result": adoption_res, @@ -2236,7 +2321,7 @@ def test_poll_rejects_upgrade_result_from_wrong_starting_version_despite_matchin package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2290,7 +2375,7 @@ def test_poll_rejects_cold_install_result_for_upgrade_campaign(self) -> None: package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2344,7 +2429,7 @@ def test_poll_accepts_upgrade_result_matching_starting_version(self) -> None: package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2356,7 +2441,7 @@ def test_poll_accepts_upgrade_result_matching_starting_version(self) -> None: adoption_res = _mock_adoption_result_full( release_tag="v2.0.0", normalized_version="2.0.0", - provider="cursor_bugbot", + provider="cursor_cloud_agent", qualification_context="upgrade", starting_version="1.0.0", ending_version="2.0.0", @@ -2365,7 +2450,7 @@ def test_poll_accepts_upgrade_result_matching_starting_version(self) -> None: wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "release_tag": "v2.0.0", "idempotency_key": idempotency_key, "adoption_result": adoption_res, @@ -2395,7 +2480,7 @@ def test_poll_rejects_wrong_idempotency_key(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2443,7 +2528,7 @@ def test_poll_rejects_spoofed_author(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2663,23 +2748,50 @@ def test_cursor_bugbot_accepts_known_trusted_bot_author(self) -> None: """Cursor BugBot's registry defaults trust cursor[bot]'s bound reply.""" with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" + campaigns_dir.mkdir(parents=True) - campaign = release_campaigns.initialize_campaign( - release_tag="v1.0.0", - package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], - repo_slug="owner/repo", - ) - campaign.status = "running" - campaign.providers[0]["state"] = "running" - campaign.providers[0]["dispatch_ref"] = {"issue_number": "99"} - release_campaigns.save_campaign(campaign, campaigns_dir) + # Create stored legacy cursor_bugbot campaign + idempotency_key = "campaign-v1.0.0_cursor_bugbot_cold_install" + campaign_dict = { + "schema": release_campaigns.CAMPAIGN_SCHEMA, + "campaign_id": "campaign-v1.0.0", + "release_tag": "v1.0.0", + "package_spec": "code-mower==1.0.0", + "package_identity": "code-mower", + "normalized_version": "1.0.0", + "qualification_context": "cold_install", + "starting_version": "", + "created_at": "2026-09-04T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "status": "running", + "applied": True, + "providers": [ + { + "provider": "cursor_bugbot", + "lane_id": "cursor_bugbot", + "driver": "saas_event", + "state": "running", + "environment": "local/python_3.12", + "elapsed_seconds": 0.0, + "idempotency_key": idempotency_key, + "dispatch_mode": "apply", + "attempted_at": None, + "dispatched_at": None, + "completed_at": None, + "error": None, + "next_action": "", + "next_detail": "", + "dispatch_ref": {"issue_number": "99"}, + } + ], + } + campaign_file = campaigns_dir / "campaign-v1.0.0.json" + campaign_file.write_text(json.dumps(campaign_dict, indent=2), encoding="utf-8") - idempotency_key = campaign.providers[0]["idempotency_key"] adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass") wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, - "campaign_id": campaign.campaign_id, + "campaign_id": "campaign-v1.0.0", "provider": "cursor_bugbot", "release_tag": "v1.0.0", "idempotency_key": idempotency_key, @@ -2707,22 +2819,16 @@ def test_cursor_bugbot_rejects_spoofed_author(self) -> None: with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" - campaign = release_campaigns.initialize_campaign( - release_tag="v1.0.0", - package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], - repo_slug="owner/repo", + # Create stored legacy cursor_bugbot campaign + campaign_dict = self._create_stored_cursor_bugbot_campaign( + campaigns_dir, status="running", state="running" ) - campaign.status = "running" - campaign.providers[0]["state"] = "running" - campaign.providers[0]["dispatch_ref"] = {"issue_number": "99"} - release_campaigns.save_campaign(campaign, campaigns_dir) + idempotency_key = campaign_dict["providers"][0]["idempotency_key"] - idempotency_key = campaign.providers[0]["idempotency_key"] adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass") wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, - "campaign_id": campaign.campaign_id, + "campaign_id": "campaign-v1.0.0", "provider": "cursor_bugbot", "release_tag": "v1.0.0", "idempotency_key": idempotency_key, @@ -2751,22 +2857,16 @@ def test_cursor_bugbot_bot_authors_env_override_adds_trusted_login(self) -> None with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" - campaign = release_campaigns.initialize_campaign( - release_tag="v1.0.0", - package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], - repo_slug="owner/repo", + # Create stored legacy cursor_bugbot campaign + campaign_dict = self._create_stored_cursor_bugbot_campaign( + campaigns_dir, status="running", state="running" ) - campaign.status = "running" - campaign.providers[0]["state"] = "running" - campaign.providers[0]["dispatch_ref"] = {"issue_number": "99"} - release_campaigns.save_campaign(campaign, campaigns_dir) + idempotency_key = campaign_dict["providers"][0]["idempotency_key"] - idempotency_key = campaign.providers[0]["idempotency_key"] adoption_res = _mock_adoption_result(release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass") wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, - "campaign_id": campaign.campaign_id, + "campaign_id": "campaign-v1.0.0", "provider": "cursor_bugbot", "release_tag": "v1.0.0", "idempotency_key": idempotency_key, @@ -2822,8 +2922,8 @@ def test_devin_dispatch_includes_trigger_comments(self) -> None: self.assertEqual(trigger_body.splitlines()[0], "@devin run") self.assertIn("CODE_MOWER_RELEASE_TRIGGER", trigger_body) - def test_cursor_bugbot_dispatch_posts_trigger_comment(self) -> None: - """Cursor BugBot dispatch posts the trigger command as a separate actionable comment.""" + def test_cursor_cloud_agent_dispatch_posts_trigger_comment(self) -> None: + """Cursor Cloud Agent dispatch posts the trigger command as a separate actionable comment.""" with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" bodies: list[str] = [] @@ -2831,13 +2931,13 @@ def test_cursor_bugbot_dispatch_posts_trigger_comment(self) -> None: release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="99", apply=True, command_runner=_capturing_dispatch_command_runner(bodies), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token", "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY": "1"}, ) self.assertEqual(len(bodies), 2) @@ -2845,12 +2945,11 @@ def test_cursor_bugbot_dispatch_posts_trigger_comment(self) -> None: trigger_body = bodies[1] # Dispatch body should document the trigger commands - self.assertIn("bugbot run", dispatch_body) - self.assertIn("@cursor review", dispatch_body) + self.assertIn("@cursor run", dispatch_body) # The actionable command stays first; the hidden marker makes a # crash-after-post retry externally idempotent. - self.assertEqual(trigger_body.splitlines()[0], "bugbot run") + self.assertEqual(trigger_body.splitlines()[0], "@cursor run") self.assertIn("CODE_MOWER_RELEASE_TRIGGER", trigger_body) def test_failed_trigger_post_retries_on_resume(self) -> None: @@ -2892,14 +2991,14 @@ def mock_gh_json(args, **kwargs): release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=failing_then_succeeding_runner, gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Should have dispatch comment + failed trigger attempt @@ -2921,7 +3020,7 @@ def mock_gh_json(args, **kwargs): apply=True, gh_json_runner=mock_gh_json, command_runner=failing_then_succeeding_runner, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Should have 1 more trigger attempt, no new dispatch @@ -2932,7 +3031,7 @@ def mock_gh_json(args, **kwargs): retried = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) assert retried is not None self.assertEqual(retried["providers"][0]["trigger_posted"], True) - self.assertIn("poll cursor_bugbot remote progress marker", retried["providers"][0]["next_action"]) + self.assertIn("poll cursor_cloud_agent remote progress marker", retried["providers"][0]["next_action"]) def test_crash_after_dispatch_before_trigger_is_retriable(self) -> None: """Simulates process crash after dispatch but before trigger is recorded.""" @@ -2944,7 +3043,7 @@ def test_crash_after_dispatch_before_trigger_is_retriable(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -2968,12 +3067,12 @@ def mock_gh_json(args, **kwargs): apply=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Should have posted exactly 1 trigger (no redispatch) self.assertEqual(len(bodies), 1) - self.assertIn("bugbot run", bodies[0]) + self.assertIn("@cursor run", bodies[0]) self.assertNotIn("CODE_MOWER_RELEASE_CAMPAIGN", bodies[0]) resumed = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) @@ -2986,7 +3085,7 @@ def test_resume_withholds_trigger_until_dispatch_is_confirmed(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3027,7 +3126,7 @@ def test_resume_withholds_trigger_until_dispatch_is_confirmed(self) -> None: }, "", ), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3079,7 +3178,7 @@ def test_resume_reconciles_posted_trigger_marker_without_reposting(self) -> None campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3094,7 +3193,7 @@ def test_resume_reconciles_posted_trigger_marker_without_reposting(self) -> None { "schema": release_campaigns.TRIGGER_MARKER_SCHEMA, "campaign_id": "campaign-v1.0.0", - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "reconciliation_key": "trigger-key", }, sort_keys=True, @@ -3111,14 +3210,14 @@ def test_resume_reconciles_posted_trigger_marker_without_reposting(self) -> None "comments": [ { "author": {"login": "cursor[bot]"}, - "body": "bugbot run\n\n" + "body": "@cursor run\n\n" f"", } ] }, "", ), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3132,7 +3231,7 @@ def test_dispatch_nonce_cannot_forge_trigger_reconciliation(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3148,7 +3247,7 @@ def test_dispatch_nonce_cannot_forge_trigger_reconciliation(self) -> None: { "schema": release_campaigns.TRIGGER_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "reconciliation_key": "public-dispatch-key", }, sort_keys=True, @@ -3165,18 +3264,18 @@ def test_dispatch_nonce_cannot_forge_trigger_reconciliation(self) -> None: { "comments": [ { - "body": "bugbot run\n\n" + "body": "@cursor run\n\n" f"" } ] }, "", ), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(len(bodies), 1) - self.assertIn("bugbot run", bodies[0]) + self.assertIn("@cursor run", bodies[0]) self.assertIn("private-trigger-key", bodies[0]) self.assertNotIn("public-dispatch-key", bodies[0]) resumed = release_campaigns.load_campaign_by_id( @@ -3191,7 +3290,7 @@ def test_completed_result_is_consumed_before_trigger_retry(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3205,12 +3304,12 @@ def test_completed_result_is_consumed_before_trigger_retry(self) -> None: wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "release_tag": "v1.0.0", "idempotency_key": provider["idempotency_key"], "adoption_result": _mock_adoption_result( release_tag="v1.0.0", - provider="cursor_bugbot", + provider="cursor_cloud_agent", outcome="pass", ), } @@ -3231,7 +3330,7 @@ def test_completed_result_is_consumed_before_trigger_retry(self) -> None: }, "", ), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3245,7 +3344,7 @@ def test_resume_without_apply_never_posts_missing_trigger(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3264,7 +3363,7 @@ def test_resume_without_apply_never_posts_missing_trigger(self) -> None: resume=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=lambda args, **kwargs: ({"comments": []}, ""), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3279,7 +3378,7 @@ def test_resume_treats_legacy_missing_trigger_field_as_unposted(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) provider = campaign.providers[0] @@ -3297,7 +3396,7 @@ def test_resume_treats_legacy_missing_trigger_field_as_unposted(self) -> None: resume=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=lambda args, **kwargs: ({"comments": []}, ""), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3317,7 +3416,7 @@ def test_explicit_retry_does_not_duplicate_trigger(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -3357,13 +3456,13 @@ def mock_gh_json(args, **kwargs): release_campaigns.campaign_command( release_tag="v1.0.0", campaigns_dir=campaigns_dir, - retry_provider="cursor_bugbot", + retry_provider="cursor_cloud_agent", apply=True, repo_slug="owner/repo", issue="42", command_runner=counting_runner, gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Should have 1 dispatch + 1 trigger (not 2 triggers) @@ -3479,14 +3578,14 @@ def mock_gh_json(args, **kwargs): common_kwargs = dict( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) release_campaigns.campaign_command(**common_kwargs) @@ -3580,13 +3679,13 @@ def mock_gh_json(args, **kwargs): release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=False, command_runner=_capturing_dispatch_command_runner(bodies), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(bodies, []) @@ -3599,7 +3698,7 @@ def mock_gh_json(args, **kwargs): apply=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) release_campaigns.campaign_command(**dispatch_kwargs) # Cursor BugBot has trigger_comments, so 2 bodies: dispatch + trigger @@ -3673,7 +3772,7 @@ def test_conflicting_context_for_existing_campaign_is_rejected(self) -> None: release_campaigns.campaign_command( release_tag="v2.0.0", package_spec="code-mower==2.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, apply=False, ) @@ -3686,13 +3785,13 @@ def test_conflicting_context_for_existing_campaign_is_rejected(self) -> None: package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=command_runner, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(ret, 1) @@ -3710,7 +3809,7 @@ def test_conflicting_providers_for_existing_campaign_is_rejected(self) -> None: release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, apply=False, ) @@ -3743,13 +3842,13 @@ def _dispatch_upgrade_campaign( package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.3", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=_capturing_dispatch_command_runner(bodies), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) saved = release_campaigns.load_campaign_by_id("campaign-v2.0.0", campaigns_dir) assert saved is not None @@ -3786,13 +3885,13 @@ def test_cold_install_dispatch_omits_starting_version(self) -> None: release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=_capturing_dispatch_command_runner(bodies), - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Cursor BugBot has trigger_comments, so 2 bodies: dispatch + trigger @@ -3842,7 +3941,7 @@ def mock_gh_json(args, **kwargs): apply=True, command_runner=_capturing_dispatch_command_runner(dispatch_calls), gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(ret, 0) @@ -3870,7 +3969,7 @@ def test_upgrade_dispatch_without_starting_version_fails_closed(self) -> None: package_spec="code-mower==2.0.0", qualification_context="upgrade", starting_version="1.0.3", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) tampered = campaign.to_dict() @@ -3885,7 +3984,7 @@ def test_upgrade_dispatch_without_starting_version_fails_closed(self) -> None: issue="42", apply=True, command_runner=command_runner, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) command_runner.assert_not_called() @@ -3946,7 +4045,7 @@ def _preview( adapter_runner = mock.MagicMock() transport_vars = { "devin": "CODE_MOWER_DEVIN_CAMPAIGN_TRANSPORT_READY", - "cursor_bugbot": "CODE_MOWER_CURSOR_BUGBOT_CAMPAIGN_TRANSPORT_READY", + "cursor_cloud_agent": "CODE_MOWER_CURSOR_CLOUD_AGENT_CAMPAIGN_TRANSPORT_READY", } ret = release_campaigns.campaign_command( release_tag="v1.0.0", @@ -3982,7 +4081,7 @@ def test_hosted_dry_run_without_issue_is_unavailable(self) -> None: """Credentials alone are not readiness: the preview names the missing --issue.""" for provider, token_env in ( ("devin", "DEVIN_AUDIT_LABEL_TOKEN"), - ("cursor_bugbot", "CURSOR_BUGBOT_AUDIT_LABEL_TOKEN"), + ("cursor_cloud_agent", "CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN"), ): with self.subTest(provider=provider), tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" @@ -4399,13 +4498,13 @@ def _seed_two_campaigns(campaigns_dir: Path) -> None: older = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], ).to_dict() older["updated_at"] = "2026-09-03T12:00:00Z" newer = release_campaigns.initialize_campaign( release_tag="v1.1.0", package_spec="code-mower==1.1.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], ).to_dict() newer["updated_at"] = "2026-09-04T12:00:00Z" release_campaigns.save_campaign(older, campaigns_dir) @@ -4548,8 +4647,7 @@ def test_alias_collision_is_rejected(self) -> None: """Two different names for one canonical provider are still one provider.""" for names, canonical in ( (["claude", "claude_code"], "claude"), - (["cursor", "grok_bot"], "cursor_bugbot"), - (["codex", "cursor_bugbot", "cursor"], "cursor_bugbot"), + (["cursor", "cursor_cloud_agent"], "cursor_cloud_agent"), ): with self.subTest(names=names): with self.assertRaises(ValueError) as ctx: @@ -4570,7 +4668,7 @@ def test_distinct_providers_are_accepted_and_canonicalized(self) -> None: providers=["claude", "codex", "cursor"], ) names = [p["provider"] for p in campaign.providers] - self.assertEqual(names, ["claude", "codex", "cursor_bugbot"]) + self.assertEqual(names, ["claude", "codex", "cursor_cloud_agent"]) keys = {p["idempotency_key"] for p in campaign.providers} self.assertEqual(len(keys), 3) result_files = { @@ -4578,6 +4676,130 @@ def test_distinct_providers_are_accepted_and_canonicalized(self) -> None: } self.assertEqual(len(result_files), 3) + def test_cursor_and_grok_are_now_distinct(self) -> None: + """cursor (builder) and grok_bot (reviewer) are now separate providers.""" + cursor_name, cursor_lane = release_campaigns.resolve_provider_lane("cursor") + grok_name, grok_lane = release_campaigns.resolve_provider_lane("grok_bot") + + # cursor resolves to cursor_cloud_agent (builder) + self.assertEqual(cursor_name, "cursor_cloud_agent") + self.assertEqual(cursor_lane.provider_config.get("capability"), "work_order_execution") + + # grok_bot resolves to cursor_bugbot (reviewer) + self.assertEqual(grok_name, "cursor_bugbot") + self.assertEqual(grok_lane.provider_config.get("capability"), "code_review") + + # They are distinct providers + self.assertNotEqual(cursor_name, grok_name) + + def test_cursor_cloud_agent_and_bugbot_alias_mappings(self) -> None: + """Verify all alias mappings match documentation.""" + # Cursor Cloud Agent (builder) aliases + for alias in ["cursor", "cursor_cloud_agent"]: + canonical, lane = release_campaigns.resolve_provider_lane(alias) + self.assertEqual(canonical, "cursor_cloud_agent", f"{alias} should resolve to cursor_cloud_agent") + self.assertEqual(lane.provider_config.get("role"), "builder") + self.assertEqual(lane.provider_config.get("capability"), "work_order_execution") + + # Cursor BugBot (reviewer) aliases + for alias in ["cursor_bugbot", "cursor_grok_bot", "grok_bot"]: + canonical, lane = release_campaigns.resolve_provider_lane(alias) + self.assertEqual(canonical, "cursor_bugbot", f"{alias} should resolve to cursor_bugbot") + self.assertEqual(lane.provider_config.get("role"), "reviewer") + self.assertEqual(lane.provider_config.get("capability"), "code_review") + + def test_stored_cursor_bugbot_campaigns_remain_cursor_bugbot(self) -> None: + """Existing stored cursor_bugbot campaigns are not reinterpreted as cursor_cloud_agent.""" + with tempfile.TemporaryDirectory() as tmp: + campaigns_dir = Path(tmp) / "campaigns" + campaigns_dir.mkdir(parents=True) + + # Create a legacy campaign with cursor_bugbot + legacy_campaign = { + "schema": release_campaigns.CAMPAIGN_SCHEMA, + "campaign_id": "campaign-v1.0.0", + "release_tag": "v1.0.0", + "package_spec": "code-mower==1.0.0", + "package_identity": "code-mower", + "normalized_version": "1.0.0", + "qualification_context": "cold_install", + "starting_version": "", + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "status": "queued", + "applied": False, + "providers": [ + { + "provider": "cursor_bugbot", + "state": "queued", + "idempotency_key": "campaign-v1.0.0_cursor_bugbot_cold_install", + "attempted_at": None, + "dispatched_at": None, + "completed_at": None, + "elapsed_seconds": None, + "error": None, + "next_action": "run with --apply to dispatch providers", + "dispatch_mode": "preview", + "dispatch_ref": None, + } + ], + } + + # Save the legacy campaign + campaign_file = campaigns_dir / "campaign-v1.0.0.json" + campaign_file.write_text(json.dumps(legacy_campaign, indent=2), encoding="utf-8") + + # Load it back and verify it stays cursor_bugbot + loaded = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert loaded is not None + self.assertEqual(loaded["providers"][0]["provider"], "cursor_bugbot") + + # Resume operations should preserve the provider identity + release_campaigns.campaign_command( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + campaigns_dir=campaigns_dir, + resume=True, + apply=False, + ) + + reloaded = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) + assert reloaded is not None + self.assertEqual(reloaded["providers"][0]["provider"], "cursor_bugbot") + + def test_initialize_campaign_rejects_reviewer_only_providers(self) -> None: + """New campaigns reject providers lacking work_order_execution capability.""" + reviewer_aliases = ["cursor_bugbot", "grok_bot", "cursor_grok_bot"] + + for alias in reviewer_aliases: + with self.assertRaises(ValueError) as cm: + release_campaigns.initialize_campaign( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=[alias], + ) + self.assertIn("review-only lane", str(cm.exception)) + self.assertIn("work_order_execution", str(cm.exception)) + self.assertIn("code_review", str(cm.exception)) + + # Builder providers should still be accepted + campaign = release_campaigns.initialize_campaign( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["cursor_cloud_agent"], + ) + self.assertEqual(len(campaign.providers), 1) + self.assertEqual(campaign.providers[0]["provider"], "cursor_cloud_agent") + + # Other providers without explicit capability should still be accepted + campaign = release_campaigns.initialize_campaign( + release_tag="v1.0.0", + package_spec="code-mower==1.0.0", + providers=["codex"], + ) + self.assertEqual(len(campaign.providers), 1) + self.assertEqual(campaign.providers[0]["provider"], "codex") + def test_default_provider_set_has_no_duplicates(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", @@ -4621,14 +4843,14 @@ class CampaignRepoSlugSupplyTests(unittest.TestCase): recorded. A slug that conflicts with a non-empty stored one is refused. """ - _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + _ENV = {"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"} def _create_without_slug(self, campaigns_dir: Path) -> dict[str, Any]: ret = release_campaigns.campaign_command( action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, apply=False, command_runner=mock.MagicMock(), @@ -4736,7 +4958,7 @@ def test_conflicting_slug_is_rejected_without_mutation_or_dispatch(self) -> None action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", apply=False, @@ -4807,14 +5029,14 @@ class CampaignQualificationContextSupplyTests(unittest.TestCase): before any mutation, polling, or dispatch. """ - _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + _ENV = {"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"} def _create_upgrade(self, campaigns_dir: Path) -> dict[str, Any]: ret = release_campaigns.campaign_command( action="create", release_tag="v1.1.0", package_spec="code-mower==1.1.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], qualification_context="upgrade", starting_version="1.0.0", campaigns_dir=campaigns_dir, @@ -4932,7 +5154,7 @@ def test_omitted_context_creates_cold_install_campaign(self) -> None: action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, apply=False, command_runner=mock.MagicMock(), @@ -4952,7 +5174,7 @@ def test_explicit_cold_install_creates_cold_install_campaign(self) -> None: action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], qualification_context="cold_install", campaigns_dir=campaigns_dir, apply=False, @@ -5049,14 +5271,14 @@ class CampaignPackageSourceSupplyTests(unittest.TestCase): `--qualification-context`. """ - _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + _ENV = {"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"} def _create_testpypi(self, campaigns_dir: Path) -> dict[str, Any]: ret = release_campaigns.campaign_command( action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], package_source="testpypi", campaigns_dir=campaigns_dir, repo_slug="owner/repo", @@ -5077,7 +5299,7 @@ def test_omitted_source_creates_pypi_campaign(self) -> None: action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, apply=False, command_runner=mock.MagicMock(), @@ -5232,15 +5454,7 @@ class PackageSourceBackwardCompatibilityTests(unittest.TestCase): def test_legacy_campaign_missing_the_field_is_read_as_pypi(self) -> None: with tempfile.TemporaryDirectory() as tmp: campaigns_dir = Path(tmp) / "campaigns" - campaigns_dir.mkdir(parents=True) - legacy = release_campaigns.initialize_campaign( - release_tag="v1.0.0", - package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], - repo_slug="owner/repo", - ).to_dict() - del legacy["package_source"] - release_campaigns.save_campaign(legacy, campaigns_dir) + ReleaseCampaignTests._create_stored_cursor_bugbot_campaign(campaigns_dir) loaded = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) assert loaded is not None @@ -5373,7 +5587,7 @@ def test_maintained_adapter_argv_template_includes_package_source(self) -> None: class HostedDispatchPackageSourceTests(unittest.TestCase): """Hosted dispatch instructions receive the same closed source contract.""" - _ENV = {"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"} + _ENV = {"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"} def test_pypi_dispatch_marker_and_body_name_the_default_source(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -5382,7 +5596,7 @@ def test_pypi_dispatch_marker_and_body_name_the_default_source(self) -> None: action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", apply=False, @@ -5423,7 +5637,7 @@ def test_testpypi_dispatch_marker_and_body_name_the_canonical_index_urls(self) - action="create", release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], package_source="testpypi", campaigns_dir=campaigns_dir, repo_slug="owner/repo", @@ -6095,7 +6309,7 @@ def _seed(campaigns_dir: Path) -> dict[str, Any]: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], ).to_dict() release_campaigns.save_campaign(campaign, campaigns_dir) return campaign @@ -6444,7 +6658,7 @@ def _seed( campaign = release_campaigns.initialize_campaign( release_tag=release_tag, package_spec=f"code-mower=={normalized}", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaign_id=campaign_id, repo_slug=repo_slug, ).to_dict() @@ -6763,7 +6977,7 @@ def _seed( campaign = release_campaigns.initialize_campaign( release_tag=release_tag, package_spec=f"code-mower=={normalized}", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaign_id=campaign_id, ).to_dict() release_campaigns.save_campaign(campaign, campaigns_dir) @@ -6972,7 +7186,7 @@ def _seed(campaigns_dir: Path) -> dict[str, Any]: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ).to_dict() release_campaigns.save_campaign(campaign, campaigns_dir) @@ -7092,7 +7306,7 @@ def test_status_with_retry_provider_does_not_silently_drop_the_retry(self) -> No with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): ret = release_campaigns.campaign_command( status=True, - retry_provider="cursor_bugbot", + retry_provider="cursor_cloud_agent", release_tag="v1.0.0", repo_slug="owner/repo", issue="99", @@ -7116,7 +7330,7 @@ def test_status_with_record_result_records_nothing(self) -> None: self._seed(campaigns_dir) result_path = Path(tmp) / "result.json" result_path.write_text( - json.dumps(_mock_adoption_result(provider="cursor_bugbot")), encoding="utf-8" + json.dumps(_mock_adoption_result(provider="cursor_cloud_agent")), encoding="utf-8" ) with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr( @@ -7127,7 +7341,7 @@ def test_status_with_record_result_records_nothing(self) -> None: release_tag="v1.0.0", campaigns_dir=campaigns_dir, record_result=result_path, - record_provider="cursor_bugbot", + record_provider="cursor_cloud_agent", ) self.assertEqual(ret, 1) stored = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) @@ -7141,7 +7355,7 @@ def test_status_with_record_result_records_nothing(self) -> None: release_tag="v1.0.0", campaigns_dir=campaigns_dir, record_result=result_path, - record_provider="cursor_bugbot", + record_provider="cursor_cloud_agent", ) self.assertEqual(ret, 0) stored = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) @@ -7211,7 +7425,7 @@ def _seed(campaigns_dir: Path) -> dict[str, Any]: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ).to_dict() release_campaigns.save_campaign(campaign, campaigns_dir) @@ -7260,7 +7474,7 @@ def recording_gh_json(args, **_kwargs): ret = release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="99", @@ -7521,11 +7735,11 @@ def _wrapper(campaign: Any, **extra: Any) -> dict[str, Any]: return { "schema": release_campaigns.RESULT_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "release_tag": "v1.0.0", "idempotency_key": campaign.providers[0]["idempotency_key"], "adoption_result": _mock_adoption_result( - release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass" + release_tag="v1.0.0", provider="cursor_cloud_agent", outcome="pass" ), **extra, } @@ -7535,7 +7749,7 @@ def _running_campaign(campaigns_dir: Path) -> Any: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -8124,14 +8338,14 @@ def mock_gh_json(args, **kwargs): release_campaigns.campaign_command( release_tag="v1.0.0", package_spec="code-mower==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], campaigns_dir=campaigns_dir, repo_slug="owner/repo", issue="42", apply=True, command_runner=_capturing_dispatch_command_runner(bodies), gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) # Cursor BugBot has trigger_comments, so 2 comments posted: dispatch + trigger self.assertEqual(len(bodies), 2) @@ -8146,7 +8360,7 @@ def mock_gh_json(args, **kwargs): campaigns_dir=campaigns_dir, resume=True, gh_json_runner=mock_gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) polled = release_campaigns.load_campaign_by_id("campaign-v1.0.0", campaigns_dir) @@ -8358,7 +8572,7 @@ def test_github_comment_result_is_bound_to_the_campaign_package(self) -> None: campaign = release_campaigns.initialize_campaign( release_tag="v1.0.0", package_spec="other-widget==1.0.0", - providers=["cursor_bugbot"], + providers=["cursor_cloud_agent"], repo_slug="owner/repo", ) campaign.status = "running" @@ -8367,13 +8581,13 @@ def test_github_comment_result_is_bound_to_the_campaign_package(self) -> None: release_campaigns.save_campaign(campaign, campaigns_dir) adoption_res = _mock_adoption_result( - release_tag="v1.0.0", provider="cursor_bugbot", outcome="pass" + release_tag="v1.0.0", provider="cursor_cloud_agent", outcome="pass" ) adoption_res["package_identity"] = identity wrapper = { "schema": release_campaigns.RESULT_MARKER_SCHEMA, "campaign_id": campaign.campaign_id, - "provider": "cursor_bugbot", + "provider": "cursor_cloud_agent", "release_tag": "v1.0.0", "idempotency_key": campaign.providers[0]["idempotency_key"], "adoption_result": adoption_res, @@ -10741,7 +10955,7 @@ def gh_json(args: list[str], **kwargs: Any) -> tuple[dict[str, Any], str]: time_fn=self.clock.time, sleep_fn=self.clock.sleep, gh_json_runner=gh_json, - env={"CURSOR_BUGBOT_AUDIT_LABEL_TOKEN": "token"}, + env={"CURSOR_CLOUD_AGENT_AUDIT_LABEL_TOKEN": "token"}, ) self.assertEqual(summary["stop_reason"], "complete") diff --git a/tests/test_release_qualify.py b/tests/test_release_qualify.py index e8d0e004..bef3b99a 100644 --- a/tests/test_release_qualify.py +++ b/tests/test_release_qualify.py @@ -3,6 +3,8 @@ from __future__ import annotations +import contextlib +import io import json import sys import tempfile @@ -19,6 +21,14 @@ class ReleaseQualifyTests(unittest.TestCase): """Tests for release qualification command.""" + def test_campaign_help_names_builder_default(self) -> None: + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout), self.assertRaises(SystemExit) as caught: + release_qualify.main(["campaign", "--help"]) + self.assertEqual(caught.exception.code, 0) + self.assertIn("cursor_cloud_agent", stdout.getvalue()) + self.assertNotIn("cursor_bugbot", stdout.getvalue()) + def test_safe_identifier_rejects_unsafe(self) -> None: """Provider/executor must be safe identifiers.""" with self.assertRaises(ValueError) as ctx: