Skip to content

fix(broadcast): lease-based race guard + structured partial-failure recovery#3476

Merged
koala73 merged 4 commits into
mainfrom
notifications-broadcast-ramp-race-fix
Apr 28, 2026
Merged

fix(broadcast): lease-based race guard + structured partial-failure recovery#3476
koala73 merged 4 commits into
mainfrom
notifications-broadcast-ramp-race-fix

Conversation

@koala73

@koala73 koala73 commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses two P1 review findings on PR #3473 (`rampRunner.ts`, already merged).

P1 #1 — Race condition that allowed DUPLICATE EMAIL SENDS

The runner reads `currentTier` and runs `assignAndExportWave` + `createProLaunchBroadcast` + `sendProLaunchBroadcast` BEFORE recording the tier in `_recordWaveSent`. Two overlapping cron runs (or cron + manual trigger, or Convex action retry) both pass kill-gate / tier checks, both proceed through all 3 external side effects, and only collide at `_recordWaveSent` — by then duplicate emails have already gone out to every recipient in the wave. The tier check there is post-hoc, not a pre-claim. Sender reputation impact: catastrophic on the canary-warmup audience.

Fix: atomic lease, claimed BEFORE any external side effect.

```ts
// new mutation: writes pendingRunId + pendingRunStartedAt iff:
// - currentTier matches expected
// - no fresh lease (or held lease > STALE_LEASE_MS = 30min, override)
_claimTierForRun({ runId, expectedCurrentTier })
```

