Skip to content

feat(x402): Grant reconciled credit purchases and retry failed grants - #6664

Merged
osmarluz merged 6 commits into
feat/x402/reconciliationfrom
feat/x402/reconciliation-grants
Oct 14, 2026
Merged

osmarluz merged 6 commits into
feat/x402/reconciliationfrom
feat/x402/reconciliation-grants

Conversation

@osmarluz

@osmarluz osmarluz commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Context

Once reconciliation confirms an x402 credit purchase, the agent has paid but holds no credits until the purchase is granted. The same goes for a purchase whose grant failed after its settle.

Description

  • X402::Settlements::ReconcileService grants a settled credit purchase through X402::CreditPurchases::GrantService, which also bills it. A failed grant leaves the purchase settled, logs a warning and reports to Sentry. When the grant committed but a job enqueued after it could not be, the warning says so.
  • Clock::ReconcileX402SettlementsJob also picks up settled credit purchases left without a grant for two minutes, so the grant is retried every minute. The reconciliation job still succeeds when the grant fails: a raised job would go to the dead set every minute (retry: 0).
  • A partial index on x402_settlements.id, limited to settled credit purchases without a wallet transaction, serves that lookup in primary key order.

## Context

A credit purchase that reconciliation confirms is money received with no credits until something grants it.

## Description

`X402::Settlements::ReconcileService` runs `X402::CreditPurchases::GrantService` for a settled credit purchase that has no wallet transaction, right after confirming it or when handed one directly. The grant bills the purchase. A failed grant leaves the purchase settled, logs a warning and reports to Sentry, so the next run can retry it.
## Context

Reconciliation will look up settled credit purchases left without a grant every minute.

## Description

A partial index on `x402_settlements.updated_at` covers settled credit purchases that have no wallet transaction.
## Context

A grant can fail after its settle, and then the money has moved while the agent holds no credits. Nothing retries it unless the middleware resends the payment.

## Description

`Clock::ReconcileX402SettlementsJob` also enqueues settled credit purchases left without a wallet transaction for two minutes, so `X402::Settlements::ReconcileService` re-runs the grant every minute until it succeeds, whatever the x402 flag says.
@osmarluz
osmarluz added this pull request to stack #6665 October 13, 2026 13:27
@osmarluz
osmarluz marked this pull request as ready for review October 13, 2026 14:28
## Context

The backstop retries a grant only while the row's `updated_at` stays older than two minutes, and nothing checked that a failed grant leaves it alone. Nothing checked either that a grant raising mid-save hands back the row as stored.

## Description

The backstop specs now cover a failed and a raised grant keeping the row due for another attempt, and a grant whose save raises returning the stored row.
## Context

When an enqueue fails after the grant committed, for example while Redis is unreachable, reconciliation logged "credit grant failed" for a row that holds its credits. The purchase is never billed, and the warning sent the operator after a missing grant instead of a missing invoice.

## Description

`X402::Settlements::ReconcileService` logs "credit grant follow-up failed" when the reloaded row already has its wallet transaction, and keeps "credit grant failed" otherwise.
## Context

The clock reads purchases left without a grant in primary key order, a thousand at a time. On an index keyed on `updated_at`, Postgres has to sort every match first. Its estimate of those matches multiplies the selectivity of three correlated filters, so it soars once invoice payments, which never get a wallet transaction, fill the table. The planner then walks the whole primary key every minute instead.

## Description

The partial index on settled credit purchases without a wallet transaction is now keyed on `id`, so the clock reads it in order and stops at the batch size.
@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — a failed credit grant is swallowed as a successful reconciliation.

  • X402::Settlements::ReconcileService#grant only reports a failed GrantService result and rescues every raised error, so ReconcileJob succeeds even though settled money produced no credits. Propagate the inner failure, let unexpected exceptions fail the job, and update the specs that currently assert success/suppression; the clock scan can still retry the ungranted row.

@osmarluz

osmarluz commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Regarding the pre-review HOLD: declining it. Letting the grant failure fail the job is a deliberate design choice, not an oversight.

  • The failure is not dropped. Every failed attempt logs a warning and reports to Sentry with the organization and settlement ids. The row stays settled without a wallet transaction, so X402::Settlement.grant_retry_due picks it up and the clock retries the grant every minute. The backstop specs pin that a failed or raised grant leaves the row due.
  • Passing the failure on would only add dead jobs. ApplicationJob sets sidekiq_options retry: 0, so a raised ReconcileJob goes straight to the dead set. A grant that can't succeed, such as one against a deleted plan, would add a dead job every minute (about 1,440 a day per row). Retrying would not change, because the clock re-enqueues the row anyway, and Sentry would get the same one report per attempt. ReconcileService already handles chain-read errors the same way.

The real gap is that a grant that never succeeds is retried forever without a cap or backoff. That is tracked as a follow-up: a capped, backed-off retry is where raising would earn its place. The PR description now states why the job succeeds when the grant fails.

@osmarluz
osmarluz requested a review from mikeh-lago October 13, 2026 15:53

@mikeh-lago mikeh-lago left a comment

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.

Agree with declining the automated point: with retry: 0 raising only fills the dead set while the clock already retries. Approving. The capped, backed-off retry you mention also covers rows stuck on an inconclusive chain read in #6651, so it would be good to have it before the flag goes on for anyone.

@osmarluz
osmarluz merged commit cdc4fd7 into main Oct 14, 2026
12 checks passed
@osmarluz
osmarluz deleted the feat/x402/reconciliation-grants branch October 14, 2026 08:52
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.

2 participants