Skip to content

feat(credits): gate scheduled agent runs on credits + Telegram alert on shortfall - #782

Open
sweetmantech wants to merge 1 commit into
mainfrom
feat/scheduled-run-credit-gate
Open

feat(credits): gate scheduled agent runs on credits + Telegram alert on shortfall#782
sweetmantech wants to merge 1 commit into
mainfrom
feat/scheduled-run-credit-gate

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

runAgentWorkflow charged credits after the turn with no pre-gate, so an unfunded account (out of credits, no saved card to auto-recharge) kept running scheduled turns into a deep negative balance — WAVS Digital hit −1,563 credits (recoupable/chat#1885, "Split out — billing & engagement integrity").

This wires the existing request-path credit gate into the workflow so scheduled runs enforce the same rules as /api/research and the social-scrape routes, and alerts the team on shortfall.

Changes

  • gateWorkflowCredits (app/lib/workflows/) — a "use step" run BEFORE runAgentStep. Reuses ensureCreditsOrShortCircuit (which attempts a silent auto-recharge against a saved card) with a 1-credit floor. On shortfall it fires a Telegram alert and returns hasCredits:false; the workflow skips the run before any model token is spent. Fails open on a transient Stripe/Supabase error so an infra blip never breaks every run.
  • alertCreditShortfall (lib/credits/) — formats + sends the team Telegram alert (account/chat/session), swallowing errors so alerting never breaks the workflow (mirrors sendSalesNotification).
  • runAgentWorkflow — gate first, inside the existing try, so finally still runs cleanup (clear active stream, close writable, revoke ephemeral key) when a run is skipped. Accounts with a saved card top up transparently and proceed unchanged.

Covers both the headless /api/chat/runs path (the scheduled burners this targets) and the interactive /api/chat/workflow path — funded/carded accounts are unaffected either way.

Scope notes

  • The optional floor on deduct_credits_with_audit is intentionally left out — it's a database/ migration (cross-submodule) and this pre-gate already stops the drain at its source. Recommend a follow-up in the billing-integrity tracker.

Verification

  • TDD RED→GREEN for all three units:
    • alertCreditShortfall — sends alert w/ account+chat; never throws on Telegram failure.
    • gateWorkflowCredits — available → hasCredits:true no alert; shortfall → hasCredits:false + alert; 1-credit floor; fail-open on throw.
    • runAgentWorkflow — gates before the step; skips step/billing/auto-commit + no id minted on block; cleanup + ephemeral-key revoke still run on block.
  • Full domain suites green: lib/credits + app/lib/workflows140 tests passing.
  • tsc --noEmit: no new errors in changed files (2 pre-existing baseline errors in runAgentWorkflow.ts/.test.ts are unchanged, only line-shifted).
  • eslint clean on all changed files.

Targets main.

Relates to recoupable/chat#1885 (split-out billing & engagement integrity).

🤖 Generated with Claude Code


Summary by cubic

Add a pre-run credit gate to scheduled and interactive agent runs to block unfunded accounts before any tokens are used. On shortfall, we send a Telegram alert; carded accounts auto‑recharge and proceed.

  • New Features
    • Gate checks a 1‑credit floor before runAgentStep; on shortfall it skips the run and alerts Telegram. Cleanup still runs (stream clear/close, ephemeral key revoke).
    • Reuses the existing ensureCreditsOrShortCircuit path; silently tops up if a card is saved and fails open on transient Stripe/Supabase errors to avoid false blocks.

Written for commit 01cef4f. Summary will update on new commits.

Review in cubic

…on shortfall

`runAgentWorkflow` charged credits AFTER the turn with no pre-gate, so an
unfunded account (out of credits, no card to auto-recharge) kept running
scheduled turns into a deep negative balance — WAVS Digital hit -1,563
(recoupable/chat#1885).

Add a pre-run credit gate that reuses the request-path gate
(`ensureCreditsOrShortCircuit`, which attempts a silent auto-recharge) so
scheduled runs enforce the same rules as `/api/research` and the
social-scrape routes:

- `gateWorkflowCredits` — a `"use step"` that checks a 1-credit floor before
  `runAgentStep`. On shortfall it fires a Telegram alert and returns
  `hasCredits:false`; the workflow skips the run before any model token is
  spent. Accounts with a saved card top up transparently inside the gate.
  Fails OPEN on a transient Stripe/Supabase error so an infra blip never
  breaks every run.
- `alertCreditShortfall` — formats + sends the team Telegram alert, swallowing
  errors (alerting must not break the workflow), mirroring
  `sendSalesNotification`.
- Wire the gate first inside `runAgentWorkflow`'s try so `finally` still runs
  cleanup (clear active stream, close writable, revoke ephemeral key) when a
  run is skipped.

TDD: RED→GREEN for the alert helper, the gate step, and the workflow wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 23, 2026 11:20am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 72d3a55b-aa44-4801-85e2-7af78eee700d

📥 Commits

Reviewing files that changed from the base of the PR and between 31567ad and 01cef4f.

⛔ Files ignored due to path filters (3)
  • app/lib/workflows/__tests__/gateWorkflowCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/lib/workflows/__tests__/runAgentWorkflow.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • lib/credits/__tests__/alertCreditShortfall.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (3)
  • app/lib/workflows/gateWorkflowCredits.ts
  • app/lib/workflows/runAgentWorkflow.ts
  • lib/credits/alertCreditShortfall.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scheduled-run-credit-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 6 files

Confidence score: 2/5

  • In app/lib/workflows/runAgentWorkflow.ts and app/lib/workflows/gateWorkflowCredits.ts, credit gating is not strong enough under concurrency and low-balance edge cases, so multiple runs can pass the check and then debit later, driving scheduled accounts negative and weakening spend controls — add an atomic reserve/check (or enforce a non-negative floor at debit time) before model execution.
  • In app/lib/workflows/gateWorkflowCredits.ts, allowing a turn when balance is exactly one credit can still permit a much larger downstream debit in handleChatCredits, which creates a concrete regression risk for credit enforcement — tighten the gate to validate against expected turn cost rather than a minimal threshold.
  • In lib/credits/__tests__/alertCreditShortfall.test.ts, alert-content assertions miss parse_mode: "Markdown" and sessionId, so formatting/context regressions could slip through unnoticed — extend the test expectations to include both fields.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/lib/workflows/runAgentWorkflow.ts">

<violation number="1" location="app/lib/workflows/runAgentWorkflow.ts:95">
P1: Concurrent workflows for the same account can all pass this gate against the same balance, then spend model tokens before `handleChatCredits` debits them. An atomic reservation/check in the gate, or a floor-enforcing debit RPC, would be needed so only runs backed by available credits proceed.</violation>
</file>

<file name="app/lib/workflows/gateWorkflowCredits.ts">

<violation number="1" location="app/lib/workflows/gateWorkflowCredits.ts:51">
P1: A balance of exactly one credit still allows the model turn, even though the later `handleChatCredits` debit can be much larger than one credit; that leaves the scheduled account negative and does not achieve the stated protection against unfunded runs. A preflight/reservation based on the expected turn cost or a database-side minimum-balance check would make the gate enforceable.</violation>
</file>

<file name="lib/credits/__tests__/alertCreditShortfall.test.ts">

<violation number="1" location="lib/credits/__tests__/alertCreditShortfall.test.ts:22">
P3: The `alertCreditShortfall` test that verifies the alert content doesn't assert `parse_mode: "Markdown"` or the sessionId in the message text. The Markdown parse mode is intentional — without it, the `*bold*` formatting in the alert body would render as literal asterisks rather than bold text in Telegram. Consider adding assertions for `{ parse_mode: "Markdown" }` on the second argument and for the sessionId appearing in the text to pin the full expected behavior.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Scheduled Runner
    participant API as /api/chat/runs
    participant Wf as runAgentWorkflow
    participant Gate as gateWorkflowCredits
    participant Credits as ensureCreditsOrShortCircuit
    participant Stripe
    participant DB as Supabase
    participant Alert as alertCreditShortfall
    participant TG as Telegram Bot
    participant Step as runAgentStep
    participant Cleanup as finally block

    Note over Client,Cleanup: Scheduled agent run flow

    Client->>API: POST /api/chat/runs
    API->>Wf: runAgentWorkflow(input)
    Wf->>Wf: Enter try block

    Note over Wf,Gate: NEW: Pre-run credit gate
    Wf->>Gate: gateWorkflowCredits(accountId, chatId, sessionId)
    Gate->>Gate: "use step" (DB/Stripe I/O in workflow)

    Gate->>Credits: ensureCreditsOrShortCircuit(accountId, creditsToDeduct=1)
    Credits->>DB: Check current balance
    alt Balance < 1 AND auto-recharge available
        Credits->>Stripe: Attempt silent auto-recharge
        Stripe-->>Credits: Charge result
        Credits->>DB: Update balance
        Credits-->>Gate: null (proceed)
    else Balance >= 1
        Credits-->>Gate: null (proceed)
    else Balance < 1 AND no auto-recharge
        Credits-->>Gate: NextResponse (402)
    end

    alt Shortfall (NextResponse returned)
        Gate->>Alert: alertCreditShortfall(accountId, chatId, sessionId)
        Alert->>TG: sendMessage(formatted markdown)
        Note over Alert,TG: Swallows errors - alerting never breaks workflow
        TG-->>Alert: OK
        Gate-->>Wf: { hasCredits: false }
        Wf->>Wf: Log warning, return early
        Wf->>Cleanup: finally block runs
        Cleanup->>Cleanup: clearActiveStream, closeChatStream, revoke ephemeral key
    else Credits available or fail-open
        Gate-->>Wf: { hasCredits: true }
        Note over Wf: Original flow continues unchanged
        Wf->>Wf: Generate assistantMessageId
        Wf->>Step: runAgentStep(...)
        Step->>Credits: Post-run charge
        Step-->>Wf: result
        Wf->>Cleanup: finally block
    end

    Note over Gate: Fail-open on transient error
    Note over Gate,Credits: If ensureCreditsOrShortCircuit throws
    Gate->>Gate: Log error, return { hasCredits: true }
    Gate-->>Wf: { hasCredits: true } (run proceeds)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// (recoupable/chat#1885 — WAVS Digital hit -1,563). Gate first — before we
// even mint an assistant message id or spend a model token — and skip the
// run on shortfall, firing a Telegram alert. `finally` still runs cleanup.
const gate = await gateWorkflowCredits({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Concurrent workflows for the same account can all pass this gate against the same balance, then spend model tokens before handleChatCredits debits them. An atomic reservation/check in the gate, or a floor-enforcing debit RPC, would be needed so only runs backed by available credits proceed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/lib/workflows/runAgentWorkflow.ts, line 95:

<comment>Concurrent workflows for the same account can all pass this gate against the same balance, then spend model tokens before `handleChatCredits` debits them. An atomic reservation/check in the gate, or a floor-enforcing debit RPC, would be needed so only runs backed by available credits proceed.</comment>

<file context>
@@ -84,20 +85,41 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise<vo
+    // (recoupable/chat#1885 — WAVS Digital hit -1,563). Gate first — before we
+    // even mint an assistant message id or spend a model token — and skip the
+    // run on shortfall, firing a Telegram alert. `finally` still runs cleanup.
+    const gate = await gateWorkflowCredits({
+      accountId: input.accountId,
+      chatId: input.chatId,
</file context>

try {
const short = await ensureCreditsOrShortCircuit({
accountId,
creditsToDeduct: SCHEDULED_RUN_MIN_CREDITS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A balance of exactly one credit still allows the model turn, even though the later handleChatCredits debit can be much larger than one credit; that leaves the scheduled account negative and does not achieve the stated protection against unfunded runs. A preflight/reservation based on the expected turn cost or a database-side minimum-balance check would make the gate enforceable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/lib/workflows/gateWorkflowCredits.ts, line 51:

<comment>A balance of exactly one credit still allows the model turn, even though the later `handleChatCredits` debit can be much larger than one credit; that leaves the scheduled account negative and does not achieve the stated protection against unfunded runs. A preflight/reservation based on the expected turn cost or a database-side minimum-balance check would make the gate enforceable.</comment>

<file context>
@@ -0,0 +1,70 @@
+  try {
+    const short = await ensureCreditsOrShortCircuit({
+      accountId,
+      creditsToDeduct: SCHEDULED_RUN_MIN_CREDITS,
+      successUrl: CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL,
+    });
</file context>

expect(sendMessage).toHaveBeenCalledTimes(1);
const text = vi.mocked(sendMessage).mock.calls[0]?.[0] as string;
expect(text).toContain("acc-1");
expect(text).toContain("chat-9");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The alertCreditShortfall test that verifies the alert content doesn't assert parse_mode: "Markdown" or the sessionId in the message text. The Markdown parse mode is intentional — without it, the *bold* formatting in the alert body would render as literal asterisks rather than bold text in Telegram. Consider adding assertions for { parse_mode: "Markdown" } on the second argument and for the sessionId appearing in the text to pin the full expected behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/credits/__tests__/alertCreditShortfall.test.ts, line 22:

<comment>The `alertCreditShortfall` test that verifies the alert content doesn't assert `parse_mode: "Markdown"` or the sessionId in the message text. The Markdown parse mode is intentional — without it, the `*bold*` formatting in the alert body would render as literal asterisks rather than bold text in Telegram. Consider adding assertions for `{ parse_mode: "Markdown" }` on the second argument and for the sessionId appearing in the text to pin the full expected behavior.</comment>

<file context>
@@ -0,0 +1,32 @@
+    expect(sendMessage).toHaveBeenCalledTimes(1);
+    const text = vi.mocked(sendMessage).mock.calls[0]?.[0] as string;
+    expect(text).toContain("acc-1");
+    expect(text).toContain("chat-9");
+  });
+
</file context>

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