koala73/worldmonitor · error · Error

[_recordPendingBroadcast] lease lost: expected runId=${args.

Error message

[_recordPendingBroadcast] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? "<cleared>"}. Refusing to persist broadcast progress — operator/another run owns the state.

What it means

_recordPendingBroadcast validates pendingRunId === runId before persisting broadcast progress. A mismatch means lease ownership changed (another run or operator forceReleaseLease). It throws rather than overwriting another owner's state; the throw bubbles to Convex auto-Sentry for investigation.

Source

Thrown at convex/broadcast/rampRunner.ts:752

/**
 * Persist post-`createProLaunchBroadcast` progress. Called by the runner
 * AFTER `createProLaunchBroadcast` returns successfully. Lets
 * `recoverFromPartialFailure` recover the broadcastId without
 * operator-supplied metadata if the action dies between this point and a
 * successful `_recordWaveSent`.
 *
 * Lease-validating: same semantics as `_recordPendingExport`.
 */
export const _recordPendingBroadcast = internalMutation({
  args: {
    runId: v.string(),
    broadcastId: v.string(),
  },
  handler: async (ctx, args) => {
    const row = await loadConfig(ctx);
    if (!row) throw new Error("[_recordPendingBroadcast] no ramp configured");
    if (row.pendingRunId !== args.runId) {
      throw new Error(
        `[_recordPendingBroadcast] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? "<cleared>"}. Refusing to persist broadcast progress — operator/another run owns the state.`,
      );
    }
    await ctx.db.patch(row._id, {
      pendingBroadcastId: args.broadcastId,
      pendingBroadcastAt: Date.now(),
    });
    return { ok: true as const };
  },
});

/**
 * Internal mutation that the action calls to atomically advance the tier +
 * record a successful wave-send. Validates that the lease still belongs to
 * this runId AND clears all pending-progress markers.
 */
export const _recordWaveSent = internalMutation({
  args: {

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Investigate the current lease holder and whether its broadcast actually sent before doing anything.
  2. If stuck, forceReleaseLease then recoverFromPartialFailure (manual-finished if sent, discard-and-rotate if not).
  3. Eliminate concurrent triggers (make cron and manual mutually exclusive).
Defensive patterns

Strategy: try-catch

Validate before calling

// Runner-side: verify lease before persisting broadcast progress
const row = await ctx.db.query('broadcastRampConfig').withIndex('by_key', (q) => q.eq('key', 'current')).first();
if (!row || row.pendingRunId !== runId) {
  return { aborted: 'lease-lost' };
}

Type guard

function leaseIsOurs(row: { pendingRunId?: string } | null, runId: string): row is { pendingRunId: string } {
  return row !== null && row.pendingRunId === runId;
}

Try / catch

try {
  await ctx.runMutation(internal.broadcast.rampRunner._recordPendingBroadcast, { runId, broadcastId });
} catch (e) {
  if ((e as Error).message.includes('lease lost')) {
    return { aborted: 'lease-lost', detail: (e as Error).message };
  }
  throw e;
}

Prevention

When it happens

Trigger: Concurrent runs where a second claim overwrote pendingRunId; operator forceReleaseLease between createProLaunchBroadcast and this record; recoverFromPartialFailure cleared the lease.

Common situations: Cron overlapped a manual run; Convex runtime retry re-entered after release; operator and automation both touched a stalled run.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/e28790e95500eb28. Report an issue: GitHub.