diff --git a/frontend/src/scenes/inbox/components/config/scouts/ScoutBadges.tsx b/frontend/src/scenes/inbox/components/config/scouts/ScoutBadges.tsx index 0df818441cdc..163982451936 100644 --- a/frontend/src/scenes/inbox/components/config/scouts/ScoutBadges.tsx +++ b/frontend/src/scenes/inbox/components/config/scouts/ScoutBadges.tsx @@ -1,27 +1,69 @@ import { LemonTag, Tooltip } from '@posthog/lemon-ui' -import type { ScoutOriginEnumApi } from 'products/signals/frontend/generated/api.schemas' +import { dayjs } from 'lib/dayjs' + +import type { + ScoutOriginEnumApi, + SignalScoutConfigApi as SignalScoutConfig, +} from 'products/signals/frontend/generated/api.schemas' /** - * Shown when a scout paused itself after failing repeatedly (`status=paused_by_system`, - * `pause_reason=repeated_failures`). Without it the pause is only visible in the API - * response, and a scout that has silently stopped looks the same as one a person turned off. + * Where the scout stands with the system writers that can pause it: the failure breaker + * (`repeated_failures`) or the inactivity sweep (`no_output` / `ignored`), plus the sweep's + * warning state (`pending_pause`). Without it a scout that has silently stopped looks the same + * as one a person turned off. Nothing renders for a healthy scout or a user pause. */ -export function ScoutPausedBadge(): JSX.Element { - return ( - - This scout paused itself because its last few runs all failed. It retries about once a day and - resumes its normal schedule on the first successful run. Turn it on to resume it right away. - - } - > - - Paused - - - ) +export function ScoutLifecycleBadge({ config }: { config: SignalScoutConfig }): JSX.Element | null { + if (config.status === 'paused_by_system') { + if (config.pause_reason === 'repeated_failures') { + return ( + + This scout paused itself because its last few runs all failed. It retries about once a day + and resumes its normal schedule on the first successful run. Turn it on to resume it right + away. + + } + > + + Paused + + + ) + } + if (config.pause_reason === 'no_output' || config.pause_reason === 'ignored') { + const pausedOn = config.status_changed_at + ? `Paused on ${dayjs(config.status_changed_at).format('MMMM D, YYYY')}` + : 'Paused' + const why = + config.pause_reason === 'ignored' + ? 'because nothing came of its recent reports' + : 'after two weeks without surfacing anything' + return ( + + + Paused + + + ) + } + return null + } + if (config.status === 'pending_pause') { + const why = + config.pause_reason === 'ignored' + ? "Nothing has come of this scout's recent reports, so it pauses in about a week unless that changes." + : "It hasn't surfaced anything in the last two weeks, so it pauses in about a week unless it finds something." + return ( + + + Quiet + + + ) + } + return null } /** Canonical (PostHog-maintained) vs Custom (team-authored) scout badge. */ diff --git a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx index 1b79a92754b4..6a93db5ee675 100644 --- a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx +++ b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.test.tsx @@ -21,6 +21,8 @@ const config: SignalScoutConfigApi = { output_destinations: {}, last_run_at: null, consecutive_failure_count: 0, + status_changed_at: null, + auto_pause_exempt: false, created_at: '2026-07-21T12:00:00Z', } diff --git a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.tsx b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.tsx index f2800e25d040..71d7ace2a174 100644 --- a/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.tsx +++ b/frontend/src/scenes/inbox/components/config/scouts/ScoutConfigControls.tsx @@ -135,6 +135,22 @@ export function ScoutConfigForm({ /> ) : null} +
+
+ Keep running while quiet + + A scout that goes two weeks without surfacing anything anyone uses is paused. Turn this on for a + scout whose job is to stay quiet. + +
+ onUpdate(config.id, { auto_pause_exempt: checked })} + aria-label={`${config.skill_name} keep running while quiet`} + /> +
onUpdate(config.id, { output_destinations: outputDestinations })} diff --git a/frontend/src/scenes/inbox/components/config/scouts/ScoutRowCard.tsx b/frontend/src/scenes/inbox/components/config/scouts/ScoutRowCard.tsx index d5d867d4f29c..b850e4365263 100644 --- a/frontend/src/scenes/inbox/components/config/scouts/ScoutRowCard.tsx +++ b/frontend/src/scenes/inbox/components/config/scouts/ScoutRowCard.tsx @@ -21,7 +21,7 @@ import { ScoutRollup, } from '../../../utils/scoutRunsWindow' import { agentSetupModalLogic } from '../../shell/agentSetupModalLogic' -import { ScoutOriginBadge, ScoutPausedBadge } from './ScoutBadges' +import { ScoutLifecycleBadge, ScoutOriginBadge } from './ScoutBadges' import { ScoutConfigForm, ScoutEnabledSwitch } from './ScoutConfigControls' import { ScoutRunBoxes } from './ScoutRunBoxes' @@ -129,9 +129,7 @@ export function ScoutRowCard({ - {config.status === 'paused_by_system' && config.pause_reason === 'repeated_failures' ? ( - - ) : null} +
diff --git a/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts b/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts index 1b7c4c03ace9..6ff4eaaa7da3 100644 --- a/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts +++ b/frontend/src/scenes/inbox/logics/scoutCreateModalLogic.test.ts @@ -41,6 +41,8 @@ const CREATED_SCOUT: SignalScoutCreateResponseApi = { output_destinations: {}, last_run_at: null, consecutive_failure_count: 0, + status_changed_at: null, + auto_pause_exempt: false, created_at: '2026-07-24T00:00:00Z', }, } diff --git a/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts b/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts index fb0d0aed3aa3..7a8c4581b2db 100644 --- a/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts +++ b/frontend/src/scenes/inbox/logics/scoutFleetLogic.test.ts @@ -46,6 +46,8 @@ const BASE_CONFIG: SignalScoutConfigApi = { output_destinations: {}, last_run_at: null, consecutive_failure_count: 0, + status_changed_at: null, + auto_pause_exempt: false, created_at: '2026-07-22T00:00:00Z', } diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index fae054030013..383457e70677 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -336,6 +336,7 @@ class Meta: "run_interval_minutes": "run interval (minutes)", "emit": "emit findings", "pause_reason": "pause reason", + "auto_pause_exempt": "never pause for inactivity", }, "OAuthApplication": { "_provisioning_config": "provisioning config", diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py index cc0726290faa..bc4ac9c23a20 100644 --- a/posthog/tasks/scheduled.py +++ b/posthog/tasks/scheduled.py @@ -97,7 +97,11 @@ from products.logs.backend.facade.tasks import logs_alert_events_cleanup_task from products.pulse.backend.tasks import mark_stale_pulse_briefs_failed from products.reminders.backend.tasks import process_due_reminders -from products.signals.backend.tasks import refresh_signal_repository_activity, sync_pending_signals_refund_credits +from products.signals.backend.tasks import ( + pause_inactive_signal_scouts, + refresh_signal_repository_activity, + sync_pending_signals_refund_credits, +) from products.stamphog.backend.facade.tasks import DAILY_DIGEST_CRONTAB, send_daily_digests from products.streamlit_apps.backend.facade.api import ( auto_restart_crashed_streamlit_sandboxes, @@ -311,6 +315,13 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None: name="sync pending signals refund credits", ) + # Warn, then pause signals scouts that produce nothing anyone uses - daily at 6:15 AM + sender.add_periodic_task( + crontab(hour="6", minute="15"), + pause_inactive_signal_scouts.s(), + name="pause inactive signals scouts", + ) + # Keep the signals repository area-activity cache warm - weekly, Monday early morning sender.add_periodic_task( crontab(day_of_week="mon", hour="5", minute="35"), diff --git a/products/signals/ARCHITECTURE.md b/products/signals/ARCHITECTURE.md index 163c08196798..0382a8cc26bc 100644 --- a/products/signals/ARCHITECTURE.md +++ b/products/signals/ARCHITECTURE.md @@ -547,6 +547,7 @@ Per-scout binding for the headless **Signals agent**: one row per `(team, skill_ | `pause_reason` | Char (nullable) | Why the system paused or warned: `no_output` / `ignored` / `repeated_failures`. Also names the writer that owns the pause: a system writer may only clear or overwrite a pause carrying its own reason (`transition_status_by_system()`). Null outside `pending_pause` / `paused_by_system`. | | `status_changed_at` | DateTime (nullable) | When `status` last changed. Anchors the cold-start grace window (`in_cold_start_grace()`): any move back to `active` re-anchors it, deliberately independent of the actor so the window survives account deletion. | | `status_changed_by` | FK → User (nullable) | The human behind the last status change; null on system transitions, so a human re-enable and a system resume are distinguishable and a system pause never reads as a person's action. | +| `auto_pause_exempt` | Boolean | Opt-out from the daily inactivity sweep (`scout_harness/inactivity.py`, the `no_output` / `ignored` writer), for watchdog scouts whose whole job is staying quiet. Defaults `False`. Also set automatically when a human re-enables a scout the sweep paused, so the sweep never overrules a person twice. | | `emit` | Boolean | Dry-run vs emit. Defaults `True`: a freshly authored scout is live from its first tick. Flip to `False` for dry-run — the scout runs and logs but `emit_finding` writes nothing — to validate it on a team before its findings reach the inbox. | | `run_interval_minutes` | PositiveInteger | Minutes between runs. The coordinator dispatches rolling schedules when `last_run_at is None or now - last_run_at >= run_interval_minutes`. Default `1440` (daily). Validated `30 <= N <= 43200`. | | `run_cron_schedule` | Char (nullable) | Optional five-field cron expression anchoring runs to wall-clock slots; takes precedence over `run_interval_minutes` when set. Interpreted in the project's timezone so daylight-saving changes are applied automatically. Occurrences must be ≥ 30 minutes apart. Null keeps the rolling interval behavior. | diff --git a/products/signals/backend/activity_logging.py b/products/signals/backend/activity_logging.py index 238a98121423..4a6868896852 100644 --- a/products/signals/backend/activity_logging.py +++ b/products/signals/backend/activity_logging.py @@ -18,6 +18,7 @@ changes_between, log_activity, ) +from posthog.models.activity_logging.model_activity import get_current_trigger from posthog.models.signals import model_activity_signal, mutable_receiver from posthog.models.user import User @@ -57,6 +58,9 @@ def handle_signal_scout_config_change( detail=Detail( changes=changes_between(scope, previous=before_update, current=after_update), name=instance.skill_name, + # Set by system-driven saves (the inactivity sweep) so an entry with no user reads as + # "this job did it" rather than as an unattributed edit. + trigger=get_current_trigger(), context=SignalScoutConfigContext(skill_name=instance.skill_name), ), ) diff --git a/products/signals/backend/migrations/0083_signalscoutconfig_auto_pause_exempt.py b/products/signals/backend/migrations/0083_signalscoutconfig_auto_pause_exempt.py new file mode 100644 index 000000000000..974e53b28c6f --- /dev/null +++ b/products/signals/backend/migrations/0083_signalscoutconfig_auto_pause_exempt.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("signals", "0082_validate_scout_status_constraints"), + ] + + operations = [ + migrations.AddField( + model_name="signalscoutconfig", + name="auto_pause_exempt", + field=models.BooleanField(db_default=False, default=False), + ), + ] diff --git a/products/signals/backend/migrations/max_migration.txt b/products/signals/backend/migrations/max_migration.txt index 8440e2ffd447..a7f55dffb602 100644 --- a/products/signals/backend/migrations/max_migration.txt +++ b/products/signals/backend/migrations/max_migration.txt @@ -1 +1 @@ -0082_validate_scout_status_constraints +0083_signalscoutconfig_auto_pause_exempt diff --git a/products/signals/backend/models.py b/products/signals/backend/models.py index b7ce00749f42..25134d28f656 100644 --- a/products/signals/backend/models.py +++ b/products/signals/backend/models.py @@ -1129,6 +1129,12 @@ class PauseReason(models.TextChoices): # scheduled by the coordinator. `pending_pause` still runs; the warning is not a pause. RUNNABLE_STATUSES = (Status.ACTIVE, Status.PENDING_PAUSE) + # The pause reasons the inactivity sweep owns (`scout_harness/inactivity.py`): `no_output` + # for a scout that surfaced nothing, `ignored` for one whose output nobody picked up. On the + # model rather than the sweep because the update serializer also reads them — a human + # re-enable of a pause carrying one of these marks the scout `auto_pause_exempt`. + INACTIVITY_PAUSE_REASONS = (PauseReason.NO_OUTPUT, PauseReason.IGNORED) + # How long a scout is treated as provisional after creation or a human re-enable, during # which system writers should leave it alone (`in_cold_start_grace`). COLD_START_GRACE = timedelta(days=14) @@ -1196,6 +1202,13 @@ class PauseReason(models.TextChoices): db_constraint=False, db_index=False, ) + # Opt-out from the inactivity sweep (`scout_harness/inactivity.py`) — a watchdog whose whole + # value is staying quiet (health checks, inbox validation) is *supposed* to surface nothing + # most weeks, so silence must never read as waste. Also set by the update serializer when a + # human re-enables a scout the sweep paused: that re-enable is a human overruling the rule, + # and the sweep must not undo it one window later. `db_default` alongside `default` keeps the + # AddField non-blocking and the column populated for writers that don't know about it yet. + auto_pause_exempt = models.BooleanField(default=False, db_default=False) # Dry-run vs emit. Defaults emit-on so a freshly authored scout is live from its first # tick. Flip to False for dry-run — the scout runs and logs but `emit_finding` writes # nothing — to validate it on a team before its findings reach the inbox. diff --git a/products/signals/backend/scout_harness/AGENTS.md b/products/signals/backend/scout_harness/AGENTS.md index 4a788cc7e292..ca533b4c283a 100644 --- a/products/signals/backend/scout_harness/AGENTS.md +++ b/products/signals/backend/scout_harness/AGENTS.md @@ -36,6 +36,11 @@ In production it is driven by `SignalsScoutCoordinatorWorkflow` (periodic tick e - `builders.py` — deterministic builders that compute the inventory payload for `SignalProjectProfile`. Sections fall into three layers: capability / configured (sticky — `products_in_use`, `integrations`, `external_data_sources`, `signal_source_configs`, `scout_fleet`, …), aggregated recency (`recent_activity` — per-scope counts off the activity log, cross-cutting orientation across every entity type), and per-entity recent inventory (`recent_surveys`, `recent_feature_flags`, `recent_experiments`, `recent_alerts`, `recent_hog_functions`, `recent_hog_flows`, `recent_notebooks`, `recent_cohorts`, `recent_actions`, `recent_dashboards`, `business_knowledge`). Per-entity sections are deliberately light (counts + 5 most-recent items with name, status, timestamp); deep drilldowns go via the per-entity MCP list tools. See the module docstring at `profile/builders.py` for the authoritative section list — when adding or renaming a section, bump `INVENTORY_SOURCE_VERSION` so the cache invalidates cleanly. +- `inactivity.py` + The stop switch on the waste axis (the failure breaker below covers the failure axis). `sweep_inactive_scouts()` warns, then auto-pauses any scout that goes `INACTIVITY_WINDOW` without either producing output on **any** of the three emit channels (`emitted_finding_ids` / `emitted_report_ids` / `edited_report_ids` — the finding tally alone would read every report-channel scout as silent) or having a **person** engage with a report it wrote earlier (a log artefact attributed to a user, or the report reaching a user-driven status — pipeline writers append log artefacts of their own, so unattributed ones don't count). + It speaks the scout lifecycle vocabulary rather than keeping fields of its own: the warning is `status=pending_pause` (still scheduled), the pause is `status=paused_by_system` (syncing `enabled=False`), both through `transition_status_by_system` with a `pause_reason` the sweep owns — `no_output` for a scout that surfaces nothing, `ignored` for one whose reports nobody picks up, separated because the two want different fixes — and the helper's reason-scoped ownership rule keeps the sweep and the failure breaker off each other's pauses. + Driven by the daily `pause_inactive_signal_scouts` Celery task rather than the coordinator tick, which stays short-lived and bounded. A dry-run scout, one inside `in_cold_start_grace()`, one that has barely run (`MIN_RUNS_IN_WINDOW`), and one flagged `auto_pause_exempt` (watchdogs whose value is staying quiet) are all left alone; new warnings are capped per sweep (`MAX_WARNS_PER_SWEEP`), which also bounds later pauses, since a pause only ever follows a warning by `WARNING_GRACE`. + Unlike the failure breaker there is no probe — an inactivity pause never runs again on its own. Re-enabling through the config API resumes the scout, re-anchors its grace window, and marks it `auto_pause_exempt` so the sweep never overrules a person twice; that resume also emits `signals_scout_auto_pause_reverted`, the sweep's false-positive metric (the warn and pause emit `signals_scout_auto_pause_warned` / `signals_scout_auto_paused`). - `limits.py` Runtime ceilings as module constants: `DEFAULT_MAX_RUNTIME_S` (per-run budget), `ACTIVITY_SLACK_S`, and `WORKFLOW_HARD_CEILING_S` (`= DEFAULT_MAX_RUNTIME_S + ACTIVITY_SLACK_S`, the activity-level ceiling that gates the workflow's `start_to_close_timeout`). Also the failure-streak circuit breaker's two knobs: `FAILURE_STREAK_PAUSE_THRESHOLD` (consecutive failed runs before the breaker pauses the lane) and `AUTO_PAUSE_PROBE_INTERVAL_S` (how long a paused lane holds before the coordinator dispatches one probe). diff --git a/products/signals/backend/scout_harness/inactivity.py b/products/signals/backend/scout_harness/inactivity.py new file mode 100644 index 000000000000..f0e469836b96 --- /dev/null +++ b/products/signals/backend/scout_harness/inactivity.py @@ -0,0 +1,310 @@ +"""Inactivity sweep: warn, then auto-pause scouts that produce nothing anyone uses. + +`SignalScoutConfig.enabled` only ever moves by hand, so a scout that surfaces nothing keeps +spending sandbox runs on its cadence indefinitely. This module is the missing stop: once a day +(`tasks.pause_inactive_signal_scouts`, deliberately not the 30-minute coordinator tick, which is +kept short-lived and bounded) it decides whether each enabled scout is still earning its runs. + +A scout counts as **productive** if either half holds over the window: + +- *output* — any run in the window recorded something on any of the three emit channels + (`emitted_finding_ids`, `emitted_report_ids`, `edited_report_ids`). All three matter: a + report-channel scout writes through `emit_report` / `edit_report`, so judging on the finding + tally alone would read every one of them as silent. +- *engagement* — a person acted on a report the scout wrote or edited before the window: they left a + log artefact on it (a note, a dismissal, a code reference…) inside the window, or the report + reached a state only a human action produces. Reports the scout touched + *inside* the window are excluded from this half — they are already covered by the output half, and + including them would count the scout's own writes as engagement with itself. Client-side opens + aren't persisted server-side, so they can't count either way. + +The sweep speaks the scout lifecycle vocabulary rather than keeping fields of its own: a warning is +`status=pending_pause` (still scheduled), the pause is `status=paused_by_system`, and both carry a +`pause_reason` the sweep owns — `no_output` (it surfaced nothing at all) or `ignored` (it surfaced +reports nobody picked up). Every write goes through `transition_status_by_system`, whose +reason-scoped ownership rule keeps this sweep and the failure breaker (`repeated_failures`) from +touching each other's pauses, and whose `evaluated_at` check makes a racing human edit win over a +sweep decision made on stale reads. There is no half-open probe on this axis: an inactivity pause +never runs again on its own — a human re-enable is the only exit, and the update serializer marks +that re-enable `auto_pause_exempt` so the sweep never overrules it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta + +from django.utils import timezone + +import structlog + +from posthog.models.activity_logging.activity_log import Trigger +from posthog.models.activity_logging.model_activity import ActivityTriggerContext + +from products.signals.backend.models import SignalReport, SignalReportArtefact, SignalScoutConfig, SignalScoutRun + +logger = structlog.get_logger(__name__) + +# How far back productivity is judged. Long enough that a daily scout is assessed on a couple of +# weeks of runs rather than a bad afternoon, short enough that the waste stops mattering in days. +INACTIVITY_WINDOW = timedelta(days=14) + +# Breathing room between the warning and the pause: the team gets a full week's notice on the fleet +# page, and a scout that surfaces something in the meantime clears its own warning. +WARNING_GRACE = timedelta(days=7) + +# A scout is only judged once it has actually had a fair number of attempts in the window. Guards +# the sparse cases — a monthly cron, or a scout whose team spent its daily budget elsewhere — where +# "no output" says more about how rarely it ran than about what it found. +MIN_RUNS_IN_WINDOW = 5 + +# How far back to look for the reports a scout has touched. Bounds the run scan on a table that grows +# a row per scout per interval forever; a report nobody engaged with in three months isn't about to +# rescue the scout that wrote it. +TOUCHED_REPORT_LOOKBACK = timedelta(days=90) + +# Blast-radius cap: new warnings issued per sweep. Most of the running fleet qualifies as inactive +# on this rule the day it ships, and a pause can only ever follow a warning by `WARNING_GRACE`, so +# capping warnings alone bounds the pauses each later sweep can land while keeping every warned +# scout's "pauses in a week" promise honest. Applied in iteration order (team id, then skill name) — +# deterministic, so the sweep works through the backlog rather than re-sampling it. Deferrals are +# counted and logged: a capped sweep must read as "more to do", never as a clean bill of health. +MAX_WARNS_PER_SWEEP = 200 + +# The two pause reasons this sweep owns, from the model (`INACTIVITY_PAUSE_REASONS`): `no_output` +# for a scout that surfaced nothing at all, `ignored` for one that surfaced reports nobody picked +# up. Separated because they call for different fixes — an ignored scout needs retuning, a silent +# one may be watching a surface this project doesn't have. The reason-scoped ownership rule means +# the sweep may only advance or recover a warning carrying one of these. +SWEEP_REASONS: frozenset[str] = frozenset(SignalScoutConfig.INACTIVITY_PAUSE_REASONS) + +# Artefact types that mean work was done on a report. Only ones attributed to a person count (see +# `_engaged_report_ids`). The status artefacts (safety / actionability / priority judgments, repo +# selection) are excluded outright — they are pipeline assessments, never a person's work. +_ENGAGEMENT_ARTEFACT_TYPES: frozenset[str] = SignalReportArtefact.LOG_ARTEFACT_TYPES | frozenset( + {SignalReportArtefact.ArtefactType.DISMISSAL} +) + +# Statuses no pipeline transition produces: archiving, resolving, and deleting a report are all +# user-driven, which is what makes "report is in this state, and moved recently" a durable +# engagement signal even when the action left no artefact behind. +_ENGAGED_REPORT_STATUSES: frozenset[str] = frozenset( + { + SignalReport.Status.SUPPRESSED, + SignalReport.Status.RESOLVED, + SignalReport.Status.DELETED, + } +) + +_SWEEP_JOB_TYPE = "signals_scout_inactivity_sweep" + + +@dataclass +class SweepOutcome: + """What one sweep did, for logging and analytics.""" + + considered: int = 0 + warned: list[SignalScoutConfig] = field(default_factory=list) + paused: list[SignalScoutConfig] = field(default_factory=list) + recovered: int = 0 + # Scouts that qualified for a warning after the per-sweep cap was already spent. They stay + # active and are re-derived by the next sweep — counted so a capped sweep is visibly partial. + deferred: int = 0 + + +def sweep_inactive_scouts(now: datetime | None = None) -> SweepOutcome: + """Warn or pause every enabled scout that produced nothing anyone engaged with. + + Idempotent: re-running the same day re-derives the same verdict from the runs and reports + themselves, a warning already in place is only advanced once `WARNING_GRACE` has actually + elapsed, and every transition goes through `transition_status_by_system`, which no-ops + repeats and refuses to touch state the sweep doesn't own. + """ + now = now or timezone.now() + outcome = SweepOutcome() + + # A fleet-wide sweep is genuinely cross-team, so it reads through the unscoped manager; every + # per-team query below is then keyed on the `team_id` carried by the config rows themselves. + candidates = ( + SignalScoutConfig.all_teams.filter( + enabled=True, + # A dry-run scout emits nothing by design, so the productivity test can't say anything + # about it — `emit_finding` is a no-op there and never records output on the run. + emit=True, + auto_pause_exempt=False, + # Cheap SQL prefilter for the common case; `in_cold_start_grace()` below is the real + # gate and also covers the re-anchor on a resume, which this filter can't see. + created_at__lte=now - SignalScoutConfig.COLD_START_GRACE, + ) + .order_by("team_id", "skill_name") + .iterator() + ) + by_team: dict[int, list[SignalScoutConfig]] = {} + for config in candidates: + if config.in_cold_start_grace(): + continue + by_team.setdefault(config.team_id, []).append(config) + + for team_id, configs in by_team.items(): + outcome.considered += len(configs) + try: + assessment = _assess_team(team_id, [c.skill_name for c in configs], now) + productive, judgeable, has_past_output = assessment + except Exception: + # One team's data problem must not cost the rest of the fleet its sweep. + logger.exception("signals_scout inactivity sweep: team assessment failed", team_id=team_id) + continue + for config in configs: + sweep_warned = ( + config.status == SignalScoutConfig.Status.PENDING_PAUSE and config.pause_reason in SWEEP_REASONS + ) + if config.skill_name in productive: + # It surfaced something again — drop the pending warning so a productive scout is + # never one quiet fortnight away from a pause it already worked off. + if sweep_warned and _transition( + config, SignalScoutConfig.Status.ACTIVE, config.pause_reason, evaluated_at=now + ): + outcome.recovered += 1 + continue + if config.skill_name not in judgeable: + continue + if config.status == SignalScoutConfig.Status.ACTIVE: + if len(outcome.warned) >= MAX_WARNS_PER_SWEEP: + outcome.deferred += 1 + continue + # Two shapes of the same waste, separated because they call for different fixes: + # a scout whose reports nobody picks up needs retuning, one that finds nothing at + # all may be watching a surface this project doesn't have. + reason = ( + SignalScoutConfig.PauseReason.IGNORED + if config.skill_name in has_past_output + else SignalScoutConfig.PauseReason.NO_OUTPUT + ) + if _transition(config, SignalScoutConfig.Status.PENDING_PAUSE, reason, evaluated_at=now): + outcome.warned.append(config) + logger.info( + "signals_scout inactivity sweep: warned", + team_id=config.team_id, + skill_name=config.skill_name, + reason=config.pause_reason, + ) + elif sweep_warned: + # The pause carries the warned reason rather than reclassifying: a scout that + # produced anything since the warning recovered above, so the classification can + # only have moved by leaving the sweep's jurisdiction entirely. + warned_at = config.status_changed_at + if warned_at is None or now - warned_at < WARNING_GRACE: + continue + if _transition( + config, SignalScoutConfig.Status.PAUSED_BY_SYSTEM, config.pause_reason, evaluated_at=now + ): + outcome.paused.append(config) + logger.info( + "signals_scout inactivity sweep: paused", + team_id=config.team_id, + skill_name=config.skill_name, + reason=config.pause_reason, + ) + + return outcome + + +def _transition( + config: SignalScoutConfig, + new_status: SignalScoutConfig.Status, + reason: str | None, + *, + evaluated_at: datetime, +) -> bool: + """One sweep transition, attributed to the sweep in the activity log. + + No acting user: the sweep is the actor, so the activity entry carries the job trigger + rather than being pinned on whoever happened to enable the scout months ago. + """ + if reason is None: + return False + trigger = Trigger( + job_type=_SWEEP_JOB_TYPE, + job_id=str(config.id), + payload={"skill_name": config.skill_name, "reason": reason}, + ) + with ActivityTriggerContext(trigger): + return config.transition_status_by_system( + new_status, + pause_reason=SignalScoutConfig.PauseReason(reason), + evaluated_at=evaluated_at, + ) + + +def _assess_team(team_id: int, skill_names: list[str], now: datetime) -> tuple[set[str], set[str], set[str]]: + """Return `(productive, judgeable, has_past_output)` skill-name sets for one team. + + Judgeable means the scout ran often enough in the window for "it found nothing" to mean + anything; productive means it passed either half of the productivity test; has_past_output + means it wrote reports before the window, which is what separates an ignored scout from one + that never surfaces anything. + """ + window_start = now - INACTIVITY_WINDOW + runs = ( + SignalScoutRun.objects.for_team(team_id) + .filter( + skill_name__in=skill_names, + created_at__gte=now - TOUCHED_REPORT_LOOKBACK, + ) + .values_list("skill_name", "created_at", "emitted_finding_ids", "emitted_report_ids", "edited_report_ids") + ) + + runs_in_window: dict[str, int] = {} + productive: set[str] = set() + # Reports each scout touched *before* the window — the only ones the engagement half reads, so a + # scout's own in-window writes can't be mistaken for someone engaging with them. + touched_before_window: dict[str, set[str]] = {} + for skill_name, created_at, finding_ids, report_ids, edited_ids in runs: + emitted_anything = bool(finding_ids) or bool(report_ids) or bool(edited_ids) + if created_at >= window_start: + runs_in_window[skill_name] = runs_in_window.get(skill_name, 0) + 1 + if emitted_anything: + productive.add(skill_name) + continue + touched = {str(report_id) for report_id in (report_ids or []) + (edited_ids or [])} + if touched: + touched_before_window.setdefault(skill_name, set()).update(touched) + + judgeable = {name for name, count in runs_in_window.items() if count >= MIN_RUNS_IN_WINDOW} + pending = { + name: reports for name, reports in touched_before_window.items() if name in judgeable and name not in productive + } + if pending: + engaged = _engaged_report_ids(team_id, set().union(*pending.values()), window_start) + productive.update(name for name, reports in pending.items() if reports & engaged) + return productive, judgeable, set(touched_before_window) + + +def _engaged_report_ids(team_id: int, report_ids: set[str], window_start: datetime) -> set[str]: + """Of `report_ids`, the ones a person acted on since `window_start`.""" + if not report_ids: + return set() + engaged = { + str(report_id) + for report_id in SignalReportArtefact.objects.filter( + team_id=team_id, + report_id__in=report_ids, + type__in=_ENGAGEMENT_ARTEFACT_TYPES, + # Attributed to a person. Pipeline writers attribute to a task or to the system (both + # NULL here), and several of them append log artefacts on their own — grouping writes a + # symmetric `related_to` when a resolved report recurs, autostart writes `task_run` — so + # counting those would let a scout keep itself alive with no human anywhere in the loop. + created_by__isnull=False, + created_at__gte=window_start, + ).values_list("report_id", flat=True) + } + engaged |= { + str(report_id) + for report_id in SignalReport.objects.filter( + team_id=team_id, + id__in=report_ids, + status__in=_ENGAGED_REPORT_STATUSES, + updated_at__gte=window_start, + ).values_list("id", flat=True) + } + return engaged diff --git a/products/signals/backend/scout_harness/serializers.py b/products/signals/backend/scout_harness/serializers.py index f114e236c7c6..37bb60d4879a 100644 --- a/products/signals/backend/scout_harness/serializers.py +++ b/products/signals/backend/scout_harness/serializers.py @@ -13,12 +13,15 @@ from django.utils import timezone +import structlog +import posthoganalytics from croniter import croniter from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import PermissionDenied +from posthog.event_usage import groups from posthog.models.integration import Integration from posthog.permissions import get_authenticator_scopes @@ -45,6 +48,8 @@ ) from products.skills.backend.models.skills import LLMSkill +logger = structlog.get_logger(__name__) + # --- Run history ----------------------------------------------------------- @@ -1958,6 +1963,24 @@ class SignalScoutConfigSerializer(serializers.ModelSerializer): "about once a day; a successful retry resumes it, and so does setting `enabled=true`." ), ) + status_changed_at = serializers.DateTimeField( + read_only=True, + allow_null=True, + help_text=( + "When `status` last changed. For `pending_pause` this is when the warning was issued " + "(the pause lands about a week later unless the scout surfaces something); for the " + "paused statuses it is when the scout was paused. Null if the status never changed." + ), + ) + auto_pause_exempt = serializers.BooleanField( + read_only=True, + help_text=( + "Whether this scout is exempt from the inactivity pause. Set it on watchdog scouts whose " + "value is staying quiet, so silence is never read as waste. Also set automatically when " + "someone re-enables a scout the inactivity sweep paused, so the sweep never overrules a " + "person twice." + ), + ) @extend_schema_field(OpenApiTypes.STR) def get_description(self, obj: SignalScoutConfig) -> str: @@ -1989,6 +2012,8 @@ class Meta: "output_destinations", "last_run_at", "consecutive_failure_count", + "status_changed_at", + "auto_pause_exempt", "created_at", ] read_only_fields = ["id", "created_at"] @@ -2019,6 +2044,44 @@ def _validate_run_cron_schedule(value: str) -> str: return expr +def _capture_auto_pause_reverted( + config: SignalScoutConfig, + *, + reason: str | None, + paused_at: datetime | None, +) -> None: + """Emit the sweep's false-positive signal: a human re-enabled an inactivity-paused scout. + + `hours_since_pause` is the number to watch — a re-enable within a day of the pause means + the sweep paused something someone still wanted, no complaint required. Companion to the + `signals_scout_auto_paused` event the sweep itself emits. + """ + try: + organization = config.team.organization + hours_since_pause = ( + round((timezone.now() - paused_at).total_seconds() / 3600, 1) if paused_at is not None else None + ) + posthoganalytics.capture( + event="signals_scout_auto_pause_reverted", + distinct_id=str(organization.id), + properties={ + "team_id": config.team_id, + "organization_id": str(organization.id), + "skill_name": config.skill_name, + "pause_reason": reason, + "hours_since_pause": hours_since_pause, + "reverted_within_24h": hours_since_pause is not None and hours_since_pause <= 24, + }, + groups=groups(organization=organization), + ) + except Exception: + logger.warning( + "signals_scout: failed to capture auto-pause revert analytics event", + team_id=config.team_id, + skill_name=config.skill_name, + ) + + class SignalScoutConfigUpdateSerializer(serializers.ModelSerializer): """Editable schedule, enablement, and emit posture for one scout config.""" @@ -2057,6 +2120,13 @@ class SignalScoutConfigUpdateSerializer(serializers.ModelSerializer): required=False, help_text="Destinations that receive each finding or report this scout emits. Pass an empty object to disable delivery.", ) + auto_pause_exempt = serializers.BooleanField( + required=False, + help_text=( + "Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is " + "staying quiet, so silence is never read as waste." + ), + ) def validate_run_cron_schedule(self, value: str | None) -> str | None: return _validate_run_cron_schedule(value) if value is not None else None @@ -2098,6 +2168,18 @@ def update(self, instance: SignalScoutConfig, validated_data: dict) -> SignalSco target = SignalScoutConfig.Status.ACTIVE else: target = None + # A re-enable of an inactivity pause is a human overruling the sweep, and the sweep must + # never overrule them back: the same quiet fortnight that triggered the pause would + # otherwise re-qualify the scout the moment its fresh grace window lapses. Marking it + # exempt (unless the caller set the flag explicitly in the same request) makes the + # exemption visible and reversible where a hidden marker would not be. + reverted_reason = instance.pause_reason + reverted_paused_at = instance.status_changed_at + resumed_from_inactivity_pause = ( + target == SignalScoutConfig.Status.ACTIVE + and instance.status == SignalScoutConfig.Status.PAUSED_BY_SYSTEM + and instance.pause_reason in SignalScoutConfig.INACTIVITY_PAUSE_REASONS + ) if target is not None and target != instance.status: request = self.context.get("request") validated_data["status"] = target @@ -2108,11 +2190,25 @@ def update(self, instance: SignalScoutConfig, validated_data: dict) -> SignalSco # Same rule as `transition_status_by_system`: a resume starts with a clean # failure streak, or the next failed run re-trips the breaker off stale evidence. validated_data["consecutive_failure_count"] = 0 - return super().update(instance, validated_data) + if resumed_from_inactivity_pause: + validated_data.setdefault("auto_pause_exempt", True) + updated = super().update(instance, validated_data) + if resumed_from_inactivity_pause: + # The false-positive metric for the sweep: a re-enable soon after the pause means the + # rule paused something someone still wanted. Best-effort — never fail the resume. + _capture_auto_pause_reverted(updated, reason=reverted_reason, paused_at=reverted_paused_at) + return updated class Meta: model = SignalScoutConfig - fields = ["enabled", "emit", "run_interval_minutes", "run_cron_schedule", "output_destinations"] + fields = [ + "enabled", + "emit", + "run_interval_minutes", + "run_cron_schedule", + "output_destinations", + "auto_pause_exempt", + ] class SignalScoutConfigOptionsSerializer(serializers.Serializer): @@ -2139,6 +2235,14 @@ class SignalScoutConfigOptionsSerializer(serializers.Serializer): required=False, help_text="Destinations that receive each finding or report this scout emits. Empty by default.", ) + auto_pause_exempt = serializers.BooleanField( + required=False, + help_text=( + "Exempt this scout from the inactivity pause, which otherwise switches off a scout that " + "goes a fortnight without surfacing anything anyone engages with. Set it on watchdog " + "scouts whose value is staying quiet. Defaults to false." + ), + ) run_cron_schedule = serializers.CharField( required=False, allow_null=True, diff --git a/products/signals/backend/tasks.py b/products/signals/backend/tasks.py index 053827e99c26..dd123e625a91 100644 --- a/products/signals/backend/tasks.py +++ b/products/signals/backend/tasks.py @@ -31,6 +31,7 @@ rebuild_repository_activity, repository_activity_needs_rebuild, ) +from products.signals.backend.scout_harness.inactivity import sweep_inactive_scouts from products.signals.backend.scout_harness.slack_delivery import ( ScoutSlackOutputType, ScoutSlackPermanentDeliveryError, @@ -501,6 +502,60 @@ def rebuild_signal_repository_activity(team_id: int, repository: str, force: boo cache.delete(lock_key) +@shared_task( + name="products.signals.backend.tasks.pause_inactive_signal_scouts", + ignore_result=True, + max_retries=0, +) +@skip_team_scope_audit +def pause_inactive_signal_scouts() -> None: + """Daily sweep: warn, then auto-pause scouts nothing comes of. + + Runs here rather than on the coordinator's 30-minute tick — that tick is deliberately + short-lived and bounded, and inactivity doesn't change by the half hour. See + `scout_harness/inactivity.py` for what counts as productive. + """ + outcome = sweep_inactive_scouts() + logger.info( + "signals_scout inactivity sweep finished", + considered=outcome.considered, + warned=len(outcome.warned), + paused=len(outcome.paused), + recovered=outcome.recovered, + deferred=outcome.deferred, + ) + if not outcome.warned and not outcome.paused: + return + # The fleet's spend was measured from analytics in the first place, so report the sweep the same + # way — it's how we'll tell whether the pauses are landing on the scouts we meant. + touched = outcome.warned + outcome.paused + organizations = { + team.id: team.organization + for team in Team.objects.filter(id__in={config.team_id for config in touched}).select_related("organization") + } + with ph_scoped_capture() as capture: + for event, configs in ( + ("signals_scout_auto_pause_warned", outcome.warned), + ("signals_scout_auto_paused", outcome.paused), + ): + for config in configs: + organization = organizations.get(config.team_id) + if organization is None: + continue + capture( + distinct_id=str(organization.id), + event=event, + properties={ + "team_id": config.team_id, + "organization_id": str(organization.id), + "skill_name": config.skill_name, + "run_interval_minutes": config.run_interval_minutes, + "pause_reason": config.pause_reason, + }, + groups=groups(organization=organization), + ) + + @shared_task( name="products.signals.backend.tasks.refresh_signal_repository_activity", ignore_result=True, diff --git a/products/signals/backend/test/test_scout_harness_api.py b/products/signals/backend/test/test_scout_harness_api.py index bf062201be36..7bf493094d90 100644 --- a/products/signals/backend/test/test_scout_harness_api.py +++ b/products/signals/backend/test/test_scout_harness_api.py @@ -1441,6 +1441,92 @@ def test_create_upsert_on_existing_config_resets_the_failure_streak(self) -> Non assert config.consecutive_failure_count == 0 assert config.run_interval_minutes == 120 + @parameterized.expand( + [ + ("default_marks_exempt", {"enabled": True}, True), + ("explicit_false_wins", {"enabled": True, "auto_pause_exempt": False}, False), + ] + ) + def test_re_enabling_an_inactivity_paused_scout_marks_it_exempt( + self, _name: str, payload: dict, expected_exempt: bool + ) -> None: + # A re-enable is a human overruling the sweep, and the sweep must not overrule them back: + # the same quiet fortnight would re-qualify the scout as soon as its grace window lapses. + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=False, + status=SignalScoutConfig.Status.PAUSED_BY_SYSTEM, + pause_reason=SignalScoutConfig.PauseReason.NO_OUTPUT, + ) + + response = self.client.patch(self._detail_url(str(config.id)), data=payload, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_pause_exempt"] is expected_exempt + config.refresh_from_db() + assert config.enabled is True + assert config.status == SignalScoutConfig.Status.ACTIVE + assert config.pause_reason is None + assert config.auto_pause_exempt is expected_exempt + + def test_re_enabling_an_inactivity_paused_scout_emits_the_revert_event(self) -> None: + # The sweep's false-positive metric: a re-enable soon after the pause means the rule + # paused something someone still wanted. + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=False, + status=SignalScoutConfig.Status.PAUSED_BY_SYSTEM, + pause_reason=SignalScoutConfig.PauseReason.IGNORED, + status_changed_at=timezone.now() - timedelta(hours=2), + ) + + with patch("products.signals.backend.scout_harness.serializers.posthoganalytics.capture") as capture: + response = self.client.patch(self._detail_url(str(config.id)), data={"enabled": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + (call,) = [c for c in capture.call_args_list if c.kwargs.get("event") == "signals_scout_auto_pause_reverted"] + assert call.kwargs["properties"]["pause_reason"] == SignalScoutConfig.PauseReason.IGNORED + assert call.kwargs["properties"]["reverted_within_24h"] is True + + def test_re_enabling_a_breaker_paused_scout_does_not_mark_it_exempt(self) -> None: + # The exemption belongs to the inactivity sweep; a re-enable after repeated failures says + # nothing about whether the scout's silence is wanted. + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=False, + status=SignalScoutConfig.Status.PAUSED_BY_SYSTEM, + pause_reason=SignalScoutConfig.PauseReason.REPEATED_FAILURES, + ) + + response = self.client.patch(self._detail_url(str(config.id)), data={"enabled": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + config.refresh_from_db() + assert config.status == SignalScoutConfig.Status.ACTIVE + assert config.auto_pause_exempt is False + + def test_exempting_a_scout_clears_its_pending_warning(self) -> None: + # Exempting takes the scout out of the sweep, so nothing else would ever clear the + # warning and the fleet page would keep saying it's about to pause. + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + status=SignalScoutConfig.Status.PENDING_PAUSE, + pause_reason=SignalScoutConfig.PauseReason.NO_OUTPUT, + ) + + response = self.client.patch(self._detail_url(str(config.id)), data={"auto_pause_exempt": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_pause_exempt"] is True + config.refresh_from_db() + assert config.auto_pause_exempt is True + assert config.status == SignalScoutConfig.Status.ACTIVE + assert config.pause_reason is None + def test_resending_enabled_false_does_not_escalate_a_system_pause(self) -> None: # Clients resend whole config objects; an unchanged `enabled=false` must not convert # a system pause into a user pause the system may never resume. diff --git a/products/signals/backend/test/test_scout_inactivity.py b/products/signals/backend/test/test_scout_inactivity.py new file mode 100644 index 000000000000..3b5d175c3de9 --- /dev/null +++ b/products/signals/backend/test/test_scout_inactivity.py @@ -0,0 +1,296 @@ +from datetime import timedelta +from typing import Any + +from posthog.test.base import BaseTest +from unittest.mock import patch + +from django.apps import apps +from django.utils import timezone + +from parameterized import parameterized + +from posthog.models.activity_logging.activity_log import ActivityLog + +from products.signals.backend.models import SignalReport, SignalReportArtefact, SignalScoutConfig, SignalScoutRun +from products.signals.backend.scout_harness.config_registry import register_missing_configs +from products.signals.backend.scout_harness.inactivity import ( + INACTIVITY_WINDOW, + MIN_RUNS_IN_WINDOW, + WARNING_GRACE, + sweep_inactive_scouts, +) +from products.skills.backend.models.skills import LLMSkill + +SKILL = "signals-scout-quiet" + + +class TestScoutInactivitySweep(BaseTest): + def setUp(self) -> None: + super().setUp() + self.now = timezone.now() + self.config = self._config() + + def _config(self, skill_name: str = SKILL, **overrides: Any) -> SignalScoutConfig: + config = SignalScoutConfig.objects.create(team=self.team, skill_name=skill_name, **overrides) + # `created_at` is auto_now_add, so age the row past the cold-start grace out of band. + SignalScoutConfig.all_teams.filter(pk=config.pk).update( + created_at=self.now - SignalScoutConfig.COLD_START_GRACE - timedelta(days=1) + ) + config.refresh_from_db() + return config + + def _runs( + self, count: int, *, age: timedelta, config: SignalScoutConfig | None = None, **output: Any + ) -> list[SignalScoutRun]: + config = config or self.config + Task = apps.get_model("tasks", "Task") + TaskRun = apps.get_model("tasks", "TaskRun") + runs = [] + for _ in range(count): + task = Task.objects.create( + team=self.team, + title="scout run", + description="scout run", + origin_product=Task.OriginProduct.SIGNALS_SCOUT, + ) + run = SignalScoutRun.objects.create( + task_run=TaskRun.objects.create(task=task, team=self.team), + team=self.team, + scout_config=config, + skill_name=config.skill_name, + skill_version=1, + **output, + ) + SignalScoutRun.all_teams.filter(pk=run.pk).update(created_at=self.now - age) + runs.append(run) + return runs + + def _report(self) -> SignalReport: + return SignalReport.objects.create(team=self.team, title="A report", summary="Something") + + def _silent_runs(self, config: SignalScoutConfig | None = None) -> None: + # Recent enough that the runs stay inside the assessment window at the post-grace pause + # sweep too (the transition helper stamps wall-clock time, so tests sweep a little past + # the exact grace boundary — see `_pause_time`). + self._runs(MIN_RUNS_IN_WINDOW, age=INACTIVITY_WINDOW / 4, config=config) + + @property + def _pause_time(self) -> Any: + return self.now + WARNING_GRACE + timedelta(hours=1) + + def _reload(self) -> SignalScoutConfig: + return SignalScoutConfig.all_teams.get(pk=self.config.pk) + + def test_silent_scout_is_warned_then_paused_after_the_grace_period(self) -> None: + self._silent_runs() + + outcome = sweep_inactive_scouts(now=self.now) + warned = self._reload() + assert [c.pk for c in outcome.warned] == [self.config.pk] + assert warned.enabled is True + assert warned.status == SignalScoutConfig.Status.PENDING_PAUSE + assert warned.pause_reason == SignalScoutConfig.PauseReason.NO_OUTPUT + + # Still silent a day later: warned, not yet due to pause. + assert not sweep_inactive_scouts(now=self.now + timedelta(days=1)).paused + + outcome = sweep_inactive_scouts(now=self._pause_time) + paused = self._reload() + assert [c.pk for c in outcome.paused] == [self.config.pk] + assert paused.enabled is False + assert paused.status == SignalScoutConfig.Status.PAUSED_BY_SYSTEM + assert paused.pause_reason == SignalScoutConfig.PauseReason.NO_OUTPUT + + def test_a_scout_whose_older_reports_went_unread_is_paused_as_ignored(self) -> None: + # Separated from `no_output` because the two want different fixes: retune a scout whose + # reports nobody picks up, retire one that never finds anything. + report = self._report() + self._runs(1, age=INACTIVITY_WINDOW + timedelta(days=5), emitted_report_ids=[str(report.id)]) + self._silent_runs() + + sweep_inactive_scouts(now=self.now) + assert self._reload().pause_reason == SignalScoutConfig.PauseReason.IGNORED + sweep_inactive_scouts(now=self._pause_time) + + paused = self._reload() + assert paused.status == SignalScoutConfig.Status.PAUSED_BY_SYSTEM + assert paused.pause_reason == SignalScoutConfig.PauseReason.IGNORED + + def test_sweep_transitions_are_activity_logged_without_pinning_them_on_a_user(self) -> None: + self._silent_runs() + ActivityLog.objects.filter(scope="SignalScoutConfig").delete() + + sweep_inactive_scouts(now=self.now) + sweep_inactive_scouts(now=self._pause_time) + + entries = list(ActivityLog.objects.filter(scope="SignalScoutConfig", item_id=str(self.config.id))) + assert len(entries) == 2 + for entry in entries: + assert entry.user is None + detail = entry.detail or {} + assert detail.get("trigger", {}).get("job_type") == "signals_scout_inactivity_sweep" + + @parameterized.expand( + [ + ("findings", {"emitted_finding_ids": ["finding-1"], "emitted_count": 1}), + ("reports", {"emitted_report_ids": ["a3f0a1de-0000-4000-8000-000000000001"]}), + ("edits", {"edited_report_ids": ["a3f0a1de-0000-4000-8000-000000000002"]}), + ] + ) + def test_output_on_any_emit_channel_keeps_a_scout_running(self, _name: str, output: dict) -> None: + # `emitted_count` covers only `emit_finding`, so judging on it alone would read every + # report-channel scout as silent and pause it. + self._runs(MIN_RUNS_IN_WINDOW - 1, age=INACTIVITY_WINDOW / 2) + self._runs(1, age=INACTIVITY_WINDOW / 2, **output) + + outcome = sweep_inactive_scouts(now=self.now) + + assert outcome.warned == [] + assert self._reload().status == SignalScoutConfig.Status.ACTIVE + + @parameterized.expand( + [ + ("log_artefact", SignalReportArtefact.ArtefactType.NOTE, None), + ("dismissal_artefact", SignalReportArtefact.ArtefactType.DISMISSAL, None), + ("user_driven_status", None, SignalReport.Status.SUPPRESSED), + ] + ) + def test_engagement_with_an_older_report_keeps_a_silent_scout_running( + self, _name: str, artefact_type: str | None, report_status: str | None + ) -> None: + report = self._report() + # Authored before the window, so the run itself is no longer output — only what happened to + # the report since counts. + self._runs(1, age=INACTIVITY_WINDOW + timedelta(days=5), emitted_report_ids=[str(report.id)]) + self._silent_runs() + if artefact_type is not None: + SignalReportArtefact.objects.create( + team=self.team, report=report, type=artefact_type, content="{}", created_by=self.user + ) + if report_status is not None: + SignalReport.objects.filter(pk=report.pk).update( + status=report_status, updated_at=self.now - timedelta(days=1) + ) + + outcome = sweep_inactive_scouts(now=self.now) + + assert outcome.warned == [] + assert self._reload().status == SignalScoutConfig.Status.ACTIVE + + @parameterized.expand( + [ + # A pipeline assessment, never a person's work. + ("status_judgment", SignalReportArtefact.ArtefactType.PRIORITY_JUDGMENT), + # The right type, but written by the pipeline: grouping appends a symmetric `related_to` + # when a resolved report recurs, and autostart appends `task_run`, both unattributed. + ("unattributed_log", SignalReportArtefact.ArtefactType.RELATED_TO), + ] + ) + def test_pipeline_artefacts_are_not_engagement(self, _name: str, artefact_type: str) -> None: + report = self._report() + self._runs(1, age=INACTIVITY_WINDOW + timedelta(days=5), emitted_report_ids=[str(report.id)]) + self._silent_runs() + SignalReportArtefact.objects.create( + team=self.team, + report=report, + type=artefact_type, + content="{}", + ) + + assert [c.pk for c in sweep_inactive_scouts(now=self.now).warned] == [self.config.pk] + + def test_a_scout_that_recovers_loses_its_warning(self) -> None: + self._silent_runs() + sweep_inactive_scouts(now=self.now) + self._runs(1, age=timedelta(hours=1), emitted_finding_ids=["finding-1"], emitted_count=1) + + outcome = sweep_inactive_scouts(now=self.now + timedelta(days=1)) + + recovered = self._reload() + assert outcome.recovered == 1 + assert recovered.status == SignalScoutConfig.Status.ACTIVE + assert recovered.pause_reason is None + assert recovered.enabled is True + + @parameterized.expand( + [ + ("exempt", {"auto_pause_exempt": True}), + ("dry_run", {"emit": False}), + ("user_paused", {"enabled": False, "status": SignalScoutConfig.Status.PAUSED_BY_USER}), + ( + "breaker_paused", + { + "enabled": False, + "status": SignalScoutConfig.Status.PAUSED_BY_SYSTEM, + "pause_reason": SignalScoutConfig.PauseReason.REPEATED_FAILURES, + }, + ), + ] + ) + def test_scouts_the_sweep_must_not_touch(self, _name: str, overrides: dict) -> None: + SignalScoutConfig.all_teams.filter(pk=self.config.pk).update(**overrides) + self._silent_runs() + + outcome = sweep_inactive_scouts(now=self.now) + + assert outcome.warned == [] + assert outcome.paused == [] + + def test_a_cold_start_scout_is_left_alone(self) -> None: + SignalScoutConfig.all_teams.filter(pk=self.config.pk).update(created_at=self.now - timedelta(days=1)) + self._silent_runs() + + assert sweep_inactive_scouts(now=self.now).warned == [] + + def test_a_scout_that_has_barely_run_is_left_alone(self) -> None: + # Sparse runs (a monthly cron, or a team that spent its budget elsewhere) say nothing about + # what the scout would have found. + self._runs(MIN_RUNS_IN_WINDOW - 1, age=INACTIVITY_WINDOW / 2) + + assert sweep_inactive_scouts(now=self.now).warned == [] + + def test_a_resumed_scout_gets_a_full_fresh_window(self) -> None: + # A human re-enable re-anchors `in_cold_start_grace` via `status_changed_at`, so the sweep + # must not judge the resumed scout on the same silent runs that got it paused. + self._silent_runs() + sweep_inactive_scouts(now=self.now) + sweep_inactive_scouts(now=self._pause_time) + resumed_at = timezone.now() + SignalScoutConfig.all_teams.filter(pk=self.config.pk).update( + enabled=True, + status=SignalScoutConfig.Status.ACTIVE, + pause_reason=None, + status_changed_at=resumed_at, + ) + + outcome = sweep_inactive_scouts(now=resumed_at + timedelta(days=1)) + + assert outcome.warned == [] + assert self._reload().status == SignalScoutConfig.Status.ACTIVE + + def test_new_warnings_are_capped_per_sweep(self) -> None: + # The blast-radius guard: most of the fleet qualifies on day one, and a pause can only + # follow a warning, so capping warnings bounds what any later sweep can pause. + other = self._config(skill_name="signals-scout-also-quiet") + self._silent_runs() + self._silent_runs(config=other) + + with patch("products.signals.backend.scout_harness.inactivity.MAX_WARNS_PER_SWEEP", 1): + outcome = sweep_inactive_scouts(now=self.now) + + assert len(outcome.warned) == 1 + assert outcome.deferred == 1 + + def test_pause_survives_lazy_seed_reconciliation(self) -> None: + # Configs are re-reconciled on every coordinator tick; a pause that gets quietly re-enabled + # there would put the scout straight back on the schedule. + LLMSkill.objects.create(team=self.team, name=SKILL, description="Quiet", body="Look around") + self._silent_runs() + sweep_inactive_scouts(now=self.now) + sweep_inactive_scouts(now=self._pause_time) + + register_missing_configs(self.team.id) + + reloaded = self._reload() + assert reloaded.enabled is False + assert reloaded.status == SignalScoutConfig.Status.PAUSED_BY_SYSTEM diff --git a/products/signals/frontend/generated/api.schemas.ts b/products/signals/frontend/generated/api.schemas.ts index 5281b0011bd7..08f06b061e0c 100644 --- a/products/signals/frontend/generated/api.schemas.ts +++ b/products/signals/frontend/generated/api.schemas.ts @@ -1826,6 +1826,8 @@ export interface SignalScoutConfigOptionsApi { run_interval_minutes?: number /** Destinations that receive each finding or report this scout emits. Empty by default. */ output_destinations?: SignalScoutOutputDestinationsApi + /** Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false. */ + auto_pause_exempt?: boolean /** * Optional five-field cron expression, e.g. '30 9 * * *' (daily at 09:30), '0 9,17 * * *' (twice daily), or '0 9 * * 1-5' (weekday mornings). Evaluated in the project timezone. Takes precedence over `run_interval_minutes`; occurrences must be at least 30 minutes apart. * @maxLength 100 @@ -1952,6 +1954,13 @@ export interface SignalScoutConfigApi { readonly last_run_at: string | null /** How many of this scout's runs have failed in a row. Back to 0 after a successful run or any config edit. At the failure limit the scout pauses itself (`status` becomes `paused_by_system` with `pause_reason` `repeated_failures`) and retries about once a day; a successful retry resumes it, and so does setting `enabled=true`. */ readonly consecutive_failure_count: number + /** + * When `status` last changed. For `pending_pause` this is when the warning was issued (the pause lands about a week later unless the scout surfaces something); for the paused statuses it is when the scout was paused. Null if the status never changed. + * @nullable + */ + readonly status_changed_at: string | null + /** Whether this scout is exempt from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste. Also set automatically when someone re-enables a scout the inactivity sweep paused, so the sweep never overrules a person twice. */ + readonly auto_pause_exempt: boolean readonly created_at: string } @@ -1981,6 +1990,8 @@ export interface SignalScoutConfigCreateApi { run_interval_minutes?: number /** Destinations that receive each finding or report this scout emits. Empty by default. */ output_destinations?: SignalScoutOutputDestinationsApi + /** Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false. */ + auto_pause_exempt?: boolean /** * Optional five-field cron expression, e.g. '30 9 * * *' (daily at 09:30), '0 9,17 * * *' (twice daily), or '0 9 * * 1-5' (weekday mornings). Evaluated in the project timezone. Takes precedence over `run_interval_minutes`; occurrences must be at least 30 minutes apart. * @maxLength 100 @@ -2016,6 +2027,8 @@ export interface PatchedSignalScoutConfigUpdateApi { run_cron_schedule?: string | null /** Destinations that receive each finding or report this scout emits. Pass an empty object to disable delivery. */ output_destinations?: SignalScoutOutputDestinationsApi + /** Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste. */ + auto_pause_exempt?: boolean } /** diff --git a/products/signals/frontend/generated/api.zod.ts b/products/signals/frontend/generated/api.zod.ts index 7d781d6e1783..6386ed2d52b5 100644 --- a/products/signals/frontend/generated/api.zod.ts +++ b/products/signals/frontend/generated/api.zod.ts @@ -397,6 +397,12 @@ export const SignalsScoutCreateBody = /* @__PURE__ */ zod }) .optional() .describe('Destinations that receive each finding or report this scout emits. Empty by default.'), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.' + ), run_cron_schedule: zod .string() .max(signalsScoutCreateBodyConfigOneRunCronScheduleMax) @@ -469,6 +475,12 @@ export const SignalsScoutConfigCreateBody = /* @__PURE__ */ zod }) .optional() .describe('Destinations that receive each finding or report this scout emits. Empty by default.'), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.' + ), run_cron_schedule: zod .string() .max(signalsScoutConfigCreateBodyRunCronScheduleMax) @@ -555,6 +567,12 @@ export const SignalsScoutConfigUpdateBody = /* @__PURE__ */ zod .describe( 'Destinations that receive each finding or report this scout emits. Pass an empty object to disable delivery.' ), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste.' + ), }) .describe('Editable schedule, enablement, and emit posture for one scout config.') diff --git a/products/signals/skills/authoring-scouts/SKILL.md b/products/signals/skills/authoring-scouts/SKILL.md index 918ed2924076..ea8954f46248 100644 --- a/products/signals/skills/authoring-scouts/SKILL.md +++ b/products/signals/skills/authoring-scouts/SKILL.md @@ -112,6 +112,10 @@ For an **existing scout**, tune with `posthog:scout-config-update` (find the `id The standard flow is to make a scout and let it write — seeing what actually lands is the fastest way to calibrate it. Set **`emit=false` (dry-run)** only when you want to be extra careful: the scout still runs and logs its reasoning but writes nothing to the inbox. Reach for dry-run on a scout you expect to be chatty, expensive, or high-stakes; for most scouts, just writing and watching the inbox is the better loop. +- `auto_pause_exempt` — defaults to `false`. + A scout that goes two weeks without surfacing anything, and whose earlier reports nobody has picked up since, is warned and then paused automatically — every run costs a sandbox agent, so a scout going nowhere shouldn't keep running forever. + `-config-list` shows the warning as `status=pending_pause` and the pause as `status=paused_by_system`, with `pause_reason` saying which shape of quiet it was (`no_output` vs `ignored`); setting `enabled=true` again resumes the scout, and marks it exempt so the sweep never overrules a person twice. + Set `auto_pause_exempt=true` up front for a watchdog scout whose whole job is to stay quiet, so its silence is never read as waste. ## Steering with notes (no authoring needed) diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 26a9c2615642..c7ac68d8a3ab 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53662,6 +53662,8 @@ export namespace Schemas { run_cron_schedule?: string | null; /** Destinations that receive each finding or report this scout emits. Pass an empty object to disable delivery. */ output_destinations?: SignalScoutOutputDestinations; + /** Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste. */ + auto_pause_exempt?: boolean; } export interface PatchedSignalSourceConfig { @@ -63374,6 +63376,13 @@ export namespace Schemas { readonly last_run_at: string | null; /** How many of this scout's runs have failed in a row. Back to 0 after a successful run or any config edit. At the failure limit the scout pauses itself (`status` becomes `paused_by_system` with `pause_reason` `repeated_failures`) and retries about once a day; a successful retry resumes it, and so does setting `enabled=true`. */ readonly consecutive_failure_count: number; + /** + * When `status` last changed. For `pending_pause` this is when the warning was issued (the pause lands about a week later unless the scout surfaces something); for the paused statuses it is when the scout was paused. Null if the status never changed. + * @nullable + */ + readonly status_changed_at: string | null; + /** Whether this scout is exempt from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste. Also set automatically when someone re-enables a scout the inactivity sweep paused, so the sweep never overrules a person twice. */ + readonly auto_pause_exempt: boolean; readonly created_at: string; } @@ -63396,6 +63405,8 @@ export namespace Schemas { run_interval_minutes?: number; /** Destinations that receive each finding or report this scout emits. Empty by default. */ output_destinations?: SignalScoutOutputDestinations; + /** Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false. */ + auto_pause_exempt?: boolean; /** * Optional five-field cron expression, e.g. '30 9 * * *' (daily at 09:30), '0 9,17 * * *' (twice daily), or '0 9 * * 1-5' (weekday mornings). Evaluated in the project timezone. Takes precedence over `run_interval_minutes`; occurrences must be at least 30 minutes apart. * @maxLength 100 @@ -63425,6 +63436,8 @@ export namespace Schemas { run_interval_minutes?: number; /** Destinations that receive each finding or report this scout emits. Empty by default. */ output_destinations?: SignalScoutOutputDestinations; + /** Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false. */ + auto_pause_exempt?: boolean; /** * Optional five-field cron expression, e.g. '30 9 * * *' (daily at 09:30), '0 9,17 * * *' (twice daily), or '0 9 * * 1-5' (weekday mornings). Evaluated in the project timezone. Takes precedence over `run_interval_minutes`; occurrences must be at least 30 minutes apart. * @maxLength 100 diff --git a/services/mcp/src/generated/signals/api.ts b/services/mcp/src/generated/signals/api.ts index fb20eec4b4a9..ef79837f9980 100644 --- a/services/mcp/src/generated/signals/api.ts +++ b/services/mcp/src/generated/signals/api.ts @@ -502,6 +502,12 @@ export const SignalsScoutCreateBody = /* @__PURE__ */ zod }) .optional() .describe('Destinations that receive each finding or report this scout emits. Empty by default.'), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.' + ), run_cron_schedule: zod .string() .max(signalsScoutCreateBodyConfigOneRunCronScheduleMax) @@ -594,6 +600,12 @@ export const SignalsScoutConfigCreateBody = /* @__PURE__ */ zod }) .optional() .describe('Destinations that receive each finding or report this scout emits. Empty by default.'), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.' + ), run_cron_schedule: zod .string() .max(signalsScoutConfigCreateBodyRunCronScheduleMax) @@ -689,6 +701,12 @@ export const SignalsScoutConfigUpdateBody = /* @__PURE__ */ zod .describe( 'Destinations that receive each finding or report this scout emits. Pass an empty object to disable delivery.' ), + auto_pause_exempt: zod + .boolean() + .optional() + .describe( + 'Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste.' + ), }) .describe('Editable schedule, enablement, and emit posture for one scout config.') diff --git a/services/mcp/src/tools/generated/signals.ts b/services/mcp/src/tools/generated/signals.ts index 9c46eef87301..91b93cbb82a9 100644 --- a/services/mcp/src/tools/generated/signals.ts +++ b/services/mcp/src/tools/generated/signals.ts @@ -526,6 +526,9 @@ const scoutConfigCreate = (): ToolBase({ method: 'PATCH', path: `/api/projects/${encodeURIComponent(String(projectId))}/signals/scout/configs/${encodeURIComponent(String(params.id))}/`, @@ -1161,6 +1167,9 @@ const signalsScoutConfigCreate = (): ToolBase({ method: 'PATCH', path: `/api/projects/${encodeURIComponent(String(projectId))}/signals/scout/configs/${encodeURIComponent(String(params.id))}/`, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-create.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-create.json index 22643ab5a29f..5c66109b8658 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-create.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-create.json @@ -2,6 +2,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Request body for registering a scout config without waiting for the coordinator tick.\n\nUpsert keyed on `skill_name`: if the coordinator (or a concurrent caller) already\nregistered the row, the provided tunables are applied to it instead.", "properties": { + "auto_pause_exempt": { + "description": "Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.", + "type": "boolean" + }, "emit": { "description": "Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. Defaults to true.", "type": "boolean" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json index ebd5fc3eadfd..bd62a8bf490d 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json @@ -1,6 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "auto_pause_exempt": { + "description": "Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste.", + "type": "boolean" + }, "emit": { "description": "Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing.", "type": "boolean" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-create-prepare.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-create-prepare.json index 6d0d6d7a4f15..6d839a6a7490 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-create-prepare.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-create-prepare.json @@ -9,6 +9,10 @@ "config": { "description": "Optional schedule, enablement, dry-run posture, and delivery settings. Defaults to an enabled, emitting scout on the daily interval with no external destination.", "properties": { + "auto_pause_exempt": { + "description": "Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.", + "type": "boolean" + }, "emit": { "description": "Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. Defaults to true.", "type": "boolean" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-create.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-create.json index 22643ab5a29f..5c66109b8658 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-create.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-create.json @@ -2,6 +2,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Request body for registering a scout config without waiting for the coordinator tick.\n\nUpsert keyed on `skill_name`: if the coordinator (or a concurrent caller) already\nregistered the row, the provided tunables are applied to it instead.", "properties": { + "auto_pause_exempt": { + "description": "Exempt this scout from the inactivity pause, which otherwise switches off a scout that goes a fortnight without surfacing anything anyone engages with. Set it on watchdog scouts whose value is staying quiet. Defaults to false.", + "type": "boolean" + }, "emit": { "description": "Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. Defaults to true.", "type": "boolean" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json index ebd5fc3eadfd..bd62a8bf490d 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json @@ -1,6 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "auto_pause_exempt": { + "description": "Exempt this scout from the inactivity pause. Set it on watchdog scouts whose value is staying quiet, so silence is never read as waste.", + "type": "boolean" + }, "emit": { "description": "Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing.", "type": "boolean"