Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion products/signals/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ Gates, in pipeline order:
| Stage | Where | Behavior when limited (enforced) |
| ------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ingestion | `check_signals_quota_limited_activity` (buffer flush) | Batch dropped before safety filter / flush (pre-existing gate) |
| scout runs | `run_signals_scout_activity` + manual-trigger API | Run skipped / trigger 429s (pre-existing gate) |
| `scout_run` | `run_signals_scout_activity` + manual-trigger API | Run skipped / trigger 429s. The manual path captures the pause itself, since a rejected trigger never reaches the activity |
| `promotion` | `assign_and_emit_signal_activity` | Signal still assigned, weighted, and emitted, but the report is not promoted — no summary run spawns; status left untouched |
| `summary_entry` | `SignalReportSummaryWorkflow`, before any work | Workflow exits; report stays `candidate` |
| `pre_repo_selection` / `pre_research` | `SignalReportSummaryWorkflow`, between the heavy steps | Workflow reverts the report `in_progress → candidate` and exits; an in-flight research **activity** is never interrupted |
Expand All @@ -1291,6 +1291,10 @@ Enforcement exists to stop runaway generation, not to guarantee billing capture,
A cancel that errors part-way is logged loudly (`self_driving_quota_cancel_failed`) rather than counted as a quota-check fail-open, and nothing is released:
a still-alive run gets the cancel retried on the next recheck, while a run the completion signal did reach keeps its implementation records until they are released manually.

**Scout suggestion scans are deliberately not gated.**
A suggestion scan (`arun_scout_suggestions`, and the `suggestions/refresh/` endpoint) opens no pull request, so it can never move the meter, and its output is a stored batch the scouts tab reads rather than signals the pipeline would drop.
Skipping one also costs more than it saves: the coordinator stamps `last_requested_at` at dispatch, so a skipped project waits the full `refresh_days` window even after its billing period resets.

**The pause is blanket: billing-exempt reports pause too.**
Every gate reads only the org-level quota verdict; none consults a report's `billing_exempt_reason`, so PostHog-system reports (health checks, engineering analytics, exempt scout skills) — which never consume the quota — pause alongside billable work while the org is over its cap.
This is deliberate: a single org-level contract ("over the cap → self-driving pauses") stays simple to reason about and support, the paused work is PostHog-generated and resumes organically once the limit lifts, and an org at its spend ceiling also stops consuming agent compute for non-billable reports.
Expand Down
2 changes: 1 addition & 1 deletion products/signals/backend/scout_harness/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ In production it is driven by `SignalsScoutCoordinatorWorkflow` (periodic tick e
- `config_registry.py`
`register_missing_configs(team_id)` — auto-creates an enabled, default-schedule `SignalScoutConfig` for any `signals-scout-*` skill lacking one ("author a skill, get a scout"). Called by the coordinator tick; the HTTP surface registers explicitly via the write-scoped config `create` endpoint instead (reads stay side-effect free). A canonical scout's `scout-tags` frontmatter (read through `lazy_seed.canonical_config_tags_for`) is stamped on the row it creates, which is how a product surface claims its scouts by tag instead of by hardcoded skill name — AI observability's self-driving tab lists the `ai-observability`-tagged configs. Forward-only like the rest of the seed posture: adding the key to an already-seeded scout leaves that team's existing config untagged until someone tags it through the config API.
- `suggestions.py` / `suggestions_runner.py`
Pre-computed scout suggestions: the push-side twin of the "Suggest a scout" chat button. `suggestions.py` (temporalio-free, cheap to import) holds the structured-output contract the headless run returns (`ScoutSuggestionBatch`: 3-5 items, each a `canonical` "turn this on" pick or a `custom` draft with a ready-to-create `draft_body`), the `signals-scout-suggestions` flag-payload settings (`enabled`, `eligibility_tier` 0-4 (0 = allowlist only), `refresh_days`, `max_children_per_tick`, allow/blocklist, failure breaker), the planner (`plan_suggestion_runs`: recomputes the priority queue from Postgres every tick; tier, then never-generated, then most overdue, then most recently engaged; engagement is a report view or rating, a scout a person turned on or off (`status_changed_by`), or a scout a person created (`created_by`), never a plain edit or a system touch; nothing is stored as a queue; engagement is read only for the teams already in the tier map, since `SignalReportAction.last_at` is deliberately unindexed and an unbounded filter on it scans the whole action history; a team past the failure breaker doubles its refresh interval per further failure, because the cooldown is shorter than the refresh window and so could never hold a scheduled retry back), draft validation (`validate_suggestion_items` drops anything the one-click Create could not apply as-is), and persistence on `SignalScoutSuggestionSet` (one row per team, items as JSON, every write under a row lock since each is a read-modify-write of the column; dismissals carry forward by `skill_name` and survive as hidden tombstones when a batch omits the name, compacted to the dismissal fields so a dropped draft body does not sit in the row forever; a batch persisted after the fleet moved is stored `stale`, and a batch that merely aged past `refresh_days`, whether it held items or concluded there was nothing to suggest, reads as `stale` through `effective_status`, since nothing writes that status on expiry; a failed generation anywhere after the gates keeps the prior items and counts toward the breaker; `mark_stale_if_fleet_changed` is wired to `SignalScoutConfig` saves in `../receivers.py`). Reads hide dismissed, created, and already-enabled items, plus custom drafts whose name a stored skill or config has since taken; the manual refresh endpoint honors the same kill switch and blocklist as the planner, runs the scan as its authenticated caller rather than the resolved team member, and a manual dispatch stamps `last_requested_at` so the coordinator does not double up. `suggestions_runner.py` is the one piece that needs the tasks agent facade: `arun_scout_suggestions` gates (AI-processing consent, signals quota, an acting user), mints a `read_only` headless task with the reserved `SIGNALS_SCOUT_SUGGESTIONS` origin, no GitHub token and no MCP Store servers, because the scan reads project text any member can write, parses the batch with no prose salvage (an unparseable close-out is a failed generation), validates, persists, and emits `$scout_suggestions_generated`. Driven by `temporal/agentic/scout_suggestions.py` (coordinator + child, same shape as the scout coordinator, own schedule) and exposed read/dismiss/refresh at `signals/scout/suggestions/` (`../scout_suggestions_api.py`), where membership, token scope, and resource-level RBAC all anchor to the canonical parent team. The prompt shares `SCOUT_PROJECT_SCAN_GUIDANCE` (in `prompt.py`) with the chat template so the two voices never drift.
Pre-computed scout suggestions: the push-side twin of the "Suggest a scout" chat button. `suggestions.py` (temporalio-free, cheap to import) holds the structured-output contract the headless run returns (`ScoutSuggestionBatch`: 3-5 items, each a `canonical` "turn this on" pick or a `custom` draft with a ready-to-create `draft_body`), the `signals-scout-suggestions` flag-payload settings (`enabled`, `eligibility_tier` 0-4 (0 = allowlist only), `refresh_days`, `max_children_per_tick`, allow/blocklist, failure breaker), the planner (`plan_suggestion_runs`: recomputes the priority queue from Postgres every tick; tier, then never-generated, then most overdue, then most recently engaged; engagement is a report view or rating, a scout a person turned on or off (`status_changed_by`), or a scout a person created (`created_by`), never a plain edit or a system touch; nothing is stored as a queue; engagement is read only for the teams already in the tier map, since `SignalReportAction.last_at` is deliberately unindexed and an unbounded filter on it scans the whole action history; a team past the failure breaker doubles its refresh interval per further failure, because the cooldown is shorter than the refresh window and so could never hold a scheduled retry back), draft validation (`validate_suggestion_items` drops anything the one-click Create could not apply as-is), and persistence on `SignalScoutSuggestionSet` (one row per team, items as JSON, every write under a row lock since each is a read-modify-write of the column; dismissals carry forward by `skill_name` and survive as hidden tombstones when a batch omits the name, compacted to the dismissal fields so a dropped draft body does not sit in the row forever; a batch persisted after the fleet moved is stored `stale`, and a batch that merely aged past `refresh_days`, whether it held items or concluded there was nothing to suggest, reads as `stale` through `effective_status`, since nothing writes that status on expiry; a failed generation anywhere after the gates keeps the prior items and counts toward the breaker; `mark_stale_if_fleet_changed` is wired to `SignalScoutConfig` saves in `../receivers.py`). Reads hide dismissed, created, and already-enabled items, plus custom drafts whose name a stored skill or config has since taken; the manual refresh endpoint honors the same kill switch and blocklist as the planner, runs the scan as its authenticated caller rather than the resolved team member, and a manual dispatch stamps `last_requested_at` so the coordinator does not double up. `suggestions_runner.py` is the one piece that needs the tasks agent facade: `arun_scout_suggestions` gates (AI-processing consent, an acting user; deliberately no self-driving credits gate, since a scan opens no pull request and so bills nothing, while a skip would still burn the team's whole refresh window), mints a `read_only` headless task with the reserved `SIGNALS_SCOUT_SUGGESTIONS` origin, no GitHub token and no MCP Store servers, because the scan reads project text any member can write, parses the batch with no prose salvage (an unparseable close-out is a failed generation), validates, persists, and emits `$scout_suggestions_generated`. Driven by `temporal/agentic/scout_suggestions.py` (coordinator + child, same shape as the scout coordinator, own schedule) and exposed read/dismiss/refresh at `signals/scout/suggestions/` (`../scout_suggestions_api.py`), where membership, token scope, and resource-level RBAC all anchor to the canonical parent team. The prompt shares `SCOUT_PROJECT_SCAN_GUIDANCE` (in `prompt.py`) with the chat template so the two voices never drift.
- `slack_delivery.py` / `slack_delivery_queue.py` / `slack_charts.py`
Best-effort direct Slack delivery for configured scout outputs. Finding emissions and surfaced report emits/edits snapshot the run config's destination after their database write commits, then enqueue the shared retrying Celery worker. The destination targets a single channel or up to `MAX_SCOUT_SLACK_DM_TARGETS` members who each get an individual DM (one Celery task per recipient, so retries and permanent failures stay independent; a group DM would need the `mpim:write` scope the Slack app doesn't request). Integrations are project-scoped, so a workspace connected from any environment in the project can receive the canonical parent team's scout output.
A report message renders its `charts` through the exports facade (`render_png_export`, attributed to the run's acting user, so the insight access check applies; a system render that expires with its delivery url, so it stays out of the user's export quota and does not outlive its only reference) and appends them as `image` blocks pointing at a signed delivery url; chart links in the prose still reduce to their label. A threaded report carries its charts on the lead message, since the replies only hold the summary's tail. Only `InsightVizNode` / `SavedInsightNode` charts render, capped per report and by a render-time budget (a render only starts if it can finish inside the budget), with rendered assets remembered per delivery so a Slack retry does not re-render, and every failure is skipped rather than failing the delivery — the whole chart build is best effort, not only the render. The Slack integration row is resolved again after the build, since a workspace reconnected during the render window replaces the token on the same row.
Expand Down
19 changes: 15 additions & 4 deletions products/signals/backend/scout_harness/run_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from products.signals.backend.daily_limit import daily_report_limit_gate
from products.signals.backend.models import SignalScoutRun
from products.signals.backend.quota import is_team_signals_quota_limited
from products.signals.backend.quota import capture_signal_report_quota_paused, self_driving_quota_gate
from products.signals.backend.scout_harness.limits import (
STALE_RUN_CUTOFF_S,
TRIGGERED_BY_WORKFLOW,
Expand Down Expand Up @@ -99,12 +99,23 @@ def check_fleet_gates(team_id: int) -> ScoutRunRejection | None:

def check_spend_gates(team: Team) -> ScoutRunRejection | None:
"""Fail fast on the two spend gates `run_signals_scout_activity` re-checks authoritatively, so
a caller gets a clean throttle instead of a 202 whose run only skips."""
if is_team_signals_quota_limited(team.api_token):
a caller gets a clean throttle instead of a 202 whose run only skips.

The quota side captures `signal_report_quota_paused` here rather than leaving it to the
activity: a rejection dispatches nothing, so this is the only place an off-schedule run's
pause is recorded. `team.organization` must be loaded for that capture.
"""
quota_gate = self_driving_quota_gate(team)
if quota_gate.limited:
capture_signal_report_quota_paused(team, report_id=None, stage="scout_run", enforced=quota_gate.enforced)
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit only one dark-launch pause per scout run

When a team is quota-limited but enforcement is disabled, this preflight captures signal_report_quota_paused and permits the manual or workflow-triggered run to dispatch. run_signals_scout_activity() then captures the same stage="scout_run", report_id=None, enforced=false event again in scout_scheduler.py:104-105, so off-schedule dark-launch runs count twice while scheduled runs count once. This skews the would-block telemetry used to evaluate rollout; emit from the preflight only when the run will not reach the activity, or otherwise deduplicate the observations.

Useful? React with 👍 / 👎.

if quota_gate.enforced:
return ScoutRunRejection(
kind=ScoutRunRejectionKind.THROTTLED,
reason="quota_limited",
detail="This project is over its Signals credits quota. Try again later.",
detail=(
"Self-driving is paused because this project reached its pull request limit. "
"Raise the limit or wait for usage to reset."
),
)
if daily_report_limit_gate(team).limited:
return ScoutRunRejection(
Expand Down
7 changes: 4 additions & 3 deletions products/signals/backend/scout_harness/suggestions_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from posthog.sync import database_sync_to_async

from products.signals.backend.agent_runtime import STEP_SCOUT_SUGGESTIONS, resolve_agent_runtime
from products.signals.backend.quota import is_team_signals_quota_limited
from products.signals.backend.scout_harness.config_registry import (
MAX_RUN_INTERVAL_MINUTES,
MIN_RUN_INTERVAL_MINUTES,
Expand Down Expand Up @@ -69,7 +68,7 @@
return True


def validate_suggestion_items(

Check warning on line 71 in products/signals/backend/scout_harness/suggestions_runner.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`validate_suggestion_items` has cyclomatic complexity 11 (warn >10)

Check warning on line 71 in products/signals/backend/scout_harness/suggestions_runner.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`validate_suggestion_items` has cyclomatic complexity 11 (warn >10)
items: list[ScoutSuggestionItem],
*,
enabled_skill_names: set[str],
Expand Down Expand Up @@ -133,10 +132,12 @@


def _gate_skip_reason(team: Team) -> str | None:
"""No self-driving credits gate here: a scan opens no pull request, so it bills nothing, and
the coordinator stamps `last_requested_at` at dispatch — a skip would cost the team its whole
refresh window for a limit the scan never charges against.
"""
if team.organization.is_ai_data_processing_approved is not True:
return "ai_data_processing_not_approved"
if is_team_signals_quota_limited(team.api_token):
return "quota_limited"
return None


Expand Down
10 changes: 6 additions & 4 deletions products/signals/backend/scout_harness/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2208,7 +2208,7 @@ def partial_update(self, request: Request, *args, **kwargs) -> Response:
404: OpenApiResponse(description="Config not found for this project (or the scout is withheld)."),
409: OpenApiResponse(description="A run for this scout is already in progress."),
429: OpenApiResponse(
description="The project is over its Signals credits quota, its daily report limit, or its daily scout run budget; try again later."
description="Self-driving is paused at the project's pull request limit, or the project is over its daily report limit or its daily scout run budget; try again later."
),
},
summary="Run a scout now",
Expand All @@ -2217,8 +2217,9 @@ def partial_update(self, request: Request, *args, **kwargs) -> Response:
"Useful to test a scout right after authoring it, or to refresh its findings on demand. "
"The run executes asynchronously on the worker and inherits every guard the scheduled "
"path has: it is forbidden if scouts are not enabled for the project (403), and skipped "
"if the project is over its Signals credits quota, daily report limit, or daily run "
"budget (429) or a run for this scout is already in progress (409). A manual run counts "
"if self-driving is paused at the project's pull request limit, or the project is over "
"its daily report limit or daily run budget (429), or a run for this scout is already "
"in progress (409). A manual run counts "
"against the same daily run "
"budget as scheduled runs, so repeated manual runs of the same scout can exhaust the "
"project's daily allowance. A manual run does not change the scout's schedule or "
Expand Down Expand Up @@ -2267,7 +2268,8 @@ def run(self, request: Request, *args, **kwargs) -> Response:
# single-flight in the runner and at the Temporal server), but rejecting here avoids
# dispatching a workflow that would only be skipped, and turns the common cases into clean
# 429/409 responses. Shared with the workflow-triggered path (see `run_gates`).
team = Team.objects.get(pk=team_id)
# `organization` is select_related for the spend gate's quota-pause capture.
team = Team.objects.select_related("organization").get(pk=team_id)
for rejection in (check_spend_gates(team), check_run_in_flight(team_id, skill_name)):
if rejection is not None:
_raise_rejection(rejection)
Expand Down
Loading
Loading