{"record":{"id":"4b7f673ba2494613","repo":"koala73/worldmonitor","slug":"clearpartialfailure-refused-pending-progress-ma","errorCode":null,"errorMessage":"[clearPartialFailure] refused: pending progress markers present (waveLabel=${row.pendingWaveLabel ?? \"-\"}, segmentId=${row.pendingSegmentId ?? \"-\"}, broadcastId=${row.pendingBroadcastId ?? \"-\"}). The export DID run; clearing here would mask stamped contacts. Use recoverFromPartialFailure instead.","messagePattern":"\\[clearPartialFailure\\] refused: pending progress markers present \\(waveLabel=(.+?), segmentId=(.+?), broadcastId=(.+?)\\)\\. The export DID run; clearing here would mask stamped contacts\\. Use recoverFromPartialFailure instead\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"convex/broadcast/rampRunner.ts","lineNumber":313,"sourceCode":"  handler: async (ctx, { reason }) => {\n    const row = await loadConfig(ctx);\n    if (!row) throw new Error(\"[clearPartialFailure] no ramp configured\");\n    if (row.lastRunStatus !== \"partial-failure\") {\n      return {\n        ok: true as const,\n        noop: true as const,\n        currentStatus: row.lastRunStatus,\n      };\n    }\n    // Fail-closed: if any pending-progress marker exists, the export DID make\n    // progress past `assignAndExportWave` — clearing here would mask a stamped\n    // / sent wave. Force the operator to use recoverFromPartialFailure.\n    if (\n      row.pendingWaveLabel ||\n      row.pendingSegmentId ||\n      row.pendingBroadcastId\n    ) {\n      throw new Error(\n        `[clearPartialFailure] refused: pending progress markers present (waveLabel=${row.pendingWaveLabel ?? \"-\"}, segmentId=${row.pendingSegmentId ?? \"-\"}, broadcastId=${row.pendingBroadcastId ?? \"-\"}). The export DID run; clearing here would mask stamped contacts. Use recoverFromPartialFailure instead.`,\n      );\n    }\n    await ctx.db.patch(row._id, {\n      lastRunStatus: `partial-failure-cleared: ${reason.slice(0, 200)}`,\n      lastRunError: undefined,\n      pendingRunId: undefined,\n      pendingRunStartedAt: undefined,\n    });\n    return { ok: true as const };\n  },\n});\n\n/**\n * Structured recovery for `lastRunStatus === \"partial-failure\"` that ALSO\n * occurred AFTER `assignAndExportWave` succeeded (or after a forced lease\n * release on a wedged run). Two recovery modes:\n *","sourceCodeStart":295,"sourceCodeEnd":331,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/broadcast/rampRunner.ts#L295-L331","documentation":"clearPartialFailure is a fail-closed guard: if any pending-progress marker (pendingWaveLabel, pendingSegmentId, pendingBroadcastId) is present, the export side of the wave actually executed and contacts may already be stamped. Clearing would mask a partially-sent wave. The handler throws and directs the operator to recoverFromPartialFailure instead.","triggerScenarios":"Calling clearPartialFailure when the failed run had already persisted pendingWaveLabel/pendingSegmentId/pendingBroadcastId via _recordPendingExport or _recordPendingBroadcast; i.e. the runner died after the export/broadcast side effect started but before _recordWaveSent committed.","commonSituations":"A wave action timed out or the Convex action was interrupted after assignAndExportWave or createProLaunchBroadcast ran; the operator tries the 'easy' clear instead of the proper recovery path.","solutions":["Do not clear. Inspect the persisted pending* markers (getRampStatus / direct table read) to see how far the wave got.","Use recoverFromPartialFailure({recovery:'manual-finished', ...}) if the broadcast actually sent, supplying broadcastId/segmentId/assigned/sentAt.","Use recoverFromPartialFailure({recovery:'discard-and-rotate'}) only if you have verified the broadcast did NOT go out and you accept rotating the wave offset."],"exampleFix":"// before\nawait ctx.runMutation(internal.broadcast.rampRunner.clearPartialFailure, { reason, confirmNoExport: true });\n\n// after — pending markers present means the export ran; recover, do not clear\nawait ctx.runMutation(internal.broadcast.rampRunner.recoverFromPartialFailure, {\n  recovery: 'manual-finished',\n  reason,\n  broadcastId: row.pendingBroadcastId,\n  segmentId: row.pendingSegmentId,\n  assigned: row.pendingAssigned,\n  sentAt: confirmedSendTimestamp,\n});","handlingStrategy":"validation","validationCode":"// Before clearPartialFailure, detect pending-progress markers and route to recovery\nconst row = await ctx.db.query('broadcastRampConfig').withIndex('by_key', (q) => q.eq('key', 'current')).first();\nif (row && (row.pendingWaveLabel || row.pendingSegmentId || row.pendingBroadcastId)) {\n  throw new Error('Pending progress markers present — use recoverFromPartialFailure, not clearPartialFailure.');\n}","typeGuard":"function hasPendingProgress(row: { pendingWaveLabel?: string; pendingSegmentId?: string; pendingBroadcastId?: string }): boolean {\n  return Boolean(row.pendingWaveLabel || row.pendingSegmentId || row.pendingBroadcastId);\n}","tryCatchPattern":null,"preventionTips":["Treat clearPartialFailure as last-resort; prefer recoverFromPartialFailure whenever pending markers exist.","Inspect pending* markers to determine how far the wave got before choosing manual-finished vs discard-and-rotate.","Never clear pending state without confirming whether the Resend broadcast actually sent."],"tags":["convex","broadcast-ramp","partial-failure","safety-gate","fail-closed"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}