The runner exits cleanly on claim rejection — no external state touched. `_recordWaveSent` now validates the lease still belongs to its runId and clears it on success. `_recordRunOutcome` / `clearPartialFailure` also clear the lease (only when runId matches, to avoid stomping another run's claim).

P1 #2 — Recovery path broken after exported-but-not-sent

When `assignAndExportWave` succeeded (contacts stamped, segment created) but `createProLaunchBroadcast` or `sendProLaunchBroadcast` threw, bare `clearPartialFailure` made the next cron retry the SAME `waveLabel` — `assignAndExportWave` rejected because contacts were already stamped. The cron thrashed on the same partial-failure forever short of `abortRamp` or hand-patching the DB.

Fix: new `recoverFromPartialFailure(recovery, ...)` action with two operator-declared modes:

Mode Use when Effect
`manual-finished` Operator manually completed the wave (e.g. via Resend dashboard or direct `sendProLaunchBroadcast` call). Pass `broadcastId`, `segmentId`, `sentAt`, `assigned`. Tier advances; next kill-gate check uses the recorded broadcastId. Equivalent to a successful cron run.
`discard-and-rotate` Wave is genuinely lost; can't or won't retry. Bumps `waveLabelOffset` by 1 so next cron uses a fresh waveLabel. Prior label's stamps remain (those contacts excluded from future picks). Tier NOT advanced.

Both modes clear the lease + partial-failure status. Runner error messages now include the `recoverFromPartialFailure` invocation hint right next to each `throw` site, so on-call ops sees the recovery path inline.

What changed

  • `convex/schema.ts` — `pendingRunId` + `pendingRunStartedAt` optional fields on `broadcastRampConfig`
  • `convex/broadcast/rampRunner.ts`:
    • new `_claimTierForRun` mutation
    • `_recordWaveSent`: lease validation, clears on success
    • `_recordRunOutcome`: takes optional `runId`, clears matching lease only
    • `runDailyRamp`: claims lease after tier-bounds check, BEFORE any external side effect; threads `runId` through every outcome record
    • new `recoverFromPartialFailure` mutation
    • `getRampStatus`: surfaces `pendingRunId`, `pendingRunStartedAt`, derived `leaseHeld` boolean
  • `convex/tests/rampRunner.test.ts` — 13 new tests

Test plan

  • 13 new ramp tests pass; 107/107 total Convex tests pass (was 94)
  • TS dual typecheck clean
  • Biome clean
  • Post-merge / pre-next-wave: verify in prod via `getRampStatus` that no stale lease is held; if one is, `abortRamp` + `initRamp` to reset
  • Optional dry-run: trigger `runDailyRamp` twice in quick succession (manual + cron); confirm only one wave goes out

Operator quick reference (post-merge)

```bash

If a wave is stuck in partial-failure with stamped+created+not-sent state:

Option A — operator finished it manually

npx convex run broadcast/rampRunner:recoverFromPartialFailure '{
"recovery": "manual-finished",
"reason": "sent wave-N via Resend dashboard after API timeout",
"broadcastId": "",
"segmentId": "",
"sentAt": ,
"assigned":
}'

Option B — write off the failed wave

npx convex run broadcast/rampRunner:recoverFromPartialFailure '{
"recovery": "discard-and-rotate",
"reason": "wave-N unrecoverable, rotating label"
}'
```

…ecovery

Addresses two P1 review findings on PR #3473 (rampRunner already merged).

P1 #1 — Race condition that allowed DUPLICATE EMAIL SENDS

The runner read currentTier and ran assignAndExportWave +
createProLaunchBroadcast + sendProLaunchBroadcast BEFORE recording the
tier in _recordWaveSent. Two overlapping cron runs (or cron + manual
trigger, or Convex action retry) both passed kill-gate / tier checks,
both proceeded through all 3 external side effects, and only collided
at _recordWaveSent — by then duplicate emails had gone out to every
recipient in the wave. The tier check there is post-hoc, not a pre-claim.

Fix: atomic lease, claimed BEFORE any external side effect. New mutation
`_claimTierForRun(runId, expectedCurrentTier)` writes pendingRunId +
pendingRunStartedAt iff:
- currentTier matches expected
- no fresh lease is already held (or held lease is older than
  STALE_LEASE_MS = 30min, in which case override the abandoned lease
  from a crashed runner)
The runner exits cleanly on claim rejection without ever touching
external state. _recordWaveSent now validates the lease still belongs
to its runId and clears it on success. _recordRunOutcome / clearPartialFailure
also clear the lease (only when runId matches, to avoid stomping
another run's claim).

P1 #2 — Recovery path broken when assignAndExportWave succeeded but
createProLaunchBroadcast or sendProLaunchBroadcast failed

In that intermediate "stamped + segment created + not sent" state, bare
clearPartialFailure makes the next cron retry the SAME waveLabel, which
fails because contacts are already stamped. The cron thrashes on the
same partial-failure forever short of abortRamp or hand-patching the DB.

Fix: new `recoverFromPartialFailure(recovery, ...)` action with two
explicit operator-declared modes:
- manual-finished: operator manually completed the wave (e.g. via Resend
  dashboard or direct sendProLaunchBroadcast call). Pass broadcastId,
  segmentId, sentAt, assigned. Tier advances; next kill-gate uses the
  recorded broadcastId. Result equivalent to a successful cron run.
- discard-and-rotate: write off the lost wave. Bumps waveLabelOffset by
  1 so the next cron uses a FRESH waveLabel; the prior label's stamps
  remain in the audience table and exclude those contacts from future
  picks (lost to this campaign; operator can manually email later).
  Tier NOT advanced (no successful send to record).

Both modes clear the lease and the partial-failure status. The runner's
partial-failure error messages now include the recoverFromPartialFailure
invocation hint so on-call ops can see the recovery path right next to
the error.

Schema additions (convex/schema.ts): pendingRunId, pendingRunStartedAt
optional fields on broadcastRampConfig. getRampStatus surfaces both for
operator visibility, plus a derived `leaseHeld` boolean.

Tests (convex/__tests__/rampRunner.test.ts): 13 cases covering
- _claimTierForRun: fresh claim, lease-held rejection (THE RACE GUARD),
  tier-moved rejection, stale-lease override
- _recordWaveSent: lease-validated success, lease-lost rejection
- _recordRunOutcome: clears own lease, leaves other lease alone
- recoverFromPartialFailure: manual-finished advances tier with operator-
  provided broadcastId, manual-finished requires all wave fields,
  discard-and-rotate bumps waveLabelOffset and DOES NOT advance tier,
  noop when status not partial-failure
- end-to-end: two concurrent claims, exactly one wins

107/107 total Convex tests pass (was 94; +13). TS dual typecheck clean,
biome clean.
@vercel

vercel Bot commented Apr 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 28, 2026 5:08am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a lease-based concurrency guard (_claimTierForRun) claimed atomically before any external side effect, and a recoverFromPartialFailure mutation with two operator-declared modes (manual-finished, discard-and-rotate) to handle the exported-but-not-sent stuck state from PR #3473.

  • P1: recoverFromPartialFailure with manual-finished throws \"Curve is complete; nothing to recover\" when currentTier === rampCurve.length - 1 (the final tier). nextTier equals rampCurve.length, tripping the >= guard — permanently blocking operator recovery for the last wave. The normal cron success path advances to newTier = rampCurve.length without issue; change >= to > to align.

Confidence Score: 3/5

Not safe to merge as-is: the final-tier manual-finished recovery path is permanently broken by an off-by-one guard, which would force operators to hand-patch the DB for any last-wave partial failure.

One P1 logic bug (>= vs > in the rampCurve.length guard) blocks a concrete, documented operator recovery path for the final tier. The fix is a one-character change, but P1 ceiling is 4/5, and the missing test coverage that would have caught it pulls the score below ceiling to 3/5.

convex/broadcast/rampRunner.ts — recoverFromPartialFailure manual-finished guard (line 512); convex/tests/rampRunner.test.ts — missing last-tier recovery test case.

Important Files Changed

Filename Overview
convex/broadcast/rampRunner.ts Core lease implementation is correct; _claimTierForRun, _recordWaveSent lease validation, and _recordRunOutcome selective clear are all sound. One P1 off-by-one in recoverFromPartialFailure blocks manual-finished recovery for the final ramp tier.
convex/tests/rampRunner.test.ts 13 well-structured tests covering the core lease race, stale-override, tier-mismatch, and both recovery modes. Missing coverage for last-tier (currentTier === rampCurve.length - 1) manual-finished recovery path where the P1 bug lives.
convex/schema.ts Adds pendingRunId and pendingRunStartedAt as optional fields on broadcastRampConfig. Schema change is minimal, backward-compatible (both optional), and correctly documented.

Sequence Diagram

sequenceDiagram
    participant Cron as runDailyRamp (action)
    participant Claim as _claimTierForRun (mutation)
    participant DB as broadcastRampConfig (DB)
    participant Ext as External (Resend API)
    participant Record as _recordWaveSent / _recordRunOutcome

    Cron->>DB: read currentTier, killGate, active
    alt early exit (kill gate / wait / tier bounds)
        Cron->>Record: _recordRunOutcome (no runId)
    else proceed
        Cron->>Claim: _claimTierForRun(runId, expectedCurrentTier)
        Claim->>DB: atomic read-check-write
        alt lease held (fresh)
            Claim-->>Cron: {ok:false, reason:"lease-held"}
            Cron-->>Cron: exit clean (no side effects)
        else tier moved
            Claim-->>Cron: {ok:false, reason:"tier-moved"}
            Cron-->>Cron: exit clean
        else stale or no lease
            Claim->>DB: pendingRunId=runId, pendingRunStartedAt=now
            Claim-->>Cron: {ok:true}
            Cron->>Ext: assignAndExportWave
            Cron->>Ext: createProLaunchBroadcast
            Cron->>Ext: sendProLaunchBroadcast
            alt success
                Cron->>Record: _recordWaveSent(runId) → validates lease, advances tier, clears lease
            else partial failure
                Cron->>Record: _recordRunOutcome(runId, partial-failure) → clears lease
                Note over Cron,DB: Operator calls recoverFromPartialFailure
            end
        end
    end
Loading

Reviews (1): Last reviewed commit: "fix(broadcast): lease-based race guard +..." | Re-trigger Greptile

Comment thread convex/broadcast/rampRunner.ts Outdated
Comment on lines +511 to +516
// (b) the lease was cleared by an operator action (recoverFromPartialFailure).
// Either way, we must NOT advance the tier — another run may be in flight
// and would conflict, or the operator has taken control. Fall through to
// an error so Convex auto-Sentry captures and ops can investigate.
throw new Error(
`[_recordWaveSent] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? "<cleared>"}. Refusing to advance tier — investigate what cleared the lease.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 manual-finished recovery impossible for the final ramp tier

When currentTier === rampCurve.length - 1 (e.g. the 5 000-send tier in a 3-element curve), nextTier equals rampCurve.length, so the guard fires and throws "Curve is complete; nothing to recover." The wave was actually sent and the operator is trying to record it, but this code path is permanently blocked. Normal cron success routes this identical state through _recordWaveSent with newTier = rampCurve.length without issue, so the guard is incorrectly tight. Change >= to > to allow the completed-final-tier case.

Suggested change
// (b) the lease was cleared by an operator action (recoverFromPartialFailure).
// Either way, we must NOT advance the tier — another run may be in flight
// and would conflict, or the operator has taken control. Fall through to
// an error so Convex auto-Sentry captures and ops can investigate.
throw new Error(
`[_recordWaveSent] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? "<cleared>"}. Refusing to advance tier — investigate what cleared the lease.`,
const nextTier = row.currentTier + 1;
if (nextTier > row.rampCurve.length) {
throw new Error(
`[recoverFromPartialFailure:manual-finished] currentTier=${row.currentTier} would advance past rampCurve.length=${row.rampCurve.length}. Curve is complete; nothing to recover.`,
);
}

Comment on lines +248 to +280
pendingRunStartedAt: Date.now(),
});
const result = await t.mutation(
internal.broadcast.rampRunner.recoverFromPartialFailure,
{
recovery: "manual-finished",
reason: "Sent wave-5 manually via Resend dashboard after createProLaunchBroadcast threw",
broadcastId: "bc-manual-789",
segmentId: "seg-manual-456",
sentAt: 1700000000000,
assigned: 1500,
},
);
expect(result.ok).toBe(true);
expect(result.recovery).toBe("manual-finished");
expect(result.advancedToTier).toBe(2);

const row = await loadRow(t);
expect(row?.currentTier).toBe(2);
expect(row?.lastWaveBroadcastId).toBe("bc-manual-789");
expect(row?.lastWaveSegmentId).toBe("seg-manual-456");
expect(row?.lastWaveAssigned).toBe(1500);
expect(row?.lastWaveSentAt).toBe(1700000000000);
expect(row?.lastRunStatus).toMatch(/succeeded-via-manual-recovery/);
expect(row?.pendingRunId).toBeUndefined();
});

test("manual-finished: rejects when required fields are missing", async () => {
const t = convexTest(schema, modules);
await seedRampConfig(t, { lastRunStatus: "partial-failure" });
await expect(
t.mutation(internal.broadcast.rampRunner.recoverFromPartialFailure, {
recovery: "manual-finished",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 No test for last-tier manual-finished recovery

The P1 off-by-one in recoverFromPartialFailure (>= vs >) is invisible in the current suite because all manual-finished tests use currentTier: 1 against a 3-element curve (nextTier = 2 < 3). A test with currentTier: 2 (last valid index) would have caught the throw at nextTier === rampCurve.length.

…gress + harden recovery (PR #3476 review round 2)

Addresses 4 P1 findings on PR #3476:

P1#1 (rampRunner.ts:455-474) — Stale-lease auto-override removed.
A 30-min wall-clock cutoff has the same failure mode the lease exists to
prevent: assignAndExportWave can legitimately exceed the cutoff for large
waves / slow Resend, and overriding while the original run is alive lets a
second run race and duplicate-send. A held lease now blocks until cleared
by the owning run (terminal outcome path) or by the operator via
forceReleaseLease (last-resort, sets lastRunStatus=partial-failure so
recoverFromPartialFailure can pick up).

P1#2 (rampRunner.ts:545-575) — _recordRunOutcome lease-mismatch is a hard
no-op. Previously it suppressed the lease-clear but still patched
lastRunStatus / lastRunError / killGateTripped / active when runId did not
match pendingRunId, clobbering the winner's authoritative outcome.

P1#3 (rampRunner.ts:236-249) — clearPartialFailure now requires
`confirmNoExport: v.literal(true)` AND fail-closes if any pending* progress
marker is set (which the runner persists as soon as any external step
succeeds). The risky path — clear-then-retry after a stamped/sent wave —
is rejected loudly; operator must use recoverFromPartialFailure instead.

P1#4 (rampRunner.ts:276-355) — Per-step progress persistence.
_recordPendingExport (after assignAndExportWave) and
_recordPendingBroadcast (after createProLaunchBroadcast) write
(waveLabel, segmentId, assigned, broadcastId) to the config row. When the
action dies between steps (Convex action timeout, OOM) before any catch
block runs, recoverFromPartialFailure falls back to the persisted state
instead of requiring the operator to reconstruct from the Resend
dashboard. Operator-supplied args still take precedence; sentAt remains
operator-only (no marker captures send-completion time).

Schema: adds pendingWaveLabel, pendingSegmentId, pendingAssigned,
pendingExportAt, pendingBroadcastId, pendingBroadcastAt to
broadcastRampConfig (all optional). _recordWaveSent clears all six on
success; recoverFromPartialFailure clears them on completion.

Tests: ramp runner suite goes 13 → 29. Inverted stale-lease test (now
asserts rejection); added forceReleaseLease lifecycle, clearPartialFailure
fail-closed-on-pending, _recordPendingExport/Broadcast lease validation,
_recordWaveSent clears pending*, manual-finished auto-fills from
persisted state, sentAt-still-required, operator-override-beats-fallback,
discard-and-rotate clears pending*.
…ure counters (PR #3476 review round 3)

P1 from review round 3: `_recordPendingExport` was called only after the
`(failed > 0 || stampFailed > 0)` and `underfilled` branches recorded
partial-failure and bailed. Those branches DID see a real export — segment
created, contacts may be stamped, contacts may be in the Resend segment —
but with the persistence call deferred past them, the row's `pending*`
markers stayed undefined. An operator running `clearPartialFailure({
confirmNoExport: true })` would then NOT trip the fail-closed guard
(because the marker check sees no `pendingWaveLabel/SegmentId/BroadcastId`),
silently masking the stamped contacts.

Fix: move `_recordPendingExport` immediately after `assignAndExportWave`
returns successfully, BEFORE inspecting `failed`, `stampFailed`, or
`underfilled`. The export ran — record that fact unconditionally on
success, regardless of whether the result counters then route us to a
partial-failure outcome. Lease validation in `_recordPendingExport`
preserves the operator-force-release safety.

Test: source-grep regression test asserts `_recordPendingExport,` call site
in `runDailyRamp` precedes every `exportResult.failed`/`stampFailed`/
`underfilled` source occurrence. Catches any future reordering that would
re-open the gap.
…e (PR #3476 review round 4)

P1 from review round 4: the underfilled branch was recording status=
"pool-drained" + deactivate + clearing the lease. By that point
assignAndExportWave had already stamped contacts AND created the Resend
segment AND pushed contacts — but no broadcast was ever created or sent.
The contacts are now excluded from future picks (stamped) but receive no
email; recoverFromPartialFailure can't help because status is
"pool-drained" not "partial-failure".

Fix: route through the recoverable partial-failure path. status=
"partial-failure" + deactivate=true + the persisted pending* markers
(set by _recordPendingExport in round 3) make
recoverFromPartialFailure({recovery:"manual-finished", sentAt:<epoch>})
able to pick up exactly where the runner stopped — operator manually
completes the broadcast in Resend, then advances the tier with
zero-reconstruction recovery. Or recoverFromPartialFailure({recovery:
"discard-and-rotate"}) to explicitly abandon the wave (operator's call,
not the runner's).

Operator-facing return status renamed to "pool-drained-partial-failure"
so logs distinguish this case from a generic partial-failure.

Test: source-grep regression test asserts the underfilled branch records
status:"partial-failure" (not "pool-drained"), keeps deactivate:true, and
includes recoverFromPartialFailure guidance in the error message.
@koala73
koala73 merged commit 3da6089 into main Apr 28, 2026
11 checks passed
@koala73
koala73 deleted the notifications-broadcast-ramp-race-fix branch April 28, 2026 05:15
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…ecovery (koala73#3476)

* fix(broadcast): lease-based race guard + structured partial-failure recovery

Addresses two P1 review findings on PR koala73#3473 (rampRunner already merged).

P1 #1 — Race condition that allowed DUPLICATE EMAIL SENDS

The runner read currentTier and ran assignAndExportWave +
createProLaunchBroadcast + sendProLaunchBroadcast BEFORE recording the
tier in _recordWaveSent. Two overlapping cron runs (or cron + manual
trigger, or Convex action retry) both passed kill-gate / tier checks,
both proceeded through all 3 external side effects, and only collided
at _recordWaveSent — by then duplicate emails had gone out to every
recipient in the wave. The tier check there is post-hoc, not a pre-claim.

Fix: atomic lease, claimed BEFORE any external side effect. New mutation
`_claimTierForRun(runId, expectedCurrentTier)` writes pendingRunId +
pendingRunStartedAt iff:
- currentTier matches expected
- no fresh lease is already held (or held lease is older than
  STALE_LEASE_MS = 30min, in which case override the abandoned lease
  from a crashed runner)
The runner exits cleanly on claim rejection without ever touching
external state. _recordWaveSent now validates the lease still belongs
to its runId and clears it on success. _recordRunOutcome / clearPartialFailure
also clear the lease (only when runId matches, to avoid stomping
another run's claim).

P1 #2 — Recovery path broken when assignAndExportWave succeeded but
createProLaunchBroadcast or sendProLaunchBroadcast failed

In that intermediate "stamped + segment created + not sent" state, bare
clearPartialFailure makes the next cron retry the SAME waveLabel, which
fails because contacts are already stamped. The cron thrashes on the
same partial-failure forever short of abortRamp or hand-patching the DB.

Fix: new `recoverFromPartialFailure(recovery, ...)` action with two
explicit operator-declared modes:
- manual-finished: operator manually completed the wave (e.g. via Resend
  dashboard or direct sendProLaunchBroadcast call). Pass broadcastId,
  segmentId, sentAt, assigned. Tier advances; next kill-gate uses the
  recorded broadcastId. Result equivalent to a successful cron run.
- discard-and-rotate: write off the lost wave. Bumps waveLabelOffset by
  1 so the next cron uses a FRESH waveLabel; the prior label's stamps
  remain in the audience table and exclude those contacts from future
  picks (lost to this campaign; operator can manually email later).
  Tier NOT advanced (no successful send to record).

Both modes clear the lease and the partial-failure status. The runner's
partial-failure error messages now include the recoverFromPartialFailure
invocation hint so on-call ops can see the recovery path right next to
the error.

Schema additions (convex/schema.ts): pendingRunId, pendingRunStartedAt
optional fields on broadcastRampConfig. getRampStatus surfaces both for
operator visibility, plus a derived `leaseHeld` boolean.

Tests (convex/__tests__/rampRunner.test.ts): 13 cases covering
- _claimTierForRun: fresh claim, lease-held rejection (THE RACE GUARD),
  tier-moved rejection, stale-lease override
- _recordWaveSent: lease-validated success, lease-lost rejection
- _recordRunOutcome: clears own lease, leaves other lease alone
- recoverFromPartialFailure: manual-finished advances tier with operator-
  provided broadcastId, manual-finished requires all wave fields,
  discard-and-rotate bumps waveLabelOffset and DOES NOT advance tier,
  noop when status not partial-failure
- end-to-end: two concurrent claims, exactly one wins

107/107 total Convex tests pass (was 94; +13). TS dual typecheck clean,
biome clean.

* fix(broadcast): drop stale-lease auto-override + persist per-step progress + harden recovery (PR koala73#3476 review round 2)

Addresses 4 P1 findings on PR koala73#3476:

P1#1 (rampRunner.ts:455-474) — Stale-lease auto-override removed.
A 30-min wall-clock cutoff has the same failure mode the lease exists to
prevent: assignAndExportWave can legitimately exceed the cutoff for large
waves / slow Resend, and overriding while the original run is alive lets a
second run race and duplicate-send. A held lease now blocks until cleared
by the owning run (terminal outcome path) or by the operator via
forceReleaseLease (last-resort, sets lastRunStatus=partial-failure so
recoverFromPartialFailure can pick up).

P1#2 (rampRunner.ts:545-575) — _recordRunOutcome lease-mismatch is a hard
no-op. Previously it suppressed the lease-clear but still patched
lastRunStatus / lastRunError / killGateTripped / active when runId did not
match pendingRunId, clobbering the winner's authoritative outcome.

P1#3 (rampRunner.ts:236-249) — clearPartialFailure now requires
`confirmNoExport: v.literal(true)` AND fail-closes if any pending* progress
marker is set (which the runner persists as soon as any external step
succeeds). The risky path — clear-then-retry after a stamped/sent wave —
is rejected loudly; operator must use recoverFromPartialFailure instead.

P1#4 (rampRunner.ts:276-355) — Per-step progress persistence.
_recordPendingExport (after assignAndExportWave) and
_recordPendingBroadcast (after createProLaunchBroadcast) write
(waveLabel, segmentId, assigned, broadcastId) to the config row. When the
action dies between steps (Convex action timeout, OOM) before any catch
block runs, recoverFromPartialFailure falls back to the persisted state
instead of requiring the operator to reconstruct from the Resend
dashboard. Operator-supplied args still take precedence; sentAt remains
operator-only (no marker captures send-completion time).

Schema: adds pendingWaveLabel, pendingSegmentId, pendingAssigned,
pendingExportAt, pendingBroadcastId, pendingBroadcastAt to
broadcastRampConfig (all optional). _recordWaveSent clears all six on
success; recoverFromPartialFailure clears them on completion.

Tests: ramp runner suite goes 13 → 29. Inverted stale-lease test (now
asserts rejection); added forceReleaseLease lifecycle, clearPartialFailure
fail-closed-on-pending, _recordPendingExport/Broadcast lease validation,
_recordWaveSent clears pending*, manual-finished auto-fills from
persisted state, sentAt-still-required, operator-override-beats-fallback,
discard-and-rotate clears pending*.

* fix(broadcast): persist pending export markers BEFORE inspecting failure counters (PR koala73#3476 review round 3)

P1 from review round 3: `_recordPendingExport` was called only after the
`(failed > 0 || stampFailed > 0)` and `underfilled` branches recorded
partial-failure and bailed. Those branches DID see a real export — segment
created, contacts may be stamped, contacts may be in the Resend segment —
but with the persistence call deferred past them, the row's `pending*`
markers stayed undefined. An operator running `clearPartialFailure({
confirmNoExport: true })` would then NOT trip the fail-closed guard
(because the marker check sees no `pendingWaveLabel/SegmentId/BroadcastId`),
silently masking the stamped contacts.

Fix: move `_recordPendingExport` immediately after `assignAndExportWave`
returns successfully, BEFORE inspecting `failed`, `stampFailed`, or
`underfilled`. The export ran — record that fact unconditionally on
success, regardless of whether the result counters then route us to a
partial-failure outcome. Lease validation in `_recordPendingExport`
preserves the operator-force-release safety.

Test: source-grep regression test asserts `_recordPendingExport,` call site
in `runDailyRamp` precedes every `exportResult.failed`/`stampFailed`/
`underfilled` source occurrence. Catches any future reordering that would
re-open the gap.

* fix(broadcast): route pool-drained through recoverable partial-failure (PR koala73#3476 review round 4)

P1 from review round 4: the underfilled branch was recording status=
"pool-drained" + deactivate + clearing the lease. By that point
assignAndExportWave had already stamped contacts AND created the Resend
segment AND pushed contacts — but no broadcast was ever created or sent.
The contacts are now excluded from future picks (stamped) but receive no
email; recoverFromPartialFailure can't help because status is
"pool-drained" not "partial-failure".

Fix: route through the recoverable partial-failure path. status=
"partial-failure" + deactivate=true + the persisted pending* markers
(set by _recordPendingExport in round 3) make
recoverFromPartialFailure({recovery:"manual-finished", sentAt:<epoch>})
able to pick up exactly where the runner stopped — operator manually
completes the broadcast in Resend, then advances the tier with
zero-reconstruction recovery. Or recoverFromPartialFailure({recovery:
"discard-and-rotate"}) to explicitly abandon the wave (operator's call,
not the runner's).

Operator-facing return status renamed to "pool-drained-partial-failure"
so logs distinguish this case from a generic partial-failure.

Test: source-grep regression test asserts the underfilled branch records
status:"partial-failure" (not "pool-drained"), keeps deactivate:true, and
includes recoverFromPartialFailure guidance in the error message.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant