feat: a source stops pulling once its membership ends, and resumes on resubscribe (3995)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m24s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m52s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m1s

Operator, 2026-09-13: "if I kill a subscription on patreon I would like the pulling to stop on curator as well", with auto-resume chosen. This reverses the 2026-09-11 "report only" decision for lapsed sources.

membership_reconcile.apply_membership_lapses runs in sync_memberships right after each platform's successful sync, so it only ever acts on the roster just written.

It stops a source (enabled=false, with the same failure-state reset as a manual disable, #1285) only when all of these hold:
- the roster is fresh
- the source's matched membership says has_paid_access is False (lapsed, or a free follow)
- the paid-through date has passed, where the platform gives one (Patreon's member.access_expires_at; SubscribeStar gives none, so it stops at once)
- the source is enabled
- the operator hasn't chosen to keep it

It never acts on absence. A source with no matched membership keeps pulling, because a rename or a never-walked source produces the same absence. An unrecognised status is never a lapse either.

It resumes only sources carrying its own `_membership_stopped` marker, once the membership is paid again.

The operator outranks the sweep both ways (SourceService.update):
- turning a stopped source back on marks it `_membership_kept`, so the next sweep leaves it alone until it's paid again
- turning a source off by hand drops the marker, so the sweep never switches it back on

Both are `_`-prefixed app-managed config keys, which operator edits already preserve. No migration.

The roster/fetch line holds. This is a source-level action by the sweep. No download path reads the roster, and the scheduler still selects on `enabled` alone. test_no_fetch_path_can_read_the_roster is unchanged.

UI: SourceRow shows a neutral "Membership ended" chip, with the status and the resume/keep explanation, ahead of the other chips. The sweep's task summary reports stopped/resumed counts.

Tests (tests/test_membership_lapses.py):
- a lapse stops the source with a clean slate and keeps the id cache
- paid-through is honoured
- absence, an unknown status and a stale roster never stop anything
- a resume touches only what the sweep stopped
- a manual on sticks, a manual off drops the marker, and a kept source is released once paid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
2026-09-13 22:08:06 -04:00
co-authored by Claude Opus 5
parent 4d84ab2816
commit 7ff8915147
5 changed files with 384 additions and 9 deletions
@@ -39,8 +39,25 @@
{{ formatRelative(source.next_check_at, { future: true }) }}
</td>
<td>
<!-- #3995: the membership sweep stopped this source because paid access
ended. First in the chain it explains why an otherwise healthy
source is off. Neutral, never error: nothing is broken. -->
<v-chip
v-if="(source.consecutive_failures || 0) > 0"
v-if="membershipStopped"
size="x-small" variant="tonal" label
prepend-icon="mdi-account-cancel-outline"
>Membership ended
<v-tooltip activator="parent" location="top" max-width="420">
<span>
Curator stopped checking this source because your membership
{{ membershipStopped.status ? `reads "${membershipStopped.status}"` : 'ended' }}.
It starts again on its own if you resubscribe. Turn it back on to
keep checking anyway.
</span>
</v-tooltip>
</v-chip>
<v-chip
v-else-if="(source.consecutive_failures || 0) > 0"
size="x-small" color="error" variant="tonal" label
>{{ source.consecutive_failures }}
<!-- #1: show the actual failure reason on hover instead of a bare count. -->
@@ -109,6 +126,12 @@ const props = defineProps({
// operator's subscription, not anything FC can retry. The count is only joined
// in by the list endpoint, so phrase it without one when it's absent rather
// than rendering a fabricated zero.
// #3995: set by the membership sweep when it stops a source whose paid access
// ended; cleared when it resumes, or when the operator toggles the source.
const membershipStopped = computed(
() => props.source.config_overrides?._membership_stopped || null,
)
const noAccessTip = computed(() => {
const n = props.source.tier_gated_count
const what = n