{"record":{"id":"e28790e95500eb28","repo":"koala73/worldmonitor","slug":"recordpendingbroadcast-lease-lost-expected-run","errorCode":null,"errorMessage":"[_recordPendingBroadcast] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? \"<cleared>\"}. Refusing to persist broadcast progress — operator/another run owns the state.","messagePattern":"\\[_recordPendingBroadcast\\] lease lost: expected runId=(.+?), found (.+?)\\. Refusing to persist broadcast progress — operator/another run owns the state\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"convex/broadcast/rampRunner.ts","lineNumber":752,"sourceCode":"/**\n * Persist post-`createProLaunchBroadcast` progress. Called by the runner\n * AFTER `createProLaunchBroadcast` returns successfully. Lets\n * `recoverFromPartialFailure` recover the broadcastId without\n * operator-supplied metadata if the action dies between this point and a\n * successful `_recordWaveSent`.\n *\n * Lease-validating: same semantics as `_recordPendingExport`.\n */\nexport const _recordPendingBroadcast = internalMutation({\n  args: {\n    runId: v.string(),\n    broadcastId: v.string(),\n  },\n  handler: async (ctx, args) => {\n    const row = await loadConfig(ctx);\n    if (!row) throw new Error(\"[_recordPendingBroadcast] no ramp configured\");\n    if (row.pendingRunId !== args.runId) {\n      throw new Error(\n        `[_recordPendingBroadcast] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? \"<cleared>\"}. Refusing to persist broadcast progress — operator/another run owns the state.`,\n      );\n    }\n    await ctx.db.patch(row._id, {\n      pendingBroadcastId: args.broadcastId,\n      pendingBroadcastAt: Date.now(),\n    });\n    return { ok: true as const };\n  },\n});\n\n/**\n * Internal mutation that the action calls to atomically advance the tier +\n * record a successful wave-send. Validates that the lease still belongs to\n * this runId AND clears all pending-progress markers.\n */\nexport const _recordWaveSent = internalMutation({\n  args: {","sourceCodeStart":734,"sourceCodeEnd":770,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/broadcast/rampRunner.ts#L734-L770","documentation":"_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.","triggerScenarios":"Concurrent runs where a second claim overwrote pendingRunId; operator forceReleaseLease between createProLaunchBroadcast and this record; recoverFromPartialFailure cleared the lease.","commonSituations":"Cron overlapped a manual run; Convex runtime retry re-entered after release; operator and automation both touched a stalled run.","solutions":["Investigate the current lease holder and whether its broadcast actually sent before doing anything.","If stuck, forceReleaseLease then recoverFromPartialFailure (manual-finished if sent, discard-and-rotate if not).","Eliminate concurrent triggers (make cron and manual mutually exclusive)."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Runner-side: verify lease before persisting broadcast progress\nconst row = await ctx.db.query('broadcastRampConfig').withIndex('by_key', (q) => q.eq('key', 'current')).first();\nif (!row || row.pendingRunId !== runId) {\n  return { aborted: 'lease-lost' };\n}","typeGuard":"function leaseIsOurs(row: { pendingRunId?: string } | null, runId: string): row is { pendingRunId: string } {\n  return row !== null && row.pendingRunId === runId;\n}","tryCatchPattern":"try {\n  await ctx.runMutation(internal.broadcast.rampRunner._recordPendingBroadcast, { runId, broadcastId });\n} catch (e) {\n  if ((e as Error).message.includes('lease lost')) {\n    return { aborted: 'lease-lost', detail: (e as Error).message };\n  }\n  throw e;\n}","preventionTips":["Prevent concurrent cron + manual triggers.","Treat lease-lost as terminal; reconcile via recoverFromPartialFailure.","Only forceReleaseLease when you have confirmed no live run is progressing."],"tags":["convex","broadcast-ramp","lease","concurrency","runner-internal"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